WhatsApp API Duplicate Messages: How to Prevent Them
A customer forwards you a screenshot: "Your order has shipped" three times in a row, two minutes apart. Or your support bot answered the same question twice. Nobody wrote code to send duplicates. They came from a retry that was doing exactly what it was told.
WhatsApp API duplicate messages come from two directions. Outbound, your system sends the same message more than once, usually after a timeout. Inbound, Meta delivers the same webhook more than once, by design, and your handler acts on each copy. Both are fixable, and on the outbound side, they're fixable only by you.
This guide covers where each kind comes from, why Meta's Cloud API can't de-duplicate sends for you, a tested outbox pattern that makes retries safe, inbound de-duplication in Postgres, and how Tenashi's Idempotency-Key handles it. See the complete guide to the WhatsApp API for the bigger picture.
Where duplicates come from
Map the source before choosing a fix. These are the patterns behind nearly every duplicate we've seen:
| Direction | Cause | What it looks like |
|---|---|---|
| Outbound | Your HTTP call timed out after Meta had already accepted the message, and you retried | Two identical messages seconds apart, two different wamids |
| Outbound | A job queue redelivered a task because the worker crashed after sending but before acknowledging | Duplicates after deploys or restarts |
| Outbound | The same business event fired twice, such as an order-status hook called by two systems | Duplicates with slightly different timestamps |
| Inbound | Meta retried a webhook because your endpoint was slow or errored | Your bot replies twice to one customer message |
| Inbound | Several workers or several apps processed the same payload | Two tickets, two CRM notes |
The first and fourth rows are the most common, and they're linked: a slow webhook handler causes Meta retries, and a slow send path causes your own retries.
The Cloud API has no idempotency key
Many payment and messaging APIs let you send an idempotency key so a repeated request is recognised and ignored. Meta's Messages API doesn't: its reference, as checked in October 2026, documents no idempotency header or field. Every accepted POST /PHONE_NUMBER_ID/messages is a new message with a new wamid, even if the body is identical to one you sent a second ago.
So de-duplication has to happen on your side, before the call. What Meta does give you is a correlation field. biz_opaque_callback_data is a string of up to 512 characters that you attach to a send; Meta doesn't process it, it just echoes it back in that message's status webhooks. It can't prevent a duplicate, but it can tell you that a send you thought had failed actually went through.
Two other Meta behaviours make naive retries worse. Delivery order isn't guaranteed, so a retry can land before the original. And Meta's pair rate limit allows only about one message every 6 seconds to the same user, so a tight retry loop to one customer starts failing with 131056 anyway.
Make sends safe with an outbox
The pattern that works is an outbox: one row per business event, keyed by something deterministic like order-1042-shipped, never by a random ID generated per attempt. The row moves through states, and a send only happens when a worker claims a row that's still pending.
This Python version uses sqlite3 to stay self-contained; the SQL is the same in Postgres. It targets Graph API v23.0, so swap in the current version from Meta's changelog. We tested it against successful sends, timeouts, throttling errors and permanent failures.
import json, os, sqlite3, requests
GRAPH = "https://graph.facebook.com/v23.0"
TOKEN = os.environ["WHATSAPP_TOKEN"]
PHONE_NUMBER_ID = os.environ["PHONE_NUMBER_ID"]
# Error responses where Meta did not accept the message, so a later retry is safe.
SAFE_TO_RETRY = {2, 4, 80007, 130429, 131000, 131016, 131056, 131057}
db = sqlite3.connect("outbox.db", isolation_level=None)
db.execute("""CREATE TABLE IF NOT EXISTS outbox (
key TEXT PRIMARY KEY, recipient TEXT NOT NULL, body TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'pending', wamid TEXT, attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT)""")
def enqueue(key, recipient, message):
# One row per business event: a second enqueue of the same key is a no-op.
db.execute("INSERT INTO outbox (key, recipient, body) VALUES (?, ?, ?) ON CONFLICT (key) DO NOTHING",
(key, recipient, json.dumps(message)))
def send(key):
claimed = db.execute("UPDATE outbox SET state = 'sending', attempts = attempts + 1 "
"WHERE key = ? AND state = 'pending'", (key,)).rowcount
if not claimed:
return # already sent, being sent, or parked for review
recipient, body = db.execute("SELECT recipient, body FROM outbox WHERE key = ?", (key,)).fetchone()
payload = {"messaging_product": "whatsapp", "recipient_type": "individual", "to": recipient,
"biz_opaque_callback_data": key, **json.loads(body)}
try:
r = requests.post(f"{GRAPH}/{PHONE_NUMBER_ID}/messages", json=payload, timeout=(5, 20),
headers={"Authorization": f"Bearer {TOKEN}"})
except requests.exceptions.ConnectTimeout:
return mark(key, "pending", error="connect timeout") # never reached Meta
except requests.exceptions.RequestException as exc:
return mark(key, "unknown", error=repr(exc)) # may or may not have been accepted
if r.ok:
return mark(key, "accepted", wamid=r.json()["messages"][0]["id"])
code = r.json().get("error", {}).get("code")
mark(key, "pending" if code in SAFE_TO_RETRY else "failed", error=f"{r.status_code} {code}")
def mark(key, state, wamid=None, error=None):
db.execute("UPDATE outbox SET state = ?, wamid = COALESCE(?, wamid), last_error = ? WHERE key = ?",
(state, wamid, error, key))
def on_status_webhook(status):
# A status webhook proves Meta accepted the message, even if our HTTP call timed out.
key = status.get("biz_opaque_callback_data")
if key:
db.execute("UPDATE outbox SET state = 'accepted', wamid = ? "
"WHERE key = ? AND state IN ('sending', 'unknown')", (status["id"], key))
Usage is enqueue("order-1042-shipped", "+919811122233", {"type": "text", "text": {"body": "..."}}) followed by send("order-1042-shipped") from a worker. Call enqueue twice for the same order and nothing changes; call send twice and the second call finds no pending row and returns.
What each design choice buys you:
- The deterministic key absorbs duplicate triggers. Two systems firing "order shipped" produce one row.
- The atomic claim (
UPDATE ... WHERE state = 'pending') means two workers can't both send the same row. - Retrying only known-rejected errors keeps throttling (
130429,131056) and transient errors retryable, while permanent ones such as131026park the row asfailed. Retried rows still need a delay; schedule them with backoff rather than callingsendagain immediately. - The
unknownstate is the honest answer to a read timeout. Meta may have accepted the message. Don't resend blindly.
Resolve the timeout case deliberately
A row in unknown has three possible fates, and the webhook decides most of them. If Meta accepted the message, a sent or failed status arrives carrying your key in biz_opaque_callback_data, and on_status_webhook moves the row to accepted. No duplicate.
If no status with your key arrives after a reasonable wait (a few minutes covers normal webhook latency), you have a business decision, not a technical one:
- Time-sensitive and harmless to repeat, such as "your table is ready": move the row back to
pendingand accept a small duplicate risk. - Embarrassing or costly to repeat, such as a payment request or a marketing message (which Meta bills per delivery): flag it for a person.
Whatever you choose, make it a rule in code, not a judgement made at 3 am. The same correlation key also makes it easy to track message statuses back to your own records without a lookup table.
De-duplicate inbound webhooks
Meta keeps re-sending unacknowledged webhooks for as long as a week, to every app subscribed to the account, and its docs leave de-duplication to you. The reliable approach is a processed-events table with a unique key, written in the same transaction as the side effects:
CREATE TABLE processed_webhook_events (
event_key text PRIMARY KEY,
processed_at timestamptz NOT NULL DEFAULT now()
);
def event_keys(payload):
"""Yield (key, kind, item) for every message and status in a webhook payload."""
for entry in payload.get("entry", []):
for change in entry.get("changes", []):
value = change.get("value", {})
for msg in value.get("messages", []):
yield f"msg:{msg['id']}", "message", msg
for st in value.get("statuses", []):
participant = st.get("recipient_participant_id", "")
yield f"status:{st['id']}:{st['status']}:{participant}", "status", st
def handle_once(conn, key, work):
"""Run work(cur) only the first time key is seen. Key and side effects commit together."""
with conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO processed_webhook_events (event_key) VALUES (%s) "
"ON CONFLICT (event_key) DO NOTHING RETURNING event_key",
(key,),
)
if cur.fetchone() is None:
return False # duplicate delivery: already handled
work(cur)
return True
This uses psycopg2, where with conn: wraps one transaction. Inbound messages are keyed by their wamid; statuses by wamid, status and, for Groups API messages, the participant. Because the key and the work commit together, a crash halfway through rolls both back, and Meta's retry processes the event properly. We ran it against repeated deliveries and a mid-transaction failure: each event ran once, and the failed one ran on retry.
Two refinements. Keep keys for at least the 7-day retry window; a month is a comfortable margin. And if a bot replies automatically, add a unique constraint on the inbound wamid it's replying to, so even a bug elsewhere can't produce two replies to one message. Keeping the handler fast matters too, since slow responses are what trigger retries; the webhooks guide covers Meta's latency targets.
Tenashi: idempotency built into the API
Tenashi's developer API has the idempotency key that the Cloud API lacks. Send an Idempotency-Key header with any unique string, and a repeat request with the same key within 24 hours returns the first response and sends nothing:
curl https://usetenashi.com/api/v1/messages \
-H "Authorization: Bearer $TENASHI_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-shipped" \
-d '{"to": "+919811122233", "text": "Hi Priya, order #1042 is on its way."}'
Use the same deterministic business key you'd use in an outbox, and timeouts stop being scary: retry with the same key and you either get the original 202 Accepted response or a new send if the first never landed. If the first request with that key is still running, the API answers 409 idempotency_in_progress, and Tenashi's docs recommend retrying server errors with the same key.
On the inbound side, Tenashi webhooks are at least once and not always in order, like Meta's. Each event has an id (also sent as X-Tenashi-Delivery) to de-duplicate on, so the handle_once pattern above works unchanged with keys like evt_5f0c8e2a91b34d7c6e1f2a90.
Where Tenashi fits
If your sends are reminders, confirmations and follow-ups to customers who already talk to your team, Tenashi removes most of this plumbing. It sends from the number you already use, linked by QR scan, takes an idempotency key on every send, and paces messages so a retry storm can't flood a customer. Every API message also appears in the shared team inbox, so a person can see at a glance whether a customer already got something. Plans start from $18/mo, API included.
Tenashi isn't Meta's Cloud API, and it's deliberately slow: one message every 4 to 12 seconds per number. For OTPs or high-volume notifications, use the outbox pattern above on the Cloud API.
Read the API docs · Get started
FAQ
Does the WhatsApp Cloud API support idempotency keys?
No. As of October 2026, Meta's Messages API reference documents no idempotency header or field, so every accepted request creates a new message with a new wamid. Prevent duplicates on your side with a deterministic key per business event, and use biz_opaque_callback_data to match status webhooks back to the send.
Why did my customer receive the same WhatsApp message twice?
Usually your code retried after a timeout even though Meta had already accepted the first request, or the same event triggered two sends. Check whether the two messages have different wamids: if so, both were separate API calls. Add an outbox keyed per business event and only auto-retry errors where Meta rejected the message.
Why does my webhook receive the same WhatsApp event multiple times?
Meta delivers webhooks at least once. If your endpoint didn't return 200, responded slowly or the connection dropped, Meta retries for up to 7 days, and retries go to every subscribed app. Store a processed-event key, such as the message wamid, with a unique constraint, and skip events you've already handled.
Is it safe to retry a WhatsApp API send after a timeout?
Not blindly. A connect timeout means the request never reached Meta, so retrying is safe. A read timeout means Meta may have accepted it. Mark the send as unknown, wait a few minutes for a status webhook carrying your biz_opaque_callback_data, and only then decide whether to resend.
How long should I keep webhook de-duplication keys?
At least 7 days, because Meta can retry an unacknowledged webhook for up to a week. Keeping them for about a month gives a safety margin at low cost. For Tenashi's API, idempotency keys on sends are honoured for 24 hours, so outbound keys only need to be unique within that window.
Duplicates are a design problem, not bad luck, and a deterministic key fixes most of them. To get an idempotency key on every send without building the outbox yourself, read Tenashi's API docs.