Skip to content
TutorialLaravel

Verifying a WhatsApp webhook in Laravel: the three silent traps

Dinesh Wijethunga

Dinesh Wijethunga

August 14, 2026Reviewed Aug 14, 2026 8 min readIntermediate
ShareX / TwitterLinkedIn
Verifying a WhatsApp webhook in Laravel: the three silent traps
dineshstack/laravel-whatsapp-cost-controlView on GitHub →

Three things go wrong when a Laravel application receives WhatsApp webhooks, and each produces a different silent failure. Meta's verification challenge arrives with dots in its query keys, which PHP renames before your code sees them — so $request->query('hub.mode') reads nothing and verification never succeeds. The delivery signature is an HMAC over the raw request bytes, so any comparison built on parsed-and-re-encoded JSON rejects genuine payloads. And Meta expects a response inside roughly twenty seconds, so a handler that processes inline works in development and starts collecting retries — then duplicate deliveries, then suspension warnings — under production load. This post walks the receiving side end to end: the handshake, the signature, the deadline, and what a processing job downstream of all three has to tolerate.

Everything here is from a live integration on a booking platform, where the webhook carries the only per-message cost data Meta will ever give you — which is what makes a silently rejecting receiver expensive rather than merely annoying.

The handshake: dots become underscores

When you register a callback URL, Meta sends a GET with three query parameters, exactly as documented:

GET /webhook?hub.mode=subscribe&hub.verify_token=YOUR_TOKEN&hub.challenge=1158201444

The documented names are the trap. PHP converts dots in incoming query keys to underscores before the request reaches userland — a legacy of register_globals, when hub.mode could not be a variable name. Laravel builds its request object on top of that, so the keys your code can actually read are hub_mode, hub_verify_token and hub_challenge:

public function verify(Request $request): Response
{
    // PHP renames hub.mode -> hub_mode before Laravel ever sees it.
    $mode      = $request->query('hub_mode');
    $token     = (string) $request->query('hub_verify_token', '');
    $challenge = (string) $request->query('hub_challenge', '');

    $expected = (string) config('messaging.verify_token', '');

    if ($mode === 'subscribe' && $expected !== '' && hash_equals($expected, $token)) {
        return response($challenge, 200)->header('Content-Type', 'text/plain');
    }

    return response('Forbidden', 403);
}

Three details that are each doing real work. The empty-string check on the expected token means an unconfigured server fails verification rather than accepting any token — the same fail-closed reasoning as the country allowlist, applied to a handshake. hash_equals() keeps the comparison constant-time. And the response is the bare challenge as plain text: not JSON, not quoted, no framework envelope. A response helper that wraps everything in {"status": true, "data": ...} will fail this handshake, and the dashboard will only tell you the URL could not be validated.

The signature: HMAC over bytes you must not touch

Every delivery POST carries an X-Hub-Signature-256 header: sha256= followed by an HMAC of the raw body, keyed with your app secret. The operative word is raw:

public function receive(Request $request): Response
{
    $raw    = $request->getContent();          // bytes as sent — never re-encoded
    $header = (string) $request->header('X-Hub-Signature-256', '');
    $secret = (string) config('messaging.app_secret', '');

    if ($header === '' || $secret === '') {
        return response('Forbidden', 403);     // unconfigured = reject, loudly
    }

    $expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);

    if (! hash_equals($expected, $header)) {
        return response('Forbidden', 403);
    }

    ProcessWebhook::dispatch(json_decode($raw, true) ?? []);

    return response('', 200);
}

The classic mistake is computing the HMAC over json_encode($request->all()). It fails intermittently, which is worse than failing always: PHP re-encodes / as \/ by default, reorders nothing but re-serialises floats and unicode differently than Meta did, and any single byte of difference produces a different digest. Payloads that happen to survive the round-trip verify; payloads with a URL or an emoji in them do not. The symptom is "some webhooks fail signature validation", which reads like an attack and is actually your own serialiser.

Two adjacent traps. Middleware that touches the body — trimming strings, converting empty strings to null — must exclude this route, because the framework request object and getContent() can diverge after mutation. And if the endpoint sits behind a proxy or gateway, that layer must pass the body through untouched: a gateway that pretty-prints, decompresses, or re-encodes JSON breaks the signature for every payload while looking completely healthy itself.

The deadline: answer in seconds, work later

Meta expects a fast 200. Take too long — the practical budget is seconds, with retries beginning when you exceed it — and the delivery is retried. Keep being slow and the same events arrive two and three times while the backlog compounds; sustained failure escalates to warnings and eventually to the subscription being disabled.

The design consequence is one line: the HTTP handler validates and queues, and nothing else. Signature check, dispatch, 200. The database writes, the Graph lookups, the cost reconciliation — all of it belongs to a queued job. In the code above, the only work between signature and response is a json_decode and a dispatch().

This is also the correct place for that work for a second reason: retries. Once processing is a queued job, a transient failure is retried by your queue with your backoff policy, instead of by Meta with theirs — and Meta's retry arrives as a fresh HTTP delivery that must re-pass signature validation and re-enter the queue, which is how duplicates are born.

The job: assume duplicates, assume disorder

