WhatsApp API & Developers

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:

DirectionCauseWhat it looks like
OutboundYour HTTP call timed out after Meta had already accepted the message, and you retriedTwo identical messages seconds apart, two different wamids
OutboundA job queue redelivered a task because the worker crashed after sending but before acknowledgingDuplicates after deploys or restarts
OutboundThe same business event fired twice, such as an order-status hook called by two systemsDuplicates with slightly different timestamps
InboundMeta retried a webhook because your endpoint was slow or erroredYour bot replies twice to one customer message
InboundSeveral workers or several apps processed the same payloadTwo 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 as 131026 park the row as failed. Retried rows still need a delay; schedule them with backoff rather than calling send again immediately.
  • The unknown state 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 pending and 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.