The 3 Lines of Stripe Webhook Code That Will Eventually Cost You Money

php dev.to

Stripe Webhooks in Laravel: 3 Production Failure Modes You Need to Handle

Your Stripe integration works.

You tested it with stripe trigger. The subscription appeared in the database. The happy path works.

So you move on to the next feature.

Then a customer emails you:

“I was charged twice.”

Or worse, nobody emails you.

Stripe stopped reaching your webhook endpoint three weeks ago, and you only discover it when your MRR chart starts looking wrong.

I spent the last few weeks building a production-grade Stripe webhook layer for Laravel 11 + Cashier. It has 40 tests and is now packaged as a standalone kit.

This article covers the three failure modes that repeatedly show up in real-world Stripe integrations—and the implementation patterns that prevent them.

The finished kit: https://boukataya.gumroad.com/l/nggzho

The lessons below are free, whether you buy the kit or build everything yourself.

  1. Stripe Retries Webhooks — So Your Handler Must Be Idempotent

This is the failure mode everyone knows about.

And one of the easiest to implement incorrectly.

Stripe retries webhook deliveries when your endpoint doesn't return a 2xx response. Retries can continue for up to three days.

The dangerous part is that your first request may still be running when Stripe sends the retry.

For example, imagine your webhook handler does this:

Receives invoice.paid
Calls the Stripe API
Sends a welcome email
Updates several database records
Finally returns 200

If that takes too long, Stripe can retry while the first request is still executing.

Now you have two workers processing the same event.

The Idempotency Check That Looks Correct

A common implementation is:

if (WebhookEvent::where('stripe_event_id', $event->id)->exists()) {
return response()->json(['received' => true]);
}

WebhookEvent::create([...]);

Looks reasonable.

It isn't safe.

The Race Condition

Two requests can execute the check at almost exactly the same time:

Worker A: SELECT ... → no row found
Worker B: SELECT ... → no row found
↑ both pass the check

Worker A: INSERT → succeeds
Worker B: INSERT → duplicate key error

HTTP 500

Stripe retries

This is a classic time-of-check-to-time-of-use race condition.

The application-level exists() check cannot close that window.

And webhooks are exactly the type of traffic that exposes small concurrency windows: retries, bursts, and overlapping requests.

Put the Guarantee in the Database

The real protection belongs at the database level:

$table->string('stripe_event_id', 191)->unique();

Now the database guarantees that the same Stripe event cannot be inserted twice.

When a concurrent request hits the unique constraint, catch the duplicate-key exception and treat it as a successful duplicate delivery.

The important distinction is:

A duplicate webhook is not a failure. It is an already-processed event.

Your responsibility is to make sure the resulting state change happens once.

Handle Multiple Database Engines

If you develop with SQLite but deploy to MySQL or PostgreSQL, don't hard-code a single error code.

For example:

private function isUniqueViolation(QueryException $e): bool
{
$sqlState = $e->errorInfo[0] ?? null;
$driverCode = $e->errorInfo[1] ?? null;

// MySQL 23000/1062, PostgreSQL 23505, SQLite 23000/19.
return $sqlState === '23000'
    || $sqlState === '23505'
    || $driverCode === 1062
    || str_contains(
        $e->getMessage(),
        'UNIQUE constraint failed'
    );
Enter fullscreen mode Exit fullscreen mode

}
Test the Race, Not Just the Happy Path

A normal unit test won't prove that your implementation is concurrency-safe.

You need to simulate the race itself.

One approach is to use DB::listen() in Pest, inject a conflicting insert during the controller's own insert, and assert that the webhook still returns 200 with something like:

{
"duplicate": true
}

rather than returning 500.

That's exactly the kind of test that catches bugs hidden behind otherwise-green test suites.

  1. Don't Do Real Work Inside the Webhook Request

The second problem is closely related to the first.

Stripe has a timeout.

Your application does not control how long Stripe is willing to wait.

If your webhook handler spends too much time processing the event, Stripe can give up and retry.