Which leads to the two properties the processing job must have. Meta redelivers on any failure it perceives — a timeout counts even if you processed the payload — and separate deliveries take separate paths, so nothing guarantees order. The job cannot prevent either; it has to be shaped so neither matters.

Idempotency by natural key. Every message and status carries a stable id (wamid for messages). Guarded writes make the second delivery a no-op:

$message = MessageLog::firstOrCreate(
    ['wamid' => $status['id']],
    ['direction' => 'outbound', 'status' => 'accepted']
);

Monotonic state. A retried sent can arrive after the delivered it precedes. Rank the lifecycle and only ever move forward:

$rank = ['accepted' => 0, 'sent' => 1, 'delivered' => 2, 'read' => 3];

if ($rank[$state] > ($rank[$message->status] ?? 0)) {
    $message->update(['status' => $state]);
}

Run both rules and redelivery becomes harmless: the row exists, the state does not regress, the second delivery changes nothing. Skip them and every Meta retry is a data corruption opportunity.

Keep the routes apart

One structural decision worth stating because the default is wrong: the webhook routes are unauthenticated by design — Meta cannot log in — and they should live in their own route file, not alongside authenticated API routes. The failure this prevents is a careless group edit: someone adds auth:api to a shared group and the webhook starts returning 401 to Meta, or removes it and an admin surface goes public. Isolation makes both mistakes structurally harder:

// routes/webhooks.php — nothing else lives here
Route::prefix('v1/webhook')->middleware('throttle:whatsapp-webhook')->group(function () {
    Route::get('whatsapp', [WebhookController::class, 'verify']);
    Route::post('whatsapp', [WebhookController::class, 'receive']);
});

The named rate limiter is not decoration either. Meta delivers status bursts during campaigns — every message in a broadcast produces its own sent and delivered callbacks — and an unnamed throttle:120,1 here would share a counting bucket with the global API throttle and enforce half the number written on it. A named limiter owns its bucket, so the declared headroom is the real headroom.

Verify the whole chain with one message

The receiving side has a property that makes it easy to believe it works when it does not: every failure mode returns a clean-looking response to somebody. Signature rejections 403 to Meta and your logs stay quiet. A slow handler 200s eventually and the retry storm happens on Meta's side. So test it end to end, with one real message, and watch the data rather than the HTTP codes:

  1. Send one template message through the API.
  2. Within seconds, the ledger row should move from accepted to sent to delivered — that is the webhook arriving, passing signature, and being processed.
  3. If the row stays at accepted: deliveries are not arriving or not validating. Check the callback URL, then log signature failures explicitly — a silent 403 is indistinguishable from no traffic.
  4. If rows appear but pricing fields stay null: the handler is processing messages but skipping the statuses array. Both live under the same messages webhook field; handling one and not the other is easy to do without noticing.

On the integration this came from, that single-message check is what proved the chain: send, sent two seconds later with pricing attached, delivered right behind it. Until you have seen that sequence in your own tables, the receiver is unverified — whatever the dashboard says.

The receiving side described here — handshake, raw-body HMAC, queued processing, idempotent monotonic writes — ships assembled in laravel-whatsapp-cost-control (MIT, Laravel 12 and 13), wired into the cost ledger those webhooks feed. The part most worth stealing even if you build your own is the discipline: validate bytes you have not touched, answer before you work, and let every retry find a system that has already made itself safe to repeat.

dineshstack/laravel-whatsapp-cost-control

Star on GitHub

Frequently Asked Questions

Why does my WhatsApp webhook verification always return 403?
Your code is probably reading hub.mode with the documented dot-name. PHP renames incoming query keys with dots to underscores, so Laravel sees hub_mode, hub_verify_token and hub_challenge. Read the underscore names and echo the challenge back as plain text.
Why does WhatsApp webhook signature validation fail on some payloads but not others?
The HMAC is being computed over re-encoded JSON instead of the raw body. PHP re-serialises escaping and unicode differently than Meta did, so payloads that survive the round-trip pass and others fail. Compute the HMAC over the raw request content.
Why does the verification challenge fail when my endpoint responds correctly?
The response is wrapped. Meta expects the bare challenge string as plain text with a 200. A framework response helper that returns a JSON envelope fails the handshake even though the token comparison succeeded.
Why am I receiving the same WhatsApp webhook event more than once?
Meta retries any delivery it considers failed, including ones that merely timed out, and retried deliveries can arrive out of order. Process in a queued job that is idempotent on the message id and only moves status forward through the lifecycle.
Why does my WhatsApp webhook work in testing but drop events under load?
The handler is doing its processing before responding. Meta's response window is short, so under load the reply exceeds it, retries begin, and the backlog compounds. Validate, queue, return 200 — do the work in the job.
Dinesh Wijethunga
Dinesh Wijethunga

Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.

Was this post helpful?

›Add a comment

Comments

Guest comments are held for moderation.

You might also like

PHP 8.4 Property Hooks: The Feature That Changes EverythingFeatured
ArticleIntermediatePHP

PHP 8.4 Property Hooks: The Feature That Changes Everything

PHP 8.4 property hooks put get/set logic directly on the property — no more getter/setter boilerplate. Full syntax, virtual properties, asymmetric visibility, why they don't work on Eloquent models, and the rest of what shipped in 8.4.

Dinesh WijethungaDinesh Wijethunga5 months ago
7m