Stripe Webhooks Will Race You. Here's How to Win.
Idempotency keys feel like a safety net until you hit 500 orders a day and realize your webhook handler has a race condition that's been double-charging people.
Stripe will deliver the same webhook event more than once. They document this openly. Most developers read it, nod, and then write a handler that's still broken under load. I was one of them until an e-commerce client's fulfillment system started creating duplicate shipments on Black Friday.
The idempotency key Stripe sends isn't the protection you think it is. It's a key you can use to deduplicate — Stripe doesn't do it for you on the receiving end. And if your handler isn't atomic, that key is decoration.
What Stripe Is Actually Doing
Stripe's webhook delivery system retries events for up to 72 hours if your endpoint doesn't return a 2xx quickly. They can also deliver the same event in rapid succession if your server responds slowly, or if you have multiple instances running (and if you're on Laravel Forge or any autoscaling setup, you do). So the real-world scenario isn't just "Stripe retried after a timeout" — it's two requests for the same event hitting two different PHP-FPM workers at nearly the same moment.
The event has an id field like evt_1PxK3j2eZvKYlo2C9mN4qRst. That's your idempotency signal. The naive handler checks for it in the database, doesn't find it, and proceeds. Two concurrent workers both do that check at the same time, both don't find it, and both proceed. You've just processed the same payment_intent.succeeded event twice.
The Broken Pattern (You've Probably Written This)
// app/Http/Controllers/StripeWebhookController.php
public function handlePaymentSucceeded(array $payload): Response
{
$eventId = $payload['id'];
// This check is NOT thread-safe
if (WebhookEvent::where('stripe_event_id', $eventId)->exists()) {
return response('Already processed', 200);
}
// Gap right here. Two workers both passed the check above.
// Both are now about to do the thing.
DB::transaction(function () use ($payload) {
$order = Order::find($payload['data']['object']['metadata']['order_id']);
$order->markPaid();
$order->createShipment();
WebhookEvent::create(['stripe_event_id' => $payload['id']]);
});
return response('OK', 200);
}
The DB::transaction() call does not save you here. Both workers pass the exists() check before either one writes to webhook_events. The transaction ensures consistency within one worker, not mutual exclusion between workers. Two shipments get created. Two fulfillment emails go out. A very annoyed customer calls your client.
The Fix: Make the Check and the Insert Atomic
The right move is a unique constraint on stripe_event_id combined with an insert-or-ignore pattern that lets the database enforce exclusivity. Here's how I actually do this now.
First, the migration:
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('stripe_event_id')->unique(); // The database enforces this
$table->string('type');
$table->string('status')->default('processing');
$table->timestamps();
});
Then the handler:
public function handlePaymentSucceeded(array $payload): Response
{
$eventId = $payload['id'];
// insertOrIgnore is atomic at the DB level.
// If the row already exists, it returns 0 affected rows.
$inserted = DB::table('webhook_events')->insertOrIgnore([
'stripe_event_id' => $eventId,
'type' => $payload['type'],
'status' => 'processing',
'created_at' => now(),
'updated_at' => now(),
]);
if ($inserted === 0) {
// Already claimed by another worker. Bail out cleanly.
return response('Already processing', 200);
}
// Only one worker ever gets past this point for a given event ID.
try {
DB::transaction(function () use ($payload, $eventId) {
$order = Order::find($payload['data']['object']['metadata']['order_id']);
$order->markPaid();
$order->createShipment();
DB::table('webhook_events')
->where('stripe_event_id', $eventId)
->update(['status' => 'processed']);
});
} catch (Throwable $e) {
// Mark failed so you can inspect it, but return 200 so Stripe
// doesn't retry something that will just fail the same way.
DB::table('webhook_events')
->where('stripe_event_id', $eventId)
->update(['status' => 'failed']);
Log::error('Webhook processing failed', [
'event_id' => $eventId,
'error' => $e->getMessage(),
]);
}
return response('OK', 200);
}
insertOrIgnore on MySQL maps to INSERT IGNORE, which is atomic. On Postgres it uses ON CONFLICT DO NOTHING. Both let the unique constraint do the mutex work. The database becomes your coordination layer, which is exactly what it's good at.
The Gotchas That Will Still Get You
Returning 500 on a legitimate business error. I've seen handlers that throw an exception, return a 500, and then wonder why Stripe is retrying an event that already half-processed. If your code fails after the insert but before setting status to processed, a retry will hit the insertOrIgnore, get 0 rows, and bail — silently. That's why the status column matters. Build an admin view that shows failed events. I do this for every client now.
Queueing the actual work without queuing the deduplication. Some teams push webhook processing onto a queue job to return 200 fast. That's fine, but the insertOrIgnore has to happen in the HTTP handler, not in the job. If you do the dedup inside the queued job, two jobs can both dequeue and both attempt the insert. Put the mutex in the controller, dispatch the job after.
// Correct pattern with queues
$inserted = DB::table('webhook_events')->insertOrIgnore([...]);
if ($inserted === 0) {
return response('Already queued', 200);
}
// Job does the business logic only — dedup is already done
ProcessStripePayment::dispatch($payload, $eventId);
return response('Queued', 200);
Stripe signature verification timing. Verify the webhook signature before any of this. \Stripe\WebhookSignature::verifyHeader() or the stripe/stripe-php helper. Don't let unauthenticated requests waste a database write. In Laravel I put the signature check in a middleware applied only to the webhook route.
Clock skew on the Stripe-Signature timestamp check. Stripe's default tolerance is 300 seconds. If your server clock is drifting — and I've seen this on older EC2 instances — you'll start rejecting valid webhooks. Sync your clocks. chronyc tracking will tell you if you're drifting.
When the spatie/laravel-stripe-webhooks Package Isn't Enough
Spatie's package is solid for routing events to jobs and handling signature verification. I use it. But it doesn't solve the atomicity problem out of the box. Its WebhookCall model does store the event, but the dedup check and the store are not a single atomic operation in the way I've described. At low volume you'll never notice. At 1,000 events per hour during a flash sale, you will.
If you're using Spatie's package, you can override WebhookCall::storeAndDispatch() or just add your insertOrIgnore guard in the job's handle() method — as long as you understand you're accepting that two jobs might spin up, and you're relying on the database constraint to let only one proceed.
When I'd Reach for This Pattern
Every Stripe integration I ship now gets the atomic insert from day one. It costs almost nothing — one migration, eight extra lines — and it has saved clients real money more than once. The fulfillment duplicate I mentioned at the top cost my client about three hours of customer service and two refunded shipping labels. The fix took twenty minutes to write and ten minutes to deploy.
If you're processing payments, subscriptions, or anything with downstream side effects (creating records, sending emails, triggering third-party API calls), you need this. If your webhook handler just logs events to a data warehouse with no side effects, you can probably relax a bit — duplicate rows are annoying, not catastrophic.
When I Wouldn't Overthink It
If you're in early development, webhooks are pointing to a local ngrok tunnel, and you have one user (yourself), skip the ceremony and get the feature working first. Build the atomic handler before you go to production, not before you write your first line of integration code.
Stripe's webhook system is well-designed and their documentation is honest about delivery guarantees. The race condition isn't Stripe's fault — it's a distributed systems problem that shows up whenever you have stateless HTTP workers and shared state. The database is already your shared-state coordinator. Use it as the lock.
Idempotency keys are a hint, not a guarantee. Build like they might arrive twice, because they will.
Need help shipping something like this? Get in touch.