Build a webhook receiver in Laravel
Two Laravel-specific traps sink most webhook routes: CSRF middleware rejecting the POST, and verifying a signature against the parsed body instead of the raw bytes. Both are one line each.
A webhook receiver has three jobs: verify the sender, acknowledge quickly, and do the real work off the request path. In Laravel, two framework-specific details decide whether the first two work at all.
1. Exclude the route from CSRF
Laravel's CSRF middleware applies to every POST in the web group, and a
provider has no token to send — so the delivery is rejected before your controller runs. The
method was renamed in Laravel 13; both forms live in bootstrap/app.php:
// bootstrap/app.php — Laravel 13.x
->withMiddleware(function (Middleware $middleware): void {
$middleware->preventRequestForgery(except: [
'webhooks/*',
]);
}) // bootstrap/app.php — Laravel 11.x / 12.x
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'webhooks/*',
]);
}) Laravel's own guidance is that routes like this typically belong outside the web middleware group altogether — routes/api.php has no CSRF middleware, which sidesteps
the exclusion list entirely.
// routes/web.php
Route::post('/webhooks/provider', WebhookController::class); 2. Verify against the raw body
Signatures are computed over the exact bytes that were sent. $request->all() and $request->input() give you the parsed body; re-encoding it changes key order, whitespace, and unicode
escaping, and the HMAC will never match. Use $request->getContent().
class WebhookController extends Controller
{
public function __invoke(Request $request)
{
// The RAW bytes — not $request->all(), which is the parsed body.
$payload = $request->getContent();
if (! $this->verify($payload, $request->header('X-Webhook-Signature'))) {
abort(401, 'bad signature');
}
// Persist first, acknowledge, then do the work off the request path.
$event = WebhookEvent::firstOrCreate(
['external_id' => json_decode($payload, true)['id']],
['payload' => $payload],
);
if ($event->wasRecentlyCreated) {
ProcessWebhook::dispatch($event);
}
return response()->noContent(); // 204
}
private function verify(string $payload, ?string $header): bool
{
if ($header === null) {
return false;
}
$expected = hash_hmac('sha256', $payload, config('services.webhooks.secret'));
// Constant-time comparison — never ===.
return hash_equals($expected, $header);
}
} 3. Acknowledge fast, queue the work
Return 2xx as soon as the payload is verified and stored,
then dispatch a queued job. Slow inline work — mail, a payment API, a report — makes the provider
time out and retry, which hands you duplicate deliveries. The firstOrCreate above doubles as the dedupe: a retry finds the existing row and no second
job is dispatched.
Two Laravel specifics worth knowing. ProcessWebhook::dispatch() only runs
out-of-band if a real queue driver is configured — with QUEUE_CONNECTION=sync the job
runs inline and you keep the timeout. And if you dispatch after the response, prefer dispatch() over dispatchAfterResponse() for anything that must survive a
process restart.
Then capture what actually arrives
Point the provider's webhook at a HookWatch endpoint and every delivery is recorded with its
request, headers, and the response your Laravel app returned — so a signature failure or a 500 is a delivery you can open and read rather than a line
in laravel.log. Fix the handler, then replay the captured delivery; for the general sequence see how to debug a failed webhook.
Keep reading
Start debugging your webhooks.
Point one endpoint at HookWatch, capture a failure, and replay it once it’s fixed. Free during beta.