How to Add Payments to Your AI-Built App (Stripe, Subscriptions, and Billing)

AI tools can generate a Stripe checkout in minutes. What they can't build is payment logic that handles failures, refunds, subscription changes, and the dozen edge cases where real money is at stake.

The gap between a Stripe demo and production payment processing is enormous. Here's what AI tools get wrong about payments — and how to build billing that handles real money from real users.

You prompted Cursor to "add Stripe payments" and got a checkout flow in five minutes. The test card works. The success page displays. You're ready to charge real money.

Except you're not. Because Stripe's test mode is a controlled environment where payments always succeed, cards never decline, networks never time out, and webhooks always arrive instantly. Production is none of those things.

Payment integration is the area where the gap between "works in demo" and "works in production" is most expensive — because failures cost real money, real revenue, and real customer trust. Here are the five payment failures AI coding tools create, and exactly how to fix each one.

Failure 1: Optimistic Access Granting

What it looks like: The app grants access to paid features the moment the user clicks "Pay" — before the payment has been confirmed by Stripe.

Why AI tools do this: It's the simplest implementation. The user clicks Pay, the frontend creates a Stripe Checkout session, and when the browser redirects to the success URL, the app updates the user's status to "paid." In test mode, this works flawlessly because test payments always succeed instantly.

Why it breaks in production: Real payments fail. Cards get declined. 3D Secure authentication times out. Network requests fail between your server and Stripe. Banks flag transactions for fraud review. Payment providers experience outages.

In every one of these cases, the user reaches the success URL — or the client-side callback fires — without the payment actually succeeding. Your app grants access. The user gets the product for free. You don't get paid.

Worse: the user's browser can be manipulated. A technically savvy user can navigate directly to your success URL, trigger the success callback manually, or modify the client-side state that grants access. If access is determined by anything in the browser, it can be bypassed.

The production fix: Access is granted exclusively through Stripe webhooks. Here's the flow.

The user clicks Pay → Stripe processes the payment → Stripe sends a checkout.session.completed webhook to your server → your server verifies the webhook signature → your server updates the user's subscription status in your database → the user gets access.

The success URL page should display a loading state that checks the server for updated subscription status. If the webhook hasn't arrived yet (which can take a few seconds), the page polls until the server confirms the payment. This is a few more lines of code than the optimistic approach, but it's the difference between getting paid and giving your product away.

Failure 2: Missing Webhook Signature Verification

What it looks like: The webhook endpoint accepts any incoming request and processes it as a legitimate Stripe event.

Why AI tools do this: When you ask AI to "handle Stripe webhooks," it generates an endpoint that parses the JSON body and processes the event. The code works — it correctly reads the event type and updates the database. What it doesn't do is verify that the request actually came from Stripe.

Why it breaks in production: Without signature verification, anyone who knows your webhook URL can send fake events. An attacker could send a checkout.session.completed event with any user ID and grant themselves access. They could send customer.subscription.updated events to manipulate subscription statuses. Your endpoint has no way to distinguish legitimate Stripe events from forged ones.

The production fix: Verify the webhook signature on every request. Stripe includes a signature in the Stripe-Signature header of every webhook request. Your server verifies this signature using your webhook secret before processing the event.

In Node.js with the Stripe SDK, this is straightforward: stripe.webhooks.constructEvent(body, signature, webhookSecret). If the signature is invalid, the function throws an error and you reject the request. This single line of code prevents the entire class of webhook forgery attacks.

Critical detail: the raw request body must be used for signature verification, not the parsed JSON. Many web frameworks (Express with express.json()) parse the body before it reaches your handler. You need to access the raw body. This is the most common implementation mistake I see — the verification code exists but silently fails because the body has been parsed.

Failure 3: No Failed Payment Handling

What it looks like: When a subscription payment fails — card expired, insufficient funds, bank decline — nothing happens. The user keeps access and the founder doesn't know the payment failed.

Why AI tools do this: AI generates the happy path. Payment succeeds → grant access. It doesn't generate handlers for invoice.payment_failed, customer.subscription.updated (with status past_due), or customer.subscription.deleted. In test mode, you never trigger these events because test cards always succeed.

Why it breaks in production: Real cards expire. Bank accounts run low. Cards get lost or stolen and replaced with new numbers. Stripe estimates that involuntary churn (failed payments) accounts for 20-40% of total churn for subscription businesses. Without handling this, you silently lose revenue every month.

The production fix: Handle the complete subscription lifecycle. Listen for these Stripe webhook events.

invoice.payment_failed — The payment attempt failed. Update the user's status to indicate the payment issue. Send them an email asking them to update their payment method. Don't immediately revoke access — give them a grace period (typically 3-7 days).

customer.subscription.updated — The subscription status changed. Check the new status: active means all is well, past_due means a payment failed and Stripe is retrying, canceled means the subscription has ended, unpaid means all retry attempts failed.

customer.subscription.deleted — The subscription has been fully canceled. Revoke access. This is the point of no return.

Stripe's built-in retry logic (called Smart Retries) automatically attempts to charge failed payments on optimal days. Your job is to keep the user informed and manage their access status appropriately during the retry period.

Failure 4: Client-Side API Keys

What it looks like: Stripe secret keys (sk_live_ or sk_test_) appear in frontend JavaScript code — visible to anyone who opens browser developer tools.

