+1 (415) 599-8902

Bulletproof Webhook Handling in Laravel: Signatures, Idempotency, Queues and Replay

Almost every Laravel application we are called in to rescue has at least one webhook endpoint, and it is usually the least defended route in the codebase: no signature check, no idempotency, business logic running inline inside the HTTP request, and no record of what the provider actually sent. It works until Stripe retries a payment event three times, or a partner replays yesterday's queue, or your database goes away for ninety seconds and the provider gives up.

This tutorial builds the version you want in production on Laravel 13: verify, persist, respond fast, process on a queue, stay idempotent, and make replay a one-command operation. The same shape works for Stripe, GitHub, Shopify, Xero, Twilio, Slack or a partner's bespoke integration.

The four rules

  1. Verify before you trust. A webhook URL is public. Signature verification, not obscurity, is your authentication.
  2. Respond in milliseconds. Store the raw payload and return 204. Providers time out — commonly at 5 to 10 seconds — and a timeout triggers a retry that you will now handle twice.
  3. Process exactly once. Retries are normal, not exceptional. Idempotency is a database constraint, not good intentions.
  4. Keep the evidence. The raw body and headers are the only thing that lets you answer "did they send it, or did we drop it?" at 2am.

Step 1: a table for received events

php artisan make:model WebhookEvent -m
public function up(): void
{
    Schema::create('webhook_events', function (Blueprint $table) {
        $table->id();
        $table->string('source', 50);            // stripe, github, partner-acme
        $table->string('external_id')->nullable(); // provider's event id
        $table->string('type')->nullable();        // invoice.paid, push, ...
        $table->json('headers');
        $table->longText('payload');               // raw body, verbatim
        $table->timestamp('processed_at')->nullable();
        $table->unsignedSmallInteger('attempts')->default(0);
        $table->text('last_error')->nullable();
        $table->timestamps();

        $table->unique(['source', 'external_id']);
        $table->index(['source', 'type', 'processed_at']);
    });
}

The unique index on (source, external_id) is the whole idempotency strategy. Everything else is bookkeeping. Store the raw body as text — not a decoded array — because signature verification runs over the exact bytes the provider signed, and because re-encoding JSON silently changes key order and float formatting.

Step 2: verify the signature in middleware

Verification belongs in middleware so no controller can forget it. Signature schemes differ, but almost all of them are "HMAC of a string built from the raw body, compared in constant time".

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

class VerifyWebhookSignature
{
    public function handle(Request $request, Closure $next, string $source)
    {
        $secret = config("webhooks.$source.secret");

        abort_unless($secret, 500, 'Webhook secret not configured.');

        $header = $request->header('X-Signature', '');
        $timestamp = (int) $request->header('X-Timestamp');

        // Reject stale payloads so a captured request cannot be replayed forever.
        if (abs(now()->timestamp - $timestamp) > 300) {
            throw new AccessDeniedHttpException('Stale webhook timestamp.');
        }

        $expected = hash_hmac('sha256', $timestamp.'.'.$request->getContent(), $secret);

        if (! hash_equals($expected, $header)) {
            throw new AccessDeniedHttpException('Invalid webhook signature.');
        }

        return $next($request);
    }
}

Three details that matter more than they look:

  • hash_equals() and not ===. String comparison short-circuits and leaks timing.
  • $request->getContent() gives you the raw body. If anything upstream has already parsed and re-encoded it, verification will fail intermittently and you will lose an afternoon.
  • The timestamp window turns a stolen-but-valid request into a five-minute problem instead of a permanent one.

Register it in bootstrap/app.php and exclude webhook routes from CSRF:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'webhook.signature' => \App\Http\Middleware\VerifyWebhookSignature::class,
    ]);

    $middleware->validateCsrfTokens(except: ['webhooks/*']);
})

For providers with a published SDK — Stripe's Webhook::constructEvent(), for instance — call the SDK inside this middleware rather than reimplementing their scheme.

Step 3: a controller that does almost nothing

Route::post('/webhooks/{source}', ReceiveWebhookController::class)
    ->middleware('webhook.signature:acme')
    ->withoutMiddleware([\Illuminate\Session\Middleware\StartSession::class]);
public function __invoke(Request $request, string $source)
{
    $payload = $request->json()->all();

    $event = WebhookEvent::firstOrCreate(
        [
            'source' => $source,
            'external_id' => $payload['id'] ?? (string) Str::uuid(),
        ],
        [
            'type' => $payload['type'] ?? null,
            'headers' => $request->headers->all(),
            'payload' => $request->getContent(),
        ]
    );

    if ($event->wasRecentlyCreated) {
        ProcessWebhookEvent::dispatch($event);
    }

    return response()->noContent();
}

If the provider retries, firstOrCreate hits the unique index, wasRecentlyCreated is false, no job is dispatched, and you still return 204. Duplicate delivery becomes a no-op at the front door.

If a provider does not send an event id, derive one deterministically — hash('sha256', $request->getContent()) — rather than generating a UUID, otherwise every retry looks like a fresh event.

Note also the missing pieces: no session, no auth middleware, no rate limiter that could throw away a legitimate delivery. Do apply a generous throttle if the endpoint is fully public, but size it well above the provider's burst rate.

Step 4: process on the queue, idempotently

namespace App\Jobs;

use App\Models\WebhookEvent;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;

class ProcessWebhookEvent implements ShouldBeUnique
{
    use Queueable;

    public int $tries = 5;
    public array $backoff = [10, 60, 300, 900];

    public function __construct(public WebhookEvent $event) {}

    public function uniqueId(): string
    {
        return $this->event->id;
    }

