Journal — April 20, 2026 · 5 min read
Stripe Integration Mistakes I've Made
Eleven specific Stripe integration mistakes with symptom, root cause and fix: webhooks, idempotency, event ordering, float amounts, SCA failures, and why your database is only a cache of Stripe.
Every payments bug I've shipped came from the same misunderstanding: I treated my database as the record of what happened, and Stripe as a service I called. It's the other way round. Stripe is the ledger. My database is a cache of it.
Here are the specific mistakes, in roughly the order I made them.
1. Trusting the client-side success callback
Symptom. A user paid, Stripe took the money, the account was never upgraded. Support ticket, refund, apology.
Cause. I granted access in the onSuccess handler after checkout. The user closed the tab during the redirect, so that code never ran.
Fix. The client callback is a UI hint and nothing more. Entitlements are granted in a webhook handler, server-side, or they aren't granted at all. Show the user an optimistic "processing" state and let the webhook do the real work.
2. Not verifying webhook signatures
Symptom. None, thankfully. Found in review.
Cause. A public endpoint that upgraded accounts based on a JSON body it received. Anyone who guessed the URL could have granted themselves a lifetime plan with one curl.
Fix. stripe.webhooks.constructEvent with the raw request body and your signing secret. Note the raw body part: if your framework has already parsed JSON, the signature won't verify and you'll waste an hour before you notice.
3. No idempotency keys on writes
Symptom. A customer charged twice for one action.
Cause. Our HTTP client retried on a timeout. The first request had actually succeeded.
Fix. An Idempotency-Key header on every mutating API call, derived from something stable like the order ID rather than a fresh UUID per attempt. Stripe then returns the original result instead of creating a second charge. This is free and I have no excuse for not having done it from the start.
4. Assuming webhooks arrive in order
Symptom. A user downgraded, then upgraded, and ended up on the cheaper plan.
Cause. Webhooks are not ordered. customer.subscription.updated events arrived out of sequence and the older one wrote last.
Fix. Every subscription object carries identifying and timing data. Store the event or object timestamp on your local row and drop any event older than what you've already applied. Or, simpler and what I do now: on any subscription event, ignore the payload's contents entirely and re-fetch the subscription from the API. Latest state wins, ordering stops mattering.
5. Storing amounts as floats
Symptom. A ₹499 charge reconciled as ₹498.99999.
Cause. A float column, and JavaScript arithmetic.
Fix. Integer minor units everywhere, the way Stripe already does it. 49900 paise, 4999 cents. The currency lives in a separate column, and you never divide until the render layer. Also worth knowing zero-decimal currencies exist: JPY amounts are not multiplied by 100, and hardcoding that factor will bite you the day you sell to Japan.
6. Doing real work inside the webhook handler
Symptom. Stripe marked our endpoint as failing and started backing off deliveries.
Cause. The handler provisioned resources, sent an email, and called an external API before responding. Occasionally it took longer than the delivery timeout, so Stripe retried, so we did all of it again.
Fix. The handler verifies the signature, writes the event to a table keyed on the Stripe event ID, returns 200. A worker does the rest. That event ID uniqueness constraint is also your idempotency guarantee for redelivery, which happens more often than you'd think.
7. Ignoring proration and mid-cycle changes
Symptom. An angry email about a charge nobody could explain.
Cause. We upgraded a customer mid-cycle with default settings. Stripe correctly issued a prorated invoice. Our product had never told the user that would happen, and our own UI showed a different number.
Fix. Decide your proration policy deliberately, set proration_behavior explicitly rather than accepting the default, and use the upcoming-invoice preview endpoint to show the user the exact amount before they confirm. Payments UX is mostly about never surprising anyone.
8. Testing only the happy path
Symptom. Real users in Europe and India hitting 3DS challenges we'd never seen, on flows that assumed success was synchronous.
Cause. Every test used 4242 4242 4242 4242, which never challenges.
Fix. Stripe publishes test cards for authentication-required, insufficient-funds, and generic-decline. Every one of those is a distinct code path in your app. SCA in Europe and RBI rules in India make these common, not edge cases. Test them before your users do.
9. Not reconciling
Symptom. Slow drift. A handful of accounts whose local subscription status didn't match Stripe's, with no single incident to blame.
Cause. Missed webhooks. They're rare, and over months, rare is not zero.
Fix. A nightly job that pages through active subscriptions in the Stripe API and compares against your table. Log every mismatch. Early on the log will surprise you.
10. Ignoring tax and cross-border reality
Symptom. Discovering at filing time that GST obligations existed on invoices that never mentioned tax.
Cause. I built checkout as if the price were the price.
Fix. Stripe Tax handles calculation and it's worth its cut for anyone selling across borders. What it doesn't handle is your own registration, invoice-format obligations, or how the money gets home, which I've written about separately in getting paid across borders.
11. Mixing test and live objects
Symptom. A production customer with a test-mode price ID. Checkout failed with an error message that explained nothing.
Cause. A hardcoded price ID copied from the dashboard while in test mode.
Fix. Price IDs, product IDs and webhook secrets are all environment-scoped. Keep them in config, never inline, and add a boot-time assertion that your key prefix and your price IDs come from the same mode. Ten lines that would have saved me a bad Friday.
Nine of these eleven are the same mistake wearing different clothes: my database believed it knew the truth. It doesn't. Stripe knows what money moved. Build every handler, every reconciliation job, and every entitlement check as though your own tables might be wrong, because sometimes they are, and the customer's bank statement is the version that gets argued about.