Queue architecture for WhatsApp at volume: a dedupe key that prevents double sends, webhooks that arrive twice and out of order, and replies that get lost.
Key takeaways
- A database uniqueness key is what prevents a duplicate message. Checking "did we already send this" before the API call prevents nothing, because two workers can pass that check at the same moment.
- Meta retries webhook delivery with decreasing frequency for up to seven days when your endpoint does not return 200, and states plainly that these retries can result in duplicate webhook notifications.
- A single outgoing message can produce up to three separate webhooks - `sent`, `delivered` and `read` - so status is not a field you overwrite but a rank you only ever move forward.
- An inbound reply gets lost when it is stored against a phone number instead of against a message. The correct link is `context.id`, which carries the wamid of the message the customer replied to.
- A business phone number sends up to 80 messages per second by default, but only one message every six seconds to the same user. It is the second limit that breaks a small campaign.
At volume the problem is not wording, it is bookkeeping. Two independent layers produce duplicates: your own retry of a send, and Meta re-delivering a webhook. A correct queue solves both with the same device - a uniqueness constraint enforced on write, not a check performed before sending.
The queue row: what is stored before the API is touched
A message is not sent from application code. It is written to a queue, and a separate worker sends it. That separation is what makes a retry safe, because the record exists before Meta knows anything about it.
| Field | Example value | Why it exists |
|---|---|---|
dedupe_key | order-44812:shipped:972501234567 | Unique index. The second write fails, so there is no second message |
to | +972501234567 | E.164, normalised once on entry to the queue |
purpose | order_shipped | What this message is, not what text it contains |
trigger_ref | orders/44812 | The business record that caused the send |
payload | Template name and variables, or free text | What will actually go out |
state | queued | A state in a machine, not a boolean flag |
attempts | 0 | How many times a send has been tried |
wamid | Empty until the API responds | The key every later webhook hangs off |
not_before | 2026-09-21T06:00:00Z | Rate shaping and quiet hours, without sleeping in code |
dedupe_key is the heart of it. It is built from the business event, never from a timestamp: same order, same stage, same recipient. If the shipping job runs twice - and "at least once" is the default guarantee of every job runner - the second write hits the unique constraint and the customer receives one message.
Why does the same customer receive two identical messages?
Almost always one of three scenarios, each with a different fix:
- The process that triggered the send ran twice. A scheduled job that restarted, a server that recovered, a user who double-clicked. Here the dedupe key solves it completely.
- The API call succeeded but the response never came back. The message really went out, your side records a failure, and the retry sends it again. The only fix is to mark the row
sendingbefore the call and never auto-retry from that state - a row stuck insendingis an item to inspect, not an item to send. - Meta delivered the same webhook again. The documentation states that if a webhook request receives an HTTP status other than 200, or cannot be delivered for another reason, Meta retries with decreasing frequency for up to seven days, and that these retries can result in duplicate webhook notifications. If such a notification creates a new record instead of updating an existing one, you have a duplicate on the receiving side.
The third is the dangerous one, because it does not produce a duplicate message for the customer. It produces a duplicate record for you, so the same customer reply is counted twice and appears twice in the interface.
Arrival order: what is guaranteed and what is not
One outgoing message can produce up to three separate webhooks: one for sent, one for delivered, one for read. Meta's reference also documents failed and played. What the documentation does not promise is that they arrive in that sequence. For current build and running costs, see how much a chatbot costs.
So status is not a field you assign, it is a rank you advance:
| Status | Rank | Meaning per the reference |
|---|---|---|
queued | 0 | Your own internal state, Meta knows nothing of it |
sending | 1 | API call issued, no response yet |
sent | 2 | The message was successfully sent from Meta's servers |
delivered | 3 | The message was delivered to the user's device |
read | 4 | The message was displayed in an open chat thread on the device |
failed | Terminal | Failure to send or deliver the message |
The rule: apply an update only if the new rank is higher than the current one. failed is the single exception and always wins. Those two lines of logic remove the symptom where a message that was read suddenly shows as merely sent, because an older webhook turned up late. The build walkthrough is in how to create a WhatsApp bot.
How does a customer reply get lost?
Not because it never arrived, but because it was stored somewhere nobody reads. Three places this happens:
- Linking on phone number alone. A customer who received three messages that day replies "yes". Without context there is no way to know to which. WhatsApp supplies
context.idon an inbound message when the user replied to a specific message - that is the wamid of the original, and it is what closes the loop. - Treating buttons as text. A message offering up to three predefined reply buttons returns an identified choice, not a free string. Code that compares strings breaks the moment somebody rewords the template.
- A webhook notification dropped during handling. If your handler returned 500 because of a missing field, Meta retries, and that is fine. If your handler returned 200 and then crashed internally, that notification is gone permanently. So store the raw webhook body first, return 200, and process it in a separate worker afterwards.
The working rule: an inbound message is always stored, including when you failed to attribute it. An unattributed conversation is a work item. A reply discarded because it did not match a pattern is a customer who thinks you ignored them.
Related: diagnosing a failed WhatsApp message, WhatsApp Cloud API error codes, turning WhatsApp orders into a work queue.
Two hundred a day against the limits that actually bite
That daily volume sounds trivial next to the throughput ceiling: a business phone number sends up to 80 messages per second by default. In practice the limit you hit is a different one entirely.
- One message every six seconds to the same user. Anyone sending a confirmation and then a reminder to one customer in quick succession needs spacing between them. That is what
not_beforein the table above is for. - Unique customers in a 24-hour period. The documented tiers are 250, 2,000, 10,000, 100,000 and unlimited. Two hundred messages a day to two hundred different people already sits near the first tier, and one one-off campaign can cross it.
- The customer service window. When a user messages or calls you, a 24-hour timer starts, and a further message from the user resets it. Outside the window only approved templates can be sent.
The consequence for queue design: check whether the window is open before sending, and if it is not, either take the template path or hold. A queue that does not know when each conversation's window closes will accumulate failures that look random, because they depend on when the customer last wrote rather than on the message itself.
What to measure to know the queue is healthy
- How many rows have been in
sendingfor more than a minute. Each one is a possible duplicate. - How many writes were rejected by the uniqueness constraint. A large number means some process is running twice and you do not know about it.
- How many webhooks arrived carrying a wamid you already had. That is Meta's duplicate rate, and it is never zero.
- How many inbound messages were stored with no conversation or message attribution. That is a work queue, not a metric.
- The gap between
sentanddeliveredover time. A gap that suddenly widens is a problem on the recipient side, not in your code.
Sources
Frequently asked questions
Is the message id Meta returns good enough as a uniqueness key?
No, because you only receive it after the call. The key that prevents duplication has to exist before the send and be derived from the business event. The wamid from the response serves an entirely different purpose: hanging status updates and inbound reply context off it.
What should the webhook endpoint return when processing fails?
Return 200 only if you managed to store the raw body. If that storage itself failed, a non-200 is correct, because Meta will retry. The common mistake is returning 200 and then processing synchronously - a processing failure then erases the notification with no way to recover it.
How long should completed queue rows be kept?
Long enough for the uniqueness constraint to do its job, which is set by the business event: a monthly reminder needs a longer window than an order confirmation. Deleting completed rows early reinstates exactly the duplicates the key was there to prevent.
One queue for every message type, or a queue per type?
One queue with a `purpose` field and a priority. Separate queues look tidy until you have to enforce the six-second spacing to a single customer, at which point two queues that do not know about each other send two messages in the same second and one of them fails.
Keep reading
Related service
WhatsApp Cloud API
Templates, a two-way inbox and reminders on the official Meta API.
About the author
Yehonatan Saadia
Freelance automation, web & MVP developer
I'm Yehonatan Saadia, a senior developer who builds business automation, custom websites, and MVPs for small and mid-sized companies across the US, Europe, and Israel. These guides come from real client work, not theory.
Work with meHave a project like this?
Tell me what you're trying to automate or build. I reply within 24 business hours with a few targeted questions, then we walk through it on a free 30-minute call, with no commitment. You come away with a scope, a timeline and a fixed price - or a straight answer that it isn't worth building.
