Software

Laravel Stripe webhooks credited 5x. An inbox table fixed it

By · Sat Oct 24 2026 · 9 min read · 0 views

View as a Web Story

Software#stripe#postgresql#Laravel#idempotency#webhooks#cashier

Stripe webhook handlers in Laravel compared: a naive handler writes 100 ledger rows for 20 invoices while an inbox table writes 20

Stripe delivers each webhook at least once, so a Laravel handler that credits an account on every delivery credited 5x in my lab, once per retry. In a lab with 20 paid invoices and 5 deliveries of each, a naive handler wrote 100 ledger rows where 20 were correct. An inbox table with a unique event id, written in the same transaction as the work, fixed it and wrote exactly 20 every time.

This post shows the inbox table, the transaction rule that makes it safe, and an ordering guard for events that arrive late. Every number comes from a scripted lab against real Laravel Cashier code. It is the second post in a series on billing and tenant isolation for a solo Laravel and PostgreSQL SaaS.

What does Stripe promise about webhook delivery?

A Stripe webhook is an HTTP POST that Stripe sends to your server when something happens in your account, such as a paid invoice or a changed subscription. Stripe promises delivery at least once, and it does not promise order. The Stripe webhook documentation says Stripe retries undelivered events in live mode for up to three days with exponential backoff. The same page says Stripe does not guarantee that events arrive in the order they were generated.

Two facts follow for a Laravel developer. First, your handler will see the same event more than once. Second, a late event can overwrite newer state. Both bugs stay invisible in testing, because a developer sends each event once, in order.

How I tested the handlers

The methodology was a scripted lab that I built and ran on one laptop. It used Laravel 13.38, Laravel Cashier 16.9.0, stripe-php 21.3.2, and PostgreSQL 18.1. A PHP server with 8 workers received signed POST requests. Each request carried a Stripe-Signature header built with Stripe's documented scheme, an HMAC-SHA256 over the timestamp and the body, so Cashier's real signature middleware ran on every call.

The events were synthetic, not sent by Stripe. That matters for one reason: the lab shows how handlers behave under duplicate, concurrent, late, and crashing deliveries, but it does not measure Stripe's real retry timing. The race test also includes a 20 ms pause between the check and the insert in one handler, which widens a window that exists in any check-then-insert code.

Does Laravel Cashier deduplicate webhook events?

No. Laravel Cashier is the official Laravel package for Stripe billing, and its webhook controller verifies the signature but never stores event ids. I read WebhookController in version 16.9.0 to confirm it. It dispatches WebhookReceived, calls the matching handle... method, and returns. The Cashier documentation on webhooks describes signature verification and handler methods, and says nothing about duplicates.

The same controller trusts event order. Its customer.subscription.updated handler copies status from the payload into subscriptions.stripe_status without comparing the event's created time to what is stored. Any listener you add for invoice.payment_succeeded inherits both gaps.

Which handler designs credit twice?

Only the unique-constraint design stayed correct in every scenario. The naive handler credited on every delivery, and the check-then-insert handler failed under concurrency. The table shows ledger rows for 20 invoices.

Handler 5 retries, one at a time 5 deliveries at once (3 runs)
Naive: credit on every delivery 100 not run
Check: select exists, then insert 20 26, 28, 28
Inbox: unique event id 20 20, 20, 20

Naive Stripe webhook handler wrote 100 ledger rows for 20 invoices, check-then-insert wrote up to 28 under concurrency, inbox table wrote 20

Advertisement

The bottom line is that a select before an insert is not idempotency. When Stripe retries quickly or sends two deliveries together, two requests both see "not credited yet" and both insert. Only the database can settle that race, and it does so with a unique constraint.

How does the inbox table work?

Idempotency means that applying the same event twice has the same effect as applying it once. An inbox table achieves it by recording each event id in a table with a primary key, and by skipping the work when the insert finds the id already there. The migration is two lines:

create table webhook_inbox (
  event_id    text primary key,
  received_at timestamptz not null default now()
);

