The full error catalogue, which failures are safe to retry, and how to recover when you do not know whether a send happened.
Every error is a flat object with a machine-readable code and a human-readable message:
{
"code": "outside_messaging_window",
"message": "The customer last messaged more than 24 hours ago; send a template instead"
}
Branch on code, never on message. Messages are written for humans and get reworded; codes are the contract.
Validation failures from the request body use the same shape, with code validation_error, plus a detail array naming each offending field:
{
"code": "validation_error",
"message": "to: Field required",
"detail": [
{
"loc": ["body", "to"],
"msg": "Field required"
}
]
}
So a robust handler reads code, uses detail to point at a field, and treats a body without code as an unknown failure.
| Status | Code | Cause | Retryable |
|---|---|---|---|
401 |
not_authenticated |
Missing Authorization header |
No |
401 |
not_authenticated |
Unknown token, or a token invalidated by reconnecting the number | No |
400 |
ambiguous_number |
The token is held by several connected numbers and X-Phone-Number-Id did not say which; the ids are in phone_number_ids |
No, add the header |
403 |
number_not_for_token |
X-Phone-Number-Id names a number this token does not hold |
No |
403 |
account_suspended |
The workspace is suspended | No |
A missing token and an unknown token return the same response deliberately, so the API cannot be used to discover which tokens exist.
The most common cause of a sudden 401 on a working integration is a reconnect. Reconnecting a number issues a new access token and invalidates the old one, with no overlap window. Fix it by copying the new token from Developer → Credentials.
| Status | Code | Cause | Retryable |
|---|---|---|---|
402 |
plan_feature_locked |
The team's plan does not include API access | No, until the plan changes |
402 |
plan_limit_reached |
A template to a number the workspace does not know would pass the plan's contact limit | No, until the plan changes or contacts are removed |
This is per team. On a multi-tenant platform, one tenant can be locked while every other keeps working. Treat it as a configuration state for that tenant, not as an outage.
| Status | Code | Cause | Retryable |
|---|---|---|---|
422 |
validation_error |
Malformed body: missing to, unknown type, a field too long, wrong JSON types, a template without template_name or template_language |
No |
422 |
validation_error |
An interactive message WhatsApp would reject: more than 3 buttons, more than 10 list rows, repeated option ids | No |
422 |
validation_error |
A limit outside 1 to 100 on GET /templates |
No |
400 |
invalid_cursor |
A malformed cursor on GET /templates |
No |
Interactive limits are enforced locally rather than handed to Meta, so you get a precise failure instead of a 502.
There is no distinct code for an unsupported type. type is a closed set (text, template, image, document, video, audio, interactive), and anything else fails validation with 422 validation_error. Location, contacts, reactions, stickers, and Flows are not sendable through Ghala at all; see the capability matrix.
| Status | Code | Cause | Retryable |
|---|---|---|---|
409 |
outside_messaging_window |
A free-form send more than 24 hours after the customer's last inbound message, or to a number that has never written in | No, not as-is |
Ghala checks this before calling Meta, so you get a clean code rather than Meta's error 131047 after the fact.
Retrying the same body will keep failing. The recovery is to send a template instead:
async function sendOrFallBackToTemplate(token, to, text) {
const resp = await post(token, { to, type: "text", text });
if (resp.ok) return resp.json();
const error = await resp.json();
if (resp.status !== 409 || error.code !== "outside_messaging_window") {
throw error;
}
// The window is shut. Offer something we know will be accepted.
const { items } = await get(token, "/templates?sendable=true&limit=1");
if (!items.length) throw error;
return post(token, {
to,
type: "template",
template_name: items[0].name,
template_language: items[0].language,
}).then((r) => r.json());
}
| Status | Code | Cause | Retryable |
|---|---|---|---|
400 |
invalid_template |
The template is not in that language; it is rejected, paused, disabled, archived, in appeal, deleted, or over the account's limit; or the parameters do not match its approved shape (missing variables, a missing media or location header, wrong parameter_name values) |
No |
422 |
template_not_found |
The template is not on the number's WhatsApp account, or WhatsApp could not be asked about it | No |
422 |
invalid_one_time_code |
An authentication template without a valid one-time code | No |
502 |
send_failed |
Meta refused the template: not approved yet, a structure or format Ghala does not check, or a policy refusal | No |
Ghala checks a template send against its copy of the template before Meta sees it, and the message names the problem. Templates and Media lists every check and the Meta refusals that can still come back, and Send One-Time Codes the one-time code rules.
GET /api/v2/templates is the cure for most of these. sendable tells you whether a send would be accepted; unsendable_reason says why not in words you can show a merchant; components gives you the approved shape so you can supply the right number of variables.
Watch quality_score. It turns RED before Meta pauses a template, which is the earliest warning you get that a working integration is about to stop working.
Media failures surface as 502 from Meta, because Meta fetches the URL itself and Ghala never sees the file. The usual causes:
localhost, a VPC-internal host, or anything behind auth will fail; Meta is the client, not you.Retrying does not help unless you fix the URL. See Templates and Media for the format and size constraints.
| Status | Code | Cause | Retryable |
|---|---|---|---|
429 |
rate_limited |
WhatsApp is rate-limiting this number | Yes, after a pause |
This is Meta's limit on the number, not a Ghala quota. Back off exponentially and retry with the same idempotency key. See Limits and Quotas.
| Status | Code | Cause | Retryable |
|---|---|---|---|
502 |
send_failed |
WhatsApp rejected the message, or the number is not connected; the reason is in message |
Depends on the reason |
500 |
internal_error |
An unexpected failure on Ghala's side | Yes, with an idempotency key |
A 502 is Ghala reporting what Meta said. Two different things wear that status:
Today the response does not distinguish them programmatically; the reason arrives as prose in message. Until it does, treat 502 as retryable only with an idempotency key and a small, bounded number of attempts, and log the message so a human can tell the two apart.
These do not surface as API errors. They are visible in the dashboard under Developer → Events, per subscription, with the HTTP status your endpoint returned, the error, and the attempt count.
An endpoint is auto-disabled after 20 consecutive failed deliveries. Reactivate it by registering the endpoint again under Developer → Events; note that registering produces a new signing secret, shown once.
Delivery attempt history is pruned after 7 days, so it is a recent window rather than a full audit trail. The event log itself is retained 30 days.
| Status | Code | Cause | Retryable |
|---|---|---|---|
409 |
idempotency_in_progress |
A request with this key is still in flight | Yes, after a short pause |
422 |
idempotency_key_reused |
This key was used with a different body | No |
idempotency_in_progress is the one 4xx worth retrying: it means your first attempt is still running. Wait, then retry with the same key. You will get either the replayed original response or a fresh result.
idempotency_key_reused means you reused a key for different content. Replaying the first response would answer a question you did not ask, so the API refuses. Derive keys from the thing you are messaging about (order-1042-confirmation), not from a counter or a timestamp.
| Status | Retry? | How |
|---|---|---|
400 |
No | Fix the request |
401 |
No | Refresh the stored token |
402 |
No | Upgrade the plan |
402 plan_limit_reached |
No | Raise the plan's contact limit |
409 outside_messaging_window |
No | Send a template instead |
409 idempotency_in_progress |
Yes | Short pause, same key |
422 |
No | Fix the request |
429 |
Yes | Exponential backoff, same key |
502 |
Cautiously | Bounded attempts, same key; read message |
5xx |
Yes | Exponential backoff, same key |
Always send an Idempotency-Key on anything you might retry. Without one, a retry after a timeout is a second message to a real person.
The hard case is not a clean error. It is a request that timed out, or a process that crashed between sending and recording the result. You do not know whether the customer got the message.
There is no GET /api/v2/messages/{id} to look the message up with. The reconciliation procedure is idempotency replay:
Re-issue the identical request with the identical Idempotency-Key.
Idempotency-Replayed: true in the headers. Nothing reaches WhatsApp. The id, wa_message_id, and status are the original ones, so you can now record what you failed to record the first time.409 idempotency_in_progress. Wait and repeat.def send_and_reconcile(token, body, idempotency_key, attempts=5):
"""Safe to call after a timeout: replays rather than double-sending."""
for attempt in range(attempts):
resp = requests.post(
"https://v2.ghala.io/api/v2/messages",
headers={
"Authorization": f"Bearer {token}",
"Idempotency-Key": idempotency_key,
},
json=body,
timeout=30,
)
if resp.ok:
replayed = resp.headers.get("Idempotency-Replayed") == "true"
return resp.json(), replayed
error = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
# Still running: wait for the first attempt to settle, do not resend.
if resp.status_code == 409 and error.get("code") == "idempotency_in_progress":
time.sleep(2**attempt * 0.5)
continue
if resp.status_code == 429 or resp.status_code >= 500:
time.sleep(2**attempt * 0.5)
continue
raise GhalaError(error.get("message", resp.reason), resp.status_code, error.get("code"))
raise TimeoutError(f"Could not settle {idempotency_key}")
This is why keys must be derived, not random. A random key regenerated after a crash reconciles nothing; order-1042-confirmation reconciles perfectly.
For a human answer to "what actually happened", the dashboard's Developer → Events log holds the number's events for 30 days, including the exact payload each subscriber was sent.
Stated plainly so you can plan around them rather than discover them:
502 with prose in message, not a structured meta_code. You cannot yet branch on Meta's error number.retryable flag. The table above is the contract; the response does not carry one.request_id. There is no correlation id on responses to quote in a support conversation. Log your own idempotency key instead, which is the closest equivalent and is meaningful on both sides.