Stripe recurring billing uses Subscriptions and Prices — stripe.subscriptions.create({ customer, items: [{ price: priceId }] }) creates a subscription. stripe.billingPortal.sessions.create({ customer, return_url }) generates a self-serve customer portal link for upgrades, downgrades, and cancellation. trial_period_days adds a free trial. proration_behavior: "create_prorations" calculates credits and charges when changing plans. subscription.cancel_at_period_end = true cancels gracefully at the billing cycle end. The customer.subscription.updated webhook handles plan changes. customer.subscription.deleted revokes access. invoice.payment_succeeded confirms successful payments. invoice.payment_failed triggers dunning. Metered billing: stripe.subscriptionItems.createUsageRecord(siId, { quantity, timestamp }) reports usage. Subscription items let one subscription have multiple prices (e.g., base + per-seat). Claude Code generates Stripe SaaS billing, trial flows, customer portals, and plan upgrade/downgrade logic.
CLAUDE.md for Stripe Subscriptions
## Stripe Subscriptions Stack
- Version: stripe >= 16.2
- Customer: const customer = await stripe.customers.create({ email, metadata: { userId } })
- Subscribe: const sub = await stripe.subscriptions.create({ customer: customerId, items: [{ price: priceId }], payment_behavior: "default_incomplete", expand: ["latest_invoice.payment_intent"] })
- Portal: const session = await stripe.billingPortal.sessions.create({ customer: customerId, return_url: "${APP_URL}/settings/billing" })
- Cancel: await stripe.subscriptions.update(subId, { cancel_at_period_end: true })
- Webhook: constructEventAsync(body, sig, secret) → handle "customer.subscription.updated" | ".deleted" | "invoice.payment_succeeded"
Subscription Service
// lib/billing/stripe.ts — Stripe subscription helpers
import Stripe from "stripe"
import { db } from "@/lib/db"
import { users, subscriptions } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-11-20.acacia",
})
export type PlanId = "free" | "pro" | "enterprise"
// Price ID mapping — store in env or DB
const PRICE_IDS: Record<Exclude<PlanId, "free">, { monthly: string; yearly: string }> = {
pro: {
monthly: process.env.STRIPE_PRO_MONTHLY_PRICE!,
yearly: process.env.STRIPE_PRO_YEARLY_PRICE!,
},
enterprise: {
monthly: process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE!,
yearly: process.env.STRIPE_ENTERPRISE_YEARLY_PRICE!,
},
}
// Get or create Stripe customer for a user
export async function getOrCreateStripeCustomer(userId: string): Promise<string> {
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { id: true, email: true, name: true, stripeCustomerId: true },
})
if (!user) throw new Error("User not found")
if (user.stripeCustomerId) return user.stripeCustomerId
const customer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: { userId: user.id },
})
await db.update(users)
.set({ stripeCustomerId: customer.id })
.where(eq(users.id, userId))
return customer.id
}
// Create checkout for new subscription
export async function createSubscriptionCheckout(params: {
userId: string
plan: Exclude<PlanId, "free">
interval: "monthly" | "yearly"
trialDays?: number
successUrl: string
cancelUrl: string
}): Promise<string> {
const customerId = await getOrCreateStripeCustomer(params.userId)
const priceId = PRICE_IDS[params.plan][params.interval]
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: params.successUrl,
cancel_url: params.cancelUrl,
allow_promotion_codes: true,
subscription_data: {
trial_period_days: params.trialDays,
metadata: { userId: params.userId, plan: params.plan },
},
metadata: { userId: params.userId },
})
return session.url!
}
// Create billing portal session
export async function createPortalSession(userId: string, returnUrl: string): Promise<string> {
const customerId = await getOrCreateStripeCustomer(userId)
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
})
return session.url
}
// Upgrade/downgrade subscription plan
export async function changeSubscriptionPlan(
subscriptionId: string,
newPlan: Exclude<PlanId, "free">,
interval: "monthly" | "yearly",
): Promise<void> {
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
const currentItemId = subscription.items.data[0].id
const newPriceId = PRICE_IDS[newPlan][interval]
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItemId, price: newPriceId }],
proration_behavior: "create_prorations",
})
}
// Cancel subscription at period end
export async function cancelSubscription(subscriptionId: string, immediate = false): Promise<void> {
if (immediate) {
await stripe.subscriptions.cancel(subscriptionId)
} else {
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
})
}
}
// Reactivate a subscription that was set to cancel
export async function reactivateSubscription(subscriptionId: string): Promise<void> {
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
})
}
Webhook Handler
// app/api/webhooks/stripe/route.ts — subscription lifecycle webhooks
import { NextRequest, NextResponse } from "next/server"
import Stripe from "stripe"
import { db } from "@/lib/db"
import { users, subscriptions } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-11-20.acacia" })
const PLAN_BY_PRICE: Record<string, "pro" | "enterprise"> = {
[process.env.STRIPE_PRO_MONTHLY_PRICE!]: "pro",
[process.env.STRIPE_PRO_YEARLY_PRICE!]: "pro",
[process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE!]: "enterprise",
[process.env.STRIPE_ENTERPRISE_YEARLY_PRICE!]: "enterprise",
}
async function upsertSubscription(stripeSubscription: Stripe.Subscription) {
const customerId = typeof stripeSubscription.customer === "string"
? stripeSubscription.customer
: stripeSubscription.customer.id
const user = await db.query.users.findFirst({
where: eq(users.stripeCustomerId, customerId),
})
if (!user) {
console.error("[Stripe webhook] No user for customer", customerId)
return
}
const priceId = stripeSubscription.items.data[0]?.price.id
const plan = PLAN_BY_PRICE[priceId] ?? "free"
await db.insert(subscriptions).values({
userId: user.id,
stripeSubscriptionId: stripeSubscription.id,
stripeCustomerId: customerId,
status: stripeSubscription.status,
plan,
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
canceledAt: stripeSubscription.canceled_at ? new Date(stripeSubscription.canceled_at * 1000) : null,
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
}).onConflictDoUpdate({
target: subscriptions.stripeSubscriptionId,
set: {
status: stripeSubscription.status,
plan,
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
canceledAt: stripeSubscription.canceled_at ? new Date(stripeSubscription.canceled_at * 1000) : null,
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
},
})
// Update user's plan
const activePlans: Stripe.Subscription.Status[] = ["active", "trialing", "past_due"]
await db.update(users)
.set({ plan: activePlans.includes(stripeSubscription.status) ? plan : "free" })
.where(eq(users.id, user.id))
}
export async function POST(request: NextRequest) {
const body = await request.text()
const sig = request.headers.get("stripe-signature")
if (!sig) return NextResponse.json({ error: "No signature" }, { status: 400 })
let event: Stripe.Event
try {
event = await stripe.webhooks.constructEventAsync(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
}
// Idempotency — skip if already processed
const processed = await db.query.stripeEvents.findFirst({
where: eq(stripeEvents.eventId, event.id),
})
if (processed) return NextResponse.json({ ok: true })
try {
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated":
await upsertSubscription(event.data.object as Stripe.Subscription)
break
case "customer.subscription.deleted":
await upsertSubscription(event.data.object as Stripe.Subscription)
break
case "invoice.payment_succeeded": {
const invoice = event.data.object as Stripe.Invoice
if (invoice.billing_reason === "subscription_create") {
// Welcome email, grant access, etc.
console.log("[Stripe] New subscriber:", invoice.customer)
}
break
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice
// Dunning — send email, downgrade after N failures
console.warn("[Stripe] Payment failed:", invoice.customer)
break
}
}
// Mark as processed
await db.insert(stripeEvents).values({ eventId: event.id }).onConflictDoNothing()
return NextResponse.json({ ok: true })
} catch (err) {
console.error("[Stripe webhook error]", err)
return NextResponse.json({ error: "Handler error" }, { status: 500 })
}
}
Billing Status Hook
// hooks/useBillingStatus.ts — subscription state for UI
"use client"
import useSWR from "swr"
type BillingStatus = {
plan: "free" | "pro" | "enterprise"
status: "active" | "trialing" | "past_due" | "canceled" | "none"
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
trialEnd: string | null
}
export function useBillingStatus() {
const { data, isLoading } = useSWR<BillingStatus>("/api/billing/status")
return {
plan: data?.plan ?? "free",
status: data?.status ?? "none",
isPro: data?.plan === "pro" || data?.plan === "enterprise",
isTrialing: data?.status === "trialing",
isPastDue: data?.status === "past_due",
willCancel: data?.cancelAtPeriodEnd ?? false,
periodEnd: data?.currentPeriodEnd ? new Date(data.currentPeriodEnd) : null,
trialEnd: data?.trialEnd ? new Date(data.trialEnd) : null,
isLoading,
}
}
For the Paddle alternative when a Merchant of Record model that handles VAT/GST compliance, European taxes, chargebacks, and global payment methods without building tax logic is preferred — Paddle trades some flexibility for handling taxes and compliance automatically, similar to Lemon Squeezy, see the Paddle guide. For the Stripe Checkout with one-time payments alternative when only a single purchase rather than recurring billing is needed — mode: "payment" in Checkout handles one-time charges without subscription management overhead, see the Stripe Checkout guide. The Claude Skills 360 bundle includes Stripe subscription skill sets covering trials, portals, and webhooks. Start with the free tier to try SaaS billing generation.