Integrating Payments with Stripe
Subscriptions, checkout flows, webhooks, customer portal, and testing with Stripe CLI.
Overview
Most SaaS apps follow this payment flow:
- User clicks "Upgrade" → Redirected to Stripe Checkout
- User pays → Stripe sends a webhook to your app
- Your app updates the user's subscription status in the database
- User manages billing through Stripe Customer Portal
Setup
Install dependencies
npm install stripe
Environment variables
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_PRO_PRICE_ID=price_...
STRIPE_ENTERPRISE_PRICE_ID=price_...
Create the Stripe client
// lib/stripe.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
})
Creating a Checkout Session
When a user clicks "Upgrade," create a Checkout Session and redirect them:
// app/api/checkout/route.ts
import { stripe } from '@/lib/stripe'
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { priceId } = await request.json()
// Get or create Stripe customer
const { data: subscription } = await supabase
.from('subscriptions')
.select('stripe_customer_id')
.eq('user_id', user.id)
.single()
let customerId = subscription?.stripe_customer_id
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { supabase_user_id: user.id },
})
customerId = customer.id
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${request.headers.get('origin')}/dashboard?upgraded=true`,
cancel_url: `${request.headers.get('origin')}/pricing`,
metadata: { supabase_user_id: user.id },
})
return NextResponse.json({ url: session.url })
}
Client-side redirect:
async function handleUpgrade(priceId: string) {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
const { url } = await response.json()
window.location.href = url
}
Handling Webhooks
Webhooks are how Stripe tells your app about payment events. This is the most critical part of your integration.
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe'
import { createClient } from '@supabase/supabase-js'
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
// Use service role for webhook handler (bypasses RLS)
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
export async function POST(request: Request) {
const body = await request.text()
const headersList = await headers()
const signature = headersList.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
)
await supabase.from('subscriptions').upsert({
user_id: session.metadata!.supabase_user_id,
stripe_customer_id: session.customer as string,
stripe_subscription_id: subscription.id,
plan: 'pro',
status: 'active',
current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
})
break
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription
await supabase
.from('subscriptions')
.update({
status: subscription.status,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
})
.eq('stripe_subscription_id', subscription.id)
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
await supabase
.from('subscriptions')
.update({ status: 'canceled', plan: 'free' })
.eq('stripe_subscription_id', subscription.id)
break
}
}
return NextResponse.json({ received: true })
}
Stripe Customer Portal
Let users manage their subscription (change plan, update payment, cancel) through Stripe's hosted portal:
// app/api/billing-portal/route.ts
import { stripe } from '@/lib/stripe'
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: subscription } = await supabase
.from('subscriptions')
.select('stripe_customer_id')
.eq('user_id', user.id)
.single()
if (!subscription?.stripe_customer_id) {
return NextResponse.json({ error: 'No subscription found' }, { status: 404 })
}
const session = await stripe.billingPortal.sessions.create({
customer: subscription.stripe_customer_id,
return_url: `${request.headers.get('origin')}/settings`,
})
return NextResponse.json({ url: session.url })
}
Testing with Stripe CLI
Install and setup
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
The CLI will output a webhook signing secret (starts with whsec_). Use this as your STRIPE_WEBHOOK_SECRET during development.
Test card numbers
| Card | Scenario |
|---|---|
4242 4242 4242 4242 |
Successful payment |
4000 0000 0000 3220 |
3D Secure required |
4000 0000 0000 9995 |
Payment declined |
4000 0025 0000 3155 |
Requires authentication |
Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
Checking Subscription Status
Create a utility to check the user's plan:
// lib/subscription.ts
import { createClient } from '@/lib/supabase/server'
export async function getUserPlan() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return 'free'
const { data } = await supabase
.from('subscriptions')
.select('plan, status')
.eq('user_id', user.id)
.single()
if (!data || data.status !== 'active') return 'free'
return data.plan
}
export async function requirePlan(requiredPlan: string) {
const plan = await getUserPlan()
if (plan !== requiredPlan && plan !== 'enterprise') {
throw new Error('Upgrade required')
}
}
Common Pitfalls
-
Never trust client-side plan checks — Always verify subscription status server-side before granting access to paid features.
-
Handle webhook retries — Stripe retries failed webhooks. Make your handler idempotent by using
upsertinstead ofinsert. -
Use Stripe's test mode — Never test with real cards. Use
sk_test_keys and test card numbers. -
Don't store card details — Stripe Checkout and the Customer Portal handle all payment UI. You never need to touch card numbers.
-
Configure the Customer Portal — Go to Stripe Dashboard > Settings > Billing > Customer Portal and enable the features you want (cancel, switch plans, update payment).