Skip to content
WhatIsUp.dev
Esta página está disponible solo en inglés por ahora.

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" }'
SituationResponse
First request with the keyProcessed normally. A 202 (sent or queued) is remembered for 24 hours.
Retry with the same key and the same requestThe 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 running409 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 key400 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

  1. Send an Idempotency-Key, and store it before the first attempt. Then retrying 429, 5xx and network timeouts is safe: a retry of a request that already went through just returns its original 202.
  2. Never retry a 4xx as-is. 400, 402, 403, 409 channel_unavailable and 422 mean the request was rejected and nothing was sent; the same request will be rejected again. The one exception is 409 idempotency_key_in_use — wait for Retry-After and retry.
  3. Without an Idempotency-Key, check before you retry. Query GET /v1/messages?limit=50 and look for your message_id, or wait for the message.sent webhook — a 202 you failed to read still sent the message.
  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.
// `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

SurfaceBehaviour
Outbound sends with Idempotency-KeyOne message per (account, key) for 24h; retries get the original 202.
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.