Laravel webhooks are HTTP POST requests that another service sends to a route in your app when something happens on their side, and handling them safely comes down to four things. Exempt the route from CSRF, verify the signature before you trust a single field, hand the real work to a queued job, and store the provider event ID behind a unique index so a retry cannot run the same logic twice.
Most webhook failures are not delivery failures. They are trust failures. The endpoint passes local testing, then a slow deploy pushes your response past the provider timeout, the provider retries, and your app quietly does the same thing again.
Writing a controller that reads a JSON body is the easy part. The hard part is the code that survives duplicate deliveries, rotated secrets, and a provider that disables your endpoint after too many 500s. This guide walks the whole loop with runnable Laravel code. You get receiving and verification, queueing, idempotency, sending your own signed events, and Pest tests that cover the failure paths instead of only the happy one.
Key TakeawaysVerify the HMAC signature against the raw request body with
hash_equalsbefore you parse or trust any field in the payload.Acknowledge in under a second by dispatching a queued job, because most providers time out around 5 to 10 seconds and then retry the delivery.A unique index on the provider event ID is what actually makes a handler idempotent. An application levelfirstOrCreatecheck still races under concurrent retries.When you send webhooks, sign a timestamped payload, keep the HTTP timeout short, and retry with escalating backoff from a job.Test signature rejection and duplicate delivery with Pest, not just the happy path.
What Laravel webhooks are and when you need them
A webhook is a reverse API call. Instead of your app polling a service for changes, the service calls a URL you own the moment an event happens. Stripe posts when an invoice is paid, GitHub posts when a branch is pushed, and a shipping provider posts when a label is scanned.
The trade is simple. Polling is predictable and easy to debug, but it burns rate limit and always lags reality. Laravel webhooks give you near real time updates with a fraction of the requests, and in exchange you own a public endpoint that anyone on the internet can reach.
Use a webhook when the event arrives on someone else's schedule:
Payments and billing. Renewals, failed charges, refunds, and disputes land minutes or days after checkout, which is why they sit at the centre of most e-commerce and subscription builds.
Long running work on another platform. Video encoding, identity checks, document signing, and label generation all finish without you asking.
Repository and pipeline events. Pushes, merged pull requests, and deployment status updates.
Your own outbound events. Letting customers subscribe to changes in your API instead of hammering it on a cron.
Anything you would otherwise poll every minute is a webhook candidate. Anything that needs an immediate synchronous answer is not.
Wiring the same four pieces by hand on every integration gets repetitive fast. You can describe the endpoint in plain English and generate a production-ready Laravel app with the route, signature middleware, queued job, and Pest tests already in place.
How to receive a Laravel webhook safely
Three pieces sit between the provider and your business logic. A route the CSRF middleware ignores, middleware that proves the request is genuine, and a controller that does almost nothing.
Keep the route out of CSRF protection
A webhook carries no session and no CSRF token, so the default web middleware group rejects it with a 419 before your controller ever runs. In Laravel 11 and 12 you declare the exception in bootstrap/app.php. The Laravel CSRF documentation covers the older kernel based syntax if you are still on Laravel 9 or 10.
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/*',
]);
})Registering the route in routes/api.php avoids the issue entirely, since the API middleware group is stateless. Either approach is fine. Pick one and stay consistent across integrations.
// routes/web.php
use App\Http\Controllers\StripeWebhookController;
use App\Http\Middleware\VerifyWebhookSignature;
Route::post('/webhooks/stripe', StripeWebhookController::class)
->middleware(VerifyWebhookSignature::class)
->name('webhooks.stripe');Verify the signature before you trust the payload
Your webhook URL is public. Assume someone will find it and post a forged event to it. Every serious provider signs the request with a shared secret, and your job is to recompute that signature and compare it in constant time.
Hash the raw request body, not the decoded array. Decoding and re-encoding changes key order and float formatting, which produces a different hash and rejects valid deliveries. Compare with hash_equals rather than ==, because a plain comparison leaks timing information and trips over PHP type juggling on numeric looking strings.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyWebhookSignature
{
public function handle(Request $request, Closure $next): Response
{
$secret = config('services.stripe.webhook_secret');
$signature = (string) $request->header('X-Signature');
$expected = hash_hmac('sha256', $request->getContent(), $secret);
abort_unless(hash_equals($expected, $signature), 403, 'Invalid signature.');
return $next($request);
}
}Providers with an official SDK usually ship their own verifier. Stripe's Webhook::constructEvent() also enforces a timestamp tolerance so an attacker cannot replay a captured request days later, and the Stripe webhooks documentation spells out the exact header format. If you roll your own, sign a timestamp alongside the body and reject anything older than five minutes.
Respond fast and do the work in a queue
Most providers wait somewhere between 5 and 10 seconds for a response, then mark the delivery failed and retry it. If your handler charges a card, sends email, and rebuilds a report inline, you will eventually blow that window under load and receive the same event again.
Store the payload, dispatch a job, return 204. Nothing else. Laravel queues give you retries, backoff, and failed job storage for free, which is exactly the machinery this problem needs.
namespace App\Http\Controllers;
use App\Jobs\ProcessStripeWebhook;
use App\Models\WebhookDelivery;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class StripeWebhookController extends Controller
{
public function __invoke(Request $request): Response
{
$payload = $request->json()->all();
$delivery = WebhookDelivery::firstOrCreate(
['provider' => 'stripe', 'event_id' => $payload['id']],
['payload' => $payload],
);
if ($delivery->wasRecentlyCreated) {
ProcessStripeWebhook::dispatch($delivery);
}
return response()->noContent();
}
}Idempotency is what makes a webhook handler safe
Every provider guarantees at least once delivery, never exactly once. Network blips, timeouts, and manual replays from a dashboard all mean the same event can arrive two or five times. Your handler has to produce the same end state every time.
The database enforces that, not your code. A firstOrCreate call reads and writes in two steps, so two queue workers handling simultaneous retries can both pass the read before either writes. A unique index turns that race into a duplicate key error you can catch. The model and migration are two Artisan commands away, and the php artisan make:model cheatsheet covers the flags worth remembering.
Schema::create('webhook_deliveries', function (Blueprint $table) {
$table->id();
$table->string('provider');
$table->string('event_id');
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->timestamps();
$table->unique(['provider', 'event_id']);
});A small SaaS team once wired the Stripe invoice.paid event straight into a controller that credited account balances. It behaved for months. Then a heavier release slowed responses just past the provider window, Stripe retried three times, and a batch of accounts were credited twice in a single afternoon. The fix was not more logging. It was a unique index on the provider event ID plus a queued job, which turns a replayed delivery into a no-op instead of a second credit.
Two more habits matter here. Re-fetch anything financial from the provider API instead of trusting amounts in the payload, since the body reflects the state at send time and may already be stale. Record a processed_at timestamp when the job finishes, so you can tell the difference between a delivery you received and one you actually handled.
How to send webhooks from your Laravel app
Sending is the mirror image, with one difference. You are now the unreliable party in someone else's integration, and their endpoint will be down, slow, or behind a firewall more often than you expect.
Sign a timestamped payload
Give consumers a way to trust you. Generate a per-subscription secret, sign the concatenation of a timestamp and the raw JSON body, and send both as headers. Document the scheme in your API reference, including the exact string that gets hashed, because that is the detail every consumer gets wrong first.
Retry from a queued job with backoff
Never send a webhook inside a web request. Dispatch a job with a short HTTP timeout, a generous $tries count, and an escalating backoff() schedule so one consumer outage does not flood your queue.
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
class SendWebhook implements ShouldQueue
{
use Queueable;
public int $tries = 8;
public function __construct(
public string $url,
public string $secret,
public array $payload,
) {}
public function handle(): void
{
$body = json_encode($this->payload, JSON_THROW_ON_ERROR);
$timestamp = now()->timestamp;
Http::withHeaders([
'X-Webhook-Timestamp' => $timestamp,
'X-Webhook-Signature' => hash_hmac('sha256', $timestamp.'.'.$body, $this->secret),
])
->timeout(5)
->withBody($body, 'application/json')
->post($this->url)
->throw();
}
public function backoff(): array
{
return [10, 60, 300, 900, 3600];
}
}If you would rather not own this layer, spatie/laravel-webhook-server handles signing, retries, and per-consumer configuration with the same queue underneath. Either way, expose delivery history and a manual resend to your customers, because support tickets about missed events are otherwise unanswerable.
This is the kind of plumbing where Laravel-native intelligence earns its keep. A generic assistant writes a PSR-18 client call. A Laravel aware one writes the queued job, the signing headers, the backoff array, and the migration that tracks each attempt. Try StackMivo Now on the integration you have been putting off.
Testing Laravel webhooks before they reach production
Webhook code is easy to test and almost nobody does it, because the happy path works on the first try and the failure paths only surface on a Tuesday afternoon in production.
Cover three things. A forged signature is rejected, a duplicate event is queued once, and the job itself does the right thing given a stored payload.
use App\Jobs\ProcessStripeWebhook;
use Illuminate\Support\Facades\Queue;
function postWebhook(array $payload,?string $signature = null)
{
$body = json_encode($payload);
$signature??= hash_hmac('sha256', $body, config('services.stripe.webhook_secret'));
return test()->call('POST', '/webhooks/stripe', [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_SIGNATURE' => $signature,
], $body);
}
it('rejects a forged signature', function () {
Queue::fake();
postWebhook(['id' => 'evt_1'], 'not-a-signature')->assertForbidden();
Queue::assertNothingPushed();
});
it('processes each event exactly once', function () {
Queue::fake();
postWebhook(['id' => 'evt_1', 'type' => 'invoice.paid'])->assertNoContent();
postWebhook(['id' => 'evt_1', 'type' => 'invoice.paid'])->assertNoContent();
Queue::assertPushed(ProcessStripeWebhook::class, 1);
});For manual checks, the provider CLI beats a tunnel. Stripe, GitHub, and most large platforms ship a listener that forwards live events to localhost, which lets you exercise real payload shapes without exposing a port.
stripe listen --forward-to localhost:8000/webhooks/stripe
php artisan queue:work --tries=5An agency team once shipped a booking integration verified by hand against a sandbox and never covered by a test. A later release rotated the signing secret in one environment but not the other. The endpoint returned 403 for two days, nobody noticed because the failures were silent, and the provider eventually disabled it. One rejection test plus an alert on failed deliveries would have caught it in minutes.
Secrets are the other half of this. Keep them in config rather than calling env() at runtime, because config caching makes env() return null outside config files. Rotating them belongs in your deploy, which is simpler when one-click deployment runs migrations and restarts queue workers in the same step.
Common Laravel webhook mistakes and the fixes
These turn up in nearly every code review of an integration written in a hurry.
Mistake | What goes wrong | The fix |
|---|---|---|
Parsing the payload before checking the signature | You act on data from anyone who guesses the URL | Verify in middleware so the controller only sees trusted requests |
Comparing signatures with | Timing leaks and PHP type juggling on numeric strings | Use |
Hashing the decoded array instead of the raw body | Key order and float formatting shift the hash, so valid deliveries fail | Sign and verify |
Doing the work inside the controller | Slow responses trip the provider timeout and trigger retries | Dispatch a queued job and return 204 immediately |
Returning 500 for business logic failures | The provider retries forever, then disables your endpoint | Acknowledge receipt, then handle failures inside the job |
No unique index on the event ID | Concurrent retries slip past an application level check | Add a composite unique index on provider and event ID |
One more worth adding. Log the raw body and headers of every delivery for at least a few days. When a provider insists they sent an event and your data says otherwise, that log is the only way to settle it.
Ship Laravel webhooks you can trust
Safe Laravel webhooks come down to a short checklist you can apply to any provider. Exempt the route from CSRF, verify the signature against the raw body with hash_equals, acknowledge in under a second by dispatching a queued job, and let a unique index on the provider event ID absorb every retry.
When you send events, invert the same rules. Sign a timestamped payload, keep the HTTP timeout short, retry with backoff from a job, and give consumers a delivery log they can inspect themselves.
Start with the integration that already worries you. Add a rejection test and a duplicate delivery test first, because those two tests tell you more about the endpoint than a week of reading the code. Then pull the work out of the controller and into a job. Webhooks stop being scary once the database, the queue, and your test suite enforce correctness instead of your memory of how the provider behaves.