Exhibit A
Press Send twice: does the customer get two messages?
https://api.example.com/v1/whatsapp/sendsPOST 201 in 640 ms(simulated)
Request
4 headers · bodyResponse
201 CreatedResponse: 201 Created after 640 ms.
What is an idempotency key?
An idempotency key is a unique value, usually a UUID or an ID built from the business event, that a client attaches to a request that changes something, such as "send this order confirmation on WhatsApp". The server stores the key with the result of the first attempt. If the same key arrives again, because the client timed out and retried or a queue redelivered the job, the server returns the stored result instead of doing the work a second time. The request becomes idempotent: running it once or five times has the same effect.
The WhatsApp Cloud API does not do this for you. A POST to the Messages Endpoint has no idempotency header, and every accepted call creates a new message with a new WhatsApp Message ID (wamid). If your HTTP client gives up after Meta has already accepted the send, a blind retry delivers the same OTP, invoice or payment reminder twice, and each delivered copy is charged. So the key has to live in your own stack: in the internal send API your services call, and in a send store that records which keys have already produced a wamid.
Idempotency keys protect outbound requests. Webhook Deduplication is the mirror image for inbound traffic: it discards repeated webhook deliveries by wamid and status. Both exist because real systems give At-Least-Once Delivery, where retries, redeliveries and duplicates are normal. The practical answer is to make every side effect safe to repeat.
Key takeaways
- An idempotency key is a unique value sent with a request so that repeating the request has the same effect as sending it once.
- The WhatsApp Cloud API has no idempotency header: every accepted send creates a new message with a new wamid.
- Keep the key in your own send API and store, claimed atomically with a unique constraint before calling Meta.
- A timed-out send may already have been accepted, so reconcile through status webhooks before you send again.
- Idempotency keys stop duplicate outbound sends; webhook deduplication stops duplicate inbound events.
An idempotency key in code
import crypto from "node:crypto";import { pool } from "./db.js"; // PostgreSQL connection poolconst MESSAGES_URL ="https://graph.facebook.com/v26.0/" + process.env.PHONE_NUMBER_ID + "/messages";export async function sendOnce(key, payload) {const bodyHash = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");// 1. Claim the key atomically: exactly one caller wins this insert.const claim = await pool.query("INSERT INTO wa_sends (idem_key, body_hash, state) VALUES ($1, $2, 'pending') " +"ON CONFLICT (idem_key) DO NOTHING RETURNING idem_key",[key, bodyHash],);if (claim.rowCount === 0) {const { rows } = await pool.query("SELECT body_hash, state, wamid FROM wa_sends WHERE idem_key = $1", [key]);if (rows[0].body_hash !== bodyHash) return { status: 422, error: "key reused with a different body" };if (rows[0].state === "sent") return { status: 200, wamid: rows[0].wamid, replayed: true };return { status: 409, error: "first attempt still unresolved" };}// 2. Send. The key rides along and comes back in the status webhooks.let res;try {res = await fetch(MESSAGES_URL, {method: "POST",headers: {Authorization: "Bearer " + process.env.ACCESS_TOKEN,"Content-Type": "application/json",},body: JSON.stringify({ ...payload, biz_opaque_callback_data: key }),signal: AbortSignal.timeout(10_000),});} catch {// Timed out or connection lost: Meta may already have accepted the send.await pool.query("UPDATE wa_sends SET state = 'unknown' WHERE idem_key = $1", [key]);return { status: 202, note: "outcome unknown, reconciling via status webhook" };}const data = await res.json();if (res.status >= 500) {// A server error is as ambiguous as a timeout: do not assume nothing was sent.await pool.query("UPDATE wa_sends SET state = 'unknown' WHERE idem_key = $1", [key]);return { status: 202, note: "outcome unknown, reconciling via status webhook" };}if (!res.ok) {// A 4xx means Meta rejected the request, so nothing was sent: free the key for a corrected retry.await pool.query("DELETE FROM wa_sends WHERE idem_key = $1", [key]);return { status: res.status, error: data.error };}const wamid = data.messages[0].id;await pool.query("UPDATE wa_sends SET state = 'sent', wamid = $2 WHERE idem_key = $1", [key, wamid]);return { status: 201, wamid, replayed: false };}
Claim the key, send with the key in biz_opaque_callback_data, and treat a timeout as unknown rather than failed.
How does an idempotency key work?
Derive the key from the business event
Generate it where the intent starts, for example
order-48213-dispatchedor a UUID saved with the order. A key that belongs to the event, rather than one created fresh on each attempt, means every retry from every worker carries the same value.Claim the key before calling Meta
Insert the key into a send store with a unique constraint and a
pendingstate. If the insert conflicts, another attempt owns the key: return its stored result, or return 409 if that attempt is still in flight.Bind the key to the request body
Store a hash of the payload with the key. A second request with the same key but a different recipient or template is a client bug. Reject it with 422 instead of quietly returning the first result.
Send and record the wamid
Call
POST /{PHONE_NUMBER_ID}/messages, save thewamidit returns and mark the keysent. Put the key inbiz_opaque_callback_dataas well, so status webhooks can be matched back to it.Resolve unknown outcomes by reconciling
If the Graph API call times out, the send may or may not have happened. Mark the key
unknownand wait for a status webhook carrying it before you send again. If none arrives within a set reconciliation window, for example 10 minutes, release the key for one resend or flag it for review, depending on how costly a duplicate would be. Expire keys only after your longest retry path, for example 72 hours.
Common mistakes with an idempotency key
Generating a new key on every attempt
A key created inside the retry loop is different each time, so it deduplicates nothing. Create it once per business event and store it with the job.
Checking, then inserting
A SELECT followed by an INSERT lets two concurrent workers both see "not found" and both send. Claim the key atomically with a unique constraint, for example
INSERT … ON CONFLICT DO NOTHING.Retrying blindly after a timeout
A Graph API call that timed out may still have succeeded. Mark the key
unknownand reconcile against status webhooks before sending again, rather than firing a second request at once.Treating it as webhook deduplication
Idempotency keys stop you sending twice. They do nothing when Meta delivers the same webhook twice, so you still need to deduplicate inbound events by
wamidand status.Expiring keys too soon
If keys expire before your queue's last retry or a dead-letter redrive, a late redelivery sends again. Keep keys for longer than the longest possible retry path.
Example: A dispatch notice that is sent exactly once
A Surat textile wholesaler's order service calls its internal notification API with Idempotency-Key: order-48213-dispatched. The notification service claims the key and calls the Cloud API. Meta accepts the message, but the response never arrives because the worker's 10-second timeout fires first, so the worker marks the key unknown instead of sending again. Meanwhile the order service retries and gets 409, because the first attempt is still unresolved. Seconds later the sent status webhook arrives with biz_opaque_callback_data set to the key. The store records the wamid, the next retry gets 200 with that wamid, and the buyer in Ludhiana receives exactly one message.
Why does an idempotency key matter?
Duplicate WhatsApp messages cost more than a little embarrassment. Two OTPs confuse a user in the middle of a login, two payment reminders look like a billing error, and every extra delivered marketing or utility message is billed. Retries cannot be avoided: networks drop, workers crash, and a Request Timeout can fire while Meta is still processing. Without idempotency keys, every one of those transient failures becomes a duplicate the customer can see. With them, your Retry Policy can recover from errors aggressively without ever sending twice.
Best practices for an idempotency key
- Use readable, event-based keys such as
order-48213-dispatched. They make support investigations much faster than random UUIDs. - Scope keys per tenant or per phone number ID so two customers can never collide.
- On a replay, return the stored response exactly, plus a header such as
Idempotent-Replayed: trueto help debugging. - Pair idempotency keys with a Transactional Outbox: the outbox prevents lost sends and the key prevents duplicate ones.