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.
There is no request-level de-duplication. client_ref is a correlation
label, not an idempotency key, and there is no Idempotency-Key header. Two
identical POST /v1/channels/{id}/messages calls produce two WhatsApp
messages. Earlier revisions of this page described a client_ref de-dup window;
that behaviour was never implemented.
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 is you calling POST twice. Each call mints its own id, so each is a distinct message to WhatsApp.
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
Because retries are not de-duplicated for you, make the decision at your end:
- Persist your
client_refand the returnedmessage_idbefore you retry. A 202 you failed to read still sent the message. - Never retry a 4xx.
400,402,403,409and422are terminal — the request was rejected, nothing was sent, and the same request will be rejected again. - Retry
429,5xxand network timeouts — but check first whether the original landed. QueryGET /v1/messages?limit=50and look for yourmessage_id, or wait for themessage.sentwebhook. Both are cheaper than an apology. - 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.
async function sendOnce(channelId, body) {
const res = await fetch(
`${API}/v1/channels/${channelId}/messages`,
{
method: 'POST',
headers: { authorization: `Bearer ${KEY}`, 'content-type': 'application/json' },
body: JSON.stringify(body),
},
);
if (res.status === 202) return res.json(); // sent OR queued — both are done
const err = await res.json().catch(() => ({}));
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: retryable, but confirm before you re-POST.
throw Object.assign(new Error('retryable'), { retryable: true, requestId: err?.request_id });
}What is idempotent elsewhere
| Surface | Behaviour |
|---|---|
| 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. |
Idempotency-Key header (not implemented)
A Stripe-style Idempotency-Key header is on the roadmap. It does not exist
today: sending the header has no effect whatsoever, and no route inspects it.
Do not write code that depends on it until this page says it has shipped.