DEV Community

Cover image for Handling WhatsApp Cloud API Webhooks in Node.js: Signatures, Retries and Duplicate Events
WAPGROWAI for WAPGROWAI

Posted on

Handling WhatsApp Cloud API Webhooks in Node.js: Signatures, Retries and Duplicate Events

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?" }
              }
            ]
          }
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

A few things in that shape matter more than they look:

  • entry[].id is your WhatsApp Business Account ID, and metadata.phone_number_id tells you which of your numbers the event is for. If you run more than one number, route on phone_number_id, not on the display number.
  • Incoming messages and delivery statuses share the same messages field. A status update has a statuses array instead of messages, with sent, delivered, read, played (voice messages) or failed.
  • 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, every change, and every item in messages and statuses — never read just index 0.

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);
});
Enter fullscreen mode Exit fullscreen mode

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 messages field 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);
};
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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, its verify callback 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 a 413, 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:

  1. Process, then acknowledge. Slow work (database writes, calling an LLM, sending a reply) delays the 200, slow responses get retried, and retries become duplicates.
  2. 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.
  3. 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;
Enter fullscreen mode Exit fullscreen mode

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),
});
Enter fullscreen mode Exit fullscreen mode

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;
  }
  // …
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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);
};
Enter fullscreen mode Exit fullscreen mode

Two more things worth knowing about statuses:

  • A successful send is not a delivered message. The send API can return 200 with a wamid, and a failed status can arrive seconds later — for example error 131049, which Meta uses when it withholds a marketing message to "maintain healthy ecosystem engagement." Store errors[0].code and errors[0].error_data.details; the details string is the one worth showing to a person.
  • A status can beat your own database write. The sent webhook can arrive before the code that called the send API has saved the returned wamid. Write statuses as an upsert keyed on wamid, 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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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 wamid with a unique index, side effects only on a new row
  • [ ] Statuses ranked so they never regress; failed always applied, with error_data.details stored
  • [ ] Statuses written as upserts keyed on wamid
  • [ ] 24-hour window anchored on Meta's timestamp
  • [ ] Every entry, change, message and status handled — never just index 0
  • [ ] 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)

Collapse
 
challan116ux profile image
challan116-ux •

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.