The handler claims the event with PostgreSQL's INSERT ... ON CONFLICT DO NOTHING. It reports how many rows it inserted. Zero rows means another delivery already claimed the event.

public function invoicePaid(array $p)
{
    return DB::transaction(function () use ($p) {
        $claimed = DB::affectingStatement(
            'insert into webhook_inbox(event_id) values (?) on conflict do nothing',
            [$p['id']]
        );

        if ($claimed === 0) {
            return response('duplicate', 200);   // tell Stripe it is handled
        }

        DB::table('ledger')->insert([
            'user_id'    => $this->userFor($p['data']['object']['customer'])->id,
            'event_id'   => $p['id'],
            'invoice_id' => $p['data']['object']['id'],
            'credits'    => 100,
        ]);

        return response('ok', 200);
    });
}

The unique key is the event id, not the invoice id. One invoice can produce several events, and Stripe may send a second distinct event for the same invoice. If your business rule is "credit once per invoice," add a unique index on ledger.invoice_id as well. Two layers cost nothing.

Why must the inbox insert and the work share one transaction?

Put both in one transaction, or a crash will either double-credit or lose the event. The lab crashed each handler on the first delivery of 20 events and let Stripe's retry arrive. The table shows the ledger rows afterwards, where 20 is correct.

Handler First delivery Retry Ledger rows
Credit, no inbox, crash after credit 500 200 40
Inbox committed first, crash before credit 500 200 0
Inbox and credit in one transaction 500 200 20

After a crash and one retry, the no-inbox handler held 40 ledger rows, the inbox-first handler 0, and the single-transaction inbox 20

The middle row is the dangerous one. The inbox row committed, the work crashed, and Stripe's retry saw a claimed event and answered 200. Stripe then stops retrying, and the customer never receives the credit. With a single transaction, the crash rolls back the claim, so the retry does the work. A handler that claims and works in separate commits trades a duplicate for a silent loss.

What happens when events arrive out of order?

Stock Cashier ended with the wrong status in the lab. I sent a customer.subscription.updated event with status canceled and created of 2000, then a stale event with status active and created of 1000. Cashier's local subscription finished as active, while Stripe's truth was canceled. A customer who cancelled kept access.

The guard is a stored timestamp. Keep the created value of the last applied event on the row, lock the row, and skip anything older.

return DB::transaction(function () use ($p, $request) {
    $sub = DB::table('subscriptions')
        ->where('stripe_id', $p['data']['object']['id'])
        ->lockForUpdate()->first();

    if ($sub && $sub->last_event_at !== null && $sub->last_event_at >= $p['created']) {
        return response('stale', 200);
    }

    $response = $this->handleWebhook($request);   // Cashier's own handler

    DB::table('subscriptions')
        ->where('stripe_id', $p['data']['object']['id'])
        ->update(['last_event_at' => $p['created']]);

    return $response;
});

With the guard, the same two events left the status at canceled. One limit applies: created has one-second resolution, so two real events in the same second tie, and the guard drops the second. If that matters, treat the webhook as a trigger and fetch the current object from the Stripe API instead. I did not measure that variant.

What does the inbox cost per webhook?

The inbox added about 0.5 ms to the median request. Across 300 sequential deliveries including the signature check, the naive handler had a median of 5.54 ms and the inbox handler 6.03 ms. The p95 values were 6.40 ms and 6.51 ms.

Handler Median p95
Naive 5.54 ms 6.40 ms
Inbox 6.03 ms 6.51 ms

The cost is small because the claim is one indexed insert in a transaction you would often open anyway. The Stripe documentation asks you to return a 2xx status quickly, before heavy logic. If the work is slow, claim the event in the request and dispatch a queued job, then make the job idempotent with the same unique ledger key.

How do you replay events and watch for duplicates?

