Errors
Status codes, response envelopes, and recovery strategies for the Lynkist Public API.
The Lynkist Public API uses standard HTTP status codes and a small, predictable JSON body. This page describes the envelopes you actually receive today, the HTTP codes you should plan for, and the headers that help you correlate, retry, and back off.
Error envelope
Every error response uses the same JSON envelope:
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "Contact not found"
},
"meta": {
"request_id": "req_04673bffe2ed",
"timestamp": "2026-06-28T05:22:51.953Z"
}
}error.code— a stable, machine-readable code from the list below. Branch on this, not on the message. A code and its HTTP status do not change inside a major version (Versioning).error.message— a sentence for a person. It can change.meta.request_id— the same value as theX-Request-Idheader. Log it; quote it in tickets.
Some codes add extra fields beside code and message. A missing scope lists what was
required and what the key has:
{
"success": false,
"error": {
"code": "INSUFFICIENT_SCOPE",
"message": "Missing required scope(s): campaigns:send",
"required_scopes": ["campaigns:send"],
"granted_scopes": ["campaigns:read", "campaigns:write"]
},
"meta": { "request_id": "req_…", "timestamp": "…" }
}Other examples: UPGRADE_REQUIRED adds upgrade_required: true and feature_key;
RATE_LIMIT_EXCEEDED adds window and retry_after; a send error adds retryable;
VALIDATION_ERROR adds a details list (see below).
Success responses are not in this envelope — they return the resource (or a
{ "data": [...], "pagination": {...} } list) directly. The success/error/meta envelope is
for errors only.
Standard response headers
Every response — success or error — carries:
| Header | Always present | Purpose |
|---|---|---|
X-Request-Id | Yes | req_<12-hex> (or whatever you sent). Log it. Quote it in support tickets. |
X-RateLimit-Limit-Minute | Yes | Your per-minute budget |
X-RateLimit-Remaining-Minute | Yes | Requests left in the current minute |
X-RateLimit-Limit-Day | Yes | Your per-day budget |
X-RateLimit-Remaining-Day | Yes | Requests left in the current day |
X-RateLimit-Reset | Yes | Unix epoch seconds when the current window resets |
Lynkist-Version | Yes | The exact API version that answered, e.g. 1.0 |
Retry-After | On 429, and on 503 when known | Seconds to wait before the next request |
See Rate Limits for the full backoff guidance.
HTTP status codes
| Status | Meaning | Retry? |
|---|---|---|
200, 201 | Success | — |
400 | The request is not valid: a missing or wrong field, a bad cursor, a rejected send | No. Fix the request. |
401 | The API key is missing, wrong, expired or revoked | No |
402 | Not enough wallet credit to send | After a top-up |
403 | The key lacks a scope, the IP is not allowed, or the plan does not include the feature | No |
404 | The resource does not exist (or belongs to another workspace) | No |
409 | The request conflicts with the current state (a duplicate contact, a paused template) | After the state changes |
429 | Rate limit. Wait for Retry-After | Yes, after the wait |
500 | An unexpected error on our side | Yes, with backoff |
502, 503, 504 | A failure on our side or at WhatsApp | Yes, with backoff. But see SEND_OUTCOME_UNKNOWN. |
A failure on our side is always 5xx, never 4xx. So the rule for a client is simple:
retry 5xx and 429 with exponential backoff, and never retry another 4xx unchanged.
Error codes
| Code | Status | Default message |
|---|---|---|
EMPTY_AUDIENCE | 400 | The audience is empty. |
INVALID_CAMPAIGN_STATE | 400 | The campaign's status does not allow this. |
INVALID_CURSOR | 400 | The pagination cursor is not valid. Start from the first page. |
INVALID_REQUEST | 400 | The request cannot be processed as it is. |
INVALID_SCHEDULE_TIME | 400 | The schedule time is not valid. |
INVALID_TEMPLATE_PARAMETERS | 400 | The template or its parameters are not valid. |
INVALID_TEMPLATE_STATE | 400 | The template's status does not allow this. |
INVALID_VARIABLE_MAPPING | 400 | The variable mapping is not valid. |
INVALID_WEBHOOK_URL | 400 | The webhook URL is not valid or not allowed. |
MEDIA_REJECTED | 400 | The attached media could not be sent. |
MESSAGE_SEND_FAILED | 400 | WhatsApp rejected the message. |
NO_CAMPAIGN_RECIPIENTS | 400 | The campaign has no recipients. |
RECIPIENT_UNDELIVERABLE | 400 | This number cannot receive the message. |
TEMPLATE_LANGUAGE_ALREADY_EXISTS | 400 | A template with this name and language already exists. |
TEMPLATE_LOCKED | 400 | The template is in review and cannot be changed now. |
TEMPLATE_NOT_APPROVED | 400 | The template is not approved. |
TEMPLATE_REJECTED | 400 | WhatsApp rejected the template or its variables. |
VALIDATION_ERROR | 400 | The request contains invalid fields. |
WEBHOOK_DELIVERY_ALREADY_SUCCEEDED | 400 | This delivery succeeded and cannot be sent again. |
API_KEY_EXPIRED | 401 | The API key has expired. |
AUTHENTICATION_REQUIRED | 401 | Access token is missing, expired, or invalid. |
INVALID_API_KEY | 401 | The API key is not valid or was revoked. |
INSUFFICIENT_WALLET_BALANCE | 402 | Insufficient wallet credit. Top up your wallet to send template messages. |
FORBIDDEN | 403 | You do not have permission to perform this action. |
INSUFFICIENT_SCOPE | 403 | The API key does not have the required scope. |
IP_NOT_ALLOWED | 403 | This IP address is not in the API key's allowlist. |
SENDING_NOT_ALLOWED | 403 | This workspace cannot send WhatsApp messages. |
UPGRADE_REQUIRED | 403 | This feature is not included in your plan. |
WORKSPACE_CLOSED | 403 | This workspace is closed or being deleted. |
CAMPAIGN_NOT_FOUND | 404 | Campaign not found. |
CONTACT_NOT_FOUND | 404 | Contact not found. |
GROUP_NOT_FOUND | 404 | Contact group not found. |
MEDIA_NOT_FOUND | 404 | Media not found. |
NOT_FOUND | 404 | Resource not found |
SOURCE_NOT_FOUND | 404 | Contact source not found. |
TEMPLATE_NOT_FOUND | 404 | Template not found. |
WEBHOOK_DELIVERY_NOT_FOUND | 404 | Webhook delivery not found. |
WEBHOOK_NOT_FOUND | 404 | Webhook endpoint not found. |
WHATSAPP_NOT_CONNECTED | 404 | No active WhatsApp Business account is connected to this workspace. |
METHOD_NOT_ALLOWED | 405 | This method is not allowed on this endpoint. |
ACCOUNT_RESTRICTED | 409 | This WhatsApp Business account is restricted. |
CAMPAIGN_ALREADY_EXISTS | 409 | A campaign with this name already exists. |
CONFLICT | 409 | Conflict |
CONTACT_ALREADY_EXISTS | 409 | A contact with this phone or email already exists. |
CUSTOMER_SERVICE_WINDOW_CLOSED | 409 | The 24-hour window is closed; only a template message can be sent. |
DISPLAY_NAME_NOT_APPROVED | 409 | This WhatsApp number cannot send yet: its display name is not approved. |
NUMBER_NOT_REGISTERED | 409 | This WhatsApp number is not fully registered. |
TEMPLATE_UNAVAILABLE | 409 | This template is paused or disabled by Meta. |
WHATSAPP_CONNECTION_ERROR | 409 | The WhatsApp connection for this number must be re-authorised. |
WHATSAPP_PAYMENT_REQUIRED | 409 | Meta rejected the send because of a billing problem on the account. |
PAYLOAD_TOO_LARGE | 413 | The request body is too large. |
MEDIA_TYPE_NOT_SUPPORTED | 415 | This media type is not supported. |
UNPROCESSABLE_REQUEST | 422 | The request is well-formed but cannot be applied. |
PLAN_LIMIT_REACHED | 429 | You have reached your plan limit for this feature. |
RATE_LIMIT_EXCEEDED | 429 | Too many requests. Please slow down. |
SEND_RATE_LIMITED | 429 | WhatsApp is limiting sends from this number. |
TEMPLATE_EDIT_QUOTA_EXCEEDED | 429 | Meta's edit limit for this template is reached. |
INTERNAL_ERROR | 500 | An unexpected error occurred. Please try again later. |
UPSTREAM_ERROR | 502 | A service we depend on returned an error. Please try again. |
SERVICE_UNAVAILABLE | 503 | The service is temporarily unavailable. Please try again shortly. |
WHATSAPP_UNAVAILABLE | 503 | WhatsApp sending is temporarily unavailable. Try again shortly. |
SEND_OUTCOME_UNKNOWN | 504 | WhatsApp did not confirm the message in time; it may still be delivered. |
UPSTREAM_TIMEOUT | 504 | A service we depend on did not answer in time. Please try again. |
An error with no specific code has the code of its status: INVALID_REQUEST (400), NOT_FOUND
(404), CONFLICT (409), UPSTREAM_ERROR (502), and so on. UPGRADE_REQUIRED is always 403
(your plan does not include the feature); a plan limit is 429 PLAN_LIMIT_REACHED.
A request with no Authorization header answers 401 AUTHENTICATION_REQUIRED with the message
Not authenticated.
Send errors
POST /messages/template can answer with one of the send codes above
(RECIPIENT_UNDELIVERABLE, TEMPLATE_REJECTED, SEND_RATE_LIMITED, WHATSAPP_UNAVAILABLE, …).
Each has retryable:
{
"success": false,
"error": {
"code": "WHATSAPP_UNAVAILABLE",
"message": "WhatsApp sending is temporarily unavailable. Wait a moment, then send the message again.",
"retryable": true
},
"meta": { "request_id": "req_…", "timestamp": "…" }
}SEND_OUTCOME_UNKNOWN (504) means WhatsApp did not confirm the message in time. The message
can still arrive. retryable is false: do not send it again automatically, or the
customer can get it twice. Check the message.* webhook events first.
Validation errors (400 VALIDATION_ERROR)
The rejected fields are in error.details. Each entry names the field, a machine-readable
code and a message. The values that you sent are never returned.
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "The request contains invalid fields.",
"details": [
{ "field": "to", "code": "missing", "message": "Field required" }
]
},
"meta": { "request_id": "req_…", "timestamp": "…" }
}A nested field has a dotted path, for example contacts.0.phone.
For now each entry also has loc, msg and type, with the same information in an older
form. They will be removed in a later major version; read field, code and message.
Rate limiting (429 RATE_LIMIT_EXCEEDED)
{
"success": false,
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded. Please slow down.",
"window": "minute",
"retry_after": 17
},
"meta": { "request_id": "req_…", "timestamp": "…" }
}Wait the number of seconds in the Retry-After header, then try again. See
Rate Limits.
Handling errors well
- Don't pattern-match on the human message. Messages are written for people and may change.
Match on the HTTP status and
error.code, plus — for403— the extra fields inerror. - Log
X-Request-Idon every failure. It is the single fastest signal we can correlate against on our side. Include it in support tickets and bug reports. - Retry only what is safe. Retry
5xxand429with exponential backoff (for example 1 s, 2 s, 4 s, … with some random jitter, and a maximum number of attempts), and obeyRetry-After. Another4xxdoes not pass on a retry. A retry loop with no backoff makes an outage longer. - Pair retries with
Idempotency-Key. A retriedPOSTwithout an idempotency key risks creating duplicate resources. See API Reference. - Treat
IP_NOT_ALLOWEDas fatal. A network move is the only fix; retrying from the same IP will fail forever.