GUIDE

Stripe payments in AI-built apps: the mistakes that cost real money

· 5 min read

Most of the AI-built apps we get asked to look at have a Stripe integration in them somewhere, and it is usually the part that got built fastest. Understandably so. Stripe’s documentation is good, the AI tools have all read it, and “add a checkout page” is about the easiest thing you can prompt for. The trouble is that a payment integration has a few rules that stay invisible while everything works, and an app taking money from strangers is a less forgiving place to learn them than a demo. This post goes through the mistakes we keep running into, a short check you can do on your own app without much technical knowledge, and the fixes in roughly the order we’d do them. Some of it overlaps with the security checklist, and where that happens I’ve linked to it instead of repeating it.

Quick answer: The webhook is the only part of a Stripe integration that reliably knows whether money moved, and AI tools tend to build everything except the webhook properly. The common problems: the secret key shipped in the browser, the checkout amount coming from the client, users upgraded when they land on the success page, webhook signatures never checked, and no handling for declines, cancellations, or refunds. Search your deployed JavaScript for sk_, look at the webhook delivery log in the Stripe dashboard, and try your success URL without paying. All of it is fixable in a few days, less if the app is small.

How does a Stripe integration work?

Stripe splits an integration across three places, and most of the mistakes come from putting something in the wrong one. The browser gets the publishable key (it starts with pk_) and uses it to open Checkout or render Stripe’s card elements. That key is designed to be public, in the same way Supabase’s anon key is, and finding it in your page source is fine. The server gets the secret key, sk_, and uses it to create the Checkout Session or Payment Intent with the prices. Then there is the webhook endpoint, a URL on your server that Stripe calls when something happens on its side: checkout finished, invoice paid, invoice failed, subscription cancelled, charge refunded. Your app finds out about money from the webhook. It can also find out from the customer being redirected to a success page, but that redirect can happen before Stripe has finished, the URL can be typed in by hand, and the customer can close the tab halfway through. So the redirect is for showing a thank-you message, and the webhook is for changing anything in your database.

A few other things are true by default and worth knowing about. Prices can live in Stripe as Price objects with IDs, so the server only has to say which price and Stripe supplies the amount. If you use Checkout or the hosted elements, card numbers never reach your code, which is what keeps your PCI paperwork down to the short self-assessment. And each webhook delivery is signed with a secret that only Stripe and your server know, so your handler can tell a real event from one somebody posted by hand. None of that requires extra work. It does require not undoing it. Our best guess at how it gets undone is a prompt along the lines of “checkout is throwing an error, fix it”, where the fastest route to no error was to bypass whatever was correctly refusing. You can’t always prove that from the finished code, but the pattern of what’s missing fits.

What do AI tools get wrong?

The secret key in the frontend is the one to check first, because it’s the worst. It gets there in a couple of ways. Sometimes the project started as a pure frontend, somebody asked for checkout, and the assistant obliged with a direct call to Stripe’s API from the browser, secret key included. More often a server call was failing, moving the same call into the client made the error disappear, and nobody went back. Either way, anyone who opens the page source has a key that can list your customers, issue refunds, and charge saved cards. Rotating it takes a minute in the dashboard under Developers, then API keys. Do that before reading the rest of this.

The amount coming from the browser is a close second. The frontend sends something like {amount: 49900} or {plan: “pro”} to the server, the server passes it to Stripe, the checkout works, and it ships. Then a customer opens the network tab, resends the request with 100 instead of 49900, and pays a dollar. Our guess is that this comes from the tutorial examples the models learned from, most of which pass an amount straight through to keep the example short, and nothing in a typical prompt says otherwise. Prices should be Price IDs that the server looks up. If the client is allowed to name a plan, the server should map the plan name to a price on its own side and ignore any amount that arrives with it.

Then there’s what the app does with the success page. A lot of generated code upgrades the account inside the /success route, on the theory that if the customer got there, they paid. They may not have. The URL is easy to guess, some payment methods can still fail after the redirect, and the customer can close the browser before the page loads at all, which leaves them paid and not upgraded. That last version is the one that generates support email. The upgrade belongs in the webhook handler, in response to checkout.session.completed or invoice.paid, and nowhere else.