Replay real events with the Stripe CLI, and count duplicates in production. The stripe events resend command sends a past event to an endpoint again, using the --webhook-endpoint flag to pick the target. Stripe says it works for up to 30 days after the event was created. Use it to confirm that a second delivery returns 200 and changes nothing. The Stripe dashboard offers a resend button for up to 15 days.

In production, log one line each time the handler answers duplicate or stale. A few per week is normal, since Stripe retries after any slow response. A sudden burst means your endpoint is timing out, and Stripe is retrying events that your server already handled. That is the moment the inbox table pays for itself, because without it each burst would have written extra ledger rows.

What should the handler return for unknown events and bad signatures?

Return 200 for event types you do not handle, and reject only requests that fail signature verification. Stripe sends every event type you subscribed to, and a non-2xx answer makes Stripe retry an event you will never act on. Cashier's signature middleware answers 403 for a bad signature, which is correct, because a forged request should not look like a success.

Subscribe your endpoint only to the events you need, such as invoice.payment_succeeded and customer.subscription.updated. Fewer subscribed types means fewer deliveries, fewer retries, and less code that can fail. For example, a solo SaaS rarely needs every charge.* event once it handles invoices.

Are Stripe idempotency keys the same thing?

No. Stripe's idempotent requests protect calls you make to Stripe, such as creating a charge, by sending an Idempotency-Key header. A webhook is the reverse direction: Stripe calls you. Protecting webhooks needs your own record of event ids, which is what the inbox table is.

How long should you keep inbox rows?

Keep rows longer than Stripe's retry window of three days. Thirty days is a comfortable margin, and a scheduled delete from webhook_inbox where received_at < now() - interval '30 days' keeps the table small. At 20 invoices a day, a solo SaaS adds about 600 rows a month, which is trivial.

How do you test it?

Replay the same signed payload twice and assert one ledger row. This test fails the moment someone removes the unique key or moves the claim outside the transaction.

it('credits once when Stripe retries', function () {
    $event = invoicePaidEvent('evt_1', 'in_1');

    postSignedWebhook($event)->assertOk();
    postSignedWebhook($event)->assertOk();

    expect(DB::table('ledger')->where('invoice_id', 'in_1')->count())->toBe(1);
});

Should you add an inbox table to your Laravel app?

Add it if a webhook handler credits, provisions, emails, or charges anything. Those effects are not safe to repeat, and a retry will repeat them. The cost is one table, one insert, and about half a millisecond.

Skip it only when every handler just sets state from the payload and cannot hurt anyone if applied twice, and even then keep the ordering guard. The numbers here come from synthetic events on one laptop, so rerun the lab on your own schema. The duplicate counts follow from the design, not from the hardware.

Advertisement

FAQ

Does Laravel Cashier handle duplicate Stripe webhooks?

No. Cashier 16.9.0 verifies the webhook signature and runs the matching handler, but it does not store event ids. A retried event runs your handler again. Add an inbox table with a unique event id so repeated deliveries do nothing.

How do I make a Stripe webhook handler idempotent in Laravel?

Insert the event id into a table with a primary key using `insert ... on conflict do nothing`, inside the same database transaction as the work. If the insert affects zero rows, return 200 and skip the work. A unique constraint settles races that a select cannot.

Why does Stripe send the same webhook twice?

Stripe guarantees at-least-once delivery. If your endpoint times out, returns an error, or the response is lost, Stripe retries the event, in live mode for up to three days. Your handler must therefore be safe to run more than once per event.

What if Stripe webhooks arrive out of order?

Store the `created` time of the last event you applied and skip older events for the same object. In the lab, a stale `active` event overwrote a newer `canceled` one in stock Cashier. Alternatively, fetch the object from the Stripe API when an event arrives.

Should the inbox row and the work be in one transaction?

Yes. If the inbox row commits and the work then crashes, Stripe's retry sees a claimed event and the work is lost. In the lab, that design left 0 ledger rows for 20 invoices. One transaction rolls the claim back on failure.

Comments

Loading…

Sign in to join the conversation.

Related posts