Idempotency
Network retries are a fact of life. This page states exactly what the gateway de-duplicates today, so you can build a retry policy that does not double-send.
Send an Idempotency-Key header on every POST /v1/channels/{id}/messages
you might retry. A retry with the same key gets the original response back
instead of sending a second message. Without the header, two identical calls
produce two WhatsApp messages. client_ref is a correlation label, not an
idempotency key — the gateway never de-duplicates on it.
What the gateway guarantees
One thing, and it is the one that matters for a hung request:
A single API call can never deliver twice, even when it is internally retried.
When you POST a send, the gateway mints the WhatsApp message id before it
does anything with it. The direct send, the offline-queue fallback and any
later reconciler resend all reuse that same id. WhatsApp clients de-duplicate by
(sender, message id), so an internal retry can never render twice on the
recipient's phone.
Concretely, this covers the dangerous case: the connected-path send times out after 10s, the gateway silently queues the message under the same id, and the original send lands anyway. The recipient sees one message.
What it does not cover on its own is you calling POST twice. Each call
mints its own id, so each is a distinct message to WhatsApp — unless both carry
the same Idempotency-Key.
The Idempotency-Key header
Pick a key that identifies the message in your system — an order id plus the message's purpose, a UUID you store before the first attempt — and send it as a header:
curl -X POST "$API/v1/channels/$CHANNEL/messages" \
-H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-H "Idempotency-Key: order-1234-confirmation" \
-d '{ "type": "text", "to": "5511999999999@s.whatsapp.net", "text": "Order #1234 confirmed" }'| Situation | Response |
|---|---|
| First request with the key | Processed normally. A 202 (sent or queued) is remembered for 24 hours. |
| Retry with the same key and the same request | The original 202, byte for byte (same message_id), plus the header Idempotent-Replayed: true. Nothing is sent again. |
| Retry while the first request is still running | 409 idempotency_key_in_use with Retry-After: 1. Retry shortly to receive the original result. |
| Same key, different request (other channel or body) | 422 idempotency_key_reused. Nothing is sent. Use a new key per message. |
| Malformed key | 400 invalid_request. Keys are 1-255 printable ASCII characters. |
Keys are scoped to your account. Only accepted sends (202) are remembered: if the
first attempt was rejected (400, 402, 409 channel_unavailable, 5xx…), fix the
cause and retry with the same key — it goes through as a fresh request.
What client_ref actually does
client_ref is an optional string, max 128 characters. The gateway stores
nothing and matches nothing on it — it is echoed straight back so you can tie a
send to your own records:
{
"type": "text",
"to": "5511999999999@s.whatsapp.net",
"text": "Order #1234 confirmed",
"client_ref": "order-1234-confirmation"
}Response (202):
{
"message_id": "3EB0C9A17F2B4D8E1A05F3D77C10B4E2A991",
"client_ref": "order-1234-confirmation",
"status": "sent"
}It comes back in exactly two places: this response, and the client_ref field
of the message.sent webhook. Omit it and both read null.
message_id is a WhatsApp-format id — 3EB0 followed by 36 uppercase hex
characters — not a msg_… handle.
Retrying safely
- Send an
Idempotency-Key, and store it before the first attempt. Then retrying429,5xxand network timeouts is safe: a retry of a request that already went through just returns its original 202. - Never retry a 4xx as-is.
400,402,403,409 channel_unavailableand422mean the request was rejected and nothing was sent; the same request will be rejected again. The one exception is409 idempotency_key_in_use— wait forRetry-Afterand retry. - Without an
Idempotency-Key, check before you retry. QueryGET /v1/messages?limit=50and look for yourmessage_id, or wait for themessage.sentwebhook — a 202 you failed to read still sent the message. - Prefer the queue over your own retry loop. If the channel is merely
offline, the gateway already returns
status: "queued"and keeps retrying under the same id. A"queued"response is a success, not a reason to re-POST.
// `idempotencyKey` is stored with your record BEFORE the first attempt,
// so every retry of this message reuses it.
async function sendOnce(channelId, body, idempotencyKey) {
const res = await fetch(
`${API}/v1/channels/${channelId}/messages`,
{
method: 'POST',
headers: {
authorization: `Bearer ${KEY}`,
'content-type': 'application/json',
'idempotency-key': idempotencyKey,
},
body: JSON.stringify(body),
},
);
if (res.status === 202) return res.json(); // sent OR queued — both are done
const err = await res.json().catch(() => ({}));
if (err?.error?.code === 'idempotency_key_in_use') {
// The first attempt is still running — retry shortly, same key.
throw Object.assign(new Error('in progress'), { retryable: true });
}
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
// Terminal. Re-sending changes nothing.
throw Object.assign(new Error(err?.error?.message ?? 'rejected'), {
code: err?.error?.code,
requestId: err?.request_id,
retryable: false,
});
}
// 429 / 5xx / timeout: retry with the SAME idempotencyKey.
throw Object.assign(new Error('retryable'), { retryable: true, requestId: err?.request_id });
}What is idempotent elsewhere
| Surface | Behaviour |
|---|---|
Outbound sends with Idempotency-Key | One message per (account, key) for 24h; retries get the original 202. |
| Inbound message storage | De-duplicated on (channel, message id). A replayed inbound message is stored once. |
| Webhook delivery | One job per (endpoint, event_id). A given event is delivered to a given endpoint once, then retried under the same event_id until it succeeds or the budget runs out. |
| Your webhook receiver | Must de-duplicate on event_id. Retries reuse it, so treat receiving the same event_id twice as normal and make your handler idempotent. |