Stop Duplicate WhatsApp API Messages

An idempotency key is a string you attach to a send request so that retrying the request cannot send the message twice. In WhaBuzz it is the Idempotency-Key header on POST /v1/messages. This post explains how duplicates happen, what WhaBuzz does with the header, and how to dedupe on the receiving side.

Why do WhatsApp API sends get duplicated?

Almost always because of a retry you wrote yourself. Your server calls the API, the network drops or your HTTP client times out, and your code cannot tell whether the message went out. The safe-looking move is to send again. If the first request had in fact reached WhatsApp, the customer now has two copies of "Your order has shipped."

Three common causes:

  • Client timeouts. Your timeout is shorter than the time the call takes. The send completes after you gave up.
  • Job queue retries. A worker crashes after sending but before it marks the job done. The queue hands the job to another worker.
  • Double clicks and double triggers. A user submits twice, or two internal events fire for the same order.

The fourth cause is upstream. Per the WhaBuzz errors page, a 502 meta_unreachable means the Graph API did not answer within 15 seconds, and the outcome is unknown. Meta may have accepted the message anyway.

A webhook from the other direction duplicates too. That is a separate problem, covered near the end.

What does the Idempotency-Key header do?

It makes a retry return the first answer instead of sending again. According to the WhaBuzz idempotency guide, you add the header to POST /v1/messages, the value can be up to 255 characters, and it can be any string you generate. A UUID works.

Networks fail between "we sent it" and "you heard back".

curl -X POST https://api.whabuzz.com/v1/messages \
  -H "Authorization: Bearer $WHABUZZ_KEY" \
  -H "Idempotency-Key: order-48213-shipped" \
  -H "Content-Type: application/json" -d @body.json
bash

The body is Meta's own /messages body plus from, as described in the sending messages guide. Nothing about the body changes when you add the key.

What happens when you reuse a key?

The result depends on what state the first request is in and whether the body is the same. The guide lists four situations:

SituationResult
First request with the keyProcessed normally. The answer is stored for 24 hours.
Same key, same body, original finishedThe stored answer is replayed with the header Idempotent-Replayed: true. Nothing is sent to Meta.
Same key, original still running409 idempotency_conflict. Wait and retry.
Same key, different body422 idempotency_conflict. Use a new key.

So the window is 24 hours from the first request. Inside it, a finished request is replayed, which means your retry gets the same response, including the same wamid, without a second message going to Meta. Check for the Idempotent-Replayed: true response header if you want to know that a replay happened.

Both conflict cases use the code idempotency_conflict, with different HTTP statuses. The 409 means wait and try again. The 422 means your code reused a key for a different message, which is a bug in how you build keys.

The guide also says: do not fire two requests with the same key at the same time.

How should you choose a key?

Derive the key from the business event, not from the attempt. order-48213-shipped is a good key, because every retry of "tell the customer order 48213 shipped" produces the same string. A fresh random UUID per attempt defeats the purpose, since each retry then looks like a new send.

A few rules that follow from the table above:

  1. One key per intended message. If you send order-shipped and order-delivered messages for the same order, they need different keys.
  2. Same key, same body. If you change the message text on retry, you get 422. Store the body with the job and resend it unchanged.
  3. Generate the key before the first attempt and persist it with the job, so a crashed worker retries with the same one.

The same pattern applies in code. This is a minimal retry loop using the Node SDK, where idempotencyKey is the option the Node SDK page documents for send:

import { WhaBuzz } from 'whabuzz';
const wb = new WhaBuzz();

async function sendOnce(body, key) {
  return wb.messages.send(body, { idempotencyKey: key });
}
js

What if WhaBuzz cannot reach Meta?

This is the case that causes most real duplicates, so it gets its own rule. If WhaBuzz could not reach Meta, you receive 502 meta_unreachable and the outcome is unknown. Per the idempotency guide, that 502 is stored against the key too. A retry with the same key returns the same 502, rather than risking a duplicate.

That looks strict, and it is on purpose. You do not get a second attempt for free. You decide after checking. The guide says to use a new key only after you have confirmed that the first message was not sent, by looking at GET /v1/events or at a message.status event for it.

In practice:

  1. Retry with the same key and get the same 502.
  2. Look for a message.outbound or message.status event for that send.
  3. If none exists, send again with a new key. If one exists, you are done.

Batches work differently. POST /v1/messages/batch takes a client_ref, your own reference that is unique per tenant, and repeating it returns the existing batch. See the batch section of the messages guide.

How do you dedupe on the receiving side?

Sending is half of it. Delivery of webhooks is at least once, so your handler will see some events twice. Every WhaBuzz envelope carries a dedupe_key. For an inbound message it is msg:{wamid}, and for a status it is st:{wamid}:{status}:{timestamp}.

The pattern is a table with a unique constraint and an insert that skips on conflict:

INSERT INTO processed_events (dedupe_key) VALUES ($1) ON CONFLICT DO NOTHING RETURNING dedupe_key;
-- no row returned: already handled, skip
sql

If you want the full treatment, including signatures, seq and replay, read WhatsApp webhooks that never lose an event. It covers the retry ladder and how long to keep keys.

The two mechanisms are independent. The Idempotency-Key stops your own double sends. The dedupe_key stops you processing the same incoming event twice. You need both for a flow such as "send a template, then act on the delivery status".

Frequently asked questions

How long does WhaBuzz remember an Idempotency-Key?

The answer to the first request is stored for 24 hours, per the idempotency guide.

What is the maximum length of the key?

255 characters. It can be any string you generate, and a UUID works.

What happens if I retry with the same key but change the message?

You get 422 idempotency_conflict. Use a new key for a different message.

Does the header work on batch sends?

The guide documents the header for POST /v1/messages. Batches use client_ref for the same purpose.

Is a replayed response sent to the customer again?

No. A replay returns the stored answer with Idempotent-Replayed: true and nothing is sent to Meta.