zain-ul-abdain/laravel-webhook-ledger

Exactly-once webhook processing for Laravel. Signature verification, atomic deduplication, and a durable event log for Stripe, PayPal, and any provider you add.

Maintainers

Package info

github.com/zain-ul-abdain/laravel-webhook-ledger

pkg:composer/zain-ul-abdain/laravel-webhook-ledger

Transparency log

Statistics

Installs: 13

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-08-29 23:35 UTC

This package is auto-updated.

Last update: 2026-08-30 00:37:49 UTC


README

Exactly-once webhook processing for Laravel. Signature verification, atomic deduplication, and a durable event log — for Stripe, PayPal, and anything else that posts JSON at you.

tests license

composer require zain-ul-abdain/laravel-webhook-ledger
php artisan migrate

The problem

Every payment provider will deliver the same webhook to you more than once. Not occasionally — routinely. They retry on timeout, on a 500, on a connection reset, and sometimes on a response they simply didn't like. Stripe retries for up to three days. PayPal does too.

If your handler credits an account, ships an order, or releases a payout, running it twice is not a cosmetic bug. It is money.

So most codebases reach for the obvious guard:

// This does not work.
if (WebhookEvent::where('event_id', $id)->exists()) {
    return response('duplicate', 200);
}

WebhookEvent::create(['event_id' => $id, ...]);
$this->handle($payload);

Why that doesn't work

There is a gap between the exists() check and the create(). Under concurrent delivery — which is exactly what a retry storm produces — two requests can both execute the check before either executes the insert:

  request A          request B
  ─────────          ─────────
  exists()? no
                     exists()? no
  insert
  handle()  ←── credits the account
                     insert
                     handle()  ←── credits it again

The window is small. It is not small enough. Any provider retrying an event a few hundred milliseconds apart will find it, and you will discover this the way everyone discovers it: from a customer, about a double refund.

The only component that can arbitrate this is the database. So this package lets it:

$table->unique(['provider', 'event_id']);

and then treats the constraint violation as the duplicate signal rather than as an error:

try {
    $event = WebhookEvent::create([...]);   // the claim
} catch (UniqueConstraintViolationException) {
    return $this->handleExisting(...);      // someone else got there first
}

$handler($payload, $event);                 // runs at most once

There is no window, because there is no gap. The insert either succeeds or it doesn't, and the database decides atomically.

Usage

use Zain\WebhookLedger\Facades\WebhookLedger;

