Every Stripe integration works in the demo. The checkout completes, the webhook arrives,
the order flips to paid, everyone ships. Then real traffic starts, and three
behaviors that never showed up in testing begin quietly costing money. I run marketplace
payments in production — these are the three that matter, and the patterns we use against them.
Behavior 1: the same event arrives twice
Stripe guarantees at-least-once delivery. Not exactly-once — at least once. If your
endpoint is slow to respond, times out, or returns a 5xx, Stripe retries. Networks being
networks, you will eventually process checkout.session.completed twice for the
same session. If your handler does this:
// The demo version — works until it doesn't
public function handleCheckoutCompleted(array $event): void
{
$order = Order::where('session_id', $event['data']['object']['id'])->first();
$order->markPaid();
$order->seller->wallet->credit($order->seller_amount); // 💸 runs twice
}
…the second delivery credits the seller twice. Nobody notices until reconciliation — or worse, until payout. The fix is an event ledger with a unique constraint: record every event ID you've processed, inside the same transaction as the work itself.
public function handle(Request $request): Response
{
$event = $this->verifiedEvent($request); // signature check first, always
try {
DB::transaction(function () use ($event) {
// Unique index on stripe_event_id makes this the idempotency gate
ProcessedWebhook::create(['stripe_event_id' => $event->id]);
$this->dispatchHandler($event);
});
} catch (UniqueConstraintViolationException) {
// Already processed — acknowledge and do nothing
}
return response()->noContent();
}
The unique constraint is the point. Checking exists() before processing is a race
condition — two retries arriving simultaneously both pass the check. The database's unique
index is the only referee that can't be outrun.
Behavior 2: events arrive in the wrong order
Stripe does not guarantee ordering. A charge.refunded can arrive before the
payment_intent.succeeded it logically follows — especially during retry storms.
Handlers written as state transitions ("when refund arrives, move order from
paid to refunded") silently drop events that arrive early: the order
isn't paid yet, the guard clause bails, the refund is never recorded.
Two patterns survive this:
-
Treat events as facts, not commands. Store what happened
(
refund of 4,200 minor units exists for charge X) separately from the derived order state, then recompute state from the facts. - Fetch truth from the API, not the event. The event tells you something changed; the authoritative current state lives on the Stripe object. On each event, re-fetch the object and reconcile against it.
// Reconcile against the source of truth, not the event payload
public function handlePaymentEvent(string $paymentIntentId): void
{
$intent = $this->stripe->paymentIntents->retrieve($paymentIntentId);
DB::transaction(function () use ($intent) {
$order = Order::where('payment_intent_id', $intent->id)
->lockForUpdate()
->firstOrFail();
$order->syncFromPaymentIntent($intent); // derive state from Stripe's truth
});
}
This also makes the handler naturally idempotent: reconciling twice against the same truth is a no-op.
Behavior 3: retry after you half-finished
The nastiest one. Your handler marks the order paid, then crashes before crediting the wallet (deploy, OOM, database hiccup). Stripe retries. Your idempotency gate from Behavior 1 says "already processed" — and the wallet credit is lost forever, invisibly.
The rule: the idempotency record and every side effect must commit in one transaction. If any part fails, the event record rolls back too, and the retry gets a clean slate. Anything that can't live in the transaction — sending email, calling another API — belongs in a queued job that is dispatched after commit:
DB::transaction(function () use ($event) {
ProcessedWebhook::create(['stripe_event_id' => $event->id]);
$order = $this->applyPayment($event); // DB work: inside
$this->creditSellerWallet($order); // DB work: inside
NotifySeller::dispatchAfterCommit($order); // side effect: after commit
});
dispatchAfterCommit matters. Dispatch inside the transaction without it, and a
rollback means the seller gets an email about an order that doesn't exist.
The checklist we run on every payment webhook
- Signature verified before anything else — an unverified endpoint is an open money API.
- Unique constraint on event ID, checked by insertion, not by lookup.
- Event record + all database side effects in one transaction.
lockForUpdate()on any row whose balance or state the handler changes.- State derived from re-fetched Stripe objects, not from event payload snapshots.
- External side effects dispatched after commit, never inside.
- Respond fast (queue heavy work) — slow responses cause the retry storms above.
- A reconciliation job that compares Stripe's records against yours daily, because the only webhook guarantee that matters is that eventually one won't arrive at all.
Payment code doesn't get to be optimistic. Every handler is written for the ugly day, because on a marketplace the ugly day is a Tuesday.
Metisix builds and rescues marketplace payment systems — Stripe Connect, payouts, refunds, webhooks. The lowest-risk start is a fixed-price payments audit: severity-ranked findings on your integration in 5 business days.
