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/json with 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 with 400.
  • 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: costUsdCents and a spend cap's moneyCentsDay are US cents, and a task's moneyUsdPerRun is 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 is error. message, when there is one, is a sentence for a person, and some refusals add fields of their own (an invalid task definition lists its issues).
  • With statusCode, the code is message, and error is 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 PUT or PATCH, 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 as priorRunId, 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).