Three ways an AI-built checkout lets people pay nothing
Your checkout works. You tested it with your own card, the money arrived, Stripe says succeeded. That test proves the happy path, and the happy path is not where payment code fails. It fails on the three paths nobody types by hand, and in all three the Stripe dashboard looks perfectly healthy afterwards, because what is missing from it is revenue that never existed.
Loïc Guillebeau10 August 2026Not every app we audit takes money. The ones that do fail in the same three places, and none of the three is a misconfiguration you can scan for. Each one is code doing exactly what it was written to do, for someone who read it more carefully than the person who shipped it.
One: the price comes from the browser
Ask for a checkout and you describe it the way you think about it: the user is on the pricing page, they picked the 49 dollar plan, charge them 49 dollars. The code that comes back does that, by sending the amount along with the request, because the amount is right there on the page.
// the browser
await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify({ plan: "pro", amount: 4900 }),
})
// the server, trusting it
const session = await stripe.checkout.sessions.create({
line_items: [{
price_data: {
currency: "usd",
product_data: { name: body.plan },
unit_amount: body.amount, // whatever the browser said
},
quantity: 1,
}],
mode: "payment",
})That request is not a form, it is a suggestion. Anyone can open the network tab, copy the call, change 4900 to 100 and replay it. Stripe will happily take one dollar, mark it succeeded, and your app will hand over the pro plan, because as far as it is concerned the payment for the pro plan succeeded. Nothing in the dashboard says anything went wrong. The only trace is that your revenue is lower than your customer list implies.
The fix is to decide the price where the user cannot reach it. The browser says which plan, the server says what a plan costs, and the cleanest version does not name a number at all: it passes a Stripe price ID that was created once, in Stripe, and looks the rest up there.
const PRICES = { pro: "price_1AbcPro", team: "price_1AbcTeam" } as const
const priceId = PRICES[body.plan as keyof typeof PRICES]
if (!priceId) return new Response("unknown plan", { status: 400 })
const session = await stripe.checkout.sessions.create({
line_items: [{ price: priceId, quantity: 1 }],
mode: "subscription",
})The same rule applies to anything else the browser sends about the purchase: quantity, currency, discount code, trial length, and the user id the order gets attached to. If the server can look it up, it should look it up. The one that bites hardest is the user id, because an order attached to whichever id the client passed is also a way to buy things in somebody else's name.
Two: the success page is where access gets granted
Stripe sends the customer back to a URL of your choosing when the payment goes through, and that page is the obvious place to write "this account is now paid". It is obvious, it works in testing, and it is wrong in both directions at once.
- Anyone can visit /success directly. If landing there is what upgrades the account, the upgrade is a bookmark, and no payment is involved.
- Customers who pay and then close the tab never land there. They have been charged and they do not have the thing. You find out about this one from a support email, which is the better of the two ways to find out.
Payment confirmation has to arrive from Stripe, not from the visitor's browser, which is what webhooks are for. The success page reads state and says thank you. It never writes the fact of payment. If you want it to feel instant, have it poll or subscribe until the webhook has done its work, and show a short "confirming your payment" state in the meantime.
Three: the webhook believes whatever it is told
So the fulfilment moves into a webhook, which is correct, and the endpoint that receives it is public, because it has to be. Stripe signs every event it sends, and verifying that signature is the only thing separating your endpoint from a stranger with curl. Skip it and your app has a public URL that reads, in effect, give this account a subscription.
Verification gets skipped for a mundane reason, and it is the same shape as the environment variable rename in the guide on keys in the bundle: the framework parses the request body into JSON before your code sees it, the signature is computed over the raw bytes, so verification fails on a perfectly legitimate event. The error is confusing, the deadline is real, and commenting out the check makes it go away.
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const raw = await req.text() // raw body, not req.json()
const sig = req.headers.get("stripe-signature")
let event
try {
event = stripe.webhooks.constructEvent(
raw,
sig!,
process.env.STRIPE_WEBHOOK_SECRET!,
)
} catch {
return new Response("bad signature", { status: 400 })
}
if (event.type === "checkout.session.completed") {
const session = event.data.object
if (session.payment_status === "paid") await fulfil(session)
}
return new Response(null, { status: 200 })
}Verifying the signature is necessary and it is not sufficient. Stripe retries an event until it gets a 2xx, and it can deliver the same event more than once, so fulfilment has to be safe to run twice: key it on the event id or the session id and do nothing the second time. A webhook that grants a month of credit per delivery will occasionally grant three.
Test all three on your own app in five minutes
Do this in Stripe test mode, against your own staging or production app, and nowhere else.
- Open the network tab and start a checkout. Look at the request your own app sends. If there is an amount, a price, a currency or a user id in that body, that value is editable by whoever is holding the browser.
- Open your success URL directly in a private window, signed in as a second account that has never paid. Then check whether that account is now on a plan. Grep the code for wherever paid, active or subscribed gets written, and confirm the only writer is the webhook.
- Post nonsense at your webhook and read the status code. Anything in the 2xx range means it did not reject an unsigned event.
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST https://your-app.com/api/webhooks/stripe \
-H 'content-type: application/json' \
-d '{"type":"checkout.session.completed","data":{"object":{"payment_status":"paid"}}}'
# 400 is the answer you want. 200 means it accepted an event nobody signed.A 400 is good news and not a certificate: it says the endpoint checks signatures, not that fulfilment is idempotent or that the amount was decided on the server. The three tests are independent, and an app can pass one and fail the other two.
Why your dashboard cannot tell you any of this
Stripe is an excellent record of the payments that happened. None of these three failures produces a payment that did not happen: they produce accounts that are paid without a charge, charges smaller than the price list, and credits granted twice. All of that is invisible in a report of successful payments, because from Stripe's side every one of those events is a success.
The reconciliation that does surface it is blunt and worth running once a month: count the accounts your database considers paying, count the active subscriptions in Stripe, and explain the difference. Every legitimate reason for a gap is one you already know about, such as comped accounts and your own test users. Anything left over is one of these three. The access-control guide is the same question one layer down, and the two findings travel together more often than not.
This is also the clearest example of why we read repositories rather than URLs. From outside, a checkout that returns 200 and takes a card is a working checkout, and there is no request a scanner can send that distinguishes a price decided on your server from one decided in the browser. That answer is in the code: which values cross the boundary, who writes the paid flag, whether constructEvent is called at all. Grace reads it there, ranks what it finds by what it would actually cost you, and the audit is free and read-only. What you do with the list is your call.

Loïc Guillebeau
Founder, Grace · founder of Beyond the Brackets
Seven years running an engineering agency, ten AI-built applications audited in the last three months. Grace came out of both.
Want to know what else is in there?
Grace reads the repository and comes back with the architecture map, the risks ranked by severity, and a health score. It is free, it is read-only, and it changes nothing.
Keep reading