How to Add Stripe Subscriptions to Your SaaS (2026 Guide)
Stripe subscriptions are the backbone of SaaS billing. This guide covers everything from first setup to production-ready implementation.
Architecture Overview
User clicks "Subscribe" → Stripe Checkout Session → Payment processed →
Webhook fires → Your database updated → User gets access
Key principle: Stripe is the source of truth for billing. Your database reflects Stripe's state via webhooks. Never trust client-side payment confirmation.
Step 1: Set Up Stripe
- Create a Stripe account at stripe.com
- Get your API keys from the Dashboard → Developers → API keys
- Install the Stripe SDK:
npm install stripe
- Create products and prices in the Stripe Dashboard:
- Product: Your SaaS (e.g., "BuildPilot Pro")
- Price: $29/month (recurring)
- Note the Price ID (price_xxx)
Step 2: Create Checkout Sessions
The simplest way to handle payments. Stripe hosts the entire checkout page.
// app/api/checkout/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { priceId, userId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
client_reference_id: userId,
metadata: { userId },
});
return Response.json({ url: session.url });
}
// Frontend: redirect to Checkout
async function handleSubscribe(priceId: string) {
const response = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ priceId, userId: currentUser.id }),
});
const { url } = await response.json();
window.location.href = url;
}
Step 3: Handle Webhooks (Critical)
Webhooks are how Stripe tells your app about payment events. This is the most important part.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response('Webhook signature verification failed', { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.client_reference_id;
const subscriptionId = session.subscription as string;
// Grant access
await db.user.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: subscriptionId,
plan: 'pro',
subscriptionStatus: 'active',
},
});
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice;
// Renewal succeeded — extend access
await db.user.update({
where: { stripeCustomerId: invoice.customer as string },
data: { subscriptionStatus: 'active' },
});
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
// Payment failed — notify user, consider grace period
await db.user.update({
where: { stripeCustomerId: invoice.customer as string },
data: { subscriptionStatus: 'past_due' },
});
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
// Subscription cancelled — revoke access
await db.user.update({
where: { stripeSubscriptionId: subscription.id },
data: {
plan: 'free',
subscriptionStatus: 'cancelled',
},
});
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
// Plan change, status change, etc.
await db.user.update({
where: { stripeSubscriptionId: subscription.id },
data: {
subscriptionStatus: subscription.status,
},
});
break;
}
}
return new Response('OK', { status: 200 });
}
Step 4: Customer Portal
Let customers manage their own subscriptions (upgrade, downgrade, cancel, update payment method).
// app/api/billing-portal/route.ts
export async function POST(req: Request) {
const { customerId } = await req.json();
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.APP_URL}/dashboard`,
});
return Response.json({ url: session.url });
}
Configure the Customer Portal in Stripe Dashboard → Settings → Customer Portal. Enable:
- Invoice history
- Payment method updates
- Subscription cancellation
- Plan switching (if you have multiple plans)
Step 5: Access Control
Check subscription status before granting access to paid features:
// middleware.ts or route protection
function requirePaidPlan(user: User) {
if (user.plan === 'free' || user.subscriptionStatus !== 'active') {
redirect('/pricing');
}
}
// Feature gating
function canAccessFeature(user: User, feature: string): boolean {
const planFeatures = {
free: ['basic_dashboard'],
pro: ['basic_dashboard', 'advanced_analytics', 'api_access', 'priority_support'],
enterprise: ['basic_dashboard', 'advanced_analytics', 'api_access', 'priority_support', 'sso', 'audit_logs'],
};
return planFeatures[user.plan]?.includes(feature) ?? false;
}
Step 6: Test Everything
- Use Stripe's test mode (test API keys)
- Test card numbers:
4242 4242 4242 4242— successful payment4000 0000 0000 0002— declined4000 0000 0000 3220— requires 3D Secure
- Test webhook events via Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.deleted
- Test the full flow: subscribe → use features → cancel → lose access
Production Checklist
- Switch to live API keys
- Set up production webhook endpoint in Stripe Dashboard
- Configure Customer Portal branding
- Add proper error handling and logging
- Implement grace period for failed payments (3-7 days)
- Set up dunning emails (Stripe handles this with Smart Retries)
- Monitor webhook failures in Stripe Dashboard
- Add idempotency handling for webhook retries
- Set up Stripe Tax if selling internationally
- Create receipts/invoices (Stripe auto-sends if configured)
Common Patterns
Free Trial
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: {
trial_period_days: 14,
},
// ...
});
Multiple Plans
Create multiple Prices in Stripe and let users choose:
const plans = [
{ name: 'Starter', priceId: 'price_starter', price: '$9/mo' },
{ name: 'Pro', priceId: 'price_pro', price: '$29/mo' },
{ name: 'Enterprise', priceId: 'price_enterprise', price: '$99/mo' },
];
Annual Discount
Create both monthly and annual prices for each product. Show the savings:
const pricing = {
monthly: { priceId: 'price_monthly', amount: 29, label: '$29/month' },
annual: { priceId: 'price_annual', amount: 290, label: '$24/month (billed annually, save 17%)' },
};
Usage-Based Add-On
Report usage to Stripe for metered billing:
await stripe.subscriptionItems.createUsageRecord(
subscriptionItemId,
{
quantity: apiCallsThisPeriod,
timestamp: Math.floor(Date.now() / 1000),
action: 'set', // or 'increment'
}
);
FAQ
Do I need Stripe Checkout or can I build my own form?
Stripe Checkout is recommended. It handles PCI compliance, 3D Secure, Apple Pay/Google Pay, and is optimized for conversion. Building your own requires Stripe Elements and significantly more code.
How do I handle upgrades/downgrades?
Use the Customer Portal (easiest) or modify subscriptions via API. Stripe handles proration automatically.
What about EU VAT / international taxes?
Enable Stripe Tax. It automatically calculates and collects the correct tax based on customer location. Worth the 0.5% fee for international SaaS.
How do I prevent users from sharing accounts?
Implement session limits (max concurrent sessions per account), device tracking, or IP-based rate limiting. This is application-level logic, not Stripe's responsibility.
What if a webhook fails?
Stripe retries failed webhooks for up to 3 days with exponential backoff. Make your webhook handler idempotent (safe to run multiple times for the same event). Always return 200 quickly.
The Bottom Line
The minimum viable Stripe subscription implementation:
- Products + Prices in Stripe Dashboard
- Checkout Sessions to collect payment
- Webhooks to sync subscription state
- Customer Portal for self-service management
- Access control based on subscription status
This covers 90% of SaaS billing needs. Add usage-based billing, trials, and custom invoicing as your business requires.