Stripe webhooks in Laravel: verify, queue, return 200, and stop double-fulfilling orders
right, they're down, where was I. a webhook setup that survived Black Friday
Contents
Kids are asleep, so here's the webhook setup we've been running on an ecommerce client for two years. It came out of a Black Friday where we shipped some lucky customers two of everything. Not Cashier, plain stripe-php, because the client has a custom checkout.
Why webhooks go wrong
Stripe retries a webhook if you don't answer with a 2xx quickly, and it keeps retrying for up to three days. If your handler does the actual work (emails, stock, calling the warehouse API) inside the request, it gets slow, Stripe times out, retries, and your work runs again. Also Stripe does not promise to deliver events once, or in order. Plan for duplicates and plan for weird order.
1. The route, outside CSRF
// bootstrap/app.php (Laravel 11)
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['stripe/*']);
})
2. Verify, store, dispatch, return
use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;
class StripeWebhookController
{
public function __invoke(Request $request)
{
try {
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret'),
);
} catch (\UnexpectedValueException|SignatureVerificationException $e) {
return response('invalid', 400);
}
$stored = StripeEvent::createOrFirst(
['stripe_id' => $event->id],
['type' => $event->type, 'payload' => $request->getContent()],
);
if ($stored->wasRecentlyCreated) {
HandleStripeEvent::dispatch($stored->id);
}
return response('ok', 200);
}
}
Two things here. Use the raw body (getContent()) for the signature check, not the parsed JSON, or the signature won't match. And stripe_id has a unique index:
Schema::create('stripe_events', function (Blueprint $table) {
$table->id();
$table->string('stripe_id')->unique();
$table->string('type');
$table->longText('payload');
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});
createOrFirst tries the insert and falls back to a select when the unique index says no, so two copies of the same event arriving at the same moment can't both get dispatched. firstOrCreate does it the other way round and has a small race window. That unique index is doing most of the work in this whole post.
3. The job does the work, idempotently
class HandleStripeEvent implements ShouldQueue
{
use Queueable;
public function __construct(public int $eventId) {}
public function handle(): void
{
$event = StripeEvent::findOrFail($this->eventId);
if ($event->processed_at) {
return;
}
if ($event->type === 'checkout.session.completed') {
$session = json_decode($event->payload)->data->object;
DB::transaction(function () use ($session, $event) {
$order = Order::whereKey($session->metadata->order_id)
->lockForUpdate()
->firstOrFail();
if ($order->paid_at === null) {
$order->update(['paid_at' => now(), 'stripe_session_id' => $session->id]);
FulfilOrder::dispatch($order->id)->afterCommit();
}
$event->update(['processed_at' => now()]);
});
}
}
}
Even if the same event somehow gets through twice, the order row lock plus the paid_at check means fulfilment is dispatched once. afterCommit() stops the fulfilment job starting before the transaction is actually committed (that one bit us too, the job ran, couldn't see paid_at yet, did nothing).
4. Testing locally
stripe listen --forward-to localhost:8000/stripe/webhook
stripe trigger checkout.session.completed
The CLI prints a webhook secret for local use, put it in .env. Different secret per environment, obviously.
Bits I'd tell past me
- Keep the raw payload. The day something weird happens you'll want exactly what Stripe sent.
- Don't trust event order. If you need the current state of a thing, fetch it from the API in the job.
- If you are on Cashier, it already has a webhook controller, extend that instead of writing this.
Black Friday since: zero double shipments. The warehouse lads were almost disappointed.
Written by
Imran Hussain
Backend dev in Leeds. Laravel and Node, mostly ecommerce. Dad of two, sleep of none. Leeds United, unfortunately.
Get the best of #php_still_pays weekly
One email each Monday with the most useful posts. No spam; unsubscribe in one click.
Comments
Sign in to join the conversation.
Sign in to comment