Home/ Developers/Errors & rate limits
One error shape, everywhere
Every failed request — a validation error, a missing scope, a closed messaging window, a rate limit — comes back with the same envelope, so error handling is one code path, not one per endpoint.
Every error response is {"error": {"code": "...", "details": "..."}},
with an HTTP status that tells you the category (400 bad input,
401/403 auth, 404 not found, 409 the
request conflicts with the resource's current state, 429 rate limited) and
error.code a stable string you can branch on without parsing the message.
The shape
{
"error": {
"code": "invalid",
"details": {
"contact_id": ["Contact was not found."]
}
}
}
{
"error": {
"code": "whatsapp_template_required",
"details": {
"detail": "The 24-hour WhatsApp customer-service window is closed; send an approved template instead."
}
}
}
details is either a field-name-keyed object (validation errors — each value is
a list of messages for that field) or {"detail": "..."} (everything else).
error.code is the field to branch on in either case — except a plain
404 for a resource that just doesn't exist, which carries the generic
api_error rather than a dedicated code. Branch on the 404 status
itself for "not found", not on error.code.
What each one means here
| Status | Meaning | Typical error.code |
|---|---|---|
| 400 | The request body or query failed validation. | invalid |
| 401 | No credential was sent. | not_authenticated |
| 401 | The credential was sent but is malformed, expired, revoked, or doesn't match the X-Tenant-ID given. | authentication_failed |
| 402 | The workspace's plan or quota can't cover this request. | billing_limit_reached |
| 403 | Authenticated, but this key's scopes (or the actor's role) don't allow this action. | permission_denied |
| 404 | The resource doesn't exist, or exists in a different workspace than your X-Tenant-ID. | api_error |
| 409 | Valid request, but it conflicts with the resource's current state — see below. | contact_suppressed, channel_unavailable, whatsapp_template_required, message_idempotency_conflict, outbound_content_unsupported, media_unavailable |
| 429 | Rate limited. Respect Retry-After before retrying. | throttled |
A 409 from sending a message is almost always one of five causes: the contact
opted out (contact_suppressed), the channel isn't active
(channel_unavailable), the 24-hour window is closed
(whatsapp_template_required), a duplicate client_message_id was
reused (message_idempotency_conflict), or the content type isn't supported on
that channel (outbound_content_unsupported). None of these queue a message —
check error.code and handle each case rather than retrying blindly.
429 responses
Retry-After: 42
{
"error": {
"code": "throttled",
"details": {
"detail": "Request was throttled. Expected available in 42 seconds."
}
}
}
Limits are per-scope (contacts/conversations/messages share one limit; webhook management
has its own; automation triggers their own) rather than a single global ceiling, and apply
per authenticated identity — a rate-limited API key doesn't affect anyone else's requests
against the same workspace. Read Retry-After off the response rather than
hardcoding a backoff; it changes if the underlying limit does.