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:
- Send one template message through the API.
- Within seconds, the ledger row should move from
acceptedtosenttodelivered— that is the webhook arriving, passing signature, and being processed. - 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. - If rows appear but pricing fields stay null: the handler is processing messages but skipping the
statusesarray. Both live under the samemessageswebhook 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.
Originally published at dineshstack.com — read the full version with code samples and updates there.
Top comments (0)