The webhook handler is usually there and usually skips the signature check. It reads the JSON body, finds the customer, updates the database. Anybody who knows the endpoint URL can post a fake invoice.paid for their own customer ID. Stripe’s libraries have a one-line check for this (constructEvent in the Node library, construct_event in Python) and it needs the raw request body plus the endpoint’s signing secret. That raw body is a little awkward to get at in some frameworks, which I suspect is why generated handlers skip it so often: the assistant took the version that ran. It got its own line in our security checklist for that reason.

Two others are quicker to describe. There is rarely any failure handling, because the prompt described the happy path and the happy path is what got built: a declined renewal doesn’t email anyone, a cancelled subscription keeps its access indefinitely, and a refund from the dashboard doesn’t take anything away. And test and live mode get mixed up, in both directions. Test keys deployed to production, so real customers get a checkout that charges nothing and the owner wonders why revenue is zero. Or a live-mode webhook endpoint still pointing at the preview URL from the AI builder, which stopped existing weeks ago, so Stripe has been getting connection errors for as long as the log goes back.

How do you check your own app in fifteen minutes?

You don’t need to read code for most of this.

  1. Open your live app, view source, find the main JavaScript file and search it for sk_live and sk_test. Search for whsec_ as well. Any hit means the key has been public since that build went out. Rotate it, then go and fix the code.
  2. In the Stripe dashboard, go to Developers, then Webhooks. There should be an endpoint with your production URL, subscribed to at least checkout.session.completed, plus the invoice events if you sell subscriptions. Click into it and look at the delivery attempts. Stripe shows the response code your app returned for each one.
  3. If you can read code, open the webhook handler and look for the signature check. If the handler starts with something like JSON.parse(req.body) and carries on from there, there isn’t one.
  4. Log into your app with a test account and paste in your success URL without going through checkout. See whether the account changes.
  5. Go through checkout with the browser’s network panel open and look at the request your app makes to its own backend before the Stripe page loads. You’re looking for an amount or a price in the request body.
  6. In test mode, run through a purchase with 4242 4242 4242 4242, then one with 4000 0000 0000 0002 (that one declines), then cancel the resulting subscription from the dashboard and refund a payment. After each step, check what the app did. Don’t be surprised if nothing happened for the last three; those events only matter after the sale, and nobody prompts for what happens after the sale.

How do you fix it without taking checkout down?

The key first, since it’s exposed while you read this. Rotate it in the dashboard, then move every call that uses it onto a server: a Next.js route handler, a Supabase edge function, an Express app, whatever the project already has. If the project has nothing server-side at all, an edge function is the least disruptive option and it’s what we usually reach for on Supabase-backed apps.

Then the prices. Create Products and Prices in the Stripe dashboard, put the Price IDs in server-side config, and have the server build the Checkout Session from those. While you’re in there, attach the Stripe customer ID to the session and store it against the user record, so later events can be matched back to an account without guessing from email addresses. A lot of generated code matches on email instead, presumably because it’s the one field that’s obviously present on both sides, and that works until a customer changes their address.

The webhook handler is where most of the effort goes. Verify the signature. Store each event ID before you act on it and skip any you’ve already seen, because Stripe retries deliveries it doesn’t get a 2xx for, up to three days in live mode, and a handler without that check will eventually grant a second month or process a second refund. Keep one table that records what each user is entitled to, and update it from checkout.session.completed, invoice.paid, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted, and charge.refunded. Everywhere else in the app, read from that table. The /success page can stay; it should show a message and nothing more.

For testing, install the Stripe CLI. stripe listen --forward-to localhost:3000/api/webhooks sends test-mode events to a local server, and stripe trigger invoice.payment_failed fires a specific one on demand, which beats clicking through checkout thirty times. If the app has no staging environment yet, this is the job that justifies making one, and our production readiness guide covers the setup. One more thing to check while you’re in the database: the entitlements table needs to be writable only by server-side code. On Supabase that means row level security policies that allow reads by the owner and writes by nobody except the service role. The Supabase security post goes through writing those policies.

What about subscriptions, refunds, and disputes?

