Integrating Stripe payments on a client's website: what the docs don't tell you

A couple of months ago a client who sells kitchenware online messaged us on a Friday at six in the evening: “we need Stripe live by Monday, we’ve already emailed our whole list announcing the launch date.” Put like that it sounds like an afternoon’s job: you grab the API, read the docs, drop in the keys, done. It wasn’t. What ate our weekend wasn’t the checkout, it was the webhooks.
Integrating Stripe payments on a small store isn’t technically hard: the SDK is solid, and Checkout or the Payment Element cover 90% of cases without you writing a single line of card-form HTML. What nobody tells you is that the payment “works” in the browser long before your backend has any idea something happened; if you trust only what the frontend reports back, sooner or later you’ll end up with orders that got charged but never show as paid in your database.
Here’s exactly what went wrong: we wired up the checkout.session.completed webhook to mark the order as paid and fire off the confirmation email. Worked fine in testing. In production, Stripe retries the same event if your endpoint doesn’t respond with a 200 in time, and ours, in the first few days, was slow because it sent the email synchronously before responding. The result: one customer got three confirmation emails for a single order, and in the logs we saw the same event.id processed twice because we weren’t storing the ids we’d already seen anywhere. Nothing actually broke money-wise, Stripe doesn’t charge twice for that, but it did dent our credibility: the shop owner called us worried, thinking he’d been overcharged.
The mess isn’t the checkout, it’s the webhooks
The fix was boring, and that’s exactly how it should be: store the event.id in a table with a unique index before processing anything, respond with a 200 in milliseconds, and push the email out to a background queue. I ended up reading through Stripe’s retry policy, up to three days of retrying a failed event, to understand why, from Stripe’s side, a slow endpoint looks identical to a dead one. If your integration isn’t idempotent from day one, this bug is coming sooner or later; it’s not a remote possibility, it’s close to a certainty.
The PCI scope nobody signs off on in the contract
The other thing that threw the client was PCI DSS compliance. He assumed that, since we were using Stripe, “Stripe handles compliance.” That’s only half true: if the card number never touches your own server, your scope shrinks to the SAQ A questionnaire, the shortest one there is. But his previous payment provider, an old-school bank terminal, had at some point promised him a card form embedded directly on the page “so customers don’t notice they’re leaving the site,” which would have pushed him into SAQ D territory, complete with an annual audit. We had to walk him through it, calculator in hand, to show that the visual polish wasn’t worth the compliance cost. We ended up using the Payment Element, tokenized through Stripe.js, so the screen stays on his site while the card data never passes through our server at all.
This connects to something we talked about when budgeting a web project: the client pays for what they can see, and the risk they can’t see is exactly what blows up later. There’s a similar pattern to the dependency that took down an entire checkout: in both cases the failure wasn’t in the business logic everyone reviews, it was in the plumbing everyone assumes just works.
We still use Stripe on nearly every ecommerce project we run, and I’d recommend it without hesitation. But every time someone asks me to have it ready by Monday, I think about those three duplicate emails and the look on that client’s face on the phone. How many payment integrations out there in production aren’t actually idempotent, and just haven’t hit the retry that would expose it yet?