דילוג לתוכן הראשי

Conventions

Learn these once and every endpoint behaves the way you expect.

Responses

A single resource is returned as itself. No envelope, nothing to unwrap:

{
"object": "conversation",
"id": "387",
"status": "open",
"…": "…"
}

Every resource carries an object field naming its type, which is what lets you write one dispatcher over mixed results.

A collection is data plus meta:

{
"data": [ { "object": "conversation", "id": "387" } ],
"meta": {
"page": 1,
"per_page": 25,
"count": 25,
"total": 155,
"total_pages": 7,
"has_more": true
}
}

A delete answers uniformly, so one handler covers all of them:

{ "id": "387", "object": "conversation", "deleted": true }

Some deletes add a field describing the cascade — unlinked_contacts on a customer, detached_conversations on a team, freed_bytes on a bulk file delete. Read them; they are how you find out a delete did more than you expected.

Pagination

ParameterDefaultRange
page1≥ 1
per_page251–100

per_page=500 is a 400, not a silent clamp. A client asking for 500 rows a page has a design assumption worth correcting now rather than discovering in production.

Loop on has_more, not on total. total is omitted where counting the full result set would cost a second scan; has_more is always present.

page = 1
while True:
result = client.get("/conversations", status="open", page=page, per_page=100)
for conversation in result["data"]:
handle(conversation)
if not result["meta"]["has_more"]:
break
page += 1
Polling for changes

Do not re-walk a whole collection on a timer. GET /v1/conversations takes updated_after, so a poll can ask only for what moved:

GET /v1/conversations?status=open&updated_after=2026-08-20T09:00:00Z

Filtering

Repeated values within one filter are OR. Both forms work, so use whichever your HTTP client produces:

?label=vip&label=urgent
?label=vip,urgent

Different filters are AND. This finds open, urgent conversations in two inboxes that nobody owns:

?status=open&priority=urgent&inbox_id=3,7&assignee_id=unassigned

Two filters take sentinel values: assignee_id=unassigned for the waiting line, and team_id=none for conversations no team owns.

Timestamps are ISO-8601. 2026-08-20T09:00:00Z or with an offset. A value that will not parse is a 400 naming the parameter rather than a silently ignored filter.

Unknown enum values are rejected, and the error lists the accepted ones — ?status=nope tells you it wants open, pending, on_hold or resolved. A filter you mistyped never silently returns everything.

Errors

One shape, everywhere:

{
"error": {
"type": "invalid_request_error",
"code": "missing_parameter",
"message": "'inbox_id' is required.",
"param": "inbox_id",
"request_id": "req_5f2a91c0e8b74d3a9c1e"
}
}

Branch on code — it is stable across releases. type groups failures the way you would handle them; message is written for a human reading a log.

Status codes

StatustypeMeans
400invalid_request_errorSomething about the request is wrong. Fix it; retrying will not help.
401authentication_errorNo token, or the token is no longer valid. Fetch a new one and retry once.
402plan_limit_errorValid and authorised, but a plan ceiling or a locked feature stopped it.
403permission_errorThe credential lacks the scope, an account switch is off, or the caller's IP is not on the account's allow-list.
404not_found_errorNo such resource in this account.
409conflict_errorValid, but conflicts with current state — a closed WhatsApp window, a duplicate team name.
413invalid_request_errorThe upload is too large.
429rate_limit_errorSlow down.
5xxapi_errorOur problem. Retry with backoff and quote the request_id.

Validation detail

Where several fields failed at once, the specifics arrive in details:

{
"error": {
"type": "invalid_request_error",
"code": "validation_failed",
"message": "One or more attribute values were rejected.",
"details": {
"errors": [
"reason_for_contact: 'refund' is not one of the allowed options",
"order_number: required"
]
}
}
}

request_id

Every response carries one, in the body on errors and in the X-Request-Id header always. Quote it when reporting a problem — it is how we find the exact request in our logs.

You may also supply your own, and it will be echoed back and used in our logs, which makes correlating with your side trivial:

X-Request-Id: my-job-4192-attempt-1

Rate limits

Limit
Authenticated requests600 per minute, per credential
POST /v1/oauth/token20 per minute, per IP

Over the limit is a 429. Back off exponentially — and if you are hitting the token limit, the fix is to cache the token rather than to retry harder.

Retries and idempotency

Most write endpoints are not idempotent: POST /v1/customers twice makes two customers. Where a retry is genuinely safe, it is because the endpoint was built that way and says so:

  • POST /v1/signature/links returns the existing link for the same document and contact instead of creating a second one, and does not charge again. created in the response says which happened.
  • POST /v1/agents rolls the agent back if the seat charge is declined, so a 402 means nothing was created.
  • PATCH endpoints are naturally idempotent — sending the same body twice leaves the same state.

For everything else, retry a 429 or a 5xx; do not blind-retry a 400.

Timestamps and ids

All timestamps are ISO-8601 in UTC, and a field that has no value is null rather than "" — so you can tell "never resolved" from "resolved at an unknown time".

Ids are opaque strings. Some look like numbers ("387"), some like hashes ("kIlBtSS5yQTGeghZmlQ3"). Store them as strings; never parse or generate one.

What this API will not do

Worth knowing before you plan around it:

  • No webhooks in this version. Poll with updated_after. Studio can already call your endpoint on conversation events if you need push today.
  • No visual editors. Studio flows, form layouts and signature field placement are coordinates on a canvas, and a JSON body of pixel offsets is not a contract anybody should have to write. Those stay in the console; this API reads them.
  • No browser access. No CORS headers, deliberately.