On this page
Every WhatsApp integration we've seen fail in production failed at the webhook layer, not the send layer. Sending a message is one HTTP call with a bearer token; anyone gets that working in an afternoon. Receiving — inbound messages, delivery receipts, read confirmations, failure notices — is where teams lose days, because webhooks are asynchronous, unordered, retried, and unforgiving about response times.
This guide walks the whole pipeline end to end: the verification handshake Meta performs before it sends you anything, the anatomy of an inbound message payload, the status lifecycle and its out-of-order traps, HMAC signature verification in Node.js, and the retry/idempotency patterns that separate a demo from a system you can run at 50,000+ messages a day. We'll close with how InfiQ's webhook relay maps onto all of this, because the payload shape is deliberately identical.
The subscription and verification handshake
Before Meta delivers a single event, it verifies that you own the endpoint. When you register a callback URL in the App Dashboard (or via the API), Meta immediately fires a GET request at it with three query parameters:
hub.mode— always the stringsubscribehub.verify_token— the secret string you chose when configuring the webhookhub.challenge— a random string Meta expects echoed back
Your job: check that hub.verify_token matches what you configured, then respond 200 with the raw hub.challenge value as the body. Not JSON-wrapped, not quoted — the bare string.
// GET /webhook — verification handshake
app.get("/webhook", (req, res) => {
const mode = req.query["hub.mode"];
const token = req.query["hub.verify_token"];
const challenge = req.query["hub.challenge"];
if (mode === "subscribe" && token === process.env.VERIFY_TOKEN) {
return res.status(200).send(challenge); // raw string, nothing else
}
return res.sendStatus(403);
});
Two gotchas that cost teams real time. First, the endpoint must be publicly reachable over HTTPS with a valid certificate at the moment you click "Verify and save" — self-signed certs and HTTP fail silently in the dashboard with an unhelpful error. Second, verifying the URL is not enough. You must also subscribe your app to the messages field under the WhatsApp Business Account (WABA), either in the dashboard or via POST /{waba-id}/subscribed_apps. Skipping this is the single most common "my webhook never fires" cause we see across accounts.
Anatomy of an inbound message payload
Every event — message or status — arrives as a POST with the same envelope: object, then an entry array, then a changes array, then a value object. The nesting looks excessive until you realise one HTTP request can batch multiple events. Here's a real-world-shaped inbound text message, annotated:
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "102290129340398", // your WABA ID
"changes": [
{
"field": "messages", // the subscribed field
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "919876543210",
"phone_number_id": "106540352242922" // which of your numbers received it
},
"contacts": [
{
"profile": { "name": "Priya Sharma" }, // sender's WhatsApp display name
"wa_id": "919812345678" // sender's number, E.164 without '+'
}
],
"messages": [
{
"from": "919812345678",
"id": "wamid.HBgLOTE5ODEyMzQ1Njc4FQIAEhggQjM0...", // the wamid — your dedup key
"timestamp": "1751871625", // Unix seconds, as a STRING
"type": "text",
"text": { "body": "Is the blue kurta available in M?" }
}
]
}
}
]
}
]
}
Things worth internalising:
phone_number_idis your routing key. If you run multiple numbers on one WABA (a D2C brand with separate support and marketing lines, say), every event tells you which number it hit. Route on this, not ondisplay_phone_number.typedrives everything downstream. Beyondtextyou'll receiveimage,document,audio,video,sticker,location,contacts,interactive(button and list replies),button(template quick replies),order(catalog carts),reaction, and the ones nobody handles until production:unsupportedandsystem. Write a switch with an explicit default branch that logs and acknowledges — an unhandled type must never crash your handler.- Media messages don't contain media. An
imagemessage carries a mediaid; you exchange it for a short-lived download URL via the media endpoint, then fetch the binary with your access token. Those URLs expire in minutes, so download promptly and store in your own object storage. - Interactive replies carry the ID you set. A button reply comes back with the
idyou assigned when sending — design those IDs as machine-readable keys (confirm_order_8841), not display text. - Timestamps are strings. Unix seconds, quoted. Parse accordingly before your ORM does something creative.
If you're staring at a payload trying to work out which nested field you need, paste it into our webhook payload formatter — it annotates each field inline and flattens the envelope for you.
The status lifecycle: sent, delivered, read, failed
For every message you send, Meta pushes status events into the same webhook, under value.statuses instead of value.messages:
| Status | Meaning | Typical arrival |
|---|---|---|
sent |
Accepted by WhatsApp servers, en route | Under a second, typically |
delivered |
Reached the recipient's device | Seconds to hours (device may be offline) |
read |
Recipient opened the chat | Only if read receipts are enabled |
failed |
Permanently undeliverable | Immediately or up to ~30 days later |
Each status object carries the id (the same wamid returned when you sent the message), status, recipient_id, timestamp, and — on billable events — a conversation and pricing object telling you which conversation category the message was attributed to.
A few practical notes on each state:
readis best-effort. Users who disable read receipts generatedeliveredand stop. Never build business logic ("escalate if unread in 10 minutes") that assumes read events exist for everyone. In our experience, read-receipt coverage typically sits somewhere in the 70–90% range depending on the audience, so treat it as a signal, not ground truth.failedcarries anerrorsarray with a code, title, and often anerror_data.detailsstring. The codes worth alerting on:131047(re-engagement needed — you messaged outside the 24-hour window without a template),131026(recipient not on WhatsApp or can't receive — common with landlines uploaded into CRMs),131049/131050(Meta limited delivery of marketing messages to that user), and130472(user is in an experiment group). A spike in131049on a broadcast is an early quality-rating warning; treat it like smoke.- A
deliveredthat never comes isn't an error. The recipient's phone may be off for a week. Design timeouts around your own business rules, not around the assumption that WhatsApp will tell you promptly.
Verifying signatures: HMAC SHA-256
Every webhook POST includes an X-Hub-Signature-256 header: sha256= followed by an HMAC of the raw request body, keyed with your app secret (from the App Dashboard — not your access token, not your verify token; three different secrets, three different jobs).
If you skip verification, anyone who discovers your endpoint URL can inject forged "inbound messages" into your system. For a commerce bot that auto-confirms orders or a support system that closes tickets on customer replies, that's not theoretical.
The critical implementation detail: the HMAC is computed over the raw bytes of the body. If your framework parses JSON before you can read the raw body, JSON.stringify(req.body) will usually reproduce the original — until a payload contains a Unicode escape or key ordering that serialises differently, and verification starts failing intermittently. Capture the raw buffer:
const crypto = require("crypto");
const express = require("express");
const app = express();
// Preserve the raw body alongside the parsed JSON
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);
function verifySignature(req) {
const header = req.get("X-Hub-Signature-256") || "";
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.APP_SECRET)
.update(req.rawBody)
.digest("hex");
// timingSafeEqual throws on length mismatch — guard first
if (header.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
app.post("/webhook", (req, res) => {
if (!verifySignature(req)) {
return res.sendStatus(401);
}
// Acknowledge immediately; process out of band
res.sendStatus(200);
enqueue(req.body); // push to your queue, then return
});
Use crypto.timingSafeEqual, not ===, to avoid timing side-channels — pedantic, perhaps, but it's one line. And rotate your app secret if it ever lands in a log or a client-side bundle; every historical payload becomes forgeable otherwise.
Retries, ordering and idempotency: key on the wamid
Meta considers a delivery failed if your endpoint doesn't return an HTTP 200 quickly — the documented budget is small, and in practice you should treat anything beyond low single-digit seconds as a timeout risk. Failed deliveries are retried with backoff, for up to several days on subscribed fields. Two consequences follow:
Acknowledge first, process later. Your webhook handler should do exactly three things synchronously: verify the signature, enqueue the payload (SQS, Pub/Sub, Redis stream, Kafka — anything durable), and return 200. Database writes, media downloads, LLM calls, CRM syncs — all of it happens off the request path. Teams that process inline see cascading retries the first time their database has a slow minute: the retry storm arrives on top of live traffic, latency climbs further, more retries queue up, and eventually Meta may pause deliveries to a persistently failing endpoint.
Every consumer must be idempotent. Because retries exist, you will receive duplicates — the same message payload two, three, occasionally more times. The wamid (messages[].id for inbound, statuses[].id for statuses) is globally unique per message and is your idempotency key. The pattern we recommend:
async function handleInboundMessage(msg) {
// Composite key: wamid + event kind, since a message and its
// statuses share the same wamid on the sending side
const dedupKey = `wh:${msg.id}:inbound`;
// SET NX with a TTL — atomic check-and-claim
const claimed = await redis.set(dedupKey, "1", "EX", 7 * 86400, "NX");
if (!claimed) {
return; // duplicate delivery — already processed or in flight
}
await processMessage(msg); // your actual business logic
}
A seven-day TTL comfortably outlives Meta's retry horizon without growing your keyspace forever. For status events, extend the key with the status name (wh:${id}:status:read), since one wamid legitimately produces up to three non-terminal status events. And remember the ordering rule from earlier: dedup stops duplicate events, but only a forward-only state machine stops out-of-order ones. You need both.
One more subtlety: a single webhook POST can contain multiple entries, each with multiple messages. Iterate every level of the envelope. Code that reads entry[0].changes[0].value.messages[0] works in testing and silently drops events in production — we see this across accounts more often than we'd like.
Testing webhooks locally
Meta can't reach localhost, so local development needs one of three approaches:
-
A tunnel.
ngrok http 3000(or Cloudflare Tunnel, which gives you a stable hostname on the free tier) exposes your dev server publicly. Point the App Dashboard's callback URL at the tunnel, run the verification handshake against your laptop, and iterate. The catch with ngrok's free tier is that the URL changes on every restart, which means redoing the handshake — a stable tunnel hostname saves genuine time. -
Replay captured payloads. Once you've logged a handful of real payloads (one per message type, plus each status), you don't need Meta at all for most iteration. A
curlwith the saved JSON body — plus a locally computedX-Hub-Signature-256using your dev app secret — exercises the full path including signature verification. Keep afixtures/directory of these in the repo; they double as regression test inputs. -
Meta's test number. Every app gets a test phone number that can message up to five verified recipients without template approval or payment setup. It's the fastest way to generate genuine payloads (send yourself a location, a document, a button reply) before you have a production number.
Whichever route you take, write integration tests that feed your handler malformed payloads too: empty changes arrays, unknown type values, statuses for wamids you've never seen (this happens after a redeploy that loses in-flight state). The handler's contract is simple — never 5xx on anything Meta could plausibly send.
How InfiQ webhooks work
If you build on InfiQ rather than raw Cloud API, the webhook model is intentionally familiar — we forward the same payload shape Meta sends, so everything above about envelope structure, message types, status lifecycle, and wamid-keyed idempotency applies unchanged. Code written against Meta's payloads ports over without a translation layer, and vice versa.
The differences are operational:
- Authentication uses an
X-INFIQ-API-KEYheader on every delivery instead of Meta's HMAC scheme. Compare it against the key from your dashboard with a constant-time check, exactly as you would a signature. One static header check replaces raw-body capture and HMAC plumbing, which removes the most common class of verification bugs — though if you prefer, the raw Meta signature material is preserved so you can verify both. - Dashboard replay. Every webhook delivery — payload, response code, latency, attempt count — is logged and inspectable in the InfiQ dashboard, and any event can be replayed to your endpoint with one click. When your consumer had a bad deploy at 2 a.m. and dropped forty minutes of events, replay beats reconstructing state from Meta's retry behaviour. It's also the fastest local-testing story: point a tunnel at your laptop and replay real production payloads against dev code.
- Managed retries with visibility. We retry failed deliveries with exponential backoff and surface the failure reason (timeout, TLS error, non-2xx) per attempt, so "why didn't my endpoint get this?" is a dashboard lookup rather than an archaeology project.
Endpoint setup, key rotation, and the full event reference live in the developer docs. Webhook access comes with the Growth plan (Rs.2,999/month) and above; once you're on Growth, you can point real payloads at your endpoint with a test number before going live. (The 7-day free trial runs on the Lite plan, which doesn't include webhooks.)
A production checklist
Before you point real traffic at your endpoint, walk this list:
| Check | Why it matters |
|---|---|
Handshake echoes raw hub.challenge |
Dashboard verification fails otherwise |
App subscribed to messages field on the WABA |
URL verification alone delivers nothing |
| Signature verified against raw body bytes | Parsed-then-restringified JSON fails intermittently |
200 returned before any processing |
Slow handlers trigger retry storms |
Dedup on wamid (+ status name for statuses) |
Retries guarantee duplicates |
| Forward-only status state machine | read can arrive before delivered |
| Every entry/change/message iterated | Single-index access drops batched events |
Unknown type logged, acknowledged, never 5xx'd |
Meta ships new types; your handler shouldn't care |
failed errors alerting on codes 131047/131049 |
Early warning for window violations and quality limits |
| Media downloaded promptly to your own storage | Media URLs expire in minutes |
None of this is exotic engineering. It's a queue, a dedup key, a state machine, and the discipline to acknowledge fast and process slow. Get those four right and the webhook layer becomes the boring part of your WhatsApp stack — which is exactly what it should be.

