Laravel Stripe webhooks credited 5x. An inbox table fixed it
By Nihar Ranjan Das · Sat Oct 24 2026 · 9 min read · 0 views
View as a Web StorySoftware#stripe#postgresql#Laravel#idempotency#webhooks#cashier

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 |

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 |

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

Laravel's RateLimited job middleware failed 240 of 300 jobs
Laravel's RateLimited job middleware protected a small tenant from a noisy one, and it also failed 240 of the noisy tenant's 300 jobs. In my lab, a limit of 20 jobs per second kept the small tenant's
Sat Oct 24 2026 · 9 min read · 0 views

Cashier locks customers out on day 0 of the retry window
Laravel Cashier locks customers out on day 0 of Stripe's retry window. The first failed renewal moves a subscription to pastdue, and Cashier's valid() returns false from that moment. Stripe's
Sat Oct 24 2026 · 9 min read · 0 views

Cashier upgrades bill late and can lock customers out
Laravel Cashier's default swap() and updateQuantity() calls do not charge a customer for an upgrade today. They send prorationbehavior=createprorations, so Stripe adds the charge to the next renewal
Sat Oct 24 2026 · 9 min read · 0 views