---name: "stripe-integration-expert"description: "Use for any Stripe integration: subscriptions, trials, proration, checkout, usage billing, webhooks, customer portal or invoicing in Next.js, Express or Django. Writes idempotent webhook handlers and debugs delivery failures."--- # 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 ```typescript// lib/stripe.tsimport 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) ```typescript// app/api/billing/checkout/route.tsimport { 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 ```typescript// lib/billing.tsexport 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 upgradeexport 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) ```typescript// app/api/webhooks/stripe/route.tsimport { 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 idempotencyasync 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 ```typescript// Report usage for metered subscriptionsexport 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 middlewareexport 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 ```typescript// app/api/billing/portal/route.tsimport { 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 ```bash# Install Stripe CLIbrew install stripe/stripe-cli/stripe # Loginstripe login # Forward webhooks to local devstripe listen --forward-to localhost:3000/api/webhooks/stripe # Trigger specific events for testingstripe trigger checkout.session.completedstripe trigger customer.subscription.updatedstripe trigger invoice.payment_failed # Test with specific customerstripe trigger customer.subscription.updated \ --override subscription:customer=cus_xxx # View recent eventsstripe 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 ```typescript// lib/subscription.tsexport 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 usageexport 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 it