Conventions
What every call has in common: requests and responses, errors, pagination, and which writes are safe to send twice.
Requests
Every route is under https://api.zerohuman.com/v1, and every call carries a token in its Authorization header
(Authentication):
curl "https://api.zerohuman.com/v1/runs?status=failed" \
-H "Authorization: Bearer zhos_…"
- A request body is JSON. Send
content-type: application/jsonwith it: without that header the body is not read as JSON. - A body larger than 100 KB is refused with
413. A body that is not valid JSON is refused with400. - The API sends no CORS headers, so a web page on another site cannot read its answers. Call it from a server or a script.
Responses
Every answer is JSON, except a member's picture and an email attachment, which are the file itself.
| Status | When |
|---|---|
200 |
A read, or a change that sends back what it changed. |
201 |
A POST that succeeded. Starting a run, deciding a gate and creating a task all answer 201. |
204 |
A change with nothing to send back, such as deleting a task. The body is empty. |
- Ids are UUIDs, as strings. An execution's id is the id of the run it started at.
- Times are ISO 8601 strings in UTC:
2026-09-28T09:00:00.000Z. - Money says its unit in its name:
costUsdCentsand a spend cap'smoneyCentsDayare US cents, and a task'smoneyUsdPerRunis US dollars.
Errors
A refused call answers with a 4xx status and a JSON body that names why. The body comes in one of two shapes, and
the code you match on is in a different field in each:
{ "error": "api_token_scope", "message": "This token needs runs:write; it has runs:read." }
{ "message": "task_version_conflict", "error": "Conflict", "statusCode": 409 }
- With no
statusCode, the code iserror.message, when there is one, is a sentence for a person, and some refusals add fields of their own (an invalid task definition lists itsissues). - With
statusCode, the code ismessage, anderroris only the name of the status.
Match on the code, not on the sentence. The statuses:
| Status | What it means |
|---|---|
400 |
The request is missing something or asks for something invalid: a required field, a value the API does not accept, a task definition that could never run. |
401 |
No token, or not one that is valid. The body is { "message": "Unauthorized", "statusCode": 401 }. |
403 |
The token cannot do this. It lacks the scope (api_token_scope), the route is the portal's alone, or what you asked to change is someone else's, such as a cloned task's definition (task_definition_read_only). |
404 |
There is nothing with that id in your enterprise. An id from another enterprise answers exactly as one that does not exist. |
409 |
The request conflicts with how things stand now: the task, or every member holding its role, is busy (overlap, member_busy), the gate is already decided (gate_not_waiting), someone saved a newer version (task_version_conflict), the slug is taken (task_slug_taken). Read the current state before you try again. |
413 |
The body is over 100 KB. |
500 |
Something failed on our side: { "statusCode": 500, "message": "Internal server error" }. |
Pagination
Six lists come a page at a time: tasks, runs, executions, webhook deliveries, the task catalogue, and a member's email. They take the same query parameters:
| Parameter | What it does |
|---|---|
page |
Which page, counting from 1. The default is 1. |
limit |
How many to a page, from 1 to 200. The default is 25, and a value outside the range is brought inside it. |
sort |
The field to sort by. Each list has its own, below; one it does not know sorts by its default. |
order |
asc or desc. Anything other than asc is desc, which is the default. |
search |
Free text, matched against the list's own fields. |
Any other parameter is a filter, such as GET /v1/runs?taskId=…&status=failed. A filter a list does not know is
ignored, not refused.
| List | sort (default first) |
Filters |
|---|---|---|
GET /v1/tasks |
slug, name, kind, status, createdAt, updatedAt |
teamId, status, kind, assigneeRole, origin; name and slug match any part |
GET /v1/runs |
createdAt, status, finishedAt |
taskId, taskVersionId, status; live=true for runs not yet finished, leaving out blocked and paused ones |
GET /v1/executions |
updatedAt, startedAt, runCount, costUsdCents, status |
teamId, memberId, status; closed=true for closed ones only |
GET /v1/webhooks |
receivedAt, status, target, responseStatus, durationMs |
status, kind |
GET /v1/catalog/tasks |
slug, name, status, provenance, createdAt, updatedAt |
status, kind, provenance, origin; slug matches any part |
GET /v1/catalog/roles |
slug, name, provenance, createdAt, updatedAt |
provenance; slug matches any part |
GET /v1/members/{id}/emails |
Always by when it arrived; order still applies |
unreadOnly=true |
A page comes back in an envelope:
{ "data": [{ "id": "…" }], "total": 142, "page": 2, "limit": 25, "pages": 6 }
total counts every match, not only this page. pages is at least 1, even when there is nothing to list, and a
page past the last has an empty data.
Every other list answers with a plain array: teams, members, roles, gates, chats, recommendations, and tool
connections. The memory lists (GET /v1/memory and the review queue) take page and limit too, and answer in a
shape of their own. GET /v1/blockers pages its failed runs with failedPage and pageSize.
Safe retries
When a call times out or its connection drops, you do not know whether it happened. What to do next depends on the call.
Reads
A GET changes nothing, so repeat it as often as you like. Two reads also mark something read: opening a chat, and
opening a member's email.
Writes that take an idempotency key
Two writes take an idempotencyKey in the body. No route reads an Idempotency-Key header.
| Call | The key | Sent again with the same key |
|---|---|---|
POST /v1/recommendations/{id}/decision |
Optional, up to 80 characters. | From the same caller, returns the decision the first call made instead of 409 already_decided, and starts no second run. |
POST /v1/webhooks/{id}/replay |
Required: 16 to 64 letters, digits, - or _. A UUID will do. |
Returns the replay the first call made, instead of replaying again. The same key on a different delivery is a 409 (replay_idempotency_key_reused). |
Use a new key for each thing you mean to do, and the same key only when you are repeating it.
Writes that are safe to repeat
- Setting something to a value: a
PUTorPATCH, such as a task's schedule, a spend cap, or the enterprise's concurrent-run limit, leaves it at that value however many times you send it. - Clearing a run from Blockers, closing an execution, and reopening one: done twice, the second changes nothing and answers as the first did.
Writes that refuse a repeat
The second call is refused with a 409, which usually means the first one went through:
| Call | The repeat is refused with |
|---|---|
| Creating a task, team or role | task_slug_taken, team_slug_taken, role_slug_taken. The slug is made from the name when you send none, so the same body twice is refused, not made twice. |
| Creating a member | member_email_taken. The email is always made from the name, so the same name twice is refused, not made twice. To add a second member with the same name, give them a name that tells them apart. |
| Deciding a gate | gate_not_waiting |
| Cancelling a run | illegal_transition, once it has finished |
Saving a task version with baseVersionId |
task_version_conflict: the version your first call saved is now the current one. Read the task: if its current version is yours, the save landed. See Tasks over the API. |
Writes to check before you repeat
- Starting a run. While the first run is under way, a repeat is refused (
409 overlap). Once it is waiting at a gate, blocked, or finished, a repeat starts a second run. Look for the first before you send another:GET /v1/runs?taskId=…lists the task's runs, newest first. - Retrying a run. A repeat returns the same new run only while that run is still
pending. Once it has started, a repeat makes another. Each retry's input carries the run it retried aspriorRunId, so the runs list shows whether yours landed. - Sending a chat message. A repeat sends the message again, unless the member is still replying to the first
(
409 chat_replying).