Stripe Integration Expert
Stripe Integration Expert
Tier: POWERFUL
Category: Engineering Team
Domain: Payments / Billing Infrastructure
Overview
Implement production-grade Stripe integrations: subscriptions with trials and proration, one-time payments, usage-based billing, checkout sessions, idempotent webhook handlers, customer portal, and invoicing. Covers Next.js, Express, and Django patterns.
Core Capabilities
Subscription lifecycle management (create, upgrade, downgrade, cancel, pause)
Trial handling and conversion tracking
Proration calculation and credit application
Usage-based billing with metered pricing
Idempotent webhook handlers with signature verification
Customer portal integration
Invoice generation and PDF access
Full Stripe CLI local testing setup
When to Use
Adding subscription billing to any web app
Implementing plan upgrades/downgrades with proration
Building usage-based or seat-based billing
Debugging webhook delivery failures
Migrating from one billing model to another
Subscription Lifecycle State Machine
FREE_TRIAL ──paid──► ACTIVE ──cancel──► CANCEL_PENDING ──period_end──► CANCELED
│ │ │
│ downgrade reactivate
│ ▼ │
│ DOWNGRADING ──period_end──► ACTIVE (lower plan) │
│ │
└──trial_end without payment──► PAST_DUE ──payment_failed 3x──► CANCELED
│
payment_success
│
▼
ACTIVE
DB subscription status values:
trialing | active | past_due | canceled | cancel_pending | paused | unpaid
Stripe Client Setup
// lib/stripe.ts
import Stripe from "stripe"
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-04-10",
typescript: true,
appInfo: {
name: "myapp",
version: "1.0.0",
},
})
// Price IDs by plan (set in env)
export const PLANS = {
starter: {
monthly: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID!,
yearly: process.env.STRIPE_STARTER_YEARLY_PRICE_ID!,
features: ["5 projects", "10k events"],
},
pro: {
monthly: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
yearly: process.env.STRIPE_PRO_YEARLY_PRICE_ID!,
features: ["Unlimited projects", "1M events"],
},
} as const
Checkout Session (Next.js App Router)
// app/api/billing/checkout/route.ts
import { NextResponse } from "next/server"
import { stripe } from "@/lib/stripe"
import { getAuthUser } from "@/lib/auth"
import { db } from "@/lib/db"
export async function POST(req: Request) {
const user = await getAuthUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { priceId, interval = "monthly" } = await req.json()
// Get or create Stripe customer
let stripeCustomerId = user.stripeCustomerId
if (!stripeCustomerId) {
const customer = await stripe.customers.create({
email: user.email,
name: "username-undefined"
metadata: { userId: user.id },
})
stripeCustomerId = customer.id
await db.user.update({ where: { id: user.id }, data: { stripeCustomerId } })
}
const session = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
allow_promotion_codes: true,
subscription_data: {
trial_period_days: user.hasHadTrial ? undefined : 14,
metadata: { userId: user.id },
},
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: { userId: user.id },
})
return NextResponse.json({ url: session.url })
}
Subscription Upgrade/Downgrade
// lib/billing.ts
export async function changeSubscriptionPlan(
subscriptionId: string,
newPriceId: string,
immediate = false
) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
const currentItem = subscription.items.data[0]
if (immediate) {
// Upgrade: apply immediately with proration
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: "always_invoice",
billing_cycle_anchor: "unchanged",
})
} else {
// Downgrade: apply at period end, no proration
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: "none",
billing_cycle_anchor: "unchanged",
})
}
}
// Preview proration before confirming upgrade
export async function previewProration(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
const prorationDate = Math.floor(Date.now() / 1000)
const invoice = await stripe.invoices.retrieveUpcoming({
customer: subscription.customer as string,
subscription: subscriptionId,
subscription_items: [{ id: subscription.items.data[0].id, price: newPriceId }],
subscription_proration_date: prorationDate,
})
return {
amountDue: invoice.amount_due,
prorationDate,
lineItems: invoice.lines.data,
}
}
Complete Webhook Handler (Idempotent)
// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server"
import { headers } from "next/headers"
import { stripe } from "@/lib/stripe"
import { db } from "@/lib/db"
import Stripe from "stripe"
// Processed events table to ensure idempotency
async function hasProcessedEvent(eventId: string): Promise<boolean> {
const existing = await db.stripeEvent.findUnique({ where: { id: eventId } })
return !!existing
}
async function markEventProcessed(eventId: string, type: string) {
await db.stripeEvent.create({ data: { id: eventId, type, processedAt: new Date() } })
}
export async function POST(req: Request) {
const body = await req.text()
const signature = headers().get("stripe-signature")!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
console.error("Webhook signature verification failed:", err)
return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
}
// Idempotency check
if (await hasProcessedEvent(event.id)) {
return NextResponse.json({ received: true, skipped: true })
}
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
break
case "customer.subscription.created":
case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription)
break
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription)
break
case "invoice.payment_succeeded":
await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice)
break
case "invoice.payment_failed":
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice)
break
default:
console.log(`Unhandled event type: ${event.type}`)
}
await markEventProcessed(event.id, event.type)
return NextResponse.json({ received: true })
} catch (err) {
console.error(`Error processing webhook ${event.type}:`, err)
// Return 500 so Stripe retries — don't mark as processed
return NextResponse.json({ error: "Processing failed" }, { status: 500 })
}
}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
if (session.mode !== "subscription") return
const userId = session.metadata?.userId
if (!userId) throw new Error("No userId in checkout session metadata")
const subscription = await stripe.subscriptions.retrieve(session.subscription as string)
await db.user.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
subscriptionStatus: subscription.status,
hasHadTrial: true,
},
})
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const user = await db.user.findUnique({
where: { stripeSubscriptionId: subscription.id },
})
if (!user) {
// Look up by customer ID as fallback
const customer = await db.user.findUnique({
where: { stripeCustomerId: subscription.customer as string },
})
if (!customer) throw new Error(`No user found for subscription ${subscription.id}`)
}
await db.user.update({
where: { stripeSubscriptionId: subscription.id },
data: {
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
subscriptionStatus: subscription.status,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
})
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await db.user.update({
where: { stripeSubscriptionId: subscription.id },
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
subscriptionStatus: "canceled",
},
})
}
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
if (!invoice.subscription) return
const attemptCount = invoice.attempt_count
await db.user.update({
where: { stripeSubscriptionId: invoice.subscription as string },
data: { subscriptionStatus: "past_due" },
})
if (attemptCount >= 3) {
// Send final dunning email
await sendDunningEmail(invoice.customer_email!, "final")
} else {
await sendDunningEmail(invoice.customer_email!, "retry")
}
}
async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) {
if (!invoice.subscription) return
await db.user.update({
where: { stripeSubscriptionId: invoice.subscription as string },
data: {
subscriptionStatus: "active",
stripeCurrentPeriodEnd: new Date(invoice.period_end * 1000),
},
})
}
Usage-Based Billing
// Report usage for metered subscriptions
export async function reportUsage(subscriptionItemId: string, quantity: number) {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: "increment",
})
}
// Example: report API calls in middleware
export async function trackApiCall(userId: string) {
const user = await db.user.findUnique({ where: { id: userId } })
if (user?.stripeSubscriptionId) {
const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId)
const meteredItem = subscription.items.data.find(
(item) => item.price.recurring?.usage_type === "metered"
)
if (meteredItem) {
await reportUsage(meteredItem.id, 1)
}
}
}
Customer Portal
// app/api/billing/portal/route.ts
import { NextResponse } from "next/server"
import { stripe } from "@/lib/stripe"
import { getAuthUser } from "@/lib/auth"
export async function POST() {
const user = await getAuthUser()
if (!user?.stripeCustomerId) {
return NextResponse.json({ error: "No billing account" }, { status: 400 })
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
})
return NextResponse.json({ url: portalSession.url })
}
Testing with Stripe CLI
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to local dev
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger specific events for testing
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
# Test with specific customer
stripe trigger customer.subscription.updated \
--override subscription:customer=cus_xxx
# View recent events
stripe events list --limit 10
# Test cards
# Success: 4242 4242 4242 4242
# Requires auth: 4000 0025 0000 3155
# Decline: 4000 0000 0000 9995
# Insufficient funds: 4000 0000 0000 9995
Feature Gating Helper
// lib/subscription.ts
export function isSubscriptionActive(user: { subscriptionStatus: string | null, stripeCurrentPeriodEnd: Date | null }) {
if (!user.subscriptionStatus) return false
if (user.subscriptionStatus === "active" || user.subscriptionStatus === "trialing") return true
// Grace period: past_due but not yet expired
if (user.subscriptionStatus === "past_due" && user.stripeCurrentPeriodEnd) {
return user.stripeCurrentPeriodEnd > new Date()
}
return false
}
// Middleware usage
export async function requireActiveSubscription() {
const user = await getAuthUser()
if (!isSubscriptionActive(user)) {
redirect("/billing?reason=subscription_required")
}
}
Common Pitfalls
Webhook delivery order not guaranteed — always re-fetch from Stripe API, never trust event data alone for DB updates
Double-processing webhooks — Stripe retries on 500; always use idempotency table
Trial conversion tracking — store hasHadTrial: true in DB to prevent trial abuse
Proration surprises — always preview proration before upgrade; show user the amount before confirming
Customer portal not configured — must enable features in Stripe dashboard under Billing → Customer portal settings
Missing metadata on checkout — always pass userId in metadata; can't link subscription to user without itdon't have the plugin yet? install it then click "run inline in claude" again.
added explicit decision points for upgrade/downgrade, webhook idempotency, grace period gating, and error handling; documented all env vars, database schema, and edge cases like rate limits and auth expiry; structured 12-step procedure with clear inputs/outputs; added edge case guidance for webhook delivery order, trial abuse prevention, and proration surprises from original pitfalls section.
build production-grade Stripe integrations covering subscription lifecycle (create, upgrade, downgrade, cancel), trial handling, proration, usage-based billing, idempotent webhook handlers with signature verification, customer portal, and invoicing. use this when adding billing to any web app, implementing plan changes with proration, building usage-based or seat-based pricing, debugging webhook failures, or migrating billing models. works with Next.js, Express, and Django patterns.
environment variables:
STRIPE_SECRET_KEY (required): Stripe secret API key (sk_live_* or sk_test_*)STRIPE_WEBHOOK_SECRET (required): webhook signing secret (whsec_*)STRIPE_STARTER_MONTHLY_PRICE_ID, STRIPE_STARTER_YEARLY_PRICE_ID, STRIPE_PRO_MONTHLY_PRICE_ID, STRIPE_PRO_YEARLY_PRICE_ID: Stripe price IDs for each plan variantNEXT_PUBLIC_APP_URL (required for frontend redirects): base URL for checkout success/cancel and portal return URLsexternal connections:
database requirements:
auth context:
create lib/stripe.ts with Stripe SDK initialization:
import Stripe from "stripe"
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-04-10",
typescript: true,
appInfo: { name: "myapp", version: "1.0.0" }
})
input: STRIPE_SECRET_KEY env var, Stripe SDK package (npm install stripe) output: stripe client instance ready for API calls
define PLANS object in lib/stripe.ts mapping plan names to price IDs:
export const PLANS = {
starter: {
monthly: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID!,
yearly: process.env.STRIPE_STARTER_YEARLY_PRICE_ID!,
features: ["5 projects", "10k events"]
},
pro: {
monthly: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
yearly: process.env.STRIPE_PRO_YEARLY_PRICE_ID!,
features: ["Unlimited projects", "1M events"]
}
} as const
input: price IDs from Stripe dashboard, feature descriptions output: PLANS constant for use in checkout and upgrade flows
build app/api/billing/checkout/route.ts (Next.js App Router) or /billing/checkout (Express/Django) that:
input: POST body with priceId and optional interval, authenticated user context output: JSON response with session.url (string) or error
implement changeSubscriptionPlan(subscriptionId, newPriceId, immediate) in lib/billing.ts:
input: subscriptionId, newPriceId, boolean immediate flag output: updated subscription object from Stripe
also implement previewProration(subscriptionId, newPriceId):
input: subscriptionId, newPriceId output: object with amountDue (cents), prorationDate (unix timestamp), lineItems (array)
build app/api/webhooks/stripe/route.ts that:
input: POST request with Stripe event body and stripe-signature header output: 200 JSON {received: true} or {received: true, skipped: true} on success, 400 on signature error, 500 on handler error
in webhook handler, handleCheckoutCompleted() receives Stripe Checkout.Session:
input: Stripe Checkout.Session object output: user database record updated
in webhook handler, set up handlers for:
input: Stripe Subscription objects output: user database record updated with new subscription state
in webhook handler, set up handlers for:
input: Stripe Invoice objects output: user database record updated, dunning emails sent
create reportUsage(subscriptionItemId, quantity) function that:
optionally add trackApiCall(userId) middleware that:
input: subscriptionItemId or userId output: usage record created in Stripe
build app/api/billing/portal/route.ts (Next.js) or /billing/portal (Express/Django) that:
input: authenticated user context output: JSON response with session.url (string) for redirect
run commands in development:
input: Stripe CLI, local server running on port 3000 (or custom port) output: webhook events forwarded to local endpoint, events visible in Stripe dashboard
create isSubscriptionActive(user) helper in lib/subscription.ts:
input: user object with subscriptionStatus and stripeCurrentPeriodEnd fields output: boolean indicating whether to grant access
if user already has a Stripe customer ID, skip customer creation: check user.stripeCustomerId before calling stripe.customers.create(). if present, reuse it. else create new customer and store ID in DB.
if upgrading subscription (immediate = true), use proration_behavior: "always_invoice": charge user immediately for the difference. if downgrading (immediate = false), use proration_behavior: "none" to apply change at period end with no credit.
if webhook event already in stripeEvent table, skip processing: return 200 with skipped: true. prevents double-charging and duplicate state updates from Stripe retries.
if webhook handler throws any error, return 500 without marking event processed: Stripe will retry delivery. only mark processed after successful handler completion.
if invoice.attempt_count >= 3, send final dunning email: else send retry email to give customer more time to update payment method.
if user.subscriptionStatus is "past_due" but stripeCurrentPeriodEnd has not passed, grant access (grace period): allow continued service during payment retry window. revoke access only when period actually ends.
if priceId not found in PLANS or env vars missing, fail checkout early: return 400 error before creating session. prevents silent failures and orphaned checkout sessions.
if Stripe API returns rate limit (429), retry with exponential backoff: Stripe SDK does this automatically, but acknowledge in error handling.
checkout session creation: returns JSON object:
{ "url": "https://checkout.stripe.com/pay/..." }
or error:
{ "error": "Unauthorized" } (401)
{ "error": "Invalid priceId" } (400)
webhook handler success: returns JSON:
{ "received": true }
{ "received": true, "skipped": true }
webhook handler error: returns JSON with 400 or 500 status:
{ "error": "Invalid signature" } (400)
{ "error": "Processing failed" } (500)
portal session creation: returns JSON:
{ "url": "https://billing.stripe.com/session/..." }
or error:
{ "error": "No billing account" } (400)
proration preview: returns JSON:
{
"amountDue": 1500,
"prorationDate": 1704067200,
"lineItems": [
{ "type": "proration", "amount": 1500, "description": "Prorated upgrade" },
{ "type": "credit", "amount": -500, "description": "Unused credit" }
]
}
user database state after successful checkout: user record contains:
webhook event idempotency table: stripeEvent record contains:
stripe events list shows checkout.session.completed, customer.subscription.created, etc.stripe events list --type customer.subscription.updated --limit 1 and verify only one processedAt timestamp in stripeEvent table for that event IDrequireActiveSubscription() middleware redirects inactive users to /billing?reason=subscription_required and allows active users through