After shipping Stripe Customer objects last week, I remembered that Stripe has a load of great documents intended for both humans and coding agents. So I pointed Claude at their best-practices documentation, and had it walk through our code to see what was wrong or missed. Seven issues came out of that audit. Six were minor tightening. One was a genuine bug that would have started costing me money the first time Stripe retried a webhook.
The bug
The function that grants credits after a successful checkout generated a random UUID as its transaction ID on every call, then used a DynamoDB condition that rejects writes where the transaction ID already exists. The intent was idempotency: if the function ran twice, the second call would be rejected. The problem is that a fresh UUID means the condition always passes, because the second call's UUID has never been seen either. The idempotency check was checking whether a random string existed. Of course it didn't.
Stripe retries webhooks for up to three days if they don't get a clean 200. One retry of a successful checkout would have doubled the user's credit balance. Every retry after that would have doubled it again.
The fix was to delete some code. I stopped generating a UUID and started using the Stripe checkout session ID directly as the transaction ID. Session IDs are unique per checkout, they're what Stripe retries with, and the existing "does this transaction ID already exist" check now works correctly because the second delivery carries the exact same ID as the first. No schema change, no new index, no extra call. Existing rows with UUID-format IDs live in a different key space and stay compatible.
Delayed payment methods
One of the other best-practice items was "check the payment status before granting credits." The reason is that Stripe supports asynchronous payment methods like ACH and bank transfers, where checkout.session.completed fires immediately but the status is still unpaid. The funds only arrive days later, via a separate async_payment_succeeded event. If you grant credits on the first event and never listen for the second, you've given away credits for a payment that could still fail.
I added the status check as defense in depth, then went looking to see whether I actually had the problem. It turned out I didn't: the Stripe Dashboard has all delayed payment methods disabled. Every method currently enabled (card, Apple Pay, Amazon Pay, Link, Klarna, Affirm, Cash App, and a handful of European redirect methods) settles instantly. The check is now in place in case I accidentally enable ACH later.
Error handling that can actually be monitored
The old checkout handler wrapped its entire flow in a single except Exception that logged and returned a generic 500. That works, but it makes it impossible to tell the difference between "user typed the wrong CVV" and "our Stripe API key has been revoked." I broke it out into specific handlers for card errors (400 back to the user), rate limits (503), authentication errors (500 with a distinctive alarm-worthy log line), and a few others. The authentication branch is particularly important -- if that ever fires, it means nobody can buy credits and I need to know immediately, not tomorrow.
Fraud prevention
The last item was figuring out what fraud tooling makes sense at my price point. Stripe Radar is already active on every account at the free tier, with ML-based scoring, automatic high-risk blocks, and CVC/AVS verification. The paid tier adds custom rules and a manual review queue, which is overkill for five-to-fifteen-dollar credit packs. I'll revisit it after launch if dispute rates justify it.
The more interesting fraud-prevention decision was the statement descriptor -- the short string that shows up on a customer's credit card statement. The single biggest cause of "friendly fraud" disputes, per Stripe's docs, is customers not recognizing a charge and filing a dispute instead of asking. I'd been about to use my holding company's name, which is the default when you set up a Stripe account. I switched it to NOWIGETIT.US so customers see the same name on their statement that they saw on the site.
The result
Five files changed, one genuine bug fixed, and twelve new tests covering idempotency, payment-status branches, zero/negative credit amounts, and every error class I added handlers for. The Stripe integration feels a lot more solid than it did going in.