Subscriptions bring a set of chores that a first build never includes. Customers need to update a card, change plan, and cancel, and if the app doesn’t offer that they’ll email you, which is worse than any of the three. Stripe’s hosted customer portal handles all of it. Your server creates a portal session with one call and redirects the user to it, and there is no interface to build. Failed renewals need a retry schedule and an email to the customer, and both live in the dashboard under Billing settings. Go and look at what’s enabled rather than assuming. The defaults aren’t the same for every account, and we wouldn’t rely on remembering them.

Refunds happen from the dashboard, usually by whoever answers support, and the app only finds out if the webhook handler listens for charge.refunded. Disputes work differently. You get an email, you have a deadline (typically a week or two) to respond with evidence, and if the email goes to an inbox nobody checks you lose by default and pay the dispute fee on top. So look at where Stripe sends notifications for the account. It’s often still whatever address the founder used to open it, because nobody thinks about notification settings when the immediate goal is getting the first payment through. Radar, Stripe’s fraud tooling, is on by default with reasonable rules; once there’s some volume, spend a little time in its settings, because card-testing attacks on unprotected checkouts are common and they arrive as a few hundred small declined charges at three in the morning.

When does this need a professional?

If the app already has paying customers, fix the exposed key today and treat the rest as a short project rather than a series of prompts. For a developer who has done a few of these it’s a few days. The audit-first approach in our developer takeover guide works well here, since the Stripe dashboard and the code together tell you most of what’s wrong before anything is changed. It is also the first thing we look at when someone brings us an app for an AI app rescue, ahead of hosting or performance, because it’s where an existing problem is most likely to be costing money right now. Once it’s fixed, the webhook endpoint is worth watching on an ongoing basis. The operations post covers monitoring generally; for Stripe specifically, the dashboard will email you when an endpoint has been failing for a while, and that email needs to go somewhere too.

Frequently asked questions

Is the publishable key in my frontend a problem?

No. Anything starting with pk_ is meant to be in the browser. It can open a checkout and tokenize card details, and that’s the extent of it. The keys that matter are the secret key (sk_) and the webhook signing secret (whsec_). If you’re not sure what you’re looking at, the API keys page in the dashboard labels each one, and the search in step one above will tell you whether a secret key has leaked.

Can I use Stripe Payment Links instead of writing code?

If you’re selling one thing and nothing inside the app changes when someone pays, yes, and it removes almost all of the risk described above. Once a payment is supposed to unlock a feature or a plan, you need the webhook regardless, because a link can’t update your database. Payment Links plus a small webhook handler is a reasonable setup for a lot of early-stage apps.

Do I need to worry about PCI compliance?

With Stripe Checkout, Payment Links, or the hosted card elements, card numbers never reach your servers and you’re in the simplest category, a short self-assessment questionnaire that Stripe helps you complete from the dashboard. Two things change that: rendering your own card form instead of Stripe’s, and logging full request bodies somewhere that happens to include card data. The second one is easy to do by accident with generated logging code.

Where does the webhook handler live if the app was built with Lovable?

On the backend the tool set up for you, which for Lovable is a Supabase edge function. The handler runs there, holds the secret key and signing secret as function secrets, and writes to the database using the service role. If you later move the app onto your own hosting, the webhook endpoint URL changes and has to be updated in the Stripe dashboard, which is one of the steps most often forgotten in a migration.

Why did a customer get charged twice?

Usually one of two things. Either the checkout request was retried without an idempotency key, so Stripe saw two separate requests and honoured both, or the webhook event was delivered twice and the handler processed it twice. Stripe retries any delivery that didn’t get a 2xx response, for up to three days, so a handler that doesn’t record event IDs will eventually do something twice. Refund the duplicate from the dashboard and then fix whichever cause it was.

I keep asking the AI to fix checkout and it keeps breaking something else. What now?

That’s the doom loop, and payments code is an expensive place to be stuck in it. The practical answer is to stop prompting for a while, go through the fifteen-minute check, and get someone to read the integration end to end once. Payment integrations have a well-known correct shape, so this tends to be a shorter job than fixing, say, a tangled data model.