Claude Code and Stripe checkout: what AI-written payment code gets wrong
Building a Stripe checkout with Claude Code? Nine bugs AI-written payment code ships with, how to check for each one, and the fix.

Claude Code can write a Stripe checkout that works in a demo. What it often gets wrong are the cases a demo never hits: a price taken from the browser, a webhook that fulfils the same order twice, a refund that never closes access, a download link anyone can share, a one-click upsell that fails on 3-D Secure, and a payment plan that never stops charging. None of these show up when you click "buy" once on your laptop. All of them show up with real buyers.
Even Teachable says so. In its own Claude Code vs Teachable post, it lists webhook reliability and failed payment recovery among the jobs that land on you after launch. That's a course platform with a reason to say it, and it's still right.
Here are nine things to check, what goes wrong, how to test for it, and the fix. If you're building your own, hand this list to your agent. At the end: how FunnelOnce handles each one.
1. Prices trusted from the browser
What goes wrong. The page sends { product: "course", price: 29700 } to your server, and the server passes the price straight to Stripe. Anyone who opens the browser's developer tools can change it to 100 and buy for a dollar.
How to check. Find the code that creates the Checkout Session. If unit_amount or amount comes from the request body, it's this bug.
The fix. The browser sends only a product id. The server looks up the price from its own list, in whole cents, and ignores anything else. Pages only display prices.
2. Webhook retries that fulfil twice
What goes wrong. Stripe resends a webhook event until your endpoint answers with a success, and it will retry for days. If your handler is slow, times out, or crashes after sending the welcome email but before answering, the same checkout.session.completed arrives again. The buyer gets two accounts, two emails, or an affiliate gets two commissions.
How to check. Look for a record of event ids you've already handled. If the handler doesn't check one, it's not safe to retry.
The fix. Store each event id (or the order id) when you process it and skip repeats. Better still, don't make delivery depend on a webhook at all: when the buyer lands on the thank-you page, ask Stripe directly whether that session is paid.
3. Refunds and chargebacks that never close access
What goes wrong. Access is granted on payment and never checked again. You refund someone and their course and download links keep working. A buyer files a chargeback, wins, and keeps everything.
How to check. Refund a test purchase in Stripe's dashboard, then open the download link. If it still works, it's this bug.
The fix. Check the charge's state when access is used, or handle charge.refunded and charge.dispute.created events and revoke access. Both refunded and disputed charges should close it.
4. Download links that aren't gated
What goes wrong. The PDF sits in a public folder: /files/meal-prep-guide.pdf. The thank-you page links to it. Someone posts the link in a group chat and it's free for everyone, forever.
How to check. Open the file's address in a private browser window with no purchase. If it downloads, it's public.
The fix. Keep paid files out of the public folder entirely. Serve them through a route that checks the order (and its refund state) every time, and never put the real file name in the page.

