Errors
One shape for every failure, so you write one handler rather than guessing per endpoint.
{
"error": {
"code": "invalid_request",
"message": "Request validation failed",
"details": { "phone": "must be E.164, starting with +" }
},
"request_id": "req_4e1f222a0b72471f83cf"
}Keep the request_id
It is logged on our side against the full stack trace. A support conversation that opens with the id is a two-minute answer; one that opens with “it failed yesterday afternoon” is an hour of searching.
Log it wherever you log the failure. It is the single most useful field in this envelope and the one most integrations discard.
401 and 403 are not interchangeable
401 means we do not know who you are — the credential is missing, malformed or revoked.
403 means we know exactly who you are and the answer is still no. An API key reaching outside its two paths gets this, and no amount of re-authenticating changes it.
Clients that collapse the two into “auth failed” end up re-authenticating in a loop against a permission problem, which looks from our side like a credential being brute-forced.
Every code
| Status | Code | Means | Do |
|---|---|---|---|
| 400 | invalid_request | A field failed validation. | Read details — it names the offending fields. Do not retry unchanged. |
| 400 | malformed_request | The body was not readable JSON. | A bug in the caller. Check serialisation and Content-Type. |
| 401 | unauthorized | No credential, or one that is not valid. | Check the X-API-Key header. Do not retry — it will not become valid. |
| 403 | forbidden | Valid credential, refused. Usually a key reaching outside /v1/events or /v1/customers. | Fix the call, or use the dashboard. Retrying returns the same. |
| 403 | feature_not_available | The workspace plan does not include this capability at all - campaigns, the API, webhooks, workflows or the AI. | Upgrade the workspace plan. Retrying returns the same. GET /v1/entitlements says what the plan includes. |
| 409 | plan_limit_reached | The capability is on the plan and its allowance is full - active workflows, team seats, connected numbers, templates or monthly events. | Remove or disable one, or upgrade. The message names the allowance; GET /v1/entitlements gives the number. |
| 404 | not_found | No such record in your workspace. | Note the scope: a record in another workspace is not found, not forbidden. |
| 405 | method_not_allowed | Right path, wrong verb. | The Allow header lists what would work. |
| 409 | conflict | Something unique already exists. | Usually a duplicate. Treat as already-done rather than failed. |
| 415 | unsupported_media_type | A body that was not JSON. | Set Content-Type: application/json. |
| 422 | unprocessable | Well-formed, and impossible in the current state. | Read the message. Retrying without changing something will not help. |
| 429 | rate_limited | Too many attempts. | Wait for Retry-After. See Rate limits. |
| 500 | internal_error | Our fault. | Safe to retry with backoff. Quote request_id if you contact support. |
What to retry
Retry: 500, 502, 503, network failures, and 429 after Retry-After. Use exponential backoff.
Do not retry: any other 4xx. The request will not become valid by being sent again, and a client retrying a validation error forever is indistinguishable from an attack.
Retry safely by sending an idempotency key
A timeout tells you nothing about whether the request arrived. With an idempotency key you can retry without wondering — the second call returns the first result rather than recording a second event and sending a second WhatsApp message.
Next
Rate limits →
What is limited today, what is not, and how to behave when you meet one.