Handling WhatsApp Webhooks: A Developer Guide
A security-forward, end-to-end guide to receiving WhatsApp Cloud API webhooks — the verification handshake, message and status payloads, HMAC signature validation, idempotent retry handling, and local testing with ngrok, plus a working Express receiver.
- WhatsApp webhooks are part of the Meta-hosted WhatsApp Cloud API. You register one Callback URL plus a Verify Token in the Meta App Dashboard under WhatsApp - Configuration.
- Setup is a one-time GET handshake: Meta sends hub.mode, hub.verify_token and hub.challenge; you check mode and token, then echo back hub.challenge with HTTP 200 (403 on mismatch).
- Every notification is a POST. Inbound messages arrive in value.messages[]; delivery receipts in value.statuses[] with status values sent, delivered, read, failed (and the conditional deleted).
- Always validate the X-Hub-Signature-256 header: HMAC-SHA256 over the raw request body, keyed by your App Secret, compared with crypto.timingSafeEqual. Never re-serialize the parsed JSON first.
- Delivery is at-least-once, so duplicates are normal. Acknowledge within ~5 seconds, process async, and deduplicate on the message id. Meta retries failures with backoff for up to ~7 days (shorter behind some BSPs), then drops the event.
What WhatsApp webhooks are (and which API they belong to)
If you want your app to react to incoming WhatsApp messages or know when a message you sent was delivered or read, you do not poll an endpoint — WhatsApp pushes events to you over HTTPS. Those pushes are webhooks. This guide is about the official path: the WhatsApp Cloud API, part of Meta's WhatsApp Business Platform. The Cloud API is Meta-hosted and is the sanctioned replacement for the deprecated, self-hosted On-Premises API. Everything here is configured in the Meta App Dashboard under WhatsApp - Configuration.
Webhooks are a Meta-wide mechanism, so the shapes you will see are the same family used across Facebook products. For WhatsApp the relevant object type is whatsapp_business_account (the WABA). A single Callback URL receives two things you care about most: inbound messages from users, and status receipts for the messages you send. Both arrive as HTTP POST requests with a JSON body, and your job is to verify them, acknowledge them quickly, and process them reliably.
Step 1: the verification handshake (hub.challenge)
Before Meta will send you any events, it confirms that you own the Callback URL. The moment you save the URL in the dashboard, Meta sends a single GET request to it with three query parameters: hub.mode (always the string subscribe), hub.verify_token (the exact string you typed into the Verify Token field), and hub.challenge (a random string Meta generates).
Your endpoint must check that hub.mode equals subscribe and that hub.verify_token matches the token you configured, then respond with HTTP 200 and the value of hub.challenge as the plain-text body. If either check fails, return 403. One Express gotcha: the parameter names literally contain dots, so you read them with bracket notation — req.query["hub.mode"], not req.query.hub.mode. The Verify Token is used only during this one-time setup; it is not a per-message credential, so do not rely on it to authenticate ongoing notifications.
// GET /webhook — Meta's one-time 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) {
// Echo the challenge back as the plain response body.
return res.status(200).send(challenge);
}
// Wrong mode or mismatched token.
return res.sendStatus(403);
});Step 2: understanding the POST payload structure
Once verified, real events arrive as POST requests. The envelope is consistent: a top-level object set to whatsapp_business_account, then an entry array. Each entry has an id (your WABA id) and a changes array. Each change carries a field (for messaging this is messages) and a value object that holds the actual content. Note the nesting — entry and changes are both arrays, so a single delivery can batch several events, and your code must loop, not index blindly at [0].
Inside value, inbound messages live in value.messages[] and outbound delivery receipts live in value.statuses[]. The two are mutually exclusive within a given change. Below is a real-shape inbound text message.
{
"object": "whatsapp_business_account",
"entry": [{
"id": "<WABA_ID>",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "...", "phone_number_id": "..." },
"contacts": [{ "profile": { "name": "..." }, "wa_id": "..." }],
"messages": [{
"from": "...",
"id": "wamid....",
"timestamp": "...",
"type": "text",
"text": { "body": "Hi!" }
}]
}
}]
}]
}The message id (the wamid... value) is the most important field for reliability — it is your idempotency key, covered later. The type field tells you how to read the body: a text message has a text.body, while images, audio, location, interactive replies and so on each have their own sub-object. The contacts[] array gives you the sender's wa_id and public profile name in the same payload.
Status receipts: sent, delivered, read, failed
When you send a message, Meta reports its lifecycle back to the same webhook as a value.statuses[] entry. Each status has an id (the wamid of the message you sent), a status string, a timestamp, the recipient_id, and — for billable conversations — conversation and pricing sub-objects. The status string moves through a predictable set of values.
| status value | What it means | Typical use |
|---|---|---|
| sent | Message accepted by WhatsApp servers, in transit | Confirm the send succeeded |
| delivered | Reached the recipient's device | Update your UI / delivery metrics |
| read | Recipient opened the chat (if read receipts on) | Trigger follow-ups, mark as seen |
| failed | Could not be delivered; inspect the errors array | Retry logic, alerting, opt-out cleanup |
| deleted | Message was deleted (conditional — see note) | Reconcile your local record |
One 2025 change to budget for: as of July 1, 2025, WhatsApp moved from conversation-based to per-message pricing, and status receipts now include a pricing_type sub-field. If you bill clients or track spend off webhook data, read pricing from the status payload rather than assuming the older conversation model. Enum values and the exact pricing fields do shift, and pricing_type is not guaranteed stable across every event shape — verify the current shape against Meta's live status reference (https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status/) before you ship billing logic, since this is the highest-stakes claim to get wrong.
Step 3: validating X-Hub-Signature-256 (do not skip this)
Your Callback URL is public, so anyone who learns it can POST fake events. The defense is signature validation. Meta signs the raw request body with HMAC-SHA256 keyed by your App Secret (not the Verify Token) and sends the result in the X-Hub-Signature-256 header, prefixed with sha256=. There is a legacy X-Hub-Signature header using SHA-1 — ignore it and validate the SHA-256 version.
const express = require("express");
const crypto = require("crypto");
const app = express();
// Capture the raw body so we can verify the signature over exact bytes.
// Mount this BEFORE any route handlers, so req.rawBody and req.body
// are populated by the time the POST /webhook handler runs.
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; }
}));
function isValidSignature(req) {
const header = req.get("X-Hub-Signature-256") || "";
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.APP_SECRET)
.update(req.rawBody) // raw Buffer, not JSON.stringify(req.body)
.digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
// Lengths must match before timingSafeEqual, or it throws.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Middleware order matters here. The express.json() call above must be mounted before your POST /webhook route so that, by the time the handler runs, the verify hook has already populated req.rawBody (and req.body is parsed). If you register the route first, or mount a second body parser ahead of this one, req.rawBody will be undefined and every signature check will fail. Apply this parser only to the webhook path if other routes need different parsing.
Compare with crypto.timingSafeEqual, never with === or ==. A plain string comparison short-circuits on the first differing character, leaking timing information an attacker can use to forge a valid signature byte by byte. timingSafeEqual runs in constant time and closes that hole. Reject any request whose signature does not match before you trust a single field in the body.
Step 4: retries, timeouts and idempotency
WhatsApp webhook delivery is at-least-once. Duplicates are not a rare edge case — they are normal, especially under load or when your endpoint is briefly slow. Your handler must be idempotent: processing the same event twice must not double-send a reply, double-charge a customer, or create duplicate database rows.
The response budget is tight. Meta expects you to acknowledge fast — the commonly cited timeout is roughly 5 seconds (some BSPs document up to 10), so design for the lower bound rather than the looser one to avoid tripping needless retries. The pattern is: validate the signature, return HTTP 200 immediately, and do the real work asynchronously — push to a queue, then process. If you return a non-200 status or time out, Meta retries with exponential backoff for up to roughly seven days, then drops the event permanently. There is no dead-letter queue and no manual replay from Meta, so a handler that throws on a poison message can quietly lose data.
// POST /webhook — validate, dedupe, ack fast, process async
app.post("/webhook", async (req, res) => {
if (!isValidSignature(req)) return res.sendStatus(401);
// Acknowledge first so Meta does not retry while we work.
res.sendStatus(200);
for (const entry of req.body.entry ?? []) {
for (const change of entry.changes ?? []) {
const value = change.value ?? {};
for (const msg of value.messages ?? []) {
// Idempotency: SET with NX returns null if the id was already seen.
const fresh = await redis.set(`wa:msg:${msg.id}`, "1",
{ NX: true, EX: 86400 });
if (!fresh) continue; // duplicate — skip
await queue.add("inbound", { value, msg });
}
for (const st of value.statuses ?? []) {
await queue.add("status", { value, st });
}
}
}
});Deduplicate on the message id: messages[].id for inbound and statuses[].id for statuses. A Redis SETNX (SET with NX) and a short TTL is the standard trick — the first time you see an id you claim it and process; if the key already exists, you have seen this event and skip it. Keep the TTL longer than Meta's retry window for a given burst (a day is a safe default) so late retries are still caught.
Step 5: testing locally with ngrok
Meta only delivers to a public HTTPS URL with a valid certificate, so localhost is unreachable during development. ngrok solves this by tunneling a public HTTPS endpoint to your local port. Run your Express server on, say, port 3000, start the tunnel, and register the resulting URL plus /webhook as your Callback URL — for example https://<name>.ngrok.app/webhook.
# Run your receiver locally
node server.js # listening on :3000
# In another terminal, expose it over HTTPS
ngrok http 3000
# -> Forwarding https://<name>.ngrok.app -> http://localhost:3000
# Use https://<name>.ngrok.app/webhook as the Callback URLOne practical limitation: because Meta validates the domain and certificate, ngrok's free, random ephemeral domains are unreliable for the WhatsApp integration. In practice you want a reserved domain on a paid (pay-as-you-go) ngrok plan so the URL is stable across restarts and passes Meta's checks. A big upside of ngrok is its request inspector, which records every webhook delivery and lets you replay it — invaluable for iterating on your handler without re-triggering real WhatsApp messages each time.
Staying compliant once messages flow
Receiving webhooks is half the story; replying responsibly is the other half. The Cloud API enforces real rules and ignoring them hurts your quality rating and can get your number restricted. The essentials: collect explicit user opt-in before messaging, and honor opt-out (a 'Reply STOP' handler is the minimum). You can reply freely with regular messages only inside the 24-hour customer service window that opens when a user messages you; outside that window, business-initiated messages must use pre-approved template messages. Respect your messaging tier limits and watch your quality rating, since both throttle how much you can send.
If your goal is simply to know whether a number is on WhatsApp, or to fetch a public profile picture, display name, about text, or business flag, you do not need a full webhook pipeline or template approvals at all — a read-only lookup is simpler and carries no sending risk.
Frequently asked questions
If you just need to know whether a number is on WhatsApp and read its public profile picture, display name, about text, and business flag, our hosted API returns it in one HTTP call — read-only, no Callback URL, no template approvals, no ban risk.
Explore the WhatsApp Profile API