Problem Summary
Webhooks are the heartbeat of a WhatsApp integration. When they stop arriving, you lose visibility into delivery status, inbound messages, and user opt-outs. The frustrating part: Meta considers a webhook delivered the moment your server returns 200 OK. If you are not returning 200, or if the verification handshake failed, Meta retries a limited number of times and then stops sending events entirely silently.
Symptoms
- Message status never updates from 'sent' no 'delivered' or 'read' events appear in your system
- Inbound customer replies are not triggering any action in your application
- The webhook URL in the Meta Developer Console shows a red error indicator
- Webhook logs in Meta show delivery attempts with non-200 response codes
- Your server logs show no incoming POST requests from Meta's IP ranges
- Webhook verification GET request returns an error when you save the URL in Meta console
- Events are missing for some fields (e.g., you receive message events but not status events)
- Everything worked locally with ngrok but fails in production
Business Impact
Without webhooks your WhatsApp integration becomes one-directional you can still send messages but you are flying blind. You cannot confirm delivery, cannot process customer replies, cannot detect opt-outs (which means you may keep sending to people who said STOP and damage your quality rating), and cannot implement any conversational flow. Effectively, the integration is broken even though outbound sending continues to work.
Root Cause
Webhook delivery fails at one of four layers: the initial verification handshake (GET request), the SSL/TLS layer, the HTTP response your server sends, or the subscription configuration in the Meta Developer Console. The most common production failure is the body parsing problem Express's json() middleware consumes and re-serializes the raw body, which can corrupt payload parsing if the middleware order is wrong. The second most common is returning a non-200 status before fully processing the event.
Meta stops retrying webhook delivery after approximately 36 hours of consecutive failures. After that point, events are permanently lost they will not be replayed when you fix the endpoint.
Diagnosis
Step 1 Confirm the Verification Handshake Is Working
When you register a webhook URL, Meta sends a GET request with three query parameters: hub.mode, hub.verify_token, and hub.challenge. Your server must respond with the hub.challenge value as plain text with a 200 status. If this fails, the URL is rejected and no events will ever be sent.
import express from "express";
const router = express.Router();
const VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN;
router.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 === VERIFY_TOKEN) {
console.log("Webhook verified");
res.status(200).send(challenge); // Must be plain text, not JSON
} else {
console.error("Verification failed", { mode, token });
res.sendStatus(403);
}
});Do not use res.json({ challenge }) Meta expects the raw challenge string as the response body, not a JSON-wrapped value. This is a common mistake that causes silent verification failure.
Step 2 Verify SSL Certificate Validity
Meta requires a valid, publicly trusted SSL certificate on your webhook endpoint. Self-signed certificates, expired certificates, or certificates with chain errors will cause Meta to reject the connection before your server even sees the request.
curl -v --max-time 10 https://your-domain.com/webhook 2>&1 | grep -E '(SSL|TLS|certificate|verify|expire)'openssl s_client -connect your-domain.com:443 -servername your-domain.com 2>/dev/null | openssl x509 -noout -datesIf the certificate is from Let's Encrypt, check that auto-renewal is configured and the cert has not expired. Certificates typically expire every 90 days.
Step 3 Audit HTTP Response Codes
Your webhook POST handler must return 200 immediately, before doing any async processing. If you await a database write or an API call before responding, and that operation takes more than 20 seconds, Meta times out and marks the delivery as failed.
router.post("/webhook", (req, res) => {
// Respond IMMEDIATELY do not await anything before this line
res.sendStatus(200);
// Process asynchronously after responding
setImmediate(() => {
processWebhookPayload(req.body).catch((err) => {
console.error("Webhook processing error", err);
});
});
});
async function processWebhookPayload(body: any) {
const entry = body.entry?.[0];
if (!entry) return;
const changes = entry.changes ?? [];
for (const change of changes) {
if (change.field === "messages") {
await handleMessagesChange(change.value);
}
}
}Step 4 Fix Raw Body vs Parsed JSON
If you need to validate the X-Hub-Signature-256 header (strongly recommended), you need access to the raw request body before Express parses it. The order of middleware matters critically here.
import crypto from "crypto";
import express from "express";
const app = express();
// IMPORTANT: Register the raw body capture BEFORE express.json()
app.use(
express.json({
verify: (req: any, _res, buf) => {
req.rawBody = buf; // Save raw body for signature validation
},
})
);
function validateSignature(req: any): boolean {
const signature = req.headers["x-hub-signature-256"] as string;
if (!signature) return false;
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.WHATSAPP_APP_SECRET!)
.update(req.rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
app.post("/webhook", (req: any, res) => {
if (!validateSignature(req)) {
return res.sendStatus(403);
}
res.sendStatus(200);
setImmediate(() => processWebhookPayload(req.body));
});Step 5 Check Webhook Subscription Fields in Meta Developer Console
Even if your endpoint is working perfectly, you will only receive events for the fields you subscribed to. In the Meta Developer Console, go to: Your App → WhatsApp → Configuration → Webhook Fields. Each field (messages, message_template_status_update, account_alerts) must be individually subscribed.
You must subscribe to the 'messages' field to receive both inbound messages AND delivery status updates. These are not separate subscriptions all message-related events come through a single 'messages' webhook field.
curl -X GET 'https://graph.facebook.com/v20.0/<APP_ID>/subscriptions?access_token=<APP_ACCESS_TOKEN>'Production Failure Scenarios
Scenario 1 Works on ngrok, Fails in Production
Your production server is likely behind a load balancer or reverse proxy that terminates SSL and forwards requests on HTTP. Meta's SSL validation happens at the edge, so the proxy must have a valid cert. Additionally, if your proxy modifies the request body (some proxies do re-encode JSON), the signature will mismatch.
Scenario 2 Webhook Stops After Deployment
A new deployment changed the WHATSAPP_VERIFY_TOKEN or WHATSAPP_APP_SECRET environment variable or the variable is simply missing in the new environment. Verify that production environment variables are set and match the values registered in the Meta Developer Console.
Scenario 3 Events Received But Wrong Field
Your handler checks change.field === 'messages' but you are looking at the wrong nesting level. The payload structure is: body.entry[0].changes[0].field. Some teams accidentally check body.field or entry.field and miss all events.
// CORRECT: navigate the full nested structure
const field = body?.entry?.[0]?.changes?.[0]?.field;
const value = body?.entry?.[0]?.changes?.[0]?.value;
// WRONG: these will always be undefined
// const field = body.field;
// const field = body.entry.field;Prevention Checklist
- Always respond to webhook POST requests with 200 before doing any async work
- Validate X-Hub-Signature-256 on every incoming webhook to prevent spoofed events
- Monitor webhook delivery logs in Meta Developer Console weekly
- Set up an uptime monitor on your /webhook GET endpoint to catch SSL expiry
- Store webhook events in a database immediately and process them from a queue never process inline
- Use a dead-letter queue for events that fail processing so they can be retried without re-delivery
- Test verification endpoint with curl before registering in Meta console
- Document all subscribed webhook fields and review after any Meta Developer Console changes
Resolution Summary
Check the four failure layers in order: verification handshake (GET handler returning raw challenge string), SSL certificate validity, HTTP response code and timing (200 before any async work), and subscription field configuration in the Meta Developer Console. The raw body / signature validation order is the most common code-level bug. Missing subscription fields is the most common configuration oversight.
Lessons Learned
- Treat the 200 response as an acknowledgment contract break it and Meta stops trusting your endpoint
- Signature validation is not optional in production; without it anyone can forge events to your endpoint
- Always persist incoming webhook payloads to a store before processing Meta will not replay lost events
- ngrok hides SSL and proxy issues; always test production-like TLS in staging before going live
- Webhook field subscriptions are app-level config that survives code deployments but can be accidentally changed in the console
Conclusion
Webhook reliability is foundational to any WhatsApp integration. The failure modes are narrow and well-defined; once you understand that Meta validates SSL, requires an immediate 200 response, and uses field-level subscriptions, debugging becomes a structured checklist rather than a guessing game. Implement signature validation, respond before processing, and store events durably those three practices eliminate the vast majority of webhook reliability issues.