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
| Parameter | Default | Range |
|---|---|---|
page | 1 | ≥ 1 |
per_page | 25 | 1–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
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
| Status | type | Means |
|---|---|---|
| 400 | invalid_request_error | Something about the request is wrong. Fix it; retrying will not help. |
| 401 | authentication_error | No token, or the token is no longer valid. Fetch a new one and retry once. |
| 402 | plan_limit_error | Valid and authorised, but a plan ceiling or a locked feature stopped it. |
| 403 | permission_error | The credential lacks the scope, an account switch is off, or the caller's IP is not on the account's allow-list. |
| 404 | not_found_error | No such resource in this account. |
| 409 | conflict_error | Valid, but conflicts with current state — a closed WhatsApp window, a duplicate team name. |
| 413 | invalid_request_error | The upload is too large. |
| 429 | rate_limit_error | Slow down. |
| 5xx | api_error | Our 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 requests | 600 per minute, per credential |
POST /v1/oauth/token | 20 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/linksreturns the existing link for the same document and contact instead of creating a second one, and does not charge again.createdin the response says which happened.POST /v1/agentsrolls the agent back if the seat charge is declined, so a402means nothing was created.PATCHendpoints 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.