Route::post('/webhooks/stripe', function (Request $request) {
    $result = WebhookLedger::process('stripe', $request, function (array $payload, $event) {
        match ($payload['type']) {
            'checkout.session.completed' => $orders->markPaid($payload['data']['object']),
            'charge.refunded'            => $orders->refund($payload['data']['object']),
            default                      => null,
        };
    });

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

The closure runs at most once per event, across concurrent requests, across queue workers, and across redeliveries days apart. Everything else — verification, deduplication, persistence, failure recording — happens around it.

Always respond 200 to a duplicate. Returning an error makes the provider retry an event you have already handled, which is how one delivery hiccup becomes a retry storm.

Configuration

php artisan vendor:publish --tag=webhook-ledger-config
'providers' => [
    'stripe' => [
        'verifier'   => StripeSignatureVerifier::class,
        'secret'     => env('STRIPE_WEBHOOK_SECRET'),
        'tolerance'  => 300,
        'paths'      => [
            'id'          => 'id',
            'type'        => 'type',
            'external_id' => ['data.object.payment_intent', 'data.object.id'],
        ],
    ],
],

paths are dot-paths into the payload. A list of candidates is tried in order — useful when a provider moves a field between API versions and you're receiving both.

Running two Stripe accounts (a second region, or a platform account for Connect)? Give each its own provider entry. They have different signing secrets, and sharing one entry would silently accept events signed by either.

What else it handles

Signature verification comes first

Verification happens before any database write. An unauthenticated caller must not be able to create rows in the table your entire correctness guarantee depends on — that's a free denial-of-service against your own deduplication.

Three verifiers ship with the package:

Verifier For
StripeSignatureVerifier Stripe's t=…,v1=… scheme, implemented directly — no SDK dependency
HmacSignatureVerifier The common case: HMAC(body, secret) in a header, with configurable algorithm and prefix
SharedSecretVerifier A static token in a header. Weak, but it's what some providers offer

All comparisons use hash_equals, so timing doesn't leak how much of a signature was correct.

The Stripe verifier enforces a timestamp tolerance. Without it, anyone who captures one validly-signed request can replay it forever.

A note on raw bodies. Signatures are computed over the exact bytes the provider sent. Never reconstruct the body with json_encode(json_decode($body)) — key order, whitespace, unicode escaping, and float formatting all drift, and your verification will fail intermittently in ways that are genuinely unpleasant to debug.

Providers that send no event id

Some don't. The ledger falls back to a content fingerprint — sha256(provider + raw body) — which deduplicates identical redeliveries correctly.

Know the limitation: two genuinely distinct but byte-identical events collapse into one. That's the right trade for retries and the wrong one for, say, a ping sent twice on purpose. Use a real event id wherever the provider offers one.

Workers that die mid-handler

A row is claimed as processing before the handler runs. If the process is killed at that moment — deploy, OOM, timeout — the row is stuck: nothing completes it, and the unique index rejects every redelivery. The event is now permanently lost.

So a claim expires. After stale_claim_after seconds, the next redelivery takes it over and increments attempts:

'stale_claim_after' => 900,

Set it comfortably above your slowest handler. Too low risks two workers running the same handler concurrently; too high leaves crashed events waiting.

For events the provider never redelivers, a scheduled sweep converts stuck claims into failures you can see and replay:

Schedule::command('webhook-ledger:sweep')->hourly();

Failures are explicit, not silent

A handler that throws marks the event failed and rethrows. The exception is yours to log, alert on, and handle.

Redelivery of a failed event is treated as a duplicate, not a retry. Retrying is a deliberate, observable act — not something that happens to occur because the provider happened to try again:

php artisan webhook-ledger:replay --pretend
php artisan webhook-ledger:replay --provider=stripe --limit=50
php artisan webhook-ledger:replay --id=1041 --id=1042

Replay needs a handler it can invoke without an HTTP request, so add an invokable class to that provider's config:

'stripe' => [
    // ...
    'handler' => App\Webhooks\StripeHandler::class,
],

Events

WebhookProcessed, WebhookDuplicateDetected, and WebhookFailed are dispatched for observability.

Duplicates are normal traffic — every provider redelivers. Worth a counter; not worth an alert unless the rate jumps.

Schema

$table->string('provider', 64);
$table->string('event_id', 191);
$table->string('event_type', 191)->nullable();
$table->string('external_id', 191)->nullable();
$table->string('status', 16);          // processing | processed | failed
$table->unsignedSmallInteger('attempts');
$table->json('payload');
$table->text('last_error')->nullable();
$table->timestamp('claimed_at')->nullable();
$table->timestamp('processed_at')->nullable();

$table->unique(['provider', 'event_id']);   // the guarantee
$table->index(['provider', 'external_id']); // "what have we received about this order?"
$table->index(['status', 'claimed_at']);    // sweep and replay

The stored payload is worth as much as the deduplication. When a customer disputes what happened, the raw event as the provider sent it is the only record that settles it.

Testing

composer install
vendor/bin/pest

22 tests covering concurrent redelivery, tampered and replayed signatures, secret rotation, fingerprint fallback, stale-claim takeover, and failure recording.

The suite runs against SQLite, PostgreSQL and MySQL, because the deduplication guarantee rests on constraint-violation behaviour and that differs by engine — PostgreSQL aborts the enclosing transaction where the others don't. A SQLite-only suite cannot establish that the duplicate path is correct.

If you have Docker, all three run without a local PHP install:

docker compose run --rm test         # sqlite
docker compose run --rm test-pgsql   # postgres
docker compose run --rm test-mysql   # mysql

Requirements

PHP 8.2+ · Laravel 12 or 13 · any database with unique constraint support

Laravel 11 is not supported: every 11.x release currently carries open security advisories, so Composer refuses to install it under default policy.

License

MIT. Built by Zain Ul Abdain — backend engineer working on payments infrastructure.

Portfolio · GitHub · LinkedIn