Skip to content
Receiver guide

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.

Get started

Start debugging your webhooks.

Point one endpoint at HookWatch, capture a failure, and replay it once it’s fixed. Free during beta.