5. One-click upsells without off-session and 3-D Secure
What goes wrong. A one-click upsell charges the card the buyer just used. That only works if the checkout saved the card for later use (setup_future_usage: 'off_session'), and the charge is made off_session. Even then, some banks, especially in Europe, ask the buyer to confirm with 3-D Secure. The first build usually shows an error, or worse, shows "thanks!" while nothing was charged.
How to check. Use Stripe's test card that always requires authentication on the upsell. If the buyer can't finish the purchase, it's this bug.
The fix. Save the card at checkout. On the upsell, try the off-session charge, and when Stripe answers authentication_required or a card error, fall back to a normal checkout for just that offer, linked to the same order. More on the mechanics in one-click upsells explained.
6. Payment plans that never end
What goes wrong. "Three payments of $109" is set up as a monthly subscription. Nothing tells it to stop, so payment four goes out. Or the opposite: the buyer cancels after payment one, keeps the course, and you've been paid a third.
How to check. In Stripe's test mode, use a test clock to fast-forward the plan past its last payment. Count the invoices.
The fix. Set cancel_at on the subscription for just after the last payment. Count paid, non-refunded invoices to decide when it's paid off. Don't let a plan buyer cancel from the billing portal before then. The difference between plans and subscriptions is in payment plans vs subscriptions.
7. Test and live keys mixed up
What goes wrong. The secret key is live but the publishable key in the page is still pk_test_, or the other way round. Checkout fails to load, or worse, you think you're testing and you're charging real cards.
How to check. Both keys should start with the same mode: sk_live_ or rk_live_ with pk_live_, and sk_test_ or rk_test_ with pk_test_.
The fix. Check the pair on startup or in a setup script, and refuse to run with a mismatch.
8. A full secret key, or a restricted key missing a permission
What goes wrong. Most tutorials paste the full secret key, which can do anything in your account, including refunds and payouts. The careful alternative, a restricted key, often breaks in production because one permission is missing: checkout works, then the billing portal or the upsell fails.
How to check. List every Stripe API call the code makes, and check each against the key's permissions.
The fix. Use a restricted key with exactly the permissions the code uses, written down in one place, so you can recreate it.
9. No idempotency on charges
What goes wrong. The buyer double-clicks "Yes, add it", or the network hiccups and the browser retries. Two PaymentIntents, two charges, one angry email.
How to check. Look at the code that creates charges or PaymentIntents. If it doesn't send an Idempotency-Key header, it's exposed.
The fix. Send an idempotency key that's the same for the same action, for example the order id plus the product. Stripe returns the first result for the repeat instead of charging again.
How to use this list with your agent
If you're building from scratch, paste this after the first working checkout:
Audit our Stripe code against these: server-side prices only, webhook dedupe, refunds and disputes close access, gated downloads, off-session upsells with a 3-D Secure fallback, plans that end with cancel_at, matching key modes, a restricted key with the exact permissions, idempotency keys on charges. For each, show me the code, say pass or fail, and write a test.
Then run the tests in Stripe's test mode, including a refund and the 3-D Secure test card.
How FunnelOnce handles each one
FunnelOnce is a funnel framework your AI deploys to your own Cloudflare, with your own Stripe. This is what's already built and covered by its test kit:
- Prices live in the config, in whole cents, server-side only. Pages display them and the browser sends only product ids.
- Delivery doesn't wait on a webhook. Orders and access are checked live against Stripe, so a retried event can't fulfil twice. The webhooks FunnelOnce sends to your other tools carry a fixed id per order, so they can drop repeats.
- Refunds and chargebacks close access. A refunded or disputed charge loses its downloads and course access the next time they're used.
- Downloads sit in a private folder, are served only through a route that checks the order every time, and their file names never reach the page.
- One-click upsells save the card at checkout, charge off-session with an idempotency key, and fall back to a normal checkout for that offer when the bank asks for 3-D Secure.
- Payment plans set
cancel_atafter the last payment, count only paid, non-refunded invoices, and give plan buyers a billing portal without the cancel button. - Key modes are checked by
doctor, the setup checker, which also tests that the key works and the billing portal is on. - One permission list for the restricted key, in the playbook, the same everywhere.
- Idempotency keys on one-click charges, so a double click is one charge.
Before you connect a real account, the agent runs all of it against a fake Stripe on your computer: checkout, bump, upsell, decline and downsell, download, refund closing access. If you'd rather start there, how to build a sales funnel with Claude Code walks through the session, and selling a course with Stripe covers the members side.
FAQ
Can Claude Code set up Stripe checkout?
Yes. It can create Checkout Sessions, a thank-you page and webhook handlers. Check the result against the list above before taking real money: server-side prices, webhook dedupe, refunds closing access, gated files, 3-D Secure on upsells, plans that end, matching keys, the right permissions and idempotency.
Do I need Stripe webhooks for a simple funnel?
Not for delivery. You can ask Stripe whether a session is paid when the buyer lands on the thank-you page, and check refunds when access is used. Webhooks are still useful for telling other tools about a sale, as long as the receiver drops repeats.
How do I test 3-D Secure on a one-click upsell?
Use Stripe's test card that always requires authentication for the first purchase, then take the upsell. The charge should fail off-session and your code should send the buyer to confirm, then return them to the next step.
Is a restricted key safer than the secret key?
Yes. A restricted key can only do what you allow, so a leaked key does less damage. Give it exactly the permissions your code uses, and keep the list written down so you can recreate it.


