When a call fails, the HTTP status tells you the class of problem and the body tells you exactly which. Branch on the code, never on the message: codes are stable, messages are written for people and are in English only.
The error envelope
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "must be a well-formed email address", "code": "INVALID_FIELD" }
]
}
}| Field | Type | Meaning |
|---|---|---|
error.code | string | A stable, machine-readable code such as VALIDATION_FAILED or CONTACT_NOT_FOUND. |
error.message | string | A sentence for a person. |
error.details | array | For VALIDATION_FAILED, one entry per bad field: field, message and code (INVALID_FIELD). Also used by a few errors to add context. |
error.data | object | Left out when there is nothing to add. What the server says about the state now, for refusals where that helps. A claim that lost a race names the winner here; a stale save returns the draft’s current version. |
Common codes
| Code | Status | Meaning |
|---|---|---|
VALIDATION_FAILED | 400 | A field is missing or malformed. error.details names each one. |
INVALID_REQUEST | 400 | The request body could not be read, or a document inside it is invalid — for example a malformed flow or segment rule. |
UNAUTHENTICATED | 401 | No token, an expired token, or one the server will not accept. Sign in again. |
USER_INACTIVE | 401 | The account behind the token has been deactivated. |
PERMISSION_DENIED | 403 | Your roles lack the permission the endpoint needs. The reference names it on each endpoint. (ACCESS_DENIED appears only as a framework fallback.) |
CONSTRAINT_VIOLATION | 409 | A uniqueness or length limit in the database was hit, for example a name that is too long. |
RATE_LIMITED | 429 | Too many requests, on endpoints that are rate-limited, such as password reset. Wait and retry. |
Specific codes
There is no generic NOT_FOUND or CONFLICT code: a missing object or a refused state change always carries a code specific to the resource, such as CONTACT_NOT_FOUND or CAMPAIGN_NOT_EDITABLE. Many endpoints answer with a more precise code than the common ones. The reference lists them under each endpoint; these are the ones you are most likely to meet.
Ownership and replies
| Code | Status | Meaning |
|---|---|---|
TICKET_ALREADY_CLAIMED | 409 | Someone else holds the ticket. error.data has assignedAgentId and assignedAgentName. |
ASSIGNMENT_CHANGED | 409 | The ticket changed since the expectedVersion you sent. |
NOT_ASSIGNEE | 409 | The queue lets only the assignee reply. |
DUPLICATE_REPLY | 409 | A colleague just sent the same text. error.data.messageId is their message. |
TEMPLATE_REQUIRED | 409 | The WhatsApp 24-hour window is closed; send an approved template. |
ONE_TARGET | 400 | An assignment named both an agent and a queue. |
NOTE_TOO_LONG | 400 | A handover note is over 2000 characters. |
TEAM_ACTION_RETIRED | 400 | Teams no longer receive tickets; transfer to a queue instead. |
Channels and agents
| Code | Status | Meaning |
|---|---|---|
CHANNEL_CREDENTIAL_IN_USE | 409 | A phone number, verify token, widget key or similar is already claimed by another active line of the same type — in any organisation. |
CHANNEL_LINE_NOT_FOUND | 404 | No channel line with this ID in your organisation. |
CHANNEL_AGENT_MANAGED_BY_PUBLISH | 409 | A Web Chat line’s agent is set by publishing, not by this call. |
BOT_NOT_FOUND | 404 | No agent with this ID. |
BOT_NOT_PUBLISHED | 409 | The agent is not active, so a line cannot be bound to it. |
BOT_DRAFT_NOT_FOUND | 404 | The agent has no draft yet. Not a fault. |
BOT_DRAFT_VERSION_CONFLICT | 409 | The draft was saved by someone else since your baseVersion. |
EXTERNAL_BOT_IN_USE | 409 | A channel line still uses this external bot. |
WHATSAPP_TOKEN_INVALID | 422 | Meta rejected the access token. |
WHATSAPP_WABA_NOT_ACCESSIBLE | 422 | The token cannot reach that WhatsApp Business account. |
WHATSAPP_REGISTER_FAILED | 422 | Registering the number with its PIN failed. |
WHATSAPP_PUBLIC_URL_MISSING | 412 | The platform has no public webhook URL configured. |
META_UNAVAILABLE | 502 | Meta’s API did not answer. |
Contacts and other conflicts
| Code | Status | Meaning |
|---|---|---|
MERGE_NOT_REVERSIBLE | 409 | A newer merge involves one of these contacts, so it cannot be split. |
ROUTING_LABEL_TAKEN | 409 | A routing label already routes to another queue. |
MEDIA_EXPIRED | 409 | The stored copy of an attachment is past its retention period. |
CONVERSATION_SUPERSEDED | 409 | The customer has started a new conversation, so this one cannot be reopened. |
Several “already exists” codes tell you a name is taken: INBOX_EXISTS, LABEL_NAME_CONFLICT, ROLE_NAME_TAKEN, EXTERNAL_BOT_NAME_TAKEN, SAVED_VIEW_NAME_CONFLICT, CANNED_RESPONSE_SHORT_CODE_CONFLICT, CUSTOM_ATTRIBUTE_KEY_CONFLICT, PRIORITY_CASE_NAME_TAKEN, ASSIGNMENT_POLICY_NAME_TAKEN, STORAGE_PROFILE_NAME_TAKEN, EMAIL_ALREADY_REGISTERED and INVITATION_PENDING. Pick another name, or work with the object that already exists.
When there is no error body
A response from a proxy or gateway may have no envelope at all, and neither may failures raised by the framework itself: an unknown route, a wrong HTTP method, a malformed query parameter or an expired token. Fall back to the HTTP status: treat 401 as UNAUTHENTICATED, 403 as PERMISSION_DENIED, and anything else as HTTP_<status> — for example HTTP_404 or HTTP_502.
Handling errors
- Branch on
code, then showerror.messageor your own wording. - Show field errors next to their fields using
error.details[].field. - Do not retry a
4xxunchanged: it will fail the same way. The exceptions are429after a wait, and a409after you have re-read the object. - Retry
5xxand network failures with backoff. For sends, reuse the sameidempotencyKeyso a retry cannot deliver twice. - On
401, sign in again. There is no refresh.