{"post":{"slug":"add-stripe-subscriptions-to-ai-app-2026","title":"How to add Stripe subscriptions to your AI-generated app (2026 step-by-step)","excerpt":"Most Stripe tutorials stop at the payment form. This one covers the part that breaks in production: products, checkout sessions, webhooks, and the subscription state machine — for apps shipped from Cursor, v0, Bolt, or Lovable.","category":"guides","date":"January 1, 2026","author":"ByteLedger Team","content":"<span class=\"dropcap\">A</span>I-generated apps from Cursor, v0, Bolt, and Lovable can ship a working SaaS frontend in an afternoon. They cannot, as of April 2026, ship a working *billing system*. Every Stripe tutorial on the internet ends right after the user clicks \"Pay\" — leaving you with the genuinely hard 80%: webhooks, subscription state, failed renewals, downgrades, proration, dunning. This guide covers all of it.\n\nBy the end you'll have: a Stripe checkout flow, a webhook handler that survives production, a `users.subscription_status` field that's always in sync with Stripe's truth, and the four lifecycle handlers (cancel, fail, renew, downgrade) that 90% of indie SaaS apps get wrong.\n\n## What you'll build\n\nA subscription system with three tiers (Free / Pro / Team), monthly and annual billing, hosted Stripe Checkout (no PCI scope), a webhook endpoint that updates your DB, and a \"Manage subscription\" portal so users can cancel without emailing you.\n\nTotal moving parts:\n\n| Piece | Where it lives |\n|---|---|\n| Products & Prices | Stripe Dashboard (or `seed.ts` script) |\n| Checkout Session | Your backend → redirect to Stripe-hosted page |\n| Webhook handler | Your backend `/api/stripe/webhook` |\n| Customer Portal session | Your backend → redirect to Stripe-hosted portal |\n| `users` table | Your DB — `stripe_customer_id`, `subscription_status`, `current_period_end` |\n\n## Prerequisites\n\n- Stripe account (test mode is fine for everything in this guide).\n- A backend with a route handler — Node/Express, NestJS, Next.js API routes, FastAPI, Rails, Laravel. The architecture is the same.\n- A `users` table with at least `id` and `email`.\n- 90 minutes.\n\n## Step 1: Create your products in Stripe\n\nOpen the Stripe Dashboard → **Products** → **Add product**. Create two products: `Pro` and `Team`. For each, create two prices (monthly and yearly). You'll end up with four price IDs that look like `price_1OabcXYZ...`.\n\nSave them in your `.env`:\n\n```bash\nSTRIPE_SECRET_KEY=sk_test_...\nSTRIPE_WEBHOOK_SECRET=whsec_...\nSTRIPE_PRICE_PRO_MONTHLY=price_1OabcMonthly\nSTRIPE_PRICE_PRO_YEARLY=price_1OabcYearly\nSTRIPE_PRICE_TEAM_MONTHLY=price_1OdefMonthly\nSTRIPE_PRICE_TEAM_YEARLY=price_1OdefYearly\n```\n\nWhy env vars: hard-coding price IDs in your frontend means every plan change requires a redeploy. This way, marketing can change pricing without you.\n\n## Step 2: Add the four columns to your users table\n\nWhatever your ORM, you need these:\n\n```sql\nALTER TABLE users ADD COLUMN stripe_customer_id TEXT;\nALTER TABLE users ADD COLUMN subscription_status TEXT DEFAULT 'free';\nALTER TABLE users ADD COLUMN subscription_tier TEXT DEFAULT 'free';\nALTER TABLE users ADD COLUMN current_period_end TIMESTAMP;\n```\n\n`subscription_status` will hold one of: `free`, `active`, `trialing`, `past_due`, `canceled`. These mirror Stripe's status values exactly — don't invent your own taxonomy or you'll regret it the first time you have a billing dispute.\n\n`current_period_end` is when the user loses access if they cancel. Always trust this column over your gut.\n\n## Step 3: Create a checkout session\n\nWhen the user clicks \"Upgrade\", your backend creates a Checkout Session and returns the URL. Stripe hosts the actual payment form — you never see a card number.\n\n```ts\nimport Stripe from 'stripe';\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);\n\nexport async function createCheckoutSession(req, res) {\n  const user = req.user; // however you load the current user\n  const { priceId } = req.body;\n\n  // Get or create the Stripe customer\n  let customerId = user.stripe_customer_id;\n  if (!customerId) {\n    const customer = await stripe.customers.create({\n      email: user.email,\n      metadata: { user_id: user.id },\n    });\n    customerId = customer.id;\n    await db.users.update(user.id, { stripe_customer_id: customerId });\n  }\n\n  const session = await stripe.checkout.sessions.create({\n    customer: customerId,\n    mode: 'subscription',\n    line_items: [{ price: priceId, quantity: 1 }],\n    success_url: `${process.env.APP_URL}/billing?success=true`,\n    cancel_url: `${process.env.APP_URL}/pricing`,\n    allow_promotion_codes: true,\n    billing_address_collection: 'auto',\n    subscription_data: {\n      metadata: { user_id: user.id },\n    },\n  });\n\n  res.json({ url: session.url });\n}\n```\n\nTwo non-obvious details:\n\n1. **Always set `metadata.user_id`** on both the customer and the subscription. Webhooks return Stripe IDs, not your IDs — metadata is the bridge.\n2. **`allow_promotion_codes: true`** lets you create discount codes in the dashboard without code changes. Marketing will love you.\n\nOn the frontend, hit this endpoint and redirect:\n\n```ts\nconst { url } = await fetch('/api/checkout', {\n  method: 'POST',\n  body: JSON.stringify({ priceId: 'price_1OabcMonthly' }),\n}).then(r => r.json());\n\nwindow.location.href = url;\n```\n\nThat's it for the upgrade flow. The user pays on Stripe's hosted page and lands back on your `/billing?success=true` page.\n\n**The catch:** at this exact moment, your database still says `subscription_status = 'free'`. Stripe charged the card, but your app doesn't know yet. That's what the webhook is for — and where most tutorials stop.\n\n## Step 4: Set up the webhook (the part that actually matters)\n\nStripe POSTs events to your backend whenever something happens to a subscription — payment succeeded, renewal, cancellation, refund, dunning. Your job is to listen and update your DB to match.\n\nFirst, the endpoint. **It must use raw body, not JSON-parsed body** — Stripe signs the raw bytes, and Express's JSON middleware will break the signature check.\n\n```ts\nimport express from 'express';\nimport Stripe from 'stripe';\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);\n\n// CRITICAL: raw body, mounted BEFORE app.use(express.json())\napp.post(\n  '/api/stripe/webhook',\n  express.raw({ type: 'application/json' }),\n  async (req, res) => {\n    const sig = req.headers['stripe-signature']!;\n    let event: Stripe.Event;\n\n    try {\n      event = stripe.webhooks.constructEvent(\n        req.body,\n        sig,\n        process.env.STRIPE_WEBHOOK_SECRET!\n      );\n    } catch (err) {\n      console.error('Webhook signature failed:', err);\n      return res.status(400).send('Invalid signature');\n    }\n\n    try {\n      await handleStripeEvent(event);\n      res.json({ received: true });\n    } catch (err) {\n      console.error('Webhook handler failed:', err);\n      // Return 500 so Stripe retries\n      res.status(500).send('Handler failed');\n    }\n  }\n);\n```\n\nThe signature check is non-negotiable. Without it, anyone who knows your endpoint URL can forge subscription upgrades and get your product for free. Don't skip it.\n\n## Step 5: Handle the four events that matter\n\nStripe sends ~50 event types. You need exactly five:\n\n```ts\nasync function handleStripeEvent(event: Stripe.Event) {\n  switch (event.type) {\n    case 'checkout.session.completed':\n      await onCheckoutCompleted(event.data.object as Stripe.Checkout.Session);\n      break;\n\n    case 'customer.subscription.updated':\n    case 'customer.subscription.created':\n      await onSubscriptionChanged(event.data.object as Stripe.Subscription);\n      break;\n\n    case 'customer.subscription.deleted':\n      await onSubscriptionCanceled(event.data.object as Stripe.Subscription);\n      break;\n\n    case 'invoice.payment_failed':\n      await onPaymentFailed(event.data.object as Stripe.Invoice);\n      break;\n\n    default:\n      // Ignore everything else\n      break;\n  }\n}\n\nasync function onSubscriptionChanged(sub: Stripe.Subscription) {\n  const userId = sub.metadata.user_id;\n  if (!userId) {\n    console.error('No user_id in subscription metadata', sub.id);\n    return;\n  }\n\n  const priceId = sub.items.data[0].price.id;\n  const tier = priceIdToTier(priceId); // your map: priceId → 'pro' | 'team'\n\n  await db.users.update(userId, {\n    subscription_status: sub.status, // 'active' | 'trialing' | 'past_due' | 'canceled'\n    subscription_tier: tier,\n    current_period_end: new Date(sub.current_period_end * 1000),\n  });\n}\n\nasync function onSubscriptionCanceled(sub: Stripe.Subscription) {\n  const userId = sub.metadata.user_id;\n  await db.users.update(userId, {\n    subscription_status: 'canceled',\n    subscription_tier: 'free',\n    // Keep current_period_end so we know when access actually ends\n  });\n}\n\nasync function onPaymentFailed(invoice: Stripe.Invoice) {\n  const userId = invoice.subscription_details?.metadata?.user_id;\n  await db.users.update(userId, {\n    subscription_status: 'past_due',\n  });\n  // Optionally: send a \"your card failed\" email\n}\n```\n\nNotice what `onSubscriptionChanged` does NOT do: it doesn't trust the user's intent, doesn't read from the checkout session, doesn't try to be clever. It just mirrors Stripe's truth into your DB. That's the entire job.\n\n## Step 6: Test the webhook locally\n\nInstall the Stripe CLI:\n\n```bash\nbrew install stripe/stripe-cli/stripe\nstripe login\nstripe listen --forward-to localhost:3000/api/stripe/webhook\n```\n\nThe CLI prints a `whsec_...` — paste it into your `.env` as `STRIPE_WEBHOOK_SECRET`. Now in another terminal:\n\n```bash\nstripe trigger customer.subscription.created\nstripe trigger invoice.payment_failed\n```\n\nWatch your logs. If your handler runs and the DB updates correctly, you're done with the hard part. If not, debug here — not in production.\n\n## Step 7: Add the Customer Portal\n\nUsers will email you to cancel if you don't give them a self-serve option. Stripe's Customer Portal handles cancel, plan changes, payment-method updates, invoice downloads, and tax forms — for free.\n\n```ts\nexport async function createPortalSession(req, res) {\n  const user = req.user;\n\n  const session = await stripe.billingPortal.sessions.create({\n    customer: user.stripe_customer_id,\n    return_url: `${process.env.APP_URL}/billing`,\n  });\n\n  res.json({ url: session.url });\n}\n```\n\nAdd a \"Manage subscription\" button on your `/billing` page that hits this. Done. No code for cancellation flow, no proration math, no PCI form for updating cards. Stripe does it all.\n\n## Step 8: Gate features by subscription_status\n\nNow use the column you've been carefully maintaining:\n\n```ts\nfunction canAccessProFeature(user) {\n  if (user.subscription_tier === 'free') return false;\n  if (user.subscription_status === 'active') return true;\n  if (user.subscription_status === 'trialing') return true;\n  // past_due users keep access until current_period_end\n  if (user.subscription_status === 'past_due' && user.current_period_end > new Date()) {\n    return true;\n  }\n  // canceled users keep access until current_period_end\n  if (user.subscription_status === 'canceled' && user.current_period_end > new Date()) {\n    return true;\n  }\n  return false;\n}\n```\n\nThis is the function the rest of your app calls. Never check `subscription_status` in isolation — always combine with `current_period_end`. Otherwise users lose access the moment they cancel, even if they paid for the rest of the month.\n\n## Common production traps\n\nAfter shipping this for a few apps, the things that bite:\n\n1. **Webhooks aren't ordered.** Stripe sends events as fast as it can. You might get `customer.subscription.updated` before `checkout.session.completed`. Make handlers idempotent — they should produce the same DB state regardless of order or replay.\n\n2. **Stripe retries failed webhooks for up to 3 days.** If your handler throws, Stripe will keep trying. Make sure throwing means \"actually broken\", not \"user didn't exist yet\" — the latter just creates a permanent retry loop.\n\n3. **Test mode and live mode have separate webhook secrets.** Failing to set the live secret in production deploy = silent revenue leak. Add a startup assertion that the secret matches the API key's mode.\n\n4. **`current_period_end` is in seconds, not milliseconds.** Stripe gives Unix timestamps. JavaScript wants milliseconds. Multiply by 1000 or you'll set everyone's expiration to 1970.\n\n5. **Don't email users from inside the webhook handler.** Email APIs flake. Push the email job to a queue and ack the webhook fast — Stripe times out at 30 seconds.\n\n6. **Annual → monthly downgrades create proration credits.** By default Stripe credits the unused time. Decide explicitly whether you want this and configure `proration_behavior` in checkout.\n\n## Production checklist\n\nBefore you ship to live mode:\n\n- [ ] Webhook signature verification enabled and tested\n- [ ] `STRIPE_WEBHOOK_SECRET` is the **live** secret, not test\n- [ ] Webhook URL is whitelisted (not behind auth middleware)\n- [ ] Customer Portal configured in Stripe Dashboard (turn on cancel, plan change, invoice download)\n- [ ] Email receipts enabled in Stripe Dashboard → Settings → Emails\n- [ ] Tax behaviour decided (Stripe Tax adds 0.5%, but handles the entire compliance nightmare)\n- [ ] Failed-payment dunning configured (Stripe Dashboard → Subscriptions → Smart Retries)\n- [ ] You have a \"billing\" support email — and a runbook for \"user says they were double-charged\"\n\n## When NOT to use Stripe (briefly)\n\nIf you're shipping to indie users who pay $5/month and you don't want to deal with EU VAT, **Lemon Squeezy or Paddle** act as merchant of record and handle taxes for you (~5% fee). For B2B contracts above $50k/year, **Stripe Invoicing** beats subscriptions. For one-time digital downloads, **Gumroad** is faster to set up. For everything in between, Stripe is the right answer.\n\n## FAQ\n\n**Do I need a webhook if I use Stripe Checkout?**\nYes. Checkout creates the subscription, but renewals, cancellations, failed payments, and plan changes only reach you via webhooks. No webhook = silently broken billing.\n\n**Can I just check the subscription status in real-time on every request?**\nYou can, by calling `stripe.subscriptions.retrieve()`. But that's a network round-trip on every page load, you'll hit rate limits, and it'll be slow. Mirror to your DB. That's why webhooks exist.\n\n**What about React/Next.js — do I still need a backend?**\nYes. Checkout sessions and webhook handling must run server-side because they use your secret key. Next.js API routes work great. Vercel functions also work.\n\n**How do I handle trials?**\nAdd `subscription_data: { trial_period_days: 14 }` to the checkout session. The user enters card details but isn't charged until day 14. The webhook handler already handles `trialing` status correctly.\n\n**What if my AI tool generated a Supabase backend?**\nUse a Supabase Edge Function for the webhook handler. Same pattern, same code (Deno-flavoured TypeScript). The Stripe Node SDK works in Deno via `npm:stripe`.\n\n**Should I store subscription data in Stripe metadata or my own DB?**\nBoth. Stripe is the source of truth for billing state. Your DB is the source of truth for \"can this user access this feature right now\". The webhook keeps them in sync.\n\n## Related reading\n\n- [Best AI coding assistants 2026 — Cursor vs Copilot vs Cody](/blog/best-ai-coding-assistants-2026)\n- [Best AI APIs for developers in 2026](/blog/best-ai-apis-for-developers-2026)\n- [Best AI tools for solopreneurs in 2026](/blog/best-ai-tools-for-solopreneurs-2026)","hue":145,"metaTitle":"Add Stripe Subscriptions to an AI-Generated App 2026","metaDescription":"Step-by-step guide to adding Stripe subscriptions to an app built with Cursor, v0, or Bolt. Covers products, checkout, webhooks","keywords":["Stripe subscriptions tutorial 2026","add Stripe to AI app","Stripe checkout session NestJS","Stripe webhooks tutorial","Cursor Stripe integration","v0 add billing","Bolt Stripe subscriptions","subscription state machine","Stripe subscription billing","AI app payment integration"],"tldr":["**Time to working subscriptions:** ~90 minutes if you copy-paste, half a day if you understand what you're doing.","**The 4 pieces:** Stripe Products & Prices → Checkout Session → Webhook handler → a `subscription_status` field on your user record. Most tutorials skip the last two.","**The trap:** building the upgrade flow but not the **downgrade, cancellation, payment-failed, and renewal** flows. They're 80% of subscription bugs.","**Stack-agnostic:** examples in Node/Express here, but the architecture is identical in NestJS, FastAPI, Rails, Laravel, or whatever your AI tool generated."],"readTime":"9 min read","wordCount":2042},"related":[{"slug":"how-to-add-payments-with-stripe-2026","title":"How to add payments with Stripe in 2026: the complete guide","excerpt":"Stripe is the default payments layer in 2026. This guide covers Checkout, webhooks, subscriptions, and the critical mistakes that break real-money flows.","category":"code","date":"March 27, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":316},{"slug":"how-to-set-up-stripe-payments-2026","title":"How to set up Stripe payments in 2026","excerpt":"Stripe is still the default payment processor for web apps in 2026. This guide covers the modern integration path — Checkout, Payment Element, webhooks, and going live safely.","category":"code","date":"April 3, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":321},{"slug":"subscription-cancellation-guide-2026","title":"Cancelling Subscriptions: When the Cancel Button Is Hidden","excerpt":"Signing up takes thirty seconds. Cancelling frequently requires finding a buried setting, calling during business hours, or discovering it must be done where you subscribed.","category":"guides","date":"January 30, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":167}],"sameCategory":[{"slug":"energy-bill-optimization-2026","title":"Energy Bill Optimization: Tariff First, Hardware Later","excerpt":"Most advice starts with buying something. The largest savings usually come from your tariff and when you run things. Here is the order that actually works.","category":"guides","date":"August 19, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":152},{"slug":"lease-break-guide-2026","title":"Breaking a Lease Without Wrecking Your Finances","excerpt":"Leaving before the term ends has costs, and they are frequently smaller than tenants fear. Here are the routes out and the duty landlords have.","category":"guides","date":"August 19, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":276},{"slug":"security-deposit-guide-2026","title":"Getting Your Security Deposit Back","excerpt":"Deposit disputes are won at move-in, not at move-out. Here is the documentation that decides them and what landlords can legally deduct.","category":"guides","date":"August 19, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":270},{"slug":"small-claims-court-guide-2026","title":"Small Claims Court: Suing Without a Lawyer","excerpt":"Small claims is designed for people to represent themselves over modest amounts. Here is how to prepare a case and the part nobody plans for.","category":"guides","date":"August 19, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":280}],"newer":{"slug":"tensor-parallelism-explained-2026","title":"Tensor Parallelism: Splitting One Layer Across GPUs","excerpt":"When a model is too large for one GPU, you split it. Tensor parallelism divides individual layers across devices, which is fast and demands an interconnect most clusters do not have.","category":"ai","date":"January 2, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":203},"older":{"slug":"ai-capacity-planning-2026","title":"AI Capacity Planning: Sizing for Tokens, Not Requests","excerpt":"Requests per second is the wrong unit. Two workloads at identical request rates can differ tenfold in what they demand, because the token shape is what actually consumes capacity.","category":"ai","date":"January 1, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":200}}