Skip to content
Taha Media

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.

Published 5 min read

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:

  1. A one-time GET verification when you click "Verify and save" in the App Dashboard.
  2. 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 timingSafeEqual throws 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 (the wamid.… value)
  • status update: statuses[].id plus 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 UNIQUE key and INSERT … 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
  • 200 within a couple of seconds; durable write before ack
  • De-duplication on wamid and wamid + 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

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.