How to Add Stripe Payments to an AI-Built App Without Getting Burned
AI tools will happily wire up Stripe in a way that looks finished and lets anyone pay $0.01 for your product. The four rules that make payments safe — server-side prices, verified webhooks, idempotent fulfilment, and a real live-mode test — explained without the jargon.
Adding payments is the moment an app becomes a business, and it's the moment mistakes start costing money. Ask an AI tool to "add Stripe" and you'll usually get something that works in a demo: a button, a checkout page, a success screen. Whether it's safe depends on details the demo never tests.
This guide covers the four rules that matter, why each one exists, and how to check that your app follows them. It uses Stripe because it's what most AI tools reach for, but the rules apply to any payment provider.
How Stripe payments actually work
Understanding the flow makes every rule below obvious. For a typical purchase using Stripe Checkout:
- The user clicks Buy. Your server asks Stripe to create a Checkout Session for a specific product and price.
- Stripe returns a URL. The user is sent to a Stripe-hosted payment page.
- The user pays. Stripe redirects them back to your "success" page.
- Separately, Stripe sends your server a webhook — a message saying "this payment succeeded."
- Your server receives the webhook and fulfils the order: marks the user as paid, unlocks the feature, sends the receipt.
Two things in that flow cause almost every problem. The price is decided in step 1, and the order is fulfilled in step 5. Get either of those wrong and the rest doesn't matter.
Rule 1: the price comes from the server, never the browser
The most dangerous thing an AI can generate looks like this:
// In the browser
fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ amount: 4900, product: 'Pro plan' }),
})
The browser is telling the server how much to charge. But the browser belongs to the user, and anyone can open the developer tools and change 4900 to 1. Your server dutifully creates a checkout for one cent.
The fix: the browser sends which product, never how much. The server looks up the price itself.
// On the server
const PRICES = { pro: 'price_1Pq...' } // Stripe Price IDs, set up in the dashboard
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: PRICES[req.body.plan], quantity: 1 }],
success_url: `${process.env.APP_URL}/success`,
cancel_url: `${process.env.APP_URL}/pricing`,
})
Create your products and prices in the Stripe dashboard and reference them by Price ID. Then the amount isn't in your code at all, let alone in the browser.
Check it: search your frontend code for amount, price, or unit_amount. If a number that decides the charge is sent from the browser, it's exploitable.
Rule 2: the success page proves nothing
After paying, the user lands on /success. Many AI-generated apps treat reaching that page as proof of payment and unlock the product right there.
Anyone can type /success into the address bar.
The success page is for saying "thank you." It is not a receipt. The webhook is the receipt, because it comes from Stripe directly to your server, not through the user's browser.
The fix: fulfil orders only in your webhook handler, on the checkout.session.completed event (and, for subscriptions, keep listening for changes — see below).
Rule 3: verify that the webhook actually came from Stripe
Your webhook is a public URL, something like /api/webhooks/stripe. If it accepts any request that looks like a Stripe event, then anyone can send it a fake "payment succeeded" message and get your product for free.
Stripe signs every webhook with a secret that only you and Stripe know. Your handler must check that signature before trusting anything in the message:
const event = stripe.webhooks.constructEvent(
rawBody, // the raw request body, exactly as received
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET // whsec_..., from the Stripe dashboard
)
If the signature doesn't match, constructEvent throws, and you return an error without doing anything.
One detail trips up almost everyone: it needs the raw body. Many web frameworks automatically parse incoming JSON, and a parsed-then-reserialised body no longer matches the signature. If verification fails on every real event, that's why — configure the webhook route to receive the unparsed body.
Check it: open your webhook handler. If you can't find constructEvent (or your language's equivalent), it's not verifying anything.
Rule 4: webhooks can arrive twice, late, or out of order
Stripe retries webhooks that don't get a quick success response, sometimes for days. Network hiccups mean the same event can arrive more than once. Events for the same customer can arrive out of order.
If your handler does "add 100 credits" every time it sees a payment event, a retried webhook gives the user 200.
The fix: make fulfilment idempotent — doing it twice has the same effect as doing it once.
- Record each processed event's ID (
evt_...) in your database, and skip any ID you've already seen. - Prefer setting state over incrementing it: "this user's plan is Pro" rather than "add one month."
- Respond to Stripe quickly (a 2xx status) and do slow work, like sending emails, afterwards.
Subscriptions: payment is not a one-time event
If you sell subscriptions, a successful first payment is the beginning, not the end. Cards expire, renewals fail, and users cancel. Your app needs to hear about all of it, or you'll keep serving people who stopped paying — or lock out people who didn't.
At minimum, handle:
| Event | What it means |
|---|---|
checkout.session.completed |
New purchase — grant access |
customer.subscription.updated |
Plan changed, renewed, or status changed |
customer.subscription.deleted |
Subscription ended — remove access |
invoice.payment_failed |
Renewal failed — warn the user |
Store the Stripe customer ID and subscription ID against your user, and decide access from the subscription's status. Don't write your own "manage billing" page: Stripe's Customer Portal lets users update cards, change plans, and cancel, and it's a few lines of server code to link to it.
Keep the secret key secret
Stripe gives you two keys. The publishable key (pk_...) is designed to be public and can live in the browser. The secret key (sk_...) can create charges, issue refunds, and read every customer you have. It belongs only in server-side environment variables.
If your app is a frontend-only project — everything runs in the browser, with no server of its own — and it uses Stripe, check very carefully where the secret key is. AI tools sometimes put it in a VITE_ or NEXT_PUBLIC_ variable to make the demo work, which publishes it to every visitor. We explain the mechanics in How to Keep API Keys Out of an AI-Built App.
Testing, then really testing
Stripe's test mode lets you run the whole flow without real money. Use the test card 4242 4242 4242 4242 with any future expiry date and any CVC.
To receive webhooks while developing, the Stripe CLI forwards them to your local server:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
It prints a webhook signing secret for local use — put that in your local STRIPE_WEBHOOK_SECRET.
Then, before launch:
- Switch to live keys in production environment variables — publishable, secret, and webhook secret. The live webhook secret is different from the test one.
- Create the webhook endpoint in the live-mode dashboard, pointing at your production URL, subscribed to the events you handle.
- Buy your own product with a real card. Check that access was granted, the webhook shows as delivered in the dashboard, and the receipt arrived. Then refund yourself.
- Try
/successdirectly without paying. Nothing should unlock. - Cancel the subscription in the Customer Portal and confirm access is removed.
The real-card test catches the most common launch-day failure: everything works in test mode, and production has a test key, a missing webhook, or a webhook pointed at localhost.
Asking an AI to check its own work
If an AI tool built your payment integration, you can use it to audit the result — with specific questions, not "is this secure?":
Show me every place the charge amount is decided. Does any of it come from the browser? Where do we grant access after payment? Is it only in the webhook handler? Show me where the webhook signature is verified, and confirm the route receives the raw body. What happens if the same webhook event is delivered twice?
Then verify the answers yourself by reading the code it points to. An AI will sometimes say "yes, this is handled" about code that doesn't exist.
The short version
- The server decides the price.
- Only the webhook grants access.
- The webhook verifies Stripe's signature.
- Processing the same event twice does nothing the second time.
Get those four right and a curious user with developer tools can't get your product for free. Get any one wrong and eventually someone will.
EasySpawn runs AI-built apps with a real server and a managed Postgres database behind them — the pieces a safe payment integration needs — with secrets kept in server-side environment variables and automatic SSL on your own domain. See how it works for AI-built apps or join the waitlist.
Related: A Security Checklist for Vibe-Coded Apps · Why Does My App Work Locally but Not in Production? · What Is an API? · Handling Webhooks Reliably
Keep reading
A Security Checklist for Vibe-Coded Apps
AI-built apps fail security in predictable ways: open databases, keys in the browser, authorization checked only in the UI. A practical checklist for non-security people — what to check, how to test it yourself, and what to fix before real users arrive.
How to Keep API Keys Out of an AI-Built App
AI-generated code hardcodes API keys all the time — and 'put it in an environment variable' isn't enough if the variable ends up in the browser. Which keys are safe to expose, which never are, and how to fix a key that's already leaked.