Skip to content
WhatIsUp.dev

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:

  1. Persist your client_ref and the returned message_id before you retry. A 202 you failed to read still sent the message.
  2. Never retry a 4xx. 400, 402, 403, 409 and 422 are terminal — the request was rejected, nothing was sent, and the same request will be rejected again.
  3. Retry 429, 5xx and network timeouts — but check first whether the original landed. Query GET /v1/messages?limit=50 and look for your message_id, or wait for the message.sent webhook. Both are cheaper than an apology.
  4. 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

SurfaceBehaviour
Inbound message storageDe-duplicated on (channel, message id). A replayed inbound message is stored once.
Webhook deliveryOne 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 receiverMust 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.