Making Laravel webhook handlers safe to receive twice

Providers will send the same event twice, sometimes out of order. Here is the pattern I use so that never matters.

Every service that sends webhooks says the same thing somewhere in its docs: events may be delivered more than once, and not always in order. Almost nobody builds as if that were true until the first customer gets two confirmation emails, or worse, is fulfilled twice.

This is the pattern I reach for in Laravel. It is not clever. It is four small decisions that, together, make a webhook endpoint boring to operate.

What the provider is actually promising

Most providers offer at-least-once delivery. If your endpoint answers with an error, or takes too long, they send the event again later. If your endpoint answers fine but their bookkeeping missed it, they send it again anyway. Events can also arrive in a different order from the one they happened in.

So the endpoint has three jobs: prove the request is genuine, remember what it has already seen, and make sure the real work happens once. Everything below serves one of those.

1. Verify before you trust anything

Check the signature against the raw request body, not a re-encoded copy of the parsed JSON. Re-encoding changes whitespace and key order, and the signature will not match. Compare with hash_equals so the check takes the same time whether the first byte is wrong or the last.

Many providers (Stripe is one) sign a timestamp together with the body and ask you to reject old timestamps. If your provider ships a verification helper in its SDK, use it instead of writing your own. The example below uses the simple sha256= header style, which is enough to show the shape.

2. Record the event ID first, and let the database say no

Create a table for incoming events with a unique index on the provider and the provider's event ID. That index is the real duplicate check. Code that does look it up, then insert if missing has a race condition when two deliveries arrive together; a unique index does not.

database/migrations/create_webhook_events_table.php
Schema::create('webhook_events', function (Blueprint $table) {
    $table->id();
    $table->string('provider', 40);
    $table->string('external_id', 120);
    $table->string('type', 120);
    $table->json('payload');
    $table->timestamp('received_at');
    $table->timestamp('processed_at')->nullable();

    // The database, not your code, decides what counts as a duplicate.
    $table->unique(['provider', 'external_id']);
});

Keeping the raw payload also pays for itself the first time something goes wrong. You can replay an event from your own table instead of asking the provider to resend it.

3. Answer quickly, do the work in a queue

The controller verifies, records, dispatches and returns. No API calls, no emails, no heavy queries. Providers give up on slow endpoints and retry, which is how a slow handler turns into a duplicate storm.

app/Http/Controllers/AcmeWebhookController.php
final class AcmeWebhookController
{
    public function __invoke(Request $request): Response
    {
        $payload = $request->getContent(); // the raw body, exactly as signed

        $secret   = config('services.acme.webhook_secret');
        $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
        $given    = (string) $request->header('X-Signature-256');

        abort_unless(hash_equals($expected, $given), 401);

        $event = json_decode($payload, true);
        abort_unless(isset($event['id'], $event['type']), 400);

        DB::table('webhook_events')->insertOrIgnore([
            'provider'    => 'acme',
            'external_id' => $event['id'],
            'type'        => $event['type'],
            'payload'     => $payload,
            'received_at' => now(),
        ]);

        // Dispatch every time: the job is the guard, so a retry heals a lost job.
        ProcessWebhookEvent::dispatch('acme', $event['id']);

        return response()->noContent();
    }
}

One detail is worth a second look: the job is dispatched on every delivery, including repeats. That looks wasteful, but it covers a real failure. If the queue was down when the first delivery arrived, the event was saved and nothing was dispatched. The provider's retry then finds the row already there. If the dispatch only happened for new rows, that event would sit unprocessed forever.

4. Make the job the guard

The job loads the stored event under a row lock, checks processed_at, does the work, and marks it done, all in one transaction. A duplicate job finds the event already processed and returns.

app/Jobs/ProcessWebhookEvent.php
final class ProcessWebhookEvent implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public int $tries = 5;

    public function __construct(
        private readonly string $provider,
        private readonly string $externalId,
    ) {}

    public function backoff(): array
    {
        return [10, 60, 300, 900];
    }

    public function handle(HandleAcmeEvent $handler): void
    {
        DB::transaction(function () use ($handler) {
            $event = WebhookEvent::query()
                ->where('provider', $this->provider)
                ->where('external_id', $this->externalId)
                ->lockForUpdate()
                ->firstOrFail();

            if ($event->processed_at !== null) {
                return; // a duplicate delivery, nothing left to do
            }

            $handler->handle($event->type, $event->payload);

            $event->update(['processed_at' => now()]);
        });
    }
}

Watch the lock. Holding a transaction open while you call another API is a good way to exhaust your connection pool. If the handler has to make slow network calls, split the work into steps and record progress as you go, or make the outside call itself idempotent with an idempotency key.

Out-of-order events

Do not assume subscription.updated arrives after subscription.created. For anything where the latest state matters, I treat the webhook as a nudge rather than a source of truth: the handler fetches the current object from the provider's API and stores that. It costs one extra request and removes a whole class of bugs.

Test the ugly case on purpose

The test that earns its keep sends the same signed event twice and asserts that the side effect happened once.

tests/Feature/AcmeWebhookTest.php
it('applies a duplicated delivery once', function () {
    $body = json_encode(['id' => 'evt_1', 'type' => 'invoice.paid']);
    $signature = 'sha256=' . hash_hmac('sha256', $body, config('services.acme.webhook_secret'));

    $this->mock(HandleAcmeEvent::class)->shouldReceive('handle')->once();

    foreach ([1, 2] as $attempt) {
        $this->call('POST', '/webhooks/acme', [], [], [], [
            'HTTP_X_SIGNATURE_256' => $signature,
            'CONTENT_TYPE'         => 'application/json',
        ], $body)->assertNoContent();
    }

    expect(DB::table('webhook_events')->count())->toBe(1);
});

Add a second test with a bad signature and a third with an event that arrives after a later one. They take minutes to write and they are exactly the cases that fail at 2 a.m.

What I keep an eye on in production

  • Events received but not processed after a few minutes. That is a stuck queue or a failing handler.
  • Signature failures. A sudden rise usually means a rotated secret.
  • Attempts per event. One that needs five tries is trying to tell you something.

None of this is glamorous, and that is the point. A webhook endpoint that is boring on a Friday evening is a well-built one.