
Payments are where AI-generated code looks finished before it is. The checkout page opens, the test card works, a "Thank you" screen appears, and it feels done. But taking the first payment is the easy part of billing. The real work is everything that happens afterwards: renewals, failed cards, cancellations, upgrades, refunds, retries and duplicate messages.
This guide covers the Stripe mistakes we would look for first in an AI-built app, why each one costs money or trust, and how to fix it. If you want the bigger picture before launch, start with our production readiness checklist.
The mental model: Stripe is the source of truth
Your customer's billing status lives in Stripe. Your own database holds a copy, and webhooks are how Stripe tells your app when something changed. A webhook is simply Stripe sending an HTTP request to a URL on your server: "this payment succeeded", "this subscription was cancelled", "this invoice failed".
Most billing bugs come from breaking that model: trusting the browser instead of Stripe, trusting messages without checking who sent them, or assuming each message arrives exactly once and in order. None of those assumptions hold.
Mistake 1: granting access from the success page
A common AI-generated flow redirects to /success after checkout and unlocks the product on that page. But the redirect is only a browser navigation. A customer can close the tab before it loads and never get access, and anyone can type the success URL by hand and get access without paying.
Fix: unlock access only when your server has received and verified the relevant Stripe event, then read the user's access from your database. The success page can show a friendly "we are confirming your payment" message while the webhook arrives.
Mistake 2: not verifying the webhook signature
Your webhook URL is just a public address. If the handler accepts any request, anyone can send a fake "payment succeeded" event for their own account. Stripe signs every event, and your server must check that signature using the endpoint's signing secret.
The check needs the raw request body. If a framework parses the body into JSON first and you verify the re-serialised version, verification fails, and AI tools sometimes "fix" that by switching verification off. This is what a correct handler looks like in a Next.js route:
export async function POST(req) {
const body = await req.text(); // raw body, not req.json()
const sig = req.headers.get("stripe-signature");
let event;
try {
event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return new Response("Invalid signature", { status: 400 });
}
// ...handle the event, then acknowledge it quickly
return new Response("ok", { status: 200 });
}
In Express, use the raw body parser for this one route. A signature error that appears only in production usually means the wrong signing secret: test and live endpoints each have their own.
Mistake 3: assuming one delivery, in order
Stripe delivers events at least once, so the same event can arrive twice, and events can arrive out of order. If your handler adds credits or creates an order every time it sees an event, a retry will do it twice.
Fix: store the ID of every event you process and skip duplicates. Make handlers set state (for example "status is active until this date") instead of incrementing counters, so repeating them is harmless. When order matters, fetch the current object from Stripe instead of trusting the order messages arrive in.
Mistake 4: handling only the first payment
A subscription has a life cycle, and each stage needs a decision in your app. These are the events most products must handle:
| Event | What it means | What your app should do |
|---|---|---|
checkout.session.completed | The customer finished checkout | Link the Stripe customer to your user and record the plan |
customer.subscription.updated | Plan, status or cancellation settings changed | Sync status, plan and the current period end |
customer.subscription.deleted | The subscription has ended | Remove paid access, keep the customer's data |
invoice.paid | A renewal or first invoice was paid | Extend access, record the payment |
invoice.payment_failed | A renewal charge failed | Flag the account, notify the customer, apply your grace policy |
Your exact list depends on your pricing model, but "we only handle checkout" is almost never enough.
Mistake 5: treating cancellation as instant
When a customer cancels, most businesses let them keep access until the end of the period they already paid for. Stripe represents this with a flag on the subscription that says it will cancel at period end. Apps that remove access immediately cause refund requests and complaints, and apps that ignore the flag keep serving customers who have left.
Show the customer the real state in your interface ("Your plan ends on 14 March"), and consider Stripe's hosted customer portal for plan changes and cancellations so you do not have to build those screens.
Mistake 6: no plan for failed payments
Cards expire, banks decline, and limits are hit. A failed renewal is normal, and a subscription in a past-due state is not a lost customer yet. Decide your policy in advance: how long is the grace period, what does the customer see, and which emails go out. Stripe can retry failed charges and send reminder emails automatically, but your app still has to respond sensibly to the status changes.
Mistake 7: mixing test and live mode
Test mode and live mode are separate worlds with their own keys, products, prices, customers and webhook endpoints. Typical errors include a live site using a test secret key, a live webhook endpoint that was never created, or a price ID copied from the wrong mode. Keep each mode's keys in separate environment variables for each environment, and never let one set into the other.
To test properly, use the Stripe CLI to forward events to your local machine and trigger sample events, and use Stripe's test clocks to simulate months of renewals, failures and cancellations in minutes.
Mistake 8: doing the heavy work inside the webhook
Stripe expects a fast response. If your handler sends emails, calls other services and updates many tables before replying, it may time out and Stripe will treat the delivery as failed. In live mode Stripe retries failed deliveries with increasing delays for up to three days, which multiplies duplicate processing if the handler is not idempotent. Acknowledge quickly and push slow work to a background job.
A short billing review you can do today
- Is the webhook signature verified using the raw body?
- Are processed event IDs stored so duplicates are ignored?
- Does access depend on subscription status in your database, not on the success page?
- Have you tested a failed payment, a cancellation, an upgrade and a refund in test mode?
- Do test and live use different keys, secrets and endpoints?
- Can you find, in one minute, why a given customer does or does not have access?
If several answers are "I don't know", billing is probably the part of your app to review first. Our AI app technical audit includes payment flows and webhook handling in its scope, and AI app repair and production launch covers completing or fixing Stripe checkout, subscription updates, cancellations and webhooks, with tests for each flow.
Frequently asked questions
Why are my Stripe webhooks failing?
Common causes are a signature mismatch because the body was parsed before verification, using the test signing secret in live mode (or the reverse), a URL that redirects or does not exist, and a handler that takes too long to respond. The delivery attempts in the Stripe dashboard show the exact error.
Should I trust the checkout success page to unlock access?
No. The success page is just a browser redirect, so it can be skipped or visited by hand. Grant access from verified webhook events and your stored subscription status.
How do I test subscriptions without waiting a month?
Use Stripe test mode with the Stripe CLI to forward and trigger events locally, and use Stripe test clocks to simulate renewals, failed payments and cancellations over time.
What happens when a customer cancels their subscription?
Usually they keep access until the end of the period they have paid for. Stripe marks the subscription to cancel at period end and sends a deletion event when it actually ends. Your app should show the end date and remove paid access only then.
How we can help
- AI App Repair & Production LaunchFix the login, Supabase permission, Stripe, API and deployment issues blocking your AI-built app, then ship a controlled production release.
- AI App Technical AuditFixed-scope review of apps built with Lovable, Cursor, Bolt, Replit or v0 — auth, Supabase RLS, Stripe, secrets and deployment — with a prioritized fix plan.
- Custom SaaS DevelopmentEnd-to-end SaaS platform development — multi-tenant architecture, Stripe billing, RBAC, audit logs, SOC 2 readiness, and AI-native features.
Talk to an engineer about your project
Tell us what you are building. We reply within one business day with a candid view on scope, approach and effort.
Book a free strategy callWritten by the UnlockLive IT engineering team. UnlockLive IT Limited works with clients through its Toronto headquarters and delivers engineering from its Dhaka delivery centre. About us