    public function handle(WebhookHandlerRegistry $registry): void
    {
        if ($this->event->processed_at) {
            return;
        }

        $payload = json_decode($this->event->payload, true, flags: JSON_THROW_ON_ERROR);

        DB::transaction(function () use ($registry, $payload) {
            $registry->for($this->event->source, $this->event->type)
                ?->handle($payload, $this->event);

            $this->event->forceFill([
                'processed_at' => now(),
                'last_error' => null,
            ])->save();
        });
    }

    public function failed(\Throwable $e): void
    {
        $this->event->forceFill([
            'last_error' => $e->getMessage(),
            'attempts' => $this->event->attempts + 1,
        ])->save();
    }
}

ShouldBeUnique stops two workers from picking up the same event if it is ever dispatched twice. The transaction means a partial handler failure leaves processed_at null, so a retry redoes the work cleanly instead of finishing half of it.

Handler side effects still need to be idempotent on their own terms. Prefer updateOrCreate over create, guard state transitions (if ($order->isPaid()) return;), and give any outbound call of your own an idempotency key.

Exponential backoff matters here too. If the failure is a third-party outage, hammering it every ten seconds for five tries buys you nothing; [10, 60, 300, 900] spans fifteen minutes of recovery time.

Step 5: route events to handlers

A registry keeps the job free of a growing match statement:

class WebhookHandlerRegistry
{
    protected array $map = [
        'acme' => [
            'invoice.paid'      => Handlers\Acme\InvoicePaid::class,
            'invoice.cancelled' => Handlers\Acme\InvoiceCancelled::class,
        ],
    ];

    public function for(string $source, ?string $type): ?WebhookHandler
    {
        $class = $this->map[$source][$type] ?? null;

        return $class ? app($class) : null;
    }
}

Unmapped event types are stored, marked processed and ignored. That is deliberate: providers add event types without warning, and an unknown type should never fail a job or page someone.

Step 6: replay, because you will need it

class ReplayWebhooks extends Command
{
    protected $signature = 'webhooks:replay
                            {source}
                            {--type=}
                            {--since=}
                            {--failed}';

    public function handle(): int
    {
        WebhookEvent::query()
            ->where('source', $this->argument('source'))
            ->when($this->option('type'), fn ($q, $t) => $q->where('type', $t))
            ->when($this->option('since'), fn ($q, $s) => $q->where('created_at', '>=', $s))
            ->when($this->option('failed'), fn ($q) => $q->whereNull('processed_at'))
            ->eachById(function (WebhookEvent $event) {
                $event->update(['processed_at' => null]);
                ProcessWebhookEvent::dispatch($event);
            });

        return self::SUCCESS;
    }
}

Because the raw payload was persisted, a bug in a handler is no longer a data-loss incident. Fix the handler, run php artisan webhooks:replay acme --type=invoice.paid --since="2 days ago", and the backlog reconciles itself.

Pair this with a scheduled prune so the table does not grow forever, and keep failures longer than successes:

Schedule::call(fn () => WebhookEvent::whereNotNull('processed_at')
    ->where('created_at', '<', now()->subDays(30))->delete())->daily();

Step 7: test it properly

it('rejects an unsigned payload', function () {
    $this->postJson('/webhooks/acme', ['id' => 'evt_1'])
        ->assertForbidden();
});

it('stores a signed payload once and queues one job', function () {
    Queue::fake();

    $body = json_encode(['id' => 'evt_1', 'type' => 'invoice.paid']);

    $post = fn () => $this->call('POST', '/webhooks/acme', [], [], [], [
        'HTTP_X_TIMESTAMP' => now()->timestamp,
        'HTTP_X_SIGNATURE' => hash_hmac('sha256', now()->timestamp.'.'.$body, 'test-secret'),
        'CONTENT_TYPE' => 'application/json',
    ], $body);

    $post()->assertNoContent();
    $post()->assertNoContent(); // provider retry

    expect(WebhookEvent::count())->toBe(1);
    Queue::assertPushed(ProcessWebhookEvent::class, 1);
});

it('is safe to run the handler twice', function () {
    $event = WebhookEvent::factory()->create(['type' => 'invoice.paid']);

    (new ProcessWebhookEvent($event))->handle(app(WebhookHandlerRegistry::class));
    (new ProcessWebhookEvent($event->fresh()))->handle(app(WebhookHandlerRegistry::class));

    expect(Payment::where('external_id', 'evt_1')->count())->toBe(1);
});

The double-delivery test is the one that earns its keep. Write it once per integration and you will never ship a double-charge.

Monitoring

Two signals are enough for most teams:

  • Unprocessed age — alert when WebhookEvent::whereNull('processed_at')->where('created_at', '<', now()->subMinutes(15))->exists(). That catches dead workers, poison payloads and silent handler bugs in one check.
  • Signature failures — log them with the source and remote IP. A sudden spike means a rotated secret on the provider's side, or somebody probing you.

Laravel Pulse or Nightwatch will happily surface both as custom cards, and Horizon already reports queue wait time for the webhooks queue. Give webhooks their own queue and their own workers so a slow bulk import never delays a payment confirmation.

What this buys you

A provider can retry, replay, reorder or briefly vanish, and the worst outcome is a delay. Nothing is processed twice, nothing is lost, and every delivery is auditable months later. That is roughly ninety minutes of work on a new integration and it removes the single most common source of "the payment went through but our system doesn't know" support tickets.

If you are integrating a payment provider, ERP, CRM or partner API into a Laravel application and want this hardened properly — or you have an existing webhook endpoint quietly dropping events — get in touch. Our Laravel Web Services Integration and Laravel Support and Maintenance teams do this work every week.