Now you have overlapping executions again.

The Webhook Should Be Boring

The HTTP endpoint should do roughly four things:

Verify the signature
Store the event
Dispatch a job
Return 200

That's it.

For example:

// Signature verified above

$record = WebhookEvent::create([...]);

ProcessStripeWebhook::dispatch(
$record->getKey(),
$eventId,
$eventType,
);

return response()->json(['received' => true]);

Everything expensive happens asynchronously.

Queue IDs, Not the Entire Payload

There is another detail that is easy to overlook.

Pass identifiers to the queue—not the entire Stripe payload.

ProcessStripeWebhook::dispatch(
$record->getKey(),
$eventId,
$eventType,
);

Why?

A large invoice.paid event containing dozens of line items can be hundreds of kilobytes.

If Stripe retries the request three times and you serialize that entire payload into your queue on every attempt, you're unnecessarily multiplying your Redis memory usage.

The event payload is already stored durably in your database.

The queue only needs the IDs required to retrieve it.

Configure Your Retry Policy

The default queue configuration is rarely ideal for payment webhooks.

For example:

public int $tries = 3;

public int $timeout = 60;

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

This gives failed processing attempts some breathing room without retrying aggressively.

But retries aren't enough.

You also need a proper failure strategy.

Failed Events Must Remain Replayable

This is the subtle part.

When the job fails permanently, do not mark the webhook as processed.

public function failed(?Throwable $exception): void
{
Log::error('Stripe webhook job failed permanently.', [
'stripe_event_id' => $this->stripeEventId,
'error' => $exception?->getMessage(),
]);

// Deliberately NOT marking processed_at.
// The event remains replayable.
Enter fullscreen mode Exit fullscreen mode

}

Why?

Imagine this sequence:

Stripe event received

Event stored

Job dispatched

Redis/API/database problem

Job fails permanently

Event marked "processed"

You've now created a much worse problem.

A temporary infrastructure failure has effectively deleted a payment event.

Keep failed events replayable.

  1. A Failed Payment Shouldn't Immediately Lock Your Customer Out

This one has a direct impact on customer experience.

A card gets declined.

Your webhook receives the failed payment event.

Your application immediately revokes access.

The customer was planning to update their card tomorrow.

Now they can't access their account.

Meanwhile, Stripe's Smart Retries may still be attempting to recover the payment.

Your application has effectively given up before Stripe has.

Give Failed Payments a Grace Period

Instead of immediately terminating access, move the subscription into a recoverable state:

$subscription->forceFill([
'stripe_status' => 'past_due',
'ends_at' => $subscription->ends_at
?? now()->addDays($graceDays),
])->save();

With Laravel Cashier, ends_at determines when access stops.

By pushing that date into the future, your application can continue treating the customer as subscribed while Stripe continues its payment recovery process.

For example:

Payment fails

past_due

7-day grace period

Stripe retries payment

Payment succeeds

Subscription restored
The Part People Forget: Reversal

When payment succeeds again, remove the grace-period state:

// on invoice.paid

$subscription->forceFill([
'stripe_status' => 'active',
'ends_at' => null,
'trial_ends_at' => null,
])->save();

Clearing ends_at matters.

Otherwise, a customer who successfully recovered their payment can remain flagged as cancelling.

Your UI might say:

“Your subscription is ending.”

Your dunning emails might say:

“Your subscription will expire soon.”

Even though the customer already paid.

That's a state-management bug.

Two More Details That Are Easy to Get Wrong

The three issues above are the big ones.

But there are two smaller implementation details worth getting right.

Verify the Signature Against the Raw Request Body

Stripe signs the exact bytes it sends.

Don't decode and re-encode the JSON before verification.

Correct
$payload = $request->getContent();
Broken
$payload = json_encode($request->all());

Those are not guaranteed to produce identical bytes.

The safe sequence is:

Raw request body

Signature verification

JSON decoding

Event processing

Not:

Request

Decode JSON

Re-encode JSON

