Skip to content

Idempotency Key

Also called: Idempotency-Key header · Idempotent request · Request deduplication key

Definition
A unique key created once per send request so that a retry after a timeout returns the first result instead of messaging the customer twice.

Exhibit A

Press Send twice: does the customer get two messages?

2 / 2
POSThttps://api.example.com/v1/whatsapp/sends

POST 201 in 640 ms

Request

4 headers · body
POST /v1/whatsapp/sends HTTP/1.1
Host: api.example.com
Authorization: Bearer {INTERNAL_TOKEN}
Content-Type: application/json
Idempotency-Key: order-48213-dispatched
{
"to": "919812345678",
"template": "order_dispatched",
"language": "en",
"params": [
"48213",
"21 Oct"
]
}

Response

201 Created
HTTP/1.1 201 Created
Content-Type: application/json
{
"idempotency_key": "order-48213-dispatched",
"state": "sent",
"wamid": "wamid.HBgMOTE5ODEyMzQ1Njc4FQIAERgSQjM2",
"replayed": false
}
Try another response
A send request to your own notification API carrying an Idempotency-Key. Toggle the alternatives to see a replayed retry, a key reused with a different body, and a retry that arrives while the first attempt is unresolved.

Response: 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 pool
const 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?

  1. Derive the key from the business event

    Generate it where the intent starts, for example order-48213-dispatched or 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.

  2. Claim the key before calling Meta

    Insert the key into a send store with a unique constraint and a pending state. If the insert conflicts, another attempt owns the key: return its stored result, or return 409 if that attempt is still in flight.

  3. 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.

  4. Send and record the wamid

    Call POST /{PHONE_NUMBER_ID}/messages, save the wamid it returns and mark the key sent. Put the key in biz_opaque_callback_data as well, so status webhooks can be matched back to it.

  5. Resolve unknown outcomes by reconciling

    If the Graph API call times out, the send may or may not have happened. Mark the key unknown and 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 unknown and 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 wamid and 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: true to help debugging.
  • Pair idempotency keys with a Transactional Outbox: the outbox prevents lost sends and the key prevents duplicate ones.

Idempotency Key FAQs

Does the WhatsApp Cloud API support an Idempotency-Key header?+
No. The Messages Endpoint has no idempotency header, so each accepted call creates a new message with a new wamid. Enforce idempotency in your own send API and store, and use biz_opaque_callback_data to match status webhooks back to your key.
What is the difference between an idempotency key and webhook deduplication?+
An idempotency key stops your system sending the same outbound message twice; webhook deduplication stops it processing the same inbound event twice. Most WhatsApp integrations need both, because retries happen in both directions.
How long should I keep idempotency keys?+
Keep them for longer than your longest retry path. If a job can be retried for 24 hours and then redriven from a dead-letter queue, 72 hours is a sensible minimum. Keys that expire earlier let late retries through.
Should an idempotency key be a UUID or a hash of the message?+
Either works if it is created once per business event and reused on every retry. Event-based keys (an order ID plus the event name) are easier to debug. A hash of the payload alone is risky, because two legitimate identical messages would collapse into one.

See InfiQ pricing

Try it on the official WhatsApp Business API

InfiQ is an official Meta Business Partner. Start a 7-day free trial — no card needed — and see this running on your own number.