Handling Payment Webhooks and Idempotency with Stripe and Similar Providers

Stripe's own documentation is explicit that webhook events can be delivered more than once, and we've seen it happen in production — a network blip on our side causes Stripe to not receive our 200 response in time, and it retries the same event minutes later. Our first version of a payment_intent.succeeded handler wasn't idempotent, and during one such retry it created a second fulfillment record for the same order, shipping a duplicate item at our client's cost.
The fix was storing every processed Stripe event ID in a dedicated table with a unique constraint, checked at the very start of the handler before any side effects run. If the event ID has already been processed, we return a 200 immediately without redoing the work — Stripe just needs the acknowledgment, not a repeat of the fulfillment logic. This is a stricter version of general request idempotency because we don't control the retry trigger; Stripe does.
Signature verification is non-negotiable and we treat it as step zero, before even parsing the payload — verifying the Stripe-Signature header against the raw request body using the webhook secret. We got this wrong once in a staging environment by parsing JSON before verification, which meant a malformed or forged payload could throw an unhandled exception before the security check ever ran.
We also learned to treat the webhook as the source of truth for payment state rather than the synchronous API response from creating a PaymentIntent. A client-side confirmation can succeed from the browser's perspective while the actual charge is still settling or requires additional authentication, so order fulfillment is gated strictly on receiving and processing the succeeded webhook, not on the initial API call returning successfully.
For webhook endpoint reliability itself, we return a 200 as fast as possible — acknowledging receipt — and push the actual fulfillment work into a background job rather than doing it inline in the webhook handler. Stripe has its own timeout for webhook responses, and a slow database write during a traffic spike used to occasionally cause Stripe to time out and retry an event we were already mid-way through processing.


