The request lifecycle
- Configure a public HTTPS callback URL and a private verification token in your Meta app settings.
- Complete Meta’s GET verification handshake by checking the mode and token, then returning the supplied challenge.
- For each POST, verify
X-Hub-Signature-256against the original raw request bytes and your Meta app secret. - Parse the verified JSON, durably enqueue or store it, then return a successful response promptly.
- Process events asynchronously and idempotently; record outcomes so retries do not repeat customer-facing work.
Meta documents signed event notifications and recommends validating the signature. Its webhook guidance also describes retries after unsuccessful deliveries, so endpoint logic must tolerate the same notification arriving again. See the Meta Webhooks guide and the WhatsApp Business Developer Hub for current platform setup and API details.
Verify signatures using the raw body
The signature is calculated over the exact bytes Meta sent. If JSON middleware parses and re-serializes the request first, whitespace or key ordering can change and signature checks may fail. Mount the raw-body handler before general JSON parsing, or use middleware that preserves the original bytes.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const appSecret = process.env.META_APP_SECRET;
const verifyToken = process.env.WHATSAPP_WEBHOOK_VERIFY_TOKEN;
if (!appSecret || !verifyToken) {
throw new Error('Configure Meta webhook secrets before starting');
}
// GET: Meta's one-time callback URL verification handshake.
app.get('/webhooks/whatsapp', (req, res) => {
const mode = req.query['hub.mode'];
const token = String(req.query['hub.verify_token'] ?? '');
const challenge = req.query['hub.challenge'];
const a = Buffer.from(token);
const b = Buffer.from(verifyToken);
const tokenMatches = a.length === b.length && crypto.timingSafeEqual(a, b);
if (mode === 'subscribe' && tokenMatches && typeof challenge === 'string') {
return res.status(200).send(challenge);
}
return res.sendStatus(403);
});
// Mount before express.json(), so req.body is the exact signed byte sequence.
app.post('/webhooks/whatsapp', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
const header = req.get('x-hub-signature-256') ?? '';
const match = /^sha256=([a-f0-9]{64})$/i.exec(header);
if (!match || !Buffer.isBuffer(req.body)) return res.sendStatus(401);
const expected = crypto.createHmac('sha256', appSecret).update(req.body).digest();
const received = Buffer.from(match[1], 'hex');
if (!crypto.timingSafeEqual(expected, received)) return res.sendStatus(401);
let event;
try {
event = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
// Persist/enqueue before acknowledging. Make the worker idempotent.
await durableQueue.enqueue(event);
return res.sendStatus(200);
});Keep the app secret and verification token in server-side secret storage or environment variables. Never include them in browser code, logs, public examples or error messages. Use HTTPS and return an error for a missing or invalid signature.
Make retries safe with idempotency
A network timeout can happen after your application has stored an event but before Meta receives your acknowledgement. The sender may retry. Design the worker so processing the same event twice does not send duplicate replies, create duplicate leads or apply an update twice.
- Persist the event or enqueue it durably before returning success; avoid relying on an in-memory queue.
- Use the WhatsApp message ID for inbound messages as a deduplication key. For status notifications, define a key that distinguishes meaningful status changes for the same message.
- Make database writes and the “already processed” marker atomic where possible. If a worker crashes, a retry should resume safely.
- Keep outbound actions separate from event receipt. Use an outbox or a job with its own idempotency guard for customer-visible replies.
- Retain enough event identifiers and processing outcomes to investigate retries without storing more customer data than needed.
A practical failure checklist
| Symptom | Check |
|---|---|
| Callback verification fails | Confirm the public URL, HTTPS certificate, GET route, exact verify token and unmodified challenge response. |
| Signature mismatch | Check that verification uses the correct Meta app secret and the original raw request bytes before JSON parsing. |
| Events arrive more than once | Treat delivery as retryable; inspect deduplication keys and ensure downstream actions are idempotent. |
| Events are delayed or missing | Check app subscriptions, subscribed fields, endpoint availability, response codes, queue health and server logs with secrets removed. |
| Endpoint times out under load | Move slow work out of the request path; durably record the event first, acknowledge, then process in workers. |
Before going live
- Test valid and invalid signatures, malformed JSON, duplicate events and temporary queue failures.
- Monitor webhook response codes, processing lag, retries, queue depth and dead-letter events.
- Redact access tokens, app secrets and sensitive message content from logs.
- Recheck Meta’s current WhatsApp policies, API version and setup requirements before release.
This guide is educational and is not a substitute for Meta’s current documentation or a security review of your application.