
How I Made a Laravel Stripe Webhook Safe to Replay
How I designed a Laravel Stripe checkout webhook to reject invalid requests, survive duplicate delivery, and expose the next reliability gaps.
- Laravel
- Stripe
- Webhooks
- Testing
On this page
A successful payment does much more than change a billing status in my Laravel application.
In the Light Code Labs dashboard, a completed Stripe checkout can move a lead to won, create a customer account, create an organization, assign its owner, store subscription details, and seed an onboarding project.
That makes checkout.session.completed one of the most consequential events in the system.
It also creates an obvious risk.
What happens if Stripe delivers the same event twice?
Without duplicate protection, a second delivery could create another workspace, another membership, another activity record, or another onboarding workflow. The customer paid once, but the application could behave as if two separate customers had arrived.
I designed the webhook so the same signed event can be sent through the endpoint again without creating a second customer workspace.
This is how it works, what the tests prove, and where I would harden it further.
The real workflow#
The webhook sits between payment and customer onboarding.
1Stripe checkout completes
2→ Laravel verifies the webhook signature
3→ Laravel checks whether the event was already processed
4→ The lead and payment state are updated
5→ The customer user is created or reused
6→ The organization is created or reused
7→ The owner membership is created or reused
8→ The onboarding project is seeded
9→ The Stripe event ID is recorded
10→ Laravel returns 200
This is not an isolated demo endpoint. It connects billing, CRM state, account provisioning, permissions, subscriptions, and onboarding.
That is why duplicate handling belongs in the original design instead of being added after duplicate data appears.
First boundary: verify that Stripe sent the request#
The controller reads the raw request body, the Stripe-Signature header, and the configured endpoint secret.
1$payload = $request->getContent();
2$sig = (string) $request->header('Stripe-Signature', '');
3$secret = (string) config('services.stripe.webhook_secret');
4
5try {
6 $event = Webhook::constructEvent($payload, $sig, $secret);
7} catch (Throwable $e) {
8 Log::warning('Stripe webhook signature verification failed', [
9 'error' => $e->getMessage(),
10 ]);
11
12 return response('Invalid signature', 400);
13}
The application does not trust parsed request data before verification. It verifies the exact payload against the endpoint secret and rejects invalid requests before any customer or billing state changes.
Stripe recommends verifying events with the raw payload, the signature header, and the endpoint secret. The Stripe webhook signature documentation explains why the raw request body matters.
Signature verification answers one question:
Did Stripe send this request?
It does not answer the second question:
Have I already processed this event?
Second boundary: track the Stripe event ID#
Every Stripe event has an ID. The application stores processed IDs in a processed_webhooks table.
The table gives event_id a unique constraint. Before processing an event, the controller checks for that ID:
1if ($this->alreadyProcessed($event->id)) {
2 return response('ok', 200);
3}
After the handler finishes, it records the event:
1DB::table('processed_webhooks')->insert([
2 'source' => 'stripe',
3 'event_id' => $eventId,
4 'event_type' => $eventType,
5 'processed_at' => now(),
6 'created_at' => now(),
7 'updated_at' => now(),
8]);
If the same event arrives later, Laravel finds the existing ID, skips the business logic, and still returns a successful response.
That final detail matters. Stripe says an event that has already been handled should be ignored while the endpoint returns a successful response. Otherwise, Stripe can continue treating it as undelivered. The Stripe guidance for undelivered events covers the expected response behavior.
Third boundary: make the domain writes repeatable#
The event ledger is not the only protection.
The provisioning code also uses stable business identifiers when creating records.
The customer is found by email:
1$user = User::firstOrCreate(
2 ['email' => $email],
3 [
4 'name' => $name ?: Str::title(Str::before($email, '@')),
5 'password' => null,
6 'role' => UserRole::Member,
7 'email_verified_at' => now(),
8 ],
9);
The organization is found by its Stripe customer ID. If it already exists, the existing organization is updated instead of replaced.
The owner membership also uses firstOrCreate with the organization and user IDs.
These checks provide defense in depth:
- The event ID prevents a completed event from running twice.
- Stable external identifiers prevent duplicate customer records.
- Domain uniqueness prevents duplicate memberships.
- Existing records can be updated when newer subscription state arrives.
Idempotency is stronger when it exists at several boundaries instead of depending on one conditional at the top of a controller.
The replay test#
The feature test builds a correctly signed checkout.session.completed payload and sends the same request to the webhook endpoint twice.
1$this->postWebhook($request);
2$this->postWebhook($request);
3
4$this->assertSame(
5 1,
6 User::where('email', 'biz2@example.com')->count(),
7);
8
9$this->assertSame(
10 1,
11 Organization::where('stripe_customer_id', 'cus_replay')->count(),
12);
13
14$this->assertSame(
15 1,
16 OrganizationMember::where(
17 'user_id',
18 User::where('email', 'biz2@example.com')->value('id'),
19 )->count(),
20);
The important assertion is not that both requests return 200.
The important assertion is that two deliveries still produce one user, one organization, and one owner membership.
I reran the focused checkout and invoice webhook suite while preparing this article. Six tests passed with twenty eight assertions.
The tests also verify that:
- A valid completed checkout provisions the expected customer records.
- A paid invoice moves the related work item into its paid state.
- Reprocessing an already paid invoice does not create another audit event.
- A failed invoice records the billing problem for the operator.
- An unknown invoice does not crash the endpoint.
The part I would harden next#
The current implementation is safe for the sequential replay covered by the test.
There is still a concurrency window.
Two requests arriving at nearly the same moment could both check the table before either request records the event. The unique constraint prevents two processed event rows, but it is inserted after the business logic. That means the constraint alone does not guarantee that every downstream side effect runs only once.
Several domain writes already protect themselves with stable identifiers and unique constraints. That reduces the risk, but it does not remove it from activity records, queued work, or other side effects.
There is another important recovery question.
Customer provisioning can succeed while onboarding project creation fails. The current controller logs that failure and continues. Because the event is then marked as processed, replaying the event will not automatically retry the missing onboarding step.
My next hardening pass would introduce explicit processing states:
receivedprocessingcompletedfailed
I would claim the event row before performing business logic, using its unique event ID as the concurrency gate. Database changes that belong together would run inside a transaction. External or queued side effects would receive their own idempotency keys.
I would also add a reconciliation command that finds incomplete customer onboarding records and safely rebuilds the missing pieces. That provides a recovery path even when the original webhook has already returned.
The goal is not merely to prevent duplicates. The goal is to make partial failure visible and repairable.
What this changed in how I think#
Webhook reliability is not one alreadyProcessed check.
It is a stack of guarantees:
- Verify who sent the request.
- Identify the event with a stable external ID.
- Make domain writes repeatable.
- Protect against concurrent processing.
- Record partial failure.
- Give operators or scheduled jobs a reconciliation path.
- Test the same event more than once.
The simplest version of a webhook asks, “Can I receive this event?”
The production question is, “Can I receive it twice, fail halfway through, recover later, and still leave the customer with one correct account?”
That is the standard I want integrations in my Laravel applications to meet.