Errors
Every 4xx and 5xx response body uses the same envelope:
{
"error": {
"code": "channel_unavailable",
"message": "Channel is not connected. Re-pair to continue.",
"details": { "channel_id": "..." }
},
"request_id": "req-3f7a1c02"
}Match on code — it's the stable contract. message is human-readable and may change between releases. details is optional and code-specific.
The full code set ships in the Zod contract ERROR_CODES. The table below
is canonical.
Auth / identity
| Code | When you'll see it |
|---|---|
auth_missing | No Authorization header sent. |
auth_invalid | Bearer token doesn't match any active key. |
auth_malformed | Header present but not Bearer …, or token format wrong. |
auth_expired | Key or token is past its expiry. (Revocation is auth_revoked.) |
auth_no_email | Dashboard session token has no verified email. |
auth_revoked | Key existed and has been revoked. |
auth_backend_unavailable | 503, not 401. Your token was valid; our user lookup failed. Retry, and keep the session — do not sign the user out. |
email_not_verified | 403. Dashboard sign-in only: first sign-in with an email your sign-in provider has not verified. Open the verification link, then sign in again. API keys never see this. |
account_suspended | 403. The account has been suspended by our team. Every API key and dashboard call is refused until it is restored — retrying won't help. Your account page and support tickets keep working; contact support. |
Channel lifecycle
| Code | When you'll see it |
|---|---|
channel_not_found | Channel UUID doesn't exist (or belongs to another customer). |
channel_access_denied | Channel-scoped API key being used against a different channel. |
insufficient_scope | 403. The key's scopes don't cover this route. details carries required and granted. An empty scopes array is unrestricted and never triggers this. |
channel_unavailable | Channel is in a terminal state (logged_out, stopped_by_user, disabled_by_admin, error, failed), or has never been paired (pending / qr with details.paired: false — scan the QR code first). |
idempotency_key_in_use | 409. A send with this Idempotency-Key is still running. Retry after Retry-After to get its result. |
idempotency_key_reused | 422. The Idempotency-Key was already used for a different request. Nothing was sent — use a new key per message. |
channel_scoped_key | Key is scoped to a single channel but a multi-channel route was called. |
invalid_lifecycle_transition | Tried to connect an already-connected channel, etc. |
pair_code_unavailable | Channel state doesn't allow generating a pair code right now. |
pair_code_failed | Upstream rejected the pair code request. |
no_qr | QR not yet ready (channel hasn't booted), or no QR for this state. |
not_disabled | Tried to re-enable a channel that wasn't admin-disabled. |
concurrent_modification | Two writes collided on the same row; retry your read-modify-write. |
Plan / quota
| Code | When you'll see it |
|---|---|
plan_channel_limit | Plan's channel cap reached. Upgrade or delete an existing channel. |
rate_limited | A token bucket is exhausted. X-RateLimit-Scope says which (customer, channel or ip; ip also guards public media downloads). Buckets are per-account and per-channel, not per key, and shared across all our servers. Honour Retry-After. |
worker_at_capacity | No worker has free slots right now — extremely rare; retry. |
trial_expired | 402. Trial ended with no paid plan. Blocks every action that reaches WhatsApp — sends, story posts, presence, groups, communities, newsletters, labels, block/unblock, call reject, profile edits, connect and pair-code — plus channel creation; reads, disconnect and logout keep working. |
payment_overdue | 402. Account is past due. Same blocking as above. |
sse_connection_limit | 429. More than 10 concurrent /v1/events streams on the account. |
Channel proxy
| Code | When you'll see it |
|---|---|
proxy_invalid | 400. Not a usable proxy URL — message says which part (scheme, host, port, credentials). |
proxy_blocked | 400. The proxy host is a private, loopback or internal address. |
proxy_unreachable | 422. The test through the proxy failed (details.check.error says why), or the saved proxy can't be read. Nothing was saved. |
proxy_not_configured | 404. POST /v1/channels/:id/proxy/test with no url, on a channel without a saved proxy. |
proxy_unavailable | 503. Proxies can't be saved right now. |
proxy_test_limit | 429. More than 10 proxy tests in a minute on your account. Honour Retry-After — nothing else is throttled. |
Webhook + media
| Code | When you'll see it |
|---|---|
webhook_endpoint_not_found | Endpoint UUID doesn't exist. |
media_gone | 410. Token is valid but the bytes aged past retention. Permanent — nothing to retry. |
media_token_expired | 401. The signed media token has expired. |
media_token_tampered | 401. Media token signature does not verify. |
media_token_malformed | 401. Media token is not parseable. |
media_too_large | 413. Media exceeds the 25 MB ceiling (pre-upload), or 64 MB for a send's media_url. |
media_fetch_failed | 400 on pre-upload: we could not fetch your source_url (DNS, non-2xx, or timeout). 422 on a send, story or picture: your media_url answered 4xx, redirected more than 3 times, or was empty. A timeout or 5xx on a send is queued and retried instead. |
media_fetch_blocked | 400. source_url / media_url resolves to a private / loopback / link-local / internal address — checked on every redirect hop. |
message_not_found | 404. No message with that id on that channel. |
chat_not_found | 404. No such chat on that channel. |
contact_not_found | 404. No such contact on that channel. |
Generic
| Code | When you'll see it |
|---|---|
invalid_request | Body or query failed Zod validation. details contains the issue list. |
not_found | Generic 404. |
bad_request | Generic 400 where no narrower code fits. |
no_op | The mutation was a no-op (already in the target state). |
other | Catch-all for upstream-provider errors. |
internal_error | Unhandled server-side exception. We're alerted; request_id is the trace key. |
undeliverable_recipient | 400. to is a @lid, has more than 15 digits, is non-numeric, has an unknown suffix, or WhatsApp says the number is not registered. details.to carries the value. |
not_implemented | 501. The endpoint exists but is a declared stub (GET /v1/channels/:id/stories, GET /v1/channels/:id/calls). |
payload_too_large | 413. Request body exceeded the server limit. |
This list is not closed. Match on code with a default branch — new
codes ship without a major version bump, and a handful of routes emit codes
outside the core set documented here.
Best practices
- Always log
request_id. Customer support uses it to find the exact request in our traces. - Match on
code, never onmessage. The messages are tuned for human readability and shift over time. - Treat unknown codes as transient. New codes get added; assume retryable until the docs are updated.
- For 429, honor
Retry-Afterinstead of guessing backoff.