WhatsApp Cloud API webhooks in Node.js and TypeScript: verification, X-Hub-Signature-256 and de-duplication
A production-minded WhatsApp Cloud API webhook with no dependencies: the verification handshake, checking Meta's signature on the raw body, acknowledging fast, and handling retries without double-processing.
Most WhatsApp Cloud API tutorials stop once the first "hello" arrives in your terminal. The bugs show up later: a webhook anyone on the internet can post fake messages to, an order confirmed twice because Meta retried a delivery, or a handler so slow that Meta keeps re-sending. This guide builds the webhook properly, using only Node's standard library.
What the webhook has to do
Meta talks to your endpoint in two ways:
- A one-time GET verification when you click "Verify and save" in the App Dashboard.
- A POST for every event after that: inbound messages, delivery and read statuses, template status changes, quality updates.
Your job, in order: verify the handshake, prove each POST really came from Meta, acknowledge quickly, and process each event exactly once.
Step 1: the verification handshake
Meta sends three query parameters: hub.mode (always subscribe), hub.verify_token (the string you typed in the dashboard), and hub.challenge. If the token matches, respond with the challenge as-is:
if (req.method === "GET") {
const mode = url.searchParams.get("hub.mode");
const token = url.searchParams.get("hub.verify_token");
const challenge = url.searchParams.get("hub.challenge");
if (mode === "subscribe" && token !== null && token === cfg.verifyToken && challenge) {
return send(res, 200, challenge);
}
return send(res, 403, "forbidden");
}The verify token is not a security boundary for later requests. It only proves to Meta that you own the URL. The signature check is what protects you.
Step 2: check X-Hub-Signature-256 on the raw body
Meta signs every POST payload with SHA-256 using your App Secret, and sends it in the X-Hub-Signature-256 header, prefixed with sha256=. Three details catch people out:
- Sign the raw bytes. If a framework has already parsed the JSON and you re-serialise it, whitespace and key order change and the signature won't match. Read the raw body first.
- Compare in constant time, using
timingSafeEqual, not===. - Validate the format before comparing, since
timingSafeEqualthrows if the buffers differ in length.
import { createHmac, timingSafeEqual } from "node:crypto";
export function isValidSignature(rawBody: Buffer, header: string | string[] | undefined, appSecret: string) {
if (!appSecret || typeof header !== "string") return false;
const value = header.trim();
if (!value.startsWith("sha256=")) return false;
const receivedHex = value.slice(7);
if (!/^[0-9a-fA-F]{64}$/.test(receivedHex)) return false;
const expected = createHmac("sha256", appSecret).update(rawBody).digest();
const received = Buffer.from(receivedHex.toLowerCase(), "hex");
return expected.length === received.length && timingSafeEqual(expected, received);
}If a request fails the check, return 401 and don't process it. If real Meta deliveries start failing, the App Secret in your environment is wrong (a common cause: copying it from a different app).
Reading the raw body with a size limit, in plain Node:
function readRawBody(req: IncomingMessage, limit = 1_000_000): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
req.on("data", (c: Buffer) => {
size += c.length;
if (size > limit) { reject(new Error("too large")); req.destroy(); return; }
chunks.push(c);
});
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}Step 3: acknowledge fast, process after
Meta treats a non-200 response, or no response in time, as a failed delivery and retries it. Third-party guides describe the retries as continuing with backoff for days. If your handler calls your ERP, sends an email and writes three tables before replying, you'll eventually be slow enough to trigger retries, and then you're processing the same message again.
So respond 200 as soon as the signature is valid, then do the work:
send(res, 200, "ok");
setImmediate(() => processBody(body).catch(logError));In production, write the raw event to a durable queue or table before acknowledging, so a crash between the ack and the processing doesn't lose it.
One more edge case: if the body is signed correctly but isn't valid JSON, acknowledge it anyway and raise an alert. Retrying will never fix it.
Step 4: process each event exactly once
Even with fast acks, duplicates happen. De-duplicate on stable IDs from the payload:
- inbound message:
messages[].id(thewamid.…value) - status update:
statuses[].idplus the status (sent,delivered,read,failed), because one message legitimately produces several status events.
The payload shape is entry[] → changes[] → value, with field === "messages" for both messages and statuses:
for (const entry of body.entry ?? []) {
for (const change of entry.changes ?? []) {
if (change.field !== "messages") continue; // template/quality/account updates
const v = change.value;
for (const m of v.messages ?? []) {
if (!(await store.claim(`msg:${m.id}`))) continue; // seen before
await onMessage(m, v.metadata.phone_number_id);
}
for (const s of v.statuses ?? []) {
if (!(await store.claim(`status:${s.id}:${s.status}`))) continue;
await onStatus(s);
}
}
}An in-memory Map is fine for a single test instance. With more than one replica or any restarts, use something shared:
- Redis:
SET key 1 NX EX 604800(returns OK only the first time) - Postgres: a table with a
UNIQUEkey andINSERT … ON CONFLICT DO NOTHING
Step 5: subscribe your app to the WhatsApp Business Account
Configuring the webhook URL in the dashboard isn't always enough. The app must also be subscribed to the business account (WABA) whose numbers you want events for. If verification passes but no messages ever arrive, check the account's subscribed apps via the Graph API (GET /{WABA_ID}/subscribed_apps) and subscribe with a POST to the same edge.
Step 6: sending replies
Inside the 24-hour customer service window (opened when the user messages you), you can send free-form messages. Outside it, only approved templates. A send is a POST to the phone number's /messages edge on the Graph API, authenticated with a system user token. The temporary token from the dashboard expires after 24 hours:
curl -X POST "https://graph.facebook.com/$GRAPH_VERSION/$PHONE_NUMBER_ID/messages" \
-H "Authorization: Bearer $WA_TOKEN" -H "Content-Type: application/json" \
-d '{"messaging_product":"whatsapp","to":"9665XXXXXXXX","type":"text","text":{"body":"Thanks, we got your order."}}'Use the current Graph API version from Meta's changelog rather than copying an old one from a tutorial.
Costs you should know before going live
Since 1 July 2025, Meta bills per delivered message, by category (marketing, utility, authentication, service) and recipient country. From 1 October 2026, service replies are free only for the first 1,000 per business phone number per month, and utility messages sent inside an open service window are billed too. Check the official rate card for your markets.
A production checklist
- Raw-body HMAC check with constant-time compare, before JSON parsing
- Body size limit
200within a couple of seconds; durable write before ack- De-duplication on
wamidandwamid + status, shared across replicas - Permanent system-user token in a secret manager, never in the repo
- Logs that mask phone numbers and message bodies
- The WABA, the number and the app are owned by the business's own Meta portfolio, not by the agency
Sources
- Meta: Getting started with Webhooks (verification parameters,
X-Hub-Signature-256) - Meta: WhatsApp Cloud API Get Started
- Meta: Pricing on the WhatsApp Business Platform
- Node.js: crypto.timingSafeEqual
- Hookdeck: Guide to WhatsApp webhooks (third-party description of retry behaviour)
- Redis: SET with NX and EX
Want this done for you?
Kit
WhatsApp Cloud API webhook starter + ownership audit
A zero-dependency TypeScript webhook with 13 tests, a template sender with dry-run, a read-only asset audit script, and a 60-item audit checklist in English and Arabic.
Service
I can connect the official WhatsApp Cloud API for you
Account ownership and billing review, a secure webhook with signature checks and de-duplication, and approved template messages. Fixed price on Khamsat, delivered in 3 days.