Handling WhatsApp Cloud API webhooks without losing your mind: echoes, retries, and the five ways a message lies to you
The previous post covered how my AI receptionist books appointments. This one is about the part of the system nobody photographs: the webhook, where Meta tells you what happened. Slowly. Twice. Occasionally in the wrong order.
The promise vs the delivery
The tutorial version of a WhatsApp integration: Meta POSTs you a message, you parse messages[0].text.body, you reply. Ship it Friday.
The production version is different. Your endpoint receives a payload with no message in it. Or the same message five times. Or a message you yourself sent. Or a status update for a message you never saw, from a sender whose WhatsApp ID has silently changed case. Each of these is a different 2am incident, and all five show up within your first month of real volume.
I learned each one the expensive way so you can learn it the cheap way. Here they are, ordered by blast radius.
1. Retries: the same webhook, five times
Meta's webhook delivery is at-least-once. If your endpoint doesn't answer 200 within a few seconds - cold start, a slow downstream call, a deploy, anything - Meta queues a redelivery. Network blip? Redelivery. Deploy restarting? Redelivery.
Now remember what the payload usually is. It's a customer asking to book something.
If you process it twice, your AI books Saturday twice, confirms twice, and the customer quietly concludes your receptionist is an over-caffeinated intern. So: dedupe on the platform message ID, with a TTL store - a Firestore doc with a TTL policy, a Redis SET NX EX, whatever you have. Check it before any business logic runs. First delivery is real, everything else is a log line.
POST /webhook
→ verify signature (X-Hub-Signature-256, HMAC of raw body)
→ extract every entry[].changes[].value
→ for each message: if seen(m.id) → skip
→ mark seen(m.id) BEFORE processing ← not after
→ process
Look at where "mark seen" sits. Before processing. Not after.
I put it after, once. Two redeliveries landed a few milliseconds apart, both passed the check, both wrote to the database, and I owned a very apologetic conversation about a double-booked Saturday. If the write is after the check, two concurrent deliveries can both pass. And Meta does retry across regions concurrently.
2. Echoes: your own bot arrives through the front door
This is the sneakiest of the five. When your bot sends a message, WhatsApp also delivers that message back to your webhook, as if it were inbound. Your own reply, arriving through the front door like a stranger.
Naive code responds to it. And then you have a bot answering itself. I have watched two instances of my own bot exchange politeness spirals, only stopping when something hit a rate limit, while the customer watched the "typing..." indicator flicker like a haunted office.
Filter on the message source. Only process entries whose from is the customer's WhatsApp ID, and ignore anything arriving under the App ID your own sends go out through. Before dedupe, not after, because echoes also fan out on retries.
One counterintuitive note: the echo stream is secretly useful. It's a built-in delivery receipt for your own sends. Just don't build state on it until the filter is bulletproof - a conversation history that contains your bot's own messages looks identical to "the customer said this," and every downstream feature breaks at once.
3. Statuses are not messages, except when they arrive for ghosts
A statuses payload is delivery metadata: sent, delivered, read, failed. It references a message ID that should exist in your store.
Should.
During a webhook outage, a manual re-subscribe, or a number migration, statuses arrive for messages your system never stored. That's a ghost reference. And if your code assumes the lookup succeeds - or worse, throws a 500 when it doesn't - you've just told Meta to retry. Forever. A retry loop over a dead reference.
Accept the webhook with 200 and move on. A status for a ghost costs nothing to ignore. Crashing on it costs you the queue.
And persist read receipts properly, because they matter more than they look like they do. Suppressing a bot takeover when the human on your team is mid-conversation - that depends on knowing whose messages are being read, and when.
4. The 24-hour window, and the errors Meta doesn't advertise
Outside 24 hours from a customer's last message, you can't send free-form text. You send an approved template. Fine, you think. Except the failure isn't clean. The API accepts your call, Meta queues the message, and later a failed status arrives with error 131047 - re-engagement required.
Which means: your booking confirmation queue reported success. The customer received nothing. The only trace is a delayed status webhook that your older code treated as noise. I know, because that was my older code.
Parse statuses[].errors[] from day one. 131047 means send the template version instead - so actually send it, automatically. 131026 (undeliverable) usually means a genuinely dead line: mark the contact, stop spending effort on it. Each code class needs its own handling, and the default handler should never be "ignore."
5. Webhooks for conversations you're no longer in
This last one is business logic wearing a technical costume.
Human takes over a conversation in the shared inbox. Customer replies. Webhook fires. The bot answers too? Obviously not - there's a takeover flag. I use a 30 minute cooldown: long enough that the human clearly has the wheel, short enough that a forgotten conversation doesn't dead-end.
But flags rot, and the dangerous window isn't the one you're thinking about. It's this: the AI decides to send at 10:00:00, and a human replies to the same thread at 10:00:04. Your pre-send check happened before their reply landed.
So the check has to run immediately before every automated send, not just when the inbound message arrives. The webhook fires; the state of the conversation is the only truth. Same discipline as the dedupe - trust the flag, but read it fresh, every time.
Verify token, signatures, and the boring gate
Setup: Meta GETs you with hub.mode=subscribe and a hub.verify_token, you echo hub.challenge if the token matches. Every POST after that carries X-Hub-Signature-256, an HMAC-SHA256 of the raw body with your app secret.
Two details that bite:
Compute the HMAC on raw bytes. Before JSON parsing. Framework body parsers will happily consume the stream otherwise - both Express's body-parser and Next.js route handlers need explicit raw-body capture. I learned this from a signature check that passed locally and failed in staging, which is exactly the kind of sentence you never want to have to say out loud.
And compare timing-safe, not ===. If the signature fails, 401 immediately, and log the app ID - the most common cause is two Meta apps firing at the same endpoint, and they look identical once the raw body is gone.
Last thing: run a subscription audit on a schedule. List what each Meta app is actually subscribed to (messages, message_template_status_update, whatever your code assumes) and diff it against what you handle. Subscriptions silently vanish when an app gets re-authed. The bug is invisible until the day a whole channel goes quiet, and then you'll spend hours checking every other layer first. I did.
The shape of it
Every defense above is the same idea wearing five hats. The webhook is a stream of claims, not facts. Dedupe the claims. Filter the echoes out. Accept the unverifiable ones quietly. And before you act on any of them, re-read the world's actual state.
Meta's delivery model is at-least-once, along every dimension - messages, statuses, retries, regions. The only safe architecture assumes every callback arrives zero, one, or several times, and that all three cases are normal.
I build Conversify, an AI receptionist that lives in front of these webhooks full time - answering, booking and selling over WhatsApp, Instagram, Messenger, email and web chat for small service businesses. Self-serve, 14-day free trial, and it tells people it's AI.
Next in the series, the calendar half of the story continued: availability search that survives multi-staff, blocked times, and the customer whose phone is in another timezone. Part one is here.
Top comments (0)