Why AI tools do this: When you ask AI to integrate Stripe, it generates the Stripe SDK initialization with the API key. If the AI generates this code in a frontend file (which it often does, especially in single-page applications), the secret key ends up in the browser.

Why it breaks in production: A Stripe secret key grants full access to your Stripe account. Anyone who finds it can: create charges on any customer, issue refunds, access customer data (names, emails, card details), modify your Stripe settings, and create new products and prices. This isn't hypothetical — exposed Stripe keys are actively exploited.

The production fix: Only the Stripe publishable key (pk_live_*) should appear in frontend code. The publishable key can only create tokens and payment intents — it can't access your account.

All operations that require the secret key — creating subscriptions, processing refunds, managing customers — must happen on your server. The frontend communicates with your server, which communicates with Stripe. The secret key never leaves the server.

Store the secret key in an environment variable, never in code. Verify by searching your entire codebase (including Git history) for sk_live_ and sk_test_. If either appears anywhere other than a .env file, move it immediately.

Failure 5: Broken Subscription State Management

What it looks like: The app only understands two states: "paid" and "not paid." It doesn't handle the transitions between them — upgrades, downgrades, cancellations, trial periods, paused subscriptions, or prorated charges.

Why AI tools do this: Binary state is simple. AI generates a boolean isPaid field on the user record and toggles it based on payment events. This works for the initial purchase — but subscriptions are a state machine with at least six states and dozens of transitions between them.

Why it breaks in production: A user cancels their subscription. When does access actually end — immediately or at the end of the billing period? A user upgrades from monthly to annual. Do they get charged the full annual amount or a prorated amount? A user's free trial ends. Does their access change at midnight UTC or midnight in their timezone? A user disputes a charge. Do they lose access immediately?

Each of these scenarios requires specific handling. AI-generated code typically handles none of them, because the prompts that generate payment integrations don't describe these requirements.

The production fix: Store the complete subscription state from Stripe, not a derived boolean. Your user record should include: subscription status (from Stripe), current period end (when the current billing cycle ends), cancel at period end (whether the user has canceled but still has access until the end of the period), and the Stripe subscription ID (for making changes).

When a user cancels, set cancel_at_period_end to true and keep their access until current_period_end. When the period ends, Stripe sends a webhook and you revoke access. When a user upgrades or downgrades, use Stripe's proration to handle the billing change and update the plan in your database.

This state machine is complex but well-documented. Stripe's subscription lifecycle documentation covers every transition. The key is modelling all six states in your application rather than reducing them to a boolean.

The Production Payment Implementation

Here's the complete payment architecture I implement across every build.

Checkout flow. User clicks Pay → frontend creates a Stripe Checkout session via your API → user completes payment on Stripe's hosted checkout page → Stripe redirects to your success URL → success page polls your API for subscription status → API returns status based on database (which is updated by webhooks).

Webhook handling. Your server listens for: checkout.session.completed (initial payment), invoice.payment_succeeded (recurring payments), invoice.payment_failed (payment failures), customer.subscription.updated (status changes), and customer.subscription.deleted (cancellation). Every webhook is signature-verified and processed idempotently (handling duplicate deliveries safely).

Subscription management. Users can view their subscription status, update their payment method, change their plan, and cancel — all through your app, all processed via server-side Stripe API calls. No secret keys in the browser. No optimistic updates. No client-side state determining access.

Idempotent processing. Stripe can deliver the same webhook multiple times (network issues, retries). Your webhook handler must be idempotent — processing the same event twice should produce the same result as processing it once. Store the event ID and skip duplicates.

This architecture takes 2-3 days to implement properly. It handles every edge case in Stripe's payment flow. And it's what separates apps that reliably collect revenue from apps that leak money through unhandled failures.

Frequently Asked Questions

Can I use Stripe's no-code products instead?

Stripe Payment Links and Stripe's customer portal work for simple one-time purchases. For SaaS subscriptions with feature gating, access control, and multiple tiers, you need server-side integration. The no-code products don't give you the control needed to manage access within your application.

How do I test all these edge cases?

Stripe provides specific test card numbers for different scenarios: 4000000000000341 triggers a failed payment, 4000002500003155 requires 3D Secure authentication, 4000000000009995 triggers insufficient funds. Test every failure scenario in test mode before going live. Stripe also provides a webhook testing tool that lets you send specific events to your endpoint.

Should I use Stripe Checkout or build my own checkout form?

Use Stripe Checkout. It handles PCI compliance, supports Apple Pay and Google Pay, manages 3D Secure authentication, handles international payment methods, and is optimised for conversion. Building a custom checkout form with AI tools is risky — PCI compliance alone is a significant burden.

How much revenue am I losing to failed payments?

Industry data suggests 20-40% of subscription churn is involuntary (failed payments, not deliberate cancellations). If you're not handling invoice.payment_failed webhooks and sending dunning emails, you're likely losing 5-15% of your monthly revenue to preventable churn.

What's the minimum viable payment integration?

At minimum: webhook-driven access control (not optimistic), signature verification on all webhooks, server-side API keys only, and handling of the active and canceled subscription states. This covers the critical failures. The full lifecycle management (past_due, trials, upgrades, downgrades) can follow, but the minimum prevents you from giving away your product for free.

---

Related reading

  • Production-Ready Checklist Before Launch
  • The Final 10% That AI Can't Build
  • The Vibe Coding Reality Check
  • From Spreadsheet to Platform: Anatomy of a 30-Day Build