AI-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.
By 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.
What you'll build
A 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.
Total moving parts:
| Piece |
Where it lives |
| Products & Prices |
Stripe Dashboard (or seed.ts script) |
| Checkout Session |
Your backend → redirect to Stripe-hosted page |
| Webhook handler |
Your backend /api/stripe/webhook |
| Customer Portal session |
Your backend → redirect to Stripe-hosted portal |
users table |
Your DB — stripe_customer_id, subscription_status, current_period_end |
Prerequisites
- Stripe account (test mode is fine for everything in this guide).
- A backend with a route handler — Node/Express, NestJS, Next.js API routes, FastAPI, Rails, Laravel. The architecture is the same.
- A
users table with at least id and email.
- 90 minutes.
Step 1: Create your products in Stripe
Open 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....
Save them in your .env:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_PRO_MONTHLY=price_1OabcMonthly
STRIPE_PRICE_PRO_YEARLY=price_1OabcYearly
STRIPE_PRICE_TEAM_MONTHLY=price_1OdefMonthly
STRIPE_PRICE_TEAM_YEARLY=price_1OdefYearly
Why env vars: hard-coding price IDs in your frontend means every plan change requires a redeploy. This way, marketing can change pricing without you.
Step 2: Add the four columns to your users table
Whatever your ORM, you need these:
ALTER TABLE users ADD COLUMN stripe_customer_id TEXT;
ALTER TABLE users ADD COLUMN subscription_status TEXT DEFAULT 'free';
ALTER TABLE users ADD COLUMN subscription_tier TEXT DEFAULT 'free';
ALTER TABLE users ADD COLUMN current_period_end TIMESTAMP;
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.
current_period_end is when the user loses access if they cancel. Always trust this column over your gut.
Step 3: Create a checkout session
When 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.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createCheckoutSession(req, res) {
const user = req.user; // however you load the current user
const { priceId } = req.body;
// Get or create the Stripe customer
let customerId = user.stripe_customer_id;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { user_id: user.id },
});
customerId = customer.id;
await db.users.update(user.id, { stripe_customer_id: customerId });
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.APP_URL}/billing?success=true`,
cancel_url: `${process.env.APP_URL}/pricing`,
allow_promotion_codes: true,
billing_address_collection: 'auto',
subscription_data: {
metadata: { user_id: user.id },
},
});
res.json({ url: session.url });
}
Two non-obvious details:
- Always set
metadata.user_id on both the customer and the subscription. Webhooks return Stripe IDs, not your IDs — metadata is the bridge.
allow_promotion_codes: true lets you create discount codes in the dashboard without code changes. Marketing will love you.
On the frontend, hit this endpoint and redirect:
const { url } = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ priceId: 'price_1OabcMonthly' }),
}).then(r => r.json());
window.location.href = url;
That's it for the upgrade flow. The user pays on Stripe's hosted page and lands back on your /billing?success=true page.
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.
Step 4: Set up the webhook (the part that actually matters)
Stripe 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.
First, 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.
import express from 'express';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// CRITICAL: raw body, mounted BEFORE app.use(express.json())
app.post(
'/api/stripe/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature']!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature failed:', err);
return res.status(400).send('Invalid signature');
}
try {
await handleStripeEvent(event);
res.json({ received: true });
} catch (err) {
console.error('Webhook handler failed:', err);
// Return 500 so Stripe retries
res.status(500).send('Handler failed');
}
}
);
The 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.
Step 5: Handle the four events that matter
Stripe sends ~50 event types. You need exactly five:
async function handleStripeEvent(event: Stripe.Event) {
switch (event.type) {
case 'checkout.session.completed':
await onCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case 'customer.subscription.updated':
case 'customer.subscription.created':
await onSubscriptionChanged(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await onSubscriptionCanceled(event.data.object as Stripe.Subscription);
break;
case 'invoice.payment_failed':
await onPaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
// Ignore everything else
break;
}
}
async function onSubscriptionChanged(sub: Stripe.Subscription) {
const userId = sub.metadata.user_id;
if (!userId) {
console.error('No user_id in subscription metadata', sub.id);
return;
}
const priceId = sub.items.data[0].price.id;
const tier = priceIdToTier(priceId); // your map: priceId → 'pro' | 'team'
await db.users.update(userId, {
subscription_status: sub.status, // 'active' | 'trialing' | 'past_due' | 'canceled'
subscription_tier: tier,
current_period_end: new Date(sub.current_period_end * 1000),
});
}
async function onSubscriptionCanceled(sub: Stripe.Subscription) {
const userId = sub.metadata.user_id;
await db.users.update(userId, {
subscription_status: 'canceled',
subscription_tier: 'free',
// Keep current_period_end so we know when access actually ends
});
}
async function onPaymentFailed(invoice: Stripe.Invoice) {
const userId = invoice.subscription_details?.metadata?.user_id;
await db.users.update(userId, {
subscription_status: 'past_due',
});
// Optionally: send a "your card failed" email
}
Notice 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.
Step 6: Test the webhook locally
Install the Stripe CLI:
brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
The CLI prints a whsec_... — paste it into your .env as STRIPE_WEBHOOK_SECRET. Now in another terminal:
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed
Watch 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.
Step 7: Add the Customer Portal
Users 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.
export async function createPortalSession(req, res) {
const user = req.user;
const session = await stripe.billingPortal.sessions.create({
customer: user.stripe_customer_id,
return_url: `${process.env.APP_URL}/billing`,
});
res.json({ url: session.url });
}
Add 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.
Step 8: Gate features by subscription_status
Now use the column you've been carefully maintaining:
function canAccessProFeature(user) {
if (user.subscription_tier === 'free') return false;
if (user.subscription_status === 'active') return true;
if (user.subscription_status === 'trialing') return true;
// past_due users keep access until current_period_end
if (user.subscription_status === 'past_due' && user.current_period_end > new Date()) {
return true;
}
// canceled users keep access until current_period_end
if (user.subscription_status === 'canceled' && user.current_period_end > new Date()) {
return true;
}
return false;
}
This 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.
Common production traps
After shipping this for a few apps, the things that bite:
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.
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.
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.
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.
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.
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.
Production checklist
Before you ship to live mode:
When NOT to use Stripe (briefly)
If 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.
FAQ
Do I need a webhook if I use Stripe Checkout?
Yes. Checkout creates the subscription, but renewals, cancellations, failed payments, and plan changes only reach you via webhooks. No webhook = silently broken billing.
Can I just check the subscription status in real-time on every request?
You 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.
What about React/Next.js — do I still need a backend?
Yes. 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.
How do I handle trials?
Add 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.
What if my AI tool generated a Supabase backend?
Use 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.
Should I store subscription data in Stripe metadata or my own DB?
Both. 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.
Related reading