Engineering & Architecture · 5 min read · Aug 21, 2026

WhatsApp Cloud API Webhooks Not Firing: Verification, Retries and Silent Drops

Debug WhatsApp Cloud API webhooks properly: verification handshake failures, subscription gaps, signature checks, retry behaviour and the drops nobody logs.

AR
AR-Inbox Engineering Team
Platform Engineering
Maintains the webhook ingest path that receives every inbound message and status callback at AR-Inbox.
Share:

Too Long? Read This in 10 Seconds

Executive bullet points for fast decision-making

  • Verification failures are almost always the challenge echo or a TLS chain problem, not the token itself.
  • Subscribed at app level but receiving nothing usually means the number is not attached to that app.
  • Signature validation must run on the raw request body — any framework that re-encodes JSON breaks it.
  • Return 200 fast and process asynchronously, or retries will duplicate your inbound messages.

Webhook problems come in four flavours, and each has a distinct signature. Find yours before changing code.

The delivery path#

Customer sends message
  → WhatsApp Cloud
    → your subscribed app
      → HTTPS POST to your callback URL
        → your 200 response (within the timeout)

Any break in that chain looks identical from the outside: nothing arrives. The differences show up in the verification step, the app configuration, or your own logs.

Verification handshake failures#

WhatsApp verifies your endpoint with a GET carrying hub.mode, hub.verify_token and hub.challenge.

Requirements that trip people up:

  • Echo the challenge as a plain body, not wrapped in JSON. {"challenge": "..."} fails.
  • Status 200, no redirect. A 301 from HTTP to HTTPS, or a trailing-slash redirect, fails.
  • Compare the token exactly. Trailing whitespace pasted from a config file is a real cause.
  • Public TLS with a complete chain. Self-signed certificates and missing intermediates fail. Test with an external checker, not with your browser, which caches intermediates other clients do not have.
  • No auth middleware in front of the route. A global auth guard returning 401 or 302 on the callback path is one of the most common causes in framework apps.
// Verification
if ($request->query('hub_verify_token') === config('services.whatsapp.verify_token')) {
    return response($request->query('hub_challenge'), 200)
        ->header('Content-Type', 'text/plain');
}
return response('', 403);

Subscribed, but nothing arrives#

Verification passed and the endpoint is silent. Check in this order:

  1. Is the phone number attached to the app whose webhook you configured? An app can be verified and configured while the number belongs to another app.
  2. Are the fields subscribed? Subscribing the app is not the same as subscribing to messages. Statuses and message events are separate fields.
  3. Is the subscription on the right WABA? Multi-business setups routinely configure the wrong one.
  4. Are you looking at the right environment? A staging URL configured months ago quietly receives production traffic.
  5. Has your endpoint been backed off? Repeated non-200 responses cause reduced delivery attempts. Fix the endpoint, then re-verify.

A quick isolation test: point the callback URL at a request-capture service temporarily. If payloads arrive there, the problem is in your application, not in the configuration.

Signature validation rejecting valid payloads#

The signature header is computed over the raw request body. Validate before any parsing or re-encoding:

$raw = $request->getContent();                 // raw, untouched
$expected = 'sha256=' . hash_hmac('sha256', $raw, config('services.whatsapp.app_secret'));

if (! hash_equals($expected, (string) $request->header('X-Hub-Signature-256'))) {
    Log::warning('whatsapp.webhook.signature_mismatch');
    return response('', 401);
}

Two failure modes worth knowing:

  • Re-encoding. json_encode(json_decode($raw)) produces different bytes — key order, escaping, whitespace. The signature will never match.
  • Body-modifying middleware. Anything that trims, normalises or unwraps the body before your handler breaks validation invisibly.

Statuses stuck at sent#

sent means WhatsApp accepted the message. delivered requires the recipient's device to receive it; read requires them to open it.

If you only ever see sent:

  • Confirm you subscribed to the statuses field, not just messages.
  • Check whether you are filtering unknown payload shapes before logging them — status callbacks have a different structure from message callbacks and are easy to drop in a strict parser.

If delivery genuinely stalls rather than the callback missing, that is a delivery problem, not a webhook one — see messages not sending and broadcast not delivering.

Retries, duplicates and idempotency#

Failed or slow deliveries are retried. Two consequences:

Return 200 immediately. Acknowledge, queue, process asynchronously. Any synchronous work — a database write chain, an external API call — risks exceeding the timeout, which produces a retry, which produces a duplicate.

Deduplicate on the message id.

if (InboundMessage::where('wa_message_id', $id)->exists()) {
    return; // already processed; retry or duplicate delivery
}

Payloads can also arrive out of order. A read status can land before the delivered you have not processed yet, so store status transitions by timestamp rather than assuming sequence.

Building a webhook you can actually debug#

  • Log the raw payload before parsing, with a trace id, and keep it for long enough to investigate a complaint.
  • Assign a trace id at ingest and carry it through every downstream job, so one customer's message can be followed end to end.
  • Store unparseable payloads rather than discarding them. New fields appear without warning; a silent continue in a parser is how you lose a week.
  • Alert on absence. A webhook that stops receiving looks exactly like a quiet hour. Alert when zero inbound events arrive during a window that historically has traffic.
  • Keep a replay path. Being able to re-run a stored payload through the pipeline turns a production incident into a local test.

A ten-minute diagnostic#

  1. curl your callback URL with the verification query string — do you get the bare challenge and a 200?
  2. Send a real message to the number from a phone. Does anything appear in your raw request log?
  3. If nothing: check number-to-app attachment, then field subscriptions.
  4. If the payload arrives but nothing is stored: signature validation, then parser.
  5. If it stores but duplicates: your handler is slow, or you are not deduplicating on message id.
  6. If statuses are missing: subscribe the statuses field and check your parser handles their shape.

AR-Inbox publishes its inbound contract, error envelopes and webhook behaviour in the public API documentation — useful whether you build your own ingest or connect to ours.

Related: why messages fail to send · how conversions are recorded once messages arrive.

Frequently asked questions

Why is my WhatsApp webhook not receiving messages? +

Either the number is not subscribed to the app receiving the callbacks, the required fields were never subscribed, or your endpoint is failing verification or returning non-200 responses and has been backed off.

How do I verify a WhatsApp Cloud API webhook? +

Respond to the GET challenge by echoing hub.challenge as a plain-text body with a 200 status, only when hub.verify_token matches the token you configured. Returning JSON or a redirect fails verification.

Why do WhatsApp message statuses stop at sent? +

Sent means accepted by WhatsApp. Delivered and read only follow if the recipient's device receives and opens it — and you only see them if you subscribed to the message status field.

Does WhatsApp retry failed webhooks? +

Yes, failed deliveries are retried with backoff for a period. That is why idempotency matters: retries after a timeout produce duplicate payloads for events you already processed.

WhatsApp policies, limits and pricing change often. This article was last reviewed on Aug 21, 2026 and is scheduled for its next review on Nov 21, 2026.

Related Articles