Stripe Integration Complete Guide: Subscriptions, Webhooks & Usage Billing (2026)
The complete Stripe integration guide for SaaS in 2026. Subscriptions, Stripe Checkout, Customer Portal, webhooks, usage-based billing, free trials, failed payment recovery — with ...
The Complete Stripe Billing System
Stripe is the gold standard for SaaS billing — but the documentation is vast and the "right" way to integrate it for a modern Next.js SaaS isn't always obvious. This guide cuts through everything and gives you the exact production pattern used by thousands of successful SaaS products.
By the end of this guide, your SaaS will handle: subscription checkout with multiple plans, Stripe-hosted Customer Portal for plan changes and cancellations, webhooks that keep your database in sync with Stripe's state, usage-based billing for metered features, free trials with card capture, and dunning that recovers failed payments automatically.
There are two ways to build Stripe billing: (1) Custom Stripe.js UI — you build the payment form, handle card elements, manage state. (2) Stripe Checkout + Customer Portal — Stripe hosts the payment page and billing management. This guide uses Option 2. It's faster to implement, Stripe handles PCI compliance, and conversion rates are typically higher on Stripe's optimized checkout pages. Option 1 is only worth building if your design requirements are extremely specific.
Setup: Stripe Dashboard, Products, and Prices
Before writing a line of code, configure your Stripe products and prices in the Stripe Dashboard. This step is critical — your pricing model in Stripe determines everything downstream : how checkout works, how webhooks fire, and how plan changes are handled.
1. Create Your Products and Prices in Stripe Dashboard
In Stripe Dashboard → Products, create one product per plan tier. For each product, create a price:
- Free plan: No Stripe product needed — just skip billing for free users
- Pro plan: Create a product "Pro", add a recurring monthly price at $29/month
- Enterprise plan: Create a product "Enterprise", add a recurring monthly price at $99/month
After creating prices, copy their Price IDs (format: price_xxxx ). These go in your environment variables.
2. Environment Variables
# Stripe keys — get from Stripe Dashboard > Developers > API keys
STRIPE_SECRET_KEY=sk_live_... # Never expose to client
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... # Safe for client
# Webhook secret — get from Stripe Dashboard > Webhooks after creating endpoint
STRIPE_WEBHOOK_SECRET=whsec_...
# Your Price IDs from Stripe Dashboard > Products
STRIPE_PRICE_PRO_MONTHLY=price_...
STRIPE_PRICE_PRO_ANNUAL=price_...
STRIPE_PRICE_ENTERPRISE_MONTHLY=price_...
# App URL — for redirect URLs in Checkout
NEXT_PUBLIC_APP_URL=https://yourdomain.com3. Install Stripe SDK
npm install stripe @stripe/stripe-js4. Initialize Stripe (Server + Client)
import Stripe from 'stripe'
// Server-side Stripe instance (never expose secret key to client)
export const stripe = new Stripe (process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2026-03-31.basil' , // Always pin the API version
typescript: true ,
})
// Pricing config — single source of truth
export const PLANS = {
free: {
name: 'Free' ,
priceMonthly: 0 ,
priceId: null ,
features: [ 'Up to 3 projects' , '5GB storage' , 'Email support' ],
limits: { projects: 3 , storage: 5 }
},
pro: {
name: 'Pro' ,
priceMonthly: 29 ,
priceId: process.env.STRIPE_PRICE_PRO_MONTHLY,
features: [ 'Unlimited projects' , '50GB storage' , 'Priority support' , 'API access' ],
limits: { projects: Infinity , storage: 50 }
},
enterprise: {
name: 'Enterprise' ,
priceMonthly: 99 ,
priceId: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY,
features: [ 'Everything in Pro' , '500GB storage' , 'SAML SSO' , 'Dedicated support' ],
limits: { projects: Infinity , storage: 500 }
}
} as const
export type Plan = keyof typeof PLANSDatabase Schema: What to Store About Each Subscriber
Your database is the source of truth for access control. Stripe is the source of truth for billing. Your job is to keep them in sync via webhooks. Here's the exact schema you need.
-- Extend your existing profiles/users table
alter table profiles
add column stripe_customer_id text unique ,
add column plan text not null default 'free' ,
add column plan_status text not null default 'active' ;
-- plan_status: 'active' | 'trialing' | 'past_due' | 'canceled'
-- Subscriptions table — tracks the active Stripe subscription
create table subscriptions (
id uuid default gen_random_uuid() primary key ,
user_id uuid references profiles(id) on delete cascade unique ,
stripe_subscription_id text unique not null ,
stripe_customer_id text not null ,
stripe_price_id text not null ,
plan text not null , -- 'pro' | 'enterprise'
status text not null , -- Stripe subscription status
current_period_start timestamptz,
current_period_end timestamptz,
cancel_at_period_end boolean default false ,
trial_end timestamptz,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Usage records for metered billing
create table usage_records (
id uuid default gen_random_uuid() primary key ,
user_id uuid references profiles(id) on delete cascade ,
metric text not null , -- 'api_calls' | 'storage_gb' etc.
quantity bigint not null ,
recorded_at timestamptz default now()
);
-- Index for fast subscription lookups by Stripe IDs
create index subs_stripe_id_idx on subscriptions (stripe_subscription_id);
create index subs_customer_id_idx on subscriptions (stripe_customer_id);Never use Stripe's subscription status as your access control directly. Your database is what your app reads on every request. Stripe fires webhooks to keep your database updated. If a webhook fails and Stripe says "active" but your DB says "past_due" — your app uses your DB. This means your webhook handling must be reliable and idempotent.
Stripe Checkout: The Subscription Flow
When a user clicks "Upgrade to Pro", you create a Stripe Checkout Session on the server and redirect them to Stripe's hosted checkout page. After payment, Stripe redirects back to your success URL and fires a webhook.
'use server'
import { stripe } from '@/lib/stripe'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export async function createCheckoutSession (priceId: string ) {
const supabase = createClient ()
const { data: { user } } = await supabase.auth. getUser ()
if (!user) throw new Error ( 'Unauthorized' )
// Get or create Stripe customer
const { data: profile } = await supabase
.from( 'profiles' )
.select( 'stripe_customer_id' )
.eq( 'id' , user.id)
.single()
let customerId = profile?.stripe_customer_id
if (!customerId) {
const customer = await stripe.customers. create ({
email: user.email!,
metadata: { supabase_user_id: user.id },
})
customerId = customer.id
await supabase
.from( 'profiles' )
.update({ stripe_customer_id: customerId })
.eq( 'id' , user.id)
}
// Create the Checkout Session
const session = await stripe.checkout.sessions. create ({
customer: customerId,
mode: 'subscription' ,
line_items: [{ price: priceId, quantity: 1 }],
// Trial period (optional) — 14 days free
subscription_data: {
trial_period_days: 14 ,
metadata: { user_id: user.id },
},
// Where to redirect after payment
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true` ,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing` ,
// Collect billing address (required for tax)
billing_address_collection: 'auto' ,
// Enable tax collection
automatic_tax: { enabled: true },
// Allow promotion codes
allow_promotion_codes: true ,
})
if (!session.url) throw new Error ( 'Failed to create checkout session' )
redirect (session.url)
}The Pricing Page UI
- ✓ 3 projects
- ✓ 5GB storage
- ✓ Email support
- ✗ API access
- ✗ Custom domains
- ✓ Unlimited projects
- ✓ 50GB storage
- ✓ Priority support
- ✓ API access
- ✗ Custom domains
- ✓ Everything in Pro
- ✓ 500GB storage
- ✓ Dedicated support
- ✓ SAML SSO
- ✓ Custom domains
'use client'
import { createCheckoutSession } from '@/app/actions/billing'
import { PLANS } from '@/lib/stripe'
import { useState } from 'react'
export default function PricingPage () {
const [loading, setLoading ] = useState < string | null >( null )
async function handleUpgrade (plan: 'pro' | 'enterprise' ) {
const priceId = PLANS[plan].priceId
if (!priceId) return
setLoading (plan)
try {
await createCheckoutSession (priceId)
// redirect() in the server action handles navigation
} catch (err) {
console. error ( 'Checkout failed:' , err)
setLoading ( null )
}
}
return (
<div className= "grid grid-cols-3 gap-6" >
{[ 'pro' , 'enterprise' ]. map ((plan) => (
<button
key={plan}
onClick={() => handleUpgrade (plan as any )}
disabled={loading === plan}
className= "bg-primary text-white rounded-lg p-4"
>
{loading === plan ? 'Loading...' : `Upgrade to ${PLANS[plan as keyof typeof PLANS].name}` }
</button>
))}
</div>
)
}Webhooks: The Heart of Stripe Integration
Webhooks are where most Stripe integrations break down. Stripe sends events to your endpoint for everything that happens — payment succeeded, subscription renewed, trial ending, payment failed. Your webhook handler is what keeps your database in sync with Stripe's reality.
Get this wrong and you'll have users who paid but don't have access, or users who cancelled but still do. Webhooks are non-negotiable.
Which Webhook Events You Must Handle
The Complete Webhook Handler
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe'
import { createAdminClient } from '@/lib/supabase/admin'
import Stripe from 'stripe'
// Required for raw body access (signature verification needs raw bytes)
export const dynamic = 'force-dynamic'
export async function POST (req: Request) {
const body = await req. text ()
const sig = ( await headers ()). get ( 'stripe-signature' )
if (!sig) {
return Response . json ({ error: 'No signature' }, { status: 400 })
}
let event: Stripe. Event
try {
event = stripe.webhooks. constructEvent (
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console. error ( 'Webhook signature failed:' , err)
return Response . json ({ error: 'Invalid signature' }, { status: 400 })
}
const supabase = createAdminClient () // Bypass RLS for webhook updates
try {
switch (event.type) {
case 'checkout.session.completed' : {
const session = event.data.object as Stripe. Checkout.Session
if (session.mode !== 'subscription' ) break
const subscription = await stripe.subscriptions. retrieve (
session.subscription as string
)
await upsertSubscription (supabase, subscription)
break
}
case 'customer.subscription.updated' :
case 'customer.subscription.deleted' : {
const subscription = event.data.object as Stripe. Subscription
await upsertSubscription (supabase, subscription)
break
}
case 'invoice.payment_failed' : {
const invoice = event.data.object as Stripe. Invoice
if (!invoice.subscription) break
await supabase
.from( 'subscriptions' )
.update({ status: 'past_due' })
.eq( 'stripe_subscription_id' , invoice.subscription)
// Also update the profile plan_status for quick access control checks
const { data: sub } = await supabase
.from( 'subscriptions' )
.select( 'user_id' )
.eq( 'stripe_subscription_id' , invoice.subscription)
.single()
if (sub) {
await supabase
.from( 'profiles' )
.update({ plan_status: 'past_due' })
.eq( 'id' , sub.user_id)
}
break
}
case 'invoice.payment_succeeded' : {
const invoice = event.data.object as Stripe. Invoice
if (!invoice.subscription) break
// Payment recovered — restore active status
await supabase
.from( 'subscriptions' )
.update({ status: 'active' })
.eq( 'stripe_subscription_id' , invoice.subscription)
break
}
default :
console. log ( `Unhandled event: ${event.type}` )
}
return Response . json ({ received: true })
} catch (err) {
console. error ( `Error handling ${event.type}:` , err)
// Return 200 to prevent Stripe retrying a permanent error
return Response . json ({ error: 'Internal error' }, { status: 500 })
}
}
// Helper: upsert subscription in DB from Stripe object
async function upsertSubscription (supabase: any , sub: Stripe. Subscription ) {
const priceId = sub.items.data[ 0 ]?.price.id
const plan = getPlanFromPriceId (priceId)
const userId = sub.metadata?.user_id
if (!userId) throw new Error ( 'No user_id in subscription metadata' )
await supabase.from( 'subscriptions' ). upsert ({
user_id: userId,
stripe_subscription_id: sub.id,
stripe_customer_id: sub.customer as string ,
stripe_price_id: priceId,
plan,
status: sub.status,
current_period_start: new Date (sub.current_period_start * 1000 ). toISOString (),
current_period_end: new Date (sub.current_period_end * 1000 ). toISOString (),
cancel_atNever process a webhook without verifying its signature using stripe.webhooks.constructEvent() . Without verification, anyone can POST to your webhook endpoint with fake events — upgrading accounts without paying, or triggering actions on arbitrary subscriptions. The signature check is the entire security model for webhooks. It is not optional.
Customer Portal: Let Stripe Handle Plan Changes
The Stripe Customer Portal is a hosted page where users can upgrade, downgrade, cancel, update their payment method, and view invoice history — without you building any of that UI. Enable it in Stripe Dashboard → Settings → Customer Portal, configure which plans can be switched to, and create a portal session when the user clicks "Manage Billing".
export async function createPortalSession () {
const supabase = createClient ()
const { data: { user } } = await supabase.auth. getUser ()
if (!user) throw new Error ( 'Unauthorized' )
const { data: profile } = await supabase
.from( 'profiles' )
.select( 'stripe_customer_id' )
.eq( 'id' , user.id)
.single()
if (!profile?.stripe_customer_id) {
throw new Error ( 'No billing account found. Please upgrade first.' )
}
const portalSession = await stripe.billingPortal.sessions. create ({
customer: profile.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings` ,
})
redirect (portalSession.url)
}
// Usage in your settings page:
// <form action={createPortalSession}>
// <button type="submit">Manage Billing</button>
// </form>When users change plans via the Customer Portal, Stripe fires customer.subscription.updated — which your webhook handler already processes. The plan change flows automatically: Stripe updates the subscription → webhook fires → your DB updates → user's access level changes. No additional code needed.
Access Control: Enforcing Plan Limits
Now that your database reflects the user's plan, enforce limits everywhere in your application. The pattern: read the plan from the user's profile, compare against PLANS[plan].limits , and block or gate accordingly.
import { PLANS, type Plan } from './stripe'
import { createClient } from './supabase/server'
export async function getUserPlan (): Promise <{ plan: Plan ; isActive: boolean }> {
const supabase = createClient ()
const { data: { user } } = await supabase.auth. getUser ()
if (!user) return { plan: 'free' , isActive: true }
const { data: profile } = await supabase
.from( 'profiles' )
.select( 'plan, plan_status' )
.eq( 'id' , user.id)
.single()
const plan = (profile?.plan ?? 'free' ) as Plan
const isActive = [ 'active' , 'trialing' ]. includes (profile?.plan_status ?? 'active' )
return { plan, isActive }
}
export async function requirePlan (minimumPlan: Plan ) {
const planOrder: Plan [] = [ 'free' , 'pro' , 'enterprise' ]
const { plan, isActive } = await getUserPlan ()
if (!isActive) {
throw new Error ( 'Your subscription is not active. Please update your billing.' )
}
if (planOrder. indexOf (plan) < planOrder. indexOf (minimumPlan)) {
throw new Error ( `This feature requires the ${minimumPlan} plan. Please upgrade.` )
}
}
// Usage in Server Actions:
export async function createProject (name: string ) {
const { plan } = await getUserPlan ()
const limits = PLANS[plan].limits
// Count existing projects
const { count } = await supabase
.from( 'projects' )
.select( '*' , { count: 'exact' })
.eq( 'user_id' , userId)
if (count !== null && count >= limits.projects) {
throw new Error ( `Project limit reached. Upgrade to create more.` )
}
// ... create the project
}Usage-Based Billing: Charge for What They Use
Usage-based (metered) billing is perfect for API calls, compute time, or storage — where different customers consume wildly different amounts. Instead of flat-rate plans, customers pay for what they actually use. Stripe handles the metering natively.
Setup Metered Pricing in Stripe
In Stripe Dashboard, create a price with Usage type: Metered and Aggregation mode: Sum . This price charges per unit of usage reported. Set the per-unit price (e.g., $0.01 per API call).
import { stripe } from './stripe'
import { createAdminClient } from './supabase/admin'
/**
* Track usage in your DB and report to Stripe.
* Call this every time a metered action is performed.
*/
export async function trackUsage (
userId: string ,
metric: string ,
quantity: number
) {
const supabase = createAdminClient ()
// 1. Record in your DB for analytics and dashboards
await supabase.from( 'usage_records' ). insert ({
user_id: userId,
metric,
quantity,
})
// 2. Get the user's subscription item ID for this metric
const { data: sub } = await supabase
.from( 'subscriptions' )
.select( 'stripe_subscription_id' )
.eq( 'user_id' , userId)
.single()
if (!sub) return // Free plan — no Stripe subscription
const subscription = await stripe.subscriptions. retrieve (
sub.stripe_subscription_id
)
// Find the subscription item for this metric's price
const meteredItem = subscription.items.data. find (
item => item.price.id === process.env[ `STRIPE_PRICE_${metric.toUpperCase()}` ]
)
if (!meteredItem) return
// 3. Report usage to Stripe (idempotency key prevents double-billing)
await stripe.subscriptionItems. createUsageRecord (
meteredItem.id,
{
quantity,
timestamp: Math . floor ( Date . now () / 1000 ),
action: 'increment' ,
},
{
idempotencyKey: `${userId}-${metric}-${Date.now()}`
}
)
}
// Usage example in your API route:
// await trackUsage(userId, 'api_calls', 1)Failed Payments: Recover Revenue Automatically
Failed payments are inevitable. Cards expire, banks decline charges, payment methods run out of funds. Stripe's Smart Retries automatically retries failed payments at optimal times — enable this in Stripe Dashboard → Settings → Subscriptions → Smart Retries. It typically recovers 50-70% of failed payments automatically.
Your job: respond to the invoice.payment_failed webhook (already handled above) by sending a dunning email that helps users update their payment method via the Customer Portal.
import { Resend } from 'resend'
import { stripe } from '@/lib/stripe'
const resend = new Resend (process.env.RESEND_API_KEY)
export async function sendPaymentFailedEmail (
userEmail: string ,
customerId: string ,
amount: number
) {
// Create a Customer Portal session for payment method update
const portalSession = await stripe.billingPortal.sessions. create ({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard` ,
})
await resend.emails. send ({
from: '[email protected]' ,
to: userEmail,
subject: 'Action required: Payment failed' ,
html: `
<h2>Your payment of $${(amount / 100).toFixed(2)} failed</h2>
<p>We couldn't process your subscription payment. Your account
remains active while we retry, but please update your payment
method to avoid service interruption.</p>
<a href="${portalSession.url}" style="background:#635bff;color:white;
padding:12px 24px;border-radius:6px;text-decoration:none;">
Update Payment Method
</a>
<p>If you have any questions, reply to this email.</p>
`
})
}The Dunning Sequence That Works
Enable Stripe Smart Retries (retry on days 3, 5, 7 after initial failure). On first failure: send friendly email "payment failed, update here." On day 3 retry failure: send urgent email "account at risk." On day 7: downgrade to free, send final email with reactivation link. This sequence recovers 60-80% of at-risk subscriptions and feels professional rather than aggressive.
Testing Stripe: The Complete Test Checklist
Testing Stripe requires Stripe's test cards and the Stripe CLI for local webhook testing. Never use real card data in development.
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login to Stripe CLI
stripe login
# Forward webhooks to your local development server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In a separate terminal, trigger specific events for testing
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
# Test cards (use in Stripe Checkout test mode):
# 4242 4242 4242 4242 — Visa, succeeds
# 4000 0000 0000 9995 — Always fails (insufficient funds)
# 4000 0025 0000 3155 — Requires 3D Secure authentication
# 4000 0000 0000 0341 — Attaches but payment failsManually test: (1) New user subscribes → check DB updated and access granted. (2) User cancels via Portal → check DB updated and cancel_at_period_end set. (3) Subscription expires → check user downgraded to free. (4) Payment fails → check status becomes past_due. (5) Payment recovered → check status returns to active. Don't go live until all 5 pass manually in test mode.
The Production Billing Checklist
- Switch to live mode keys. Replace all sk_test_ and pk_test_ keys with live mode equivalents. Create a new live webhook endpoint in Stripe Dashboard.
- Webhook endpoint registered in Stripe Dashboard. Set up your production URL (e.g., https://yourdomain.com/api/webhooks/stripe ) and add all the events you handle.
- Enable Stripe Tax. Turn on Automatic Tax in your Checkout Sessions. Stripe calculates and collects the correct tax for each user's location automatically.
- Enable Smart Retries. Stripe Dashboard → Settings → Subscriptions → Smart retries: ON. This is free revenue recovery.
- Customer Portal is configured. In Stripe Dashboard → Settings → Customer Portal — enable plan upgrades, downgrades, cancellation, and invoice history.
- Webhook signature verification is tested. Confirm your constructEvent() call rejects tampered requests.
- All webhook events have idempotent handlers. Stripe can send the same event twice. Your handlers should produce the same result if called multiple times.
- Budget alerts configured. In Stripe Dashboard → Settings → Email notifications — enable alerts for unusual activity.
- Payout schedule confirmed. Know when Stripe deposits to your bank account and factor this into your cash flow planning.
Stripe billing is not a feature you ship once. It's infrastructure that grows with your product. Build it right the first time — webhooks, idempotency, and proper error handling — and you'll never have to think about it again.
What to Read Next
- Building a Multi-Tenant SaaS with Next.js, Supabase, and Stripe →
- Clerk vs Auth0 vs NextAuth vs Supabase Auth: Which for Your SaaS? →
- DevOps for Solo Founders: Zero to $10K MRR Infrastructure →
- 10 Micro-SaaS Products You Can Build with AI This Weekend →
