Errors and limits
One envelope for every failure, one stable code per reason, and a request id on every response. This page is the whole failure surface: the codes, the statuses, cursor pagination and rate limiting.
The error envelope
Every error, from every endpoint, has the same shape. There is no second format and no bare string response.
HTTP/1.1 422 Unprocessable Entity
content-type: application/json; charset=utf-8
x-request-id: req_...
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request payload failed validation.",
"request_id": "req_...",
"details": {
"issues": [
{ "path": "to", "message": "must be an E.164 number, e.g. +9665XXXXXXXX" }
]
}
}
}| Field | Description |
|---|---|
| error.code | A stable machine-readable identifier. This is what your code branches on. |
| error.message | English text for a human. Useful in a log; never match on it. |
| error.request_id | Also the X-Request-Id response header. It identifies this exact request in the platform's logs. |
| error.details | Present for some codes only. Its shape depends on the code — a validation error carries issues; an entitlement error carries the limit and the current count. |
Branch on code, never on message
The code is part of the API contract and will not change meaning without a version bump. The message is human text and can be reworded at any time. Matching on it is the fastest way to build an integration that breaks on a copy edit.
Validation errors
A malformed request is a 422 with a machine-readable list of what was wrong.
- Validation failures are
422, not 400. The only 400 in the whole API is a missing workspace context. - docs.pages.errors.validation.rules.issues
- The value you sent is never echoed back, so an error is safe to log even when the request carried something sensitive.
- Most write bodies are strict: an unrecognised field is an error rather than an ignored key. A misspelled field name fails loudly.
Error codes
The complete public catalogue: 72 codes, grouped as the platform groups them. The message column is the API's own English string, reproduced exactly — it is response data rather than documentation prose, which is why it is not translated.
Codes may be added to this list. An existing code never changes meaning or disappears without an API version bump.
Transport and authentication
| Code | HTTP | Default message returned by the API |
|---|---|---|
| UNAUTHORIZED | 401 | Authentication is required or the provided credentials are invalid. |
| FORBIDDEN | 403 | You do not have permission to perform this action. |
| NOT_FOUND | 404 | The requested resource was not found. |
| METHOD_NOT_ALLOWED | 405 | That HTTP method is not allowed on this resource. |
| VALIDATION_ERROR | 422 | The request payload failed validation. |
| CONFLICT | 409 | The request conflicts with the current state of the resource. |
| PAYLOAD_TOO_LARGE | 413 | The request payload is too large. |
| UNSUPPORTED_MEDIA_TYPE | 415 | The request content type is not supported. |
| RATE_LIMITED | 429 | Too many requests. Please retry later. |
| IDEMPOTENCY_CONFLICT | 409 | This idempotency key was already used with a different request payload. |
| INTERNAL_ERROR | 500 | An unexpected error occurred while processing the request. |
| SERVICE_UNAVAILABLE | 503 | The service is temporarily unavailable. Please retry later. |
Accounts and sign-in
| Code | HTTP | Default message returned by the API |
|---|---|---|
| INVALID_CREDENTIALS | 401 | The email address or password is incorrect. |
| EMAIL_ALREADY_IN_USE | 409 | An account already exists for that email address. |
| EMAIL_NOT_VERIFIED | 403 | Verify your email address before continuing. |
| ACCOUNT_SUSPENDED | 403 | This account is suspended. |
| TOKEN_INVALID | 401 | That link or token is invalid or has already been used. |
| TOKEN_EXPIRED | 401 | That link or token has expired. Request a new one. |
Workspaces, members and teams
| Code | HTTP | Default message returned by the API |
|---|---|---|
| WORKSPACE_NOT_FOUND | 404 | The requested workspace was not found. |
| WORKSPACE_SUSPENDED | 403 | This workspace is suspended. |
| WORKSPACE_CONTEXT_REQUIRED | 400 | A workspace must be specified for this request. |
| MEMBER_NOT_FOUND | 404 | The requested workspace member was not found. |
| MEMBER_ALREADY_EXISTS | 409 | That person is already a member of this workspace. |
| OWNER_REQUIRED | 403 | Only the workspace owner can perform this action. |
| INVITATION_NOT_FOUND | 404 | The requested invitation was not found. |
| TEAM_NOT_FOUND | 404 | The requested team was not found. |
API keys and plans
| Code | HTTP | Default message returned by the API |
|---|---|---|
| API_KEY_NOT_FOUND | 404 | The requested API key was not found. |
| API_KEY_REVOKED | 401 | This API key has been revoked. |
| PLAN_NOT_FOUND | 404 | The requested plan was not found. |
| ENTITLEMENT_LIMIT_REACHED | 409 | The current plan limit for this resource has been reached. |
Channels and providers
| Code | HTTP | Default message returned by the API |
|---|---|---|
| CHANNEL_NOT_FOUND | 404 | The requested channel was not found. |
| CHANNEL_NOT_CONNECTED | 409 | The selected channel is not connected. |
| CHANNEL_CAPABILITY_UNAVAILABLE | 409 | The selected channel does not support this capability. |
| PROVIDER_RATE_LIMITED | 429 | The upstream messaging provider is rate limiting this channel. |
| PROVIDER_REJECTED | 502 | The upstream messaging provider rejected this request. |
Sandbox
| Code | HTTP | Default message returned by the API |
|---|---|---|
| SANDBOX_RECIPIENT_NOT_VERIFIED | 403 | The recipient is not verified for this sandbox allocation. |
| SANDBOX_TEST_KEY_REQUIRED | 403 | A test-mode API key is required for sandbox operations. |
| SANDBOX_SESSION_NOT_FOUND | 404 | The requested sandbox session was not found. |
| SANDBOX_SESSION_EXPIRED | 409 | This sandbox session has expired. Request a new one. |
| SANDBOX_NO_NUMBER_AVAILABLE | 503 | No shared test number is available right now. Please retry shortly. |
| SANDBOX_VERIFICATION_PENDING | 409 | Send the verification code from the recipient number to the assigned test number to complete verification. |
| SANDBOX_VERIFICATION_INVALID | 422 | That verification code is incorrect. |
| SANDBOX_VERIFICATION_EXPIRED | 409 | That verification code has expired. Request a new one. |
Contacts, groups and imports
| Code | HTTP | Default message returned by the API |
|---|---|---|
| CONTACT_NOT_FOUND | 404 | The requested contact was not found. |
| CONTACT_ALREADY_EXISTS | 409 | A contact with that phone number already exists in this workspace. |
| CONTACT_GROUP_NOT_FOUND | 404 | The requested contact group was not found. |
| TAG_NOT_FOUND | 404 | The requested tag was not found. |
| IMPORT_NOT_FOUND | 404 | The requested import was not found. |
| IMPORT_FILE_TOO_LARGE | 413 | The uploaded file is larger than the import limit. |
| IMPORT_FORMAT_UNSUPPORTED | 415 | That file format is not supported for contact import. |
Conversations and assignment
| Code | HTTP | Default message returned by the API |
|---|---|---|
| CONVERSATION_NOT_FOUND | 404 | The requested conversation was not found. |
| CONVERSATION_ACCESS_DENIED | 403 | You do not have access to this conversation. |
| ASSIGNEE_NOT_FOUND | 404 | The requested assignee was not found in this workspace. |
| CONVERSATION_CONFLICT | 409 | This conversation was changed by someone else. Reload and try again. |
| NOTE_NOT_FOUND | 404 | The requested note was not found. |
Templates
| Code | HTTP | Default message returned by the API |
|---|---|---|
| TEMPLATE_NOT_FOUND | 404 | The requested template was not found. |
| TEMPLATE_NOT_APPROVED | 409 | The requested template is not approved for sending. |
| TEMPLATE_ALREADY_EXISTS | 409 | A template with that name and language already exists. |
| TEMPLATE_VARIABLE_MISSING | 422 | One or more template variables were not supplied. |
| TEMPLATE_NOT_SENDABLE | 409 | This template is not available for sending. |
| META_SERVICE_WINDOW_REQUIRED_TEMPLATE | 409 | The customer service window is closed. Send an approved template instead. |
QR
| Code | HTTP | Default message returned by the API |
|---|---|---|
| QR_SAFETY_LIMIT_REACHED | 429 | The channel safety limit for this period has been reached. |
Webhooks
| Code | HTTP | Default message returned by the API |
|---|---|---|
| WEBHOOK_ENDPOINT_NOT_FOUND | 404 | The requested webhook endpoint was not found. |
| WEBHOOK_DELIVERY_NOT_FOUND | 404 | The requested webhook delivery was not found. |
| WEBHOOK_ENDPOINT_DISABLED | 409 | This webhook endpoint was disabled after repeated delivery failures. Update it and set it back to active. |
| WEBHOOK_URL_NOT_ALLOWED | 422 | That webhook URL is not accepted by this environment. |
Campaigns and automations
Reserved codes
These codes are defined and stable, but no endpoint raises them: campaigns and automations are not built. They are listed so a client that already handles them is not surprised later, not because you will see them today.
| Code | HTTP | Default message returned by the API |
|---|---|---|
| CAMPAIGN_NOT_FOUND | 404 | The requested campaign was not found. |
| CAMPAIGN_PAUSED | 409 | The campaign is paused. |
| CAMPAIGN_INVALID_STATE | 409 | That action is not available while the campaign is in its current state. |
| CAMPAIGN_AUDIENCE_EMPTY | 422 | The selected audience contains no contacts that may receive a marketing message. |
| CAMPAIGN_CHANNEL_NOT_ELIGIBLE | 409 | The selected channel cannot be used to run a campaign. |
| AUTOMATION_LIMIT_REACHED | 409 | The automation execution limit has been reached. |
HTTP statuses
The status tells you the class of problem; the code tells you which one.
| HTTP | Description |
|---|---|
| 400 | A workspace was not specified. This is the only 400 in the API. |
| 401 | No credential, or one that is not valid. |
| 403 | A valid credential that is not permitted here, or a suspended account or workspace. |
| 404 | The resource does not exist, or belongs to another workspace — the two are deliberately indistinguishable. |
| 409 | The request conflicts with the current state: a channel that is not connected, a plan limit, an idempotency conflict. |
| 413 | The payload is larger than the limit. |
| 415 | The content type or file format is not supported. |
| 422 | The payload failed validation. Check details.issues. |
| 429 | Rate limited. Read Retry-After. |
| 500 | An unexpected failure on the platform. Safe to retry. |
| 502 | The upstream messaging provider rejected the request. |
| 503 | A dependency is temporarily unavailable. Safe to retry. |
Validation is 422, not 400
If your client treats 400 as the validation status, it will miss every validation error this API produces. The one 400 is WORKSPACE_CONTEXT_REQUIRED.
Pagination
List endpoints are cursor-paginated. Read page.next_cursor and pass it back as after; stop when has_more is false.
curl "https://whats.azzamkh.sa/api/v1/messages?limit=50" \
-H "Authorization: Bearer $WA_API_KEY"
# 200 OK
# {
# "data": [ { "id": "msg_...", ... } ],
# "page": { "next_cursor": "bXNnXzAxSj...", "has_more": true }
# }
# Follow the cursor. Send it back verbatim; it is opaque.
curl "https://whats.azzamkh.sa/api/v1/messages?limit=50&after=bXNnXzAxSj..." \
-H "Authorization: Bearer $WA_API_KEY"| Parameter | Default | Description |
|---|---|---|
| limit | 50 | Clamped to 1–100. |
| after | — | The previous page's next_cursor. |
- An out-of-range or unparseable
limitis clamped, never rejected. Asking for 5000 rows returns 100. - The cursor is opaque. Send it back verbatim and do not decode it — a malformed one is ignored and you silently get the first page again.
- Most lists are newest-first, but templates are oldest-first. Do not assume a direction; follow the cursor.
next_cursoris null on the last page. Loop onhas_more, not on an empty data array.
Rate limiting
Rate-limited routes answer 429 with the standard headers and a retry hint in the body.
HTTP/1.1 429 Too Many Requests
ratelimit-limit: 600
ratelimit-remaining: 0
ratelimit-reset: 37
retry-after: 37
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please retry later.",
"request_id": "req_...",
"details": { "bucket": "messages.send", "retry_after_s": 37 }
}
}| Header | Description |
|---|---|
| RateLimit-Limit | The ceiling for this bucket and window. |
| RateLimit-Remaining | How many requests are left in the current window. Zero on a denial. |
| RateLimit-Reset | Seconds until the window resets. |
| Retry-After | Seconds to wait. Present only on a denial. Honour it. |
- Every value is in
seconds, not a timestamp. - The headers are absent when rate limiting is switched off for the environment. Treat their absence as no information, not as an unlimited quota.
- The limiter fails open: if its backing store is unreachable, requests are allowed. Never rely on it as a correctness mechanism.
PROVIDER_RATE_LIMITEDis a different thing: the upstream WhatsApp provider is throttling the channel, and no header will tell you when it stops.
What to retry
Retry with exponential backoff and jitter, and always send an Idempotency-Key on a write so a retry cannot duplicate anything.
| Code | What to do |
|---|---|
| RATE_LIMITED | Wait for Retry-After, then retry. Do not retry sooner. |
| PROVIDER_RATE_LIMITED | Back off harder — the upstream provider is throttling, and there is no header telling you when it stops. |
| SERVICE_UNAVAILABLE | Retry with backoff. A dependency is temporarily down. |
| INTERNAL_ERROR | Retry with backoff. If it persists, quote the request_id. |
| PROVIDER_REJECTED | Do not retry blindly. The provider refused the message; inspect it first. |
| VALIDATION_ERROR | Never retry. Fix the request. |
| IDEMPOTENCY_CONFLICT | Never retry as-is. Either the body or the key is wrong. |