Verify signature

Nothing upstream should modify the request body before verification.

In Laravel 11, keeping the webhook route in routes/api.php also avoids unnecessary session/CSRF middleware around the endpoint.

Enforce the Timestamp Tolerance

A valid signature from last week is still a replayable request.

Signature verification should therefore include an appropriate timestamp tolerance.

Fail Closed When the Webhook Secret Is Missing

Never do this:

if (!$secret) {
// Skip verification
}

That turns a configuration mistake into a security vulnerability.

If STRIPE_WEBHOOK_SECRET is missing, reject the request.

Make the problem obvious:

[stripe-kit] Stripe configuration incomplete:
STRIPE_WEBHOOK_SECRET not set.

The webhook endpoint will reject every request
until this is configured.

A loud failure in production is much better than silently accepting unsigned payment events.

What I Actually Built

I packaged these patterns into a drop-in Laravel + Cashier Stripe webhook kit.

Not a course.

Not a complete SaaS boilerplate.

Just the parts that tend to break.

Test Coverage

The current test suite:

40 tests passed
106 assertions

Duration: 2.49s

Tested against:

Laravel 11.56
Cashier 15.8
Stripe PHP SDK 16.6
Pest 3.8

The tests run against MySQL rather than SQLite because the unique-index and concurrency behaviour are central to the implementation.

Testing only on SQLite could give you a false sense of confidence.

What's Included
Raw-body Stripe signature verification with configurable tolerance
Database-enforced webhook idempotency
Concurrency-safe duplicate handling
Queue-based processing
Three attempts with exponential backoff
Replayable failed events
Subscription synchronization for seven relevant Stripe event types
Failed-payment grace periods
Correct grace-period reversal after successful payment
php artisan stripe:status for pipeline health checks
php artisan stripe:replay for unfinished events
DDEV environment with PHP, MySQL 8, Redis, Mailpit, and Stripe CLI
Six documentation guides covering deployment and troubleshooting
Supervisor and systemd deployment configurations
Symptom → cause → fix troubleshooting reference

Get the kit: https://boukataya.gumroad.com/l/nggzho

What It Doesn't Include

I'd rather make this explicit before you buy it.

No frontend

There is no:

Blade frontend
Inertia
Livewire
SPA
Checkout UI

The part that creates subscriptions is yours.

This kit handles what happens after Stripe starts sending events back to your application.

No authentication scaffolding

There is no auth system or admin panel.

Use something like Breeze or Filament depending on your application.

No Stripe account or products

You still need to configure your own:

Stripe account
Products
Prices
Webhook endpoint
API credentials
No magic guarantee

Stripe has edge cases.

This isn't a promise that your billing system can never fail.

It's a tested foundation built around Cashier's established patterns, with the failure modes that are easy to overlook handled explicitly.

If You're Building This Yourself

If you take nothing else from this article, take these three points:

  1. Put the uniqueness guarantee in the database $table->string('stripe_event_id', 191)->unique();

Application-level exists() checks are not enough.

  1. Queue the work

Your webhook should follow this flow:

Verify

Store

Dispatch

Return 200

Don't perform expensive business logic inside the HTTP request.

  1. Don't revoke access on the first failed payment

Stripe may still be retrying.

Give the customer a grace period and clear that grace period when the payment succeeds.

The Bigger Lesson

Stripe webhooks aren't difficult because the happy path is complicated.

They're difficult because distributed systems don't care about your happy path.

Requests overlap.

Networks fail.

Queues fail.

Payments fail.

Retries happen.

And the database has to be the final source of truth when two workers believe they're processing the same event.

That's where most of the bugs live.

If you're building this yourself, hopefully this saves you a week of debugging.

If you'd rather not rediscover these details at 3 AM:

https://boukataya.gumroad.com/l/nggzho

And if you've built a Stripe integration yourself, the interesting question isn't whether it works.

It's:

What happens when it doesn't?

Source: dev.to

arrow_back Back to Tutorials