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 FastAPI's standard shape instead, with a detail array naming the offending field:
{
"detail": [
{
"loc": ["body", "to"],
"msg": "Field required",
"type": "missing"
}
]
}
So a robust handler reads code when present, falls back to detail, and treats anything else 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 |
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 |
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 | Cause | Retryable |
|---|---|---|
422 |
Malformed body: missing to, unknown type, a field too long, wrong JSON types |
No |
422 |
An interactive message WhatsApp would reject: more than 3 buttons, more than 10 list rows, repeated option ids | No |
422 |
An invalid cursor or limit on GET /templates |
No |
400 |
On GET /templates, a malformed query parameter |
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. 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 | 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 | Cause | Retryable |
|---|---|---|
400 |
The parameters supplied do not match the template's approved shape | No |
502 |
Meta refused the template: not approved, or name and language do not match | No |
GET /api/v2/templates is the cure for both. 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 | Cause | Retryable |
|---|---|---|
429 |
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 | Cause | Retryable |
|---|---|---|
502 |
WhatsApp rejected the message; the reason is in message |
Depends on the reason |
5xx |
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 |
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.