The first version of every WhatsApp webhook handler looks the same: an Express route that parses the JSON, finds messages[0].text.body, and does something with it. It works in testing, and then production finds four ways to break it:
- The signature check fails — but only on some messages, usually the ones containing emoji or accented characters.
- The same customer message is processed twice, because Meta retried a delivery your server was slow to acknowledge.
- A message shows as "delivered" after it was already "read", because status updates are not guaranteed to arrive in order.
- Events disappear when the process restarts between acknowledging a webhook and finishing the work.
This post builds a handler that deals with each of these. It is about 120 lines of Node.js 22 and Express 5 across four small files, plus a script that sends correctly signed test payloads so you can try it locally without a tunnel. Every snippet below was run against the scenarios it describes.
What Meta actually sends
Every notification is a POST with a JSON body shaped like this — an incoming text message, from Meta's webhook reference:
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "102290129340398",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "15550783881",
"phone_number_id": "106540352242922"
},
"contacts": [{ "profile": { "name": "Sheena Nelson" }, "wa_id": "16505551234" }],
"messages": [
{
"from": "16505551234",
"id": "wamid.HBgLMTY1MDM4Nzk0MzkVAgASGBQzQTRBNjU5OUFFRTAzODEwMTQ0RgA=",
"timestamp": "1749416383",
"type": "text",
"text": { "body": "Does it come in another color?" }
}
]
}
}
]
}
]
}
A few things in that shape matter more than they look:
-
entry[].idis your WhatsApp Business Account ID, andmetadata.phone_number_idtells you which of your numbers the event is for. If you run more than one number, route onphone_number_id, not on the display number. -
Incoming messages and delivery statuses share the same
messagesfield. A status update has astatusesarray instead ofmessages, withsent,delivered,read,played(voice messages) orfailed. - Every outbound and inbound message carries a
wamid.…ID. That ID is the key to almost everything below. - Meta says batching cannot be guaranteed, and payloads can be up to 3 MB. Loop over every
entry, everychange, and every item inmessagesandstatuses— never read just index0.
Step 1: The verification handshake
When you save your callback URL in the App Dashboard, Meta sends a GET with hub.mode=subscribe, your hub.verify_token and a hub.challenge. If the token matches, you reply 200 with the challenge; otherwise anything other than 200.
// One-time handshake when you save the callback URL in the App Dashboard.
app.get("/webhook", (req, res) => {
const { "hub.mode": mode, "hub.verify_token": token, "hub.challenge": challenge } = req.query;
if (mode === "subscribe" && token === VERIFY_TOKEN) {
return res.status(200).type("text/plain").send(challenge);
}
res.sendStatus(403);
});
Three setup details that cost people an afternoon:
- The endpoint must be HTTPS with a valid certificate. Self-signed certificates are not supported.
- Saving the URL is not enough: subscribe to the
messagesfield under WhatsApp → Configuration in the App Dashboard, or nothing arrives. - Some webhooks are not sent while the app is in Development mode. If inbound messages never show up, check whether the app is Live.
If you onboard other businesses' numbers through Embedded Signup, your app also has to be subscribed to each of their WhatsApp Business Accounts with POST /{WABA_ID}/subscribed_apps.
Step 2: Verify the signature over the raw bytes
Every POST carries an X-Hub-Signature-256 header: sha256= followed by an HMAC-SHA256 of the payload, keyed with your app secret. Meta's docs say you don't have to validate it, but you should — without it, anyone who finds your URL can inject fake customer messages.
// signature.js
import crypto from "node:crypto";
export const isValidSignature = (rawBody, header, appSecret) => {
if (!header?.startsWith("sha256=")) return false;
const expected = crypto.createHmac("sha256", appSecret).update(rawBody).digest();
const received = Buffer.from(header.slice("sha256=".length), "hex");
// timingSafeEqual throws on a length mismatch, so check that first.
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
};
The check itself is easy. What breaks it is what you hash. Meta signs the exact bytes it sends, and those bytes can contain escaped Unicode (é instead of é). If you let a JSON parser run first and hash JSON.stringify(req.body), you are hashing a different string:
{"text":"Héllo 👋","n":1.50} -> 2040d1f39a2f… (what Meta signed)
{"text":"Héllo 👋","n":1.5} -> 05affcf7b64c… (after parse + stringify)
Same data, different bytes, different signature. Plain-ASCII test messages pass, the first customer who sends an emoji fails, and the bug looks random. Number formatting can change in the round trip too (1.50 became 1.5 above).
So the webhook route takes the body with express.raw() and parses it after the check. Two Express details:
-
Mount the route before any global
express.json(), or that middleware consumes the body first. If you need a global JSON parser, itsverifycallback can stash the raw buffer instead. -
Raise the body limit.
express.raw()defaults to 100 KB; Meta's payloads can be up to 3 MB. A large batch over the limit gets a413, Meta retries it, and it fails again.
Also: crypto.timingSafeEqual throws when the two buffers differ in length, so a malformed header would crash the request instead of being rejected. Hence the length check before it.
Step 3: Store first, acknowledge, then process
Meta expects a 200 for every notification. Anything else, and it retries with decreasing frequency for up to 7 days — and, in Meta's words, "these retries can result in duplicate webhook notifications."
That leaves three ways to order the work:
-
Process, then acknowledge. Slow work (database writes, calling an LLM, sending a reply) delays the
200, slow responses get retried, and retries become duplicates. -
Acknowledge, then process in memory. Fast, but if the process crashes or redeploys after the
200, the event is gone — Meta considers it delivered and won't send it again. -
Store, acknowledge, then process. Write the verified payload somewhere durable, return
200, and process it from there. A crash leaves an unprocessed row that the next start picks up.
Option 3 is the one that holds up:
// server.js
import express from "express";
import { isValidSignature } from "./signature.js";
import { saveEvent } from "./store.js";
import { processEvent, processPending } from "./process.js";
const { APP_SECRET, VERIFY_TOKEN, PORT = 3000 } = process.env;
if (!APP_SECRET || !VERIFY_TOKEN) throw new Error("Set APP_SECRET and VERIFY_TOKEN");
const app = express();
// One-time handshake when you save the callback URL in the App Dashboard.
app.get("/webhook", (req, res) => {
const { "hub.mode": mode, "hub.verify_token": token, "hub.challenge": challenge } = req.query;
if (mode === "subscribe" && token === VERIFY_TOKEN) {
return res.status(200).type("text/plain").send(challenge);
}
res.sendStatus(403);
});
// express.raw keeps the exact bytes Meta signed; a parsed-and-restringified body won't match.
app.post("/webhook", express.raw({ type: "application/json", limit: "3mb" }), async (req, res) => {
if (!isValidSignature(req.body, req.get("x-hub-signature-256"), APP_SECRET)) {
return res.sendStatus(401);
}
const eventId = await saveEvent(JSON.parse(req.body.toString("utf8")));
res.sendStatus(200);
processEvent(eventId).catch((error) => console.error(`[event ${eventId}] processing failed`, error));
});
app.listen(PORT, async () => {
console.log(`Listening on :${PORT}`);
await processPending();
});
export default app;
If saveEvent throws — the database is down — Express 5 turns the rejected promise into a 500, and Meta retries later. That is exactly what you want: the event is not acknowledged until it is safe.
For this post the store is in memory so the code runs as-is. In production, saveEvent is an insert into a webhook_events table and processEvent runs in a worker or a job queue; the shape stays the same.
// store.js
// In-memory stand-ins. In production these are tables with unique keys.
const events = new Map(); // eventId -> { payload, processed }
const messages = new Map(); // wamid -> message
const statuses = new Map(); // wamid -> { status, timestamp, errors }
let nextId = 1;
export const saveEvent = async (payload) => {
const id = nextId++;
events.set(id, { payload, processed: false });
return id;
};
export const getEvent = async (id) => events.get(id);
export const markProcessed = async (id) => {
events.get(id).processed = true;
};
export const pendingEventIds = async () =>
[...events].filter(([, e]) => !e.processed).map(([id]) => id);
// Returns false when this wamid was already stored, like INSERT ... ON CONFLICT DO NOTHING.
export const insertMessageIfNew = async (message) => {
if (messages.has(message.id)) return false;
messages.set(message.id, message);
return true;
};
export const getStatus = async (wamid) => statuses.get(wamid);
export const saveStatus = async (wamid, record) => {
statuses.set(wamid, record);
};
export const debugState = () => ({
events: events.size,
messages: [...messages.keys()],
statuses: Object.fromEntries(statuses),
});
Step 4: Deduplicate on the wamid, not the request
Because retries are expected, every handler has to be idempotent. The tempting shortcut is to fingerprint each webhook request and skip ones you've seen. It doesn't work: batching isn't guaranteed, so a retried message can arrive inside a differently shaped payload.
Deduplicate on the thing that is actually unique — the message's wamid:
const handleMessage = async (message, metadata) => {
const isNew = await store.insertMessageIfNew(message);
if (!isNew) {
console.log(`[message] duplicate ${message.id} ignored`);
return;
}
// …
};
In a real database, make the database enforce it rather than checking first and inserting second (two concurrent retries can both pass a check):
-- PostgreSQL
CREATE UNIQUE INDEX messages_wamid_key ON messages (wamid);
INSERT INTO messages (wamid, from_number, body, sent_at)
VALUES ($1, $2, $3, to_timestamp($4))
ON CONFLICT (wamid) DO NOTHING
RETURNING id; -- no row back = duplicate, stop here
(MySQL: a unique index plus INSERT IGNORE, then check the affected-row count.)
Run anything with side effects — replying, starting a chatbot flow, notifying an agent — only after the insert reports a new row.
Step 5: Status updates must never move backwards
Status webhooks tell you what happened to the messages you sent. Meta doesn't promise they arrive in order, and its reference notes that a message only counts as read once delivered — so if both happen at once, you may receive read without ever seeing delivered. If you write every status as it lands, a late delivered overwrites read, and your UI shows two grey ticks on a message the customer has already read.
Give each status a rank and only move forward. failed is the exception and always applies:
// process.js
import * as store from "./store.js";
const STATUS_RANK = { sent: 1, delivered: 2, read: 3, played: 4 };
const extractChanges = (payload) =>
(payload.entry ?? []).flatMap((entry) =>
(entry.changes ?? []).filter((change) => change.field === "messages").map((change) => change.value),
);
const handleMessage = async (message, metadata) => {
const isNew = await store.insertMessageIfNew(message);
if (!isNew) {
console.log(`[message] duplicate ${message.id} ignored`);
return;
}
// Meta's timestamp, not Date.now(): the 24-hour window is measured from when Meta received it.
const receivedAt = new Date(Number(message.timestamp) * 1000);
console.log(`[message] ${message.from} -> ${metadata.phone_number_id} (${message.type}) at ${receivedAt.toISOString()}`);
};
const handleStatus = async (status) => {
const current = await store.getStatus(status.id);
const isFailure = status.status === "failed";
const movesForward = (STATUS_RANK[status.status] ?? 0) > (STATUS_RANK[current?.status] ?? 0);
if (current && !isFailure && !movesForward) {
console.log(`[status] ignored ${status.status} for ${status.id} (already ${current.status})`);
return;
}
await store.saveStatus(status.id, {
status: status.status,
timestamp: status.timestamp,
errors: status.errors ?? [],
});
if (isFailure) {
const [error] = status.errors ?? [];
console.log(`[status] failed ${status.id}: ${error?.code} ${error?.error_data?.details ?? error?.title}`);
} else {
console.log(`[status] ${status.id} -> ${status.status}`);
}
};
export const processEvent = async (eventId) => {
const event = await store.getEvent(eventId);
if (!event || event.processed) return;
for (const value of extractChanges(event.payload)) {
for (const message of value.messages ?? []) await handleMessage(message, value.metadata);
for (const status of value.statuses ?? []) await handleStatus(status);
}
await store.markProcessed(eventId);
};
export const processPending = async () => {
for (const id of await store.pendingEventIds()) await processEvent(id);
};
Two more things worth knowing about statuses:
-
A successful send is not a delivered message. The send API can return
200with awamid, and afailedstatus can arrive seconds later — for example error131049, which Meta uses when it withholds a marketing message to "maintain healthy ecosystem engagement." Storeerrors[0].codeanderrors[0].error_data.details; the details string is the one worth showing to a person. -
A status can beat your own database write. The
sentwebhook can arrive before the code that called the send API has saved the returnedwamid. Write statuses as an upsert keyed onwamid, not as an update that silently matches zero rows.
Step 6: Use Meta's timestamp, not yours
The customer service window — the 24 hours in which you can reply with free-form messages instead of templates — is measured from the customer's last message. A webhook that arrives late because of a retry or a backlog still describes a message sent earlier.
That's why handleMessage uses message.timestamp (Unix seconds, as a string) rather than Date.now(). Anchor the window on your receive time and you'll believe you have minutes left that Meta doesn't agree you have, and free-form replies will start failing.
Step 7: Test it locally without a tunnel
The usual shortcut for local testing is to switch off signature verification in development. Don't: it means the code path that matters most is the one you never run. Sign your test payloads instead:
// send-test.js
import crypto from "node:crypto";
const { APP_SECRET, WEBHOOK_URL = "http://localhost:3000/webhook" } = process.env;
const payload = {
object: "whatsapp_business_account",
entry: [
{
id: "WHATSAPP_BUSINESS_ACCOUNT_ID",
changes: [
{
field: "messages",
value: {
messaging_product: "whatsapp",
metadata: { display_phone_number: "15550001111", phone_number_id: "PHONE_NUMBER_ID" },
contacts: [{ profile: { name: "Test User" }, wa_id: "15551234567" }],
messages: [
{
from: "15551234567",
id: process.argv[2] ?? "wamid.TEST1",
timestamp: String(Math.floor(Date.now() / 1000)),
type: "text",
text: { body: "Héllo 👋 from a signed test payload" },
},
],
},
},
],
},
],
};
const body = JSON.stringify(payload);
const signature = crypto.createHmac("sha256", APP_SECRET).update(body).digest("hex");
const res = await fetch(WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Hub-Signature-256": `sha256=${signature}` },
body,
});
console.log(res.status);
Run it:
npm install express@5
APP_SECRET=dev-secret VERIFY_TOKEN=dev-token node server.js
# in a second terminal
APP_SECRET=dev-secret node send-test.js wamid.TEST1 # 200, message logged
APP_SECRET=dev-secret node send-test.js wamid.TEST1 # 200, "duplicate wamid.TEST1 ignored"
APP_SECRET=wrong node send-test.js wamid.TEST2 # 401
(On Windows PowerShell, set variables with $env:APP_SECRET="dev-secret" first.) The test message deliberately contains é and an emoji, so it exercises the raw-body path from step 2.
When you're ready for real traffic, expose the server through any HTTPS tunnel, save that URL and your verify token in the App Dashboard, and send a message to your test number.
Production checklist
- [ ] Signature verified over the raw body, with the body limit raised to 3 MB
- [ ] Verified payload stored before the
200; unprocessed events picked up on restart - [ ] Messages deduplicated by
wamidwith a unique index, side effects only on a new row - [ ] Statuses ranked so they never regress;
failedalways applied, witherror_data.detailsstored - [ ] Statuses written as upserts keyed on
wamid - [ ] 24-hour window anchored on Meta's
timestamp - [ ] Every
entry,change, message and status handled — never just index0 - [ ] App secret and verify token in environment variables, never in the repo
Wrapping up
None of these failures shows up in a quick test with one ASCII message, which is why they tend to surface weeks after launch. Building for retries, duplicates and reordering from the start costs about a hundred lines.
I work on WAPGROWAI, a WhatsApp Business Platform where this pipeline runs in production — the lessons above come from it. If you'd rather build on top of an inbox, campaigns and chatbots than run webhook infrastructure yourself, our developer page covers what's available.
References
Top comments (2)
The duplicate-events part is the one people underestimate — Meta will retry, your handler will occasionally time out mid-write, and suddenly the same message is processed twice. What saved us on similar flows was recording the event ID before doing any real work, then making the handler return the original result on a repeat instead of re-running it. Signature verification on the raw body first, dedupe second, business logic third — in that order.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.