API reference
Errors and retries
Every status and error code, what causes it, and which ones are worth retrying.
Errors are always JSON and always carry an error field. Some also carry a code, which is the field to branch on, the human-readable text is written for a person reading a log and can change.
The two shapes of error
error is a string on every failure except schema validation, where it is a Zod object. A client that assumes one shape prints [object Object] into its logs at exactly the moment it most needs the detail.
1. A string
Everything except schema validation:
{ "error": "Phone number not found or not configured" }2. A Zod object
Schema validation failures, on any endpoint with a request schema. formErrors holds problems with the body as a whole; fieldErrors maps a field name to an array of messages:
{
"error": {
"formErrors": [],
"fieldErrors": {
"to": ["String must contain at least 5 character(s)"],
"event": ["Required"]
}
}
}Flatten both shapes into one line before logging:
const message =
typeof body.error === "string"
? body.error
: [
...(body.error?.formErrors ?? []),
...Object.entries(body.error?.fieldErrors ?? {}).map(
([field, errs]) => `${field}: ${errs.join(", ")}`
),
].join("; ");
console.error(`[${res.status} ${body.code ?? ""}] ${message}`);Status and code reference
| HTTP | code | Meaning | What to do |
|---|---|---|---|
400 | none | Schema validation failed. error is a Zod object. | Fix the request body. Read fieldErrors for the field names. |
400 | none | A type-specific field is missing on /v1/messages/send, e.g. `text.body` is required for type=text. | Add the object the type requires. |
400 | none | Unknown events: … on template submit. | Use catalog event keys only. |
400 | none | Template submission failed: …, Meta refused the template. Note this is 400, not 502. | Read Meta's message. Usually a name clash or a missing example block. |
400 | UNKNOWN_EVENT | Event key not in the catalog. The body carries supportedEvents. | Use one of the 18 event keys. |
401 | none | Missing, malformed, revoked or inactive key. | Check the Authorization header, then the key's state in the dashboard. |
402 | none; reason is INSUFFICIENT_CREDITS or INSUFFICIENT_BALANCE | Not enough credits or wallet balance. Nothing was sent and nothing was spent. | Top up. creditsRequired says what this particular message needed. |
403 | none | Origin "…" is not allowed for this API key. | Add the domain, or call from your server with no Origin header. |
403 | none | The 24-hour window is closed. Only on /v1/messages/send, for every type except template. | Send an approved template, or use /v1/notifications/send. |
403 | ORG_SUSPENDED | The organisation owning the key has been suspended. Every key on it stops at once. | An administrator has to lift the suspension. Retrying will not. |
404 | none | Phone number not found or not configured, Bot not found, Template not found, Message not found. | Check the id belongs to this account. |
404 | NO_PHONE | No configured WhatsApp number on this account. | Link a number in the dashboard, then register it on the Cloud API. |
409 | TEMPLATE_NOT_APPROVED | No approved template for this event on this number. details carries event, templateName and language. | POST /v1/notifications/templates/submit, then poll GET /v1/notifications/events?refresh=true. |
413 | none | The request body exceeded 1 MB and was rejected before any route ran. | Split the batch. 500 recipients with long URLs can reach the cap. |
429 | none | Key rate limit. The body has only error. | Back off. RateLimit-Reset says for how long. |
429 | none | Account sending cap. The body also carries limit, usage and limits. | Do not retry, see below. |
500 | none | Authentication error, the key lookup itself failed. | Retry with back-off. If it persists, contact support. |
502 | SEND_FAILED | Meta rejected the send. The charge is refunded automatically. | Read the message. 133010 means the number is not registered on the Cloud API; 132000 means the parameter count does not match the template. |
Telling the two 429s apart
These are unrelated controls with opposite handling, and they share a status code. The distinguishing feature is whether limit, usage and limits are present in the body.
Key rate limit, retry
The per-key budget, 120 requests a minute by default. It clears inside the 60-second window, so a short back-off fixes it. The body carries error and nothing else:
{ "error": "Rate limit exceeded for this API key. Slow down or raise the key's limit." }Account sending cap, do not retry
A per-minute, daily or monthly cap an administrator set, or an account with sending paused. It resets on its own cycle, not on a timer you control:
{
"error": "daily sending limit reached (5000/5000). Ask your administrator to raise it.",
"limit": "daily",
"usage": { "minute": 12, "day": 5000, "month": 41233 },
"limits": { "perMinute": null, "daily": 5000, "monthly": null, "paused": false }
}Fields unique to the sending cap
| Field | Required | Type | Description |
|---|---|---|---|
| limit | Yes | string | Which cap was hit: paused, perMinute, daily or monthly. |
| usage | Yes | object | { minute, day, month }, outbound messages sent in the last minute, today (UTC) and this month (UTC). |
| limits | Yes | object | { perMinute, daily, monthly, paused }, the configured caps. null means uncapped. |
Branch on the presence of those fields, not on the message text:
if (res.status === 429) {
const capped = body.limit !== undefined;
if (capped) {
"/docs/api/events"
alertOperations(body.error);
return "stop";
}
"/docs/api/notifications"
await sleep(Number(res.headers.get("RateLimit-Reset") ?? 5) * 1000);
return "retry";
}Only perMinute clears by waiting. A daily or monthly cap, and a paused account, need an administrator.
Retry policy
| HTTP | Retry? | How |
|---|---|---|
400 | No | The request is wrong. Retrying sends the same wrong request. |
401 | No | The key is wrong or revoked. |
403 | No | Configuration: a blocked origin, a closed window, or a suspended organisation. Send a template instead, or fix the setting. |
404 | No | The id does not exist on this account. |
409 | No | The template is not approved yet. Poll GET /v1/notifications/events?refresh=true instead of retrying the send. |
413 | No | Split the body and send the halves. |
402 | Yes, slowly | A top-up may land. Back off for minutes, not seconds, and alert someone. This needs human action. |
429 without limit | Yes | Key rate limit. Honour RateLimit-Reset, then retry. It clears inside 60 seconds. |
429 with limit | No | Account sending cap. Stop, alert someone, resume on the next cycle. |
500 | Yes | Exponential back-off. |
502 | Yes | Exponential back-off. The charge was already refunded, so a retry costs one fresh credit, cap your attempts. |
503 504 | Yes | Exponential back-off. |
transport error (timeout, DNS, reset) | Yes | The send may or may not have happened. De-duplicate on your own idempotency key. |
Suggested schedule: 1 s, 2 s, 4 s, 8 s, 16 s with jitter, maximum 5 attempts.
A failed send costs nothing
The charge for a message is a reservation taken before the handler runs. It is reversed automatically when the response status is 400 or above, any validation error, a closed window, a missing template, a Meta rejection, and when the client disconnects before the response is written.
So you never need to reconcile a failed send. The ledger keeps both movements, a DEDUCT and a matching REFUND, rather than deleting the row.
402 in both billing modes
The error sentence differs between credit-billed and wallet-billed plans. Match on reason, never on the text.
{
"error": "Insufficient message credits, a marketing message costs 8 credits. Ask your administrator to add more.",
"reason": "INSUFFICIENT_CREDITS",
"category": "MARKETING",
"creditsRequired": 8,
"price": "0.0099",
"currency": "USD",
"balance": 3
}{
"error": "Insufficient wallet balance. Top up to continue sending.",
"reason": "INSUFFICIENT_BALANCE",
"category": "MARKETING",
"creditsRequired": 8,
"price": "0.0099",
"currency": "USD",
"balance": "0.0031"
}402 body
| Field | Required | Type | Description |
|---|---|---|---|
| error | Yes | string | Human-readable reason. Wording differs by reason. |
| reason | Yes | string | INSUFFICIENT_CREDITS on credit plans, INSUFFICIENT_BALANCE on wallet plans. |
| category | Yes | string | The category this message was priced under. |
| creditsRequired | Yes | integer | What this particular message needed. Present on both variants. |
| price | Yes | string | Sell price as a decimal string. |
| currency | Yes | string | Currency of price. |
| balance | Yes | integer | string | Your balance at the time of the refusal, an integer credit count on credit plans, a decimal string on wallet plans. |
The failures worth recognising on sight
- 409 TEMPLATE_NOT_APPROVED
- The first thing a new account hits. Nothing sends until Meta approves your templates. Submit the catalog, then poll readiness, do not retry the send.
- 502 with (#133010)
- The number was never registered on the Cloud API. Every send fails, with approved templates and credits in hand. Register it with a 6-digit two-step PIN under Phone Numbers → Register.
- 502 with (#132000)
- Parameter count mismatch: the component array you sent does not match the approved template body. Only reachable on /v1/messages/send, the notifications API builds the array for you.
- 403 window closed
- You sent a non-template message to someone who has not messaged you in 24 hours. Send it as an approved template.
- 200 with missingVariables
- Not an error status, but treat it as one. The message was delivered with a literal - where a value should have been, and the credit was spent.
A client that handles all of it
async function send(payload, attempt = 0) {
const res = await fetch("https://whatsapp-api.growcord.in/api/v1/notifications/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const body = await res.json();
if (res.ok) {
"https://whatsapp-api.growcord.in/api/v1/notifications/send"
if (body.missingVariables?.length) {
console.error("Sent with blank fields:", body.missingVariables);
}
return body;
}
const retryable =
res.status >= 500 || (res.status === 429 && body.limit === undefined);
if (retryable && attempt < 4) {
const wait =
res.status === 429
? Number(res.headers.get("RateLimit-Reset") ?? 5) * 1000
: 2 ** attempt * 1000 + Math.random() * 500;
await new Promise((r) => setTimeout(r, wait));
return send(payload, attempt + 1);
}
const message =
typeof body.error === "string"
? body.error
: Object.entries(body.error?.fieldErrors ?? {})
.map(([field, errs]) => `${field}: ${errs.join(", ")}`)
.join("; ");
throw new Error(`[${res.status} ${body.code ?? ""}] ${message}`);
}Wrap that in a per-(event, order) deduplication check and it covers every failure mode in the table above.
