Handling WhatsApp Cloud API webhooks in Node: the four things that break in production
Last updated 12 September 2026
Last updated 12 September 2026.
A WhatsApp Cloud API webhook has to do two things Meta cares about: verify the request came from Meta, and return a 200 fast. Everything slow, including calling a model to write a reply, belongs on a queue behind it. Getting the second part wrong is what produces duplicate replies to customers, and it is the most common production bug in this integration.
This is the shape we run, with the four failures we hit getting there.
The endpoint, in full
Two routes on the same path. A GET for Meta's verification handshake, and a POST for messages.
import express from "express";
import crypto from "node:crypto";
const app = express();
// Meta signs the RAW body. If express.json() parses it before you
// capture the raw bytes, the signature will never verify.
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);
// 1. Verification handshake, called once when you set the webhook up
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.META_VERIFY_TOKEN) {
return res.status(200).send(challenge); // plain text, not JSON
}
return res.sendStatus(403);
});
// 2. Inbound messages
app.post("/webhook", async (req, res) => {
if (!verifySignature(req)) return res.sendStatus(403);
// Acknowledge first. Do the work after.
res.sendStatus(200);
try {
await enqueue(req.body);
} catch (err) {
// Never throw past the response. Meta already has its 200.
log.error({ err }, "failed to enqueue webhook");
}
});
function verifySignature(req) {
const header = req.get("x-hub-signature-256");
if (!header || !req.rawBody) return false;
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.META_APP_SECRET)
.update(req.rawBody)
.digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Failure 1: calling the model inside the handler
Meta expects a 200 quickly. A model call that writes a customer reply takes seconds, and a knowledge search in front of it takes more. Hold the response open for that and Meta treats the delivery as failed and retries it.
The retry arrives while your first call is still running. Both finish. The customer gets two replies to one question. In our experience this is the single most damaging bug in this integration, because it is invisible in development, where you send one message at a time and everything is fast.
The fix is structural, not a timeout tweak: respond 200 immediately, push the payload onto a queue, and let a worker do the slow work.
POST /webhook -> verify signature -> 200 -> queue
|
worker: retrieve, call model, send reply
Failure 2: a job id that the queue rejects
Retries still happen for ordinary reasons: a network blip on Meta's side, a deploy, a timeout somewhere. So the queue needs to be idempotent, keyed on something stable. The WhatsApp message id is the obvious key, since Meta sends the same id on a retry.
The obvious code is wrong if you use BullMQ:
// Throws: "Custom Ids cannot contain :"
await queue.add("inbound", payload, { jobId: `wa:${message.id}` });
BullMQ uses a colon as its own key separator in Redis, so a colon in a custom job id is rejected at add() time. Not at run time, at enqueue time, which means every message fails to queue. Use a separator the library allows:
await queue.add("inbound", payload, { jobId: `wa-${message.id}` });
Two things worth taking from this beyond the character itself. First, the same mistake sat in two places in our codebase, and the test for the webhook asserted the broken string, so the suite passed while production could not have worked. A test that asserts the value your code produces, rather than the value the dependency accepts, is not testing the integration. Second, an enqueue failure is silent unless you make it loud, which leads to the next one.
Failure 3: recording state before the work is queued
The sequence looks harmless:
await db.insert({ status: "queued" }); // 1
await queue.add("inbound", payload); // 2, throws
Step 2 fails, step 1 already committed, and now a row claims work is queued that nobody will ever run. In our case that pattern locked a tenant out of ingesting their website until a stale sweep ran hours later, because the code refused to start a second job while one was "in progress".
Queue first, then record, and if you need the row first, roll it back or mark it failed in the catch:
const row = await db.insert({ status: "queued" });
try {
await queue.add("inbound", payload, { jobId: `wa-${message.id}` });
} catch (err) {
await db.update(row.id, { status: "failed", error: String(err) });
throw err;
}
Failure 4: a placeholder app secret, and a silent 403
If META_APP_SECRET is unset or still a placeholder in production, verifySignature returns false for every request and the webhook answers 403 to everything. Meta's dashboard shows failed deliveries, your logs show nothing interesting, and the product looks broken in a way that points at the wrong layer.
Validate the configuration at boot, not at first request:
const required = ["META_APP_SECRET", "META_VERIFY_TOKEN", "DATABASE_URL", "REDIS_URL"];
const missing = required.filter((k) => !process.env[k] || process.env[k].startsWith("changeme"));
if (missing.length) {
throw new Error(`missing config: ${missing.join(", ")}`);
}
Two related traps from our own deploy, both in this family. A config object validated as a whole will pass when one container has every variable and another container, the migration one, does not. And z.coerce.boolean() on an environment variable makes DATABASE_SSL=false evaluate to true, because Boolean("false") is true in JavaScript. Parse booleans explicitly.
The payload shape, so you can stop unwrapping it in the console
Inbound text messages arrive nested four levels deep, and statuses arrive on the same endpoint:
const value = req.body.entry?.[0]?.changes?.[0]?.value;
const messages = value?.messages ?? []; // inbound from customers
const statuses = value?.statuses ?? []; // delivered, read, failed
const phoneNumberId = value?.metadata?.phone_number_id;
for (const m of messages) {
// m.id, m.from, m.timestamp, m.type
// m.text?.body for type "text"
// m.context?.id when the customer replied to a specific message
}
Two fields carry more weight than they look. metadata.phone_number_id is how you route an inbound message to the right tenant when one deployment serves many businesses, and it is the only reliable way to do it. context.id is the id of the message being replied to when someone uses swipe to reply, which is what lets you route a reply back to the right conversation rather than guessing at the most recent one.
Frequently asked questions
Why does my WhatsApp webhook verification fail? Three usual causes. The GET handler returns JSON instead of the plain hub.challenge value. The verify token in your environment does not match the one typed into the Meta dashboard. Or the endpoint is not reachable over public HTTPS with a valid certificate, which Meta requires.
Why is the signature never valid? Because the raw request body was consumed before you hashed it. Meta signs the exact bytes it sent, so capture the raw buffer in the JSON parser's verify hook and hash that, not the re-serialised object.
Why do customers get two replies to one message? Meta retried the delivery because your handler took too long to answer 200, and both the original and the retry produced a reply. Answer 200 before doing any work, and make the queued job idempotent on the WhatsApp message id.
Do I need a queue for a small deployment? Yes, as soon as a model call is in the path. The webhook budget is a couple of seconds and a model call is not. A queue is also what gives you retries, a dead letter view, and somewhere to look when a reply never arrived.
How do I route messages when one app serves many businesses? On metadata.phone_number_id from the webhook payload. Store it against the tenant when the number is connected, and look it up on every inbound message.
Written by Mu'men Tayyem, founder of M4B1 in Dubai. These are notes from building Jabee, a WhatsApp AI assistant that answers customers from a business's own website and calls the business's own APIs as agent tools.