Nurture TechnologiesNurture Tech
Integrations12 min·July 24, 2026

WhatsApp Webhooks Not Reaching Your Server

WhatsApp Cloud APINode.jsExpressngrokMeta Developer Console

Meta is sending webhook events but your server never processes inbound messages or delivery receipts. Trace the failure through token verification, SSL validation, response codes, body parsing, and subscription field configuration.

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.

webhook-verification.ts
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 -dates

If 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.

webhook-handler.ts
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.

body-parsing-setup.ts
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-payload-parsing.ts
// 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.

Nurture Technologies

NEED HELP WITH YOUR STACK?

Nurture Technologies builds and maintains production-quality software for startups and businesses. If engineering problems are slowing you down, our team can help.

Talk to our team →
FAQ

FREQUENTLY ASKED QUESTIONS

Why does my webhook verification keep failing even though my token matches?+

The most common cause is responding with JSON ({ challenge: '...' }) instead of the raw challenge string as plain text. Use res.status(200).send(challenge) not res.json({ challenge }).

How long does Meta retry failed webhook deliveries?+

Meta retries with exponential backoff for approximately 36 hours. After that, events are permanently lost and will not be replayed once you fix the endpoint.

Can I use HTTP instead of HTTPS for my webhook URL?+

No. Meta requires HTTPS with a valid, publicly trusted certificate for all production webhook endpoints. HTTP is only allowed for localhost development (not testable from Meta's servers).

What IP addresses does Meta use to send webhooks?+

Meta publishes its IP ranges but they change frequently. Rather than allowlisting IPs, validate the X-Hub-Signature-256 header using your app secret this is more reliable and Meta-recommended.

Why do I get some webhook events but not others?+

You are likely subscribed to some fields but not all. In Meta Developer Console go to: WhatsApp → Configuration → Webhook Fields and verify all required fields are subscribed and toggled on.

My server returns 200 but Meta still shows delivery failures why?+

Check that your 200 is returned within Meta's 20-second timeout. If your handler awaits a slow database write before responding, it may timeout even though it eventually returns 200. Respond first, process after.

How do I test webhook delivery without a public URL?+

Use ngrok (ngrok http 3000) during development. For integration testing, use the Meta Developer Console's webhook tester which can send sample payloads to any registered URL.

What is the X-Hub-Signature-256 header and do I need it?+

It is an HMAC-SHA256 signature of the request body using your app secret. You should validate it on every incoming request to ensure events are genuinely from Meta and not from a third party who discovered your webhook URL.

After fixing my endpoint, will missed events be replayed?+

No. Events that failed delivery during the outage period are permanently lost Meta does not support replay. Build your system to handle gaps by reconciling against the API if needed.

Why does my webhook work in one environment but not another?+

Environment variable mismatches are the most common cause the verify token or app secret differs between environments. Also check that the production reverse proxy is not modifying request bodies, which would break signature validation.