stripe-integration-expert
Stripe Integration Expert
Tier: POWERFUL Category: Engineering Tags: Stripe, payments, subscriptions, billing, webhooks, SCA, usage-based billing
Overview
Build production-grade Stripe integrations for SaaS products: subscriptions with trials and proration, one-time payments, usage-based billing, Checkout sessions, idempotent webhook handlers, Customer Portal, invoicing, and dunning. Covers Next.js App Router, Express, and Django patterns with emphasis on real-world edge cases that documentation does not warn you about.
Subscription Lifecycle State Machine
Understand this before writing any code. Every billing edge case maps to a state transition.
┌────────────────────────────────────────┐
│ │
┌──────────┐ paid ┌────────┐ cancel ┌──────────────┐ period_end ┌──────────┐
│ TRIALING │──────────▶│ ACTIVE │────────────▶│ CANCEL_PENDING│──────────────▶│ CANCELED │
└──────────┘ └────────┘ └──────────────┘ └──────────┘
│ │ ▲
│ │ upgrade │
│ ▼ reactivate
│ ┌──────────┐ period_end ┌────────┐ │
│ │UPGRADING │─────────────▶│ ACTIVE │ │
│ └──────────┘ (new plan) └────────┘ │
│ │
│ trial_end ┌──────────┐ 3x fail ┌──────────┐ │
└─(no payment)───▶│ PAST_DUE │───────────▶│ CANCELED │──────────────────────┘
└──────────┘ └──────────┘
│
payment_success
│
▼
┌────────┐
│ ACTIVE │
└────────┘
DB status values: trialing | active | past_due | canceled | cancel_pending | paused | unpaid
Stripe Client Setup
// lib/stripe.ts
import Stripe from "stripe";
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is required");
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2024-12-18.acacia", // Pin to specific version
typescript: true,
appInfo: {
name: "your-app-name",
version: "1.0.0",
url: "https://yourapp.com",
},
});
// Centralized plan configuration
export const PLANS = {
starter: {
monthly: process.env.STRIPE_STARTER_MONTHLY_PRICE!,
yearly: process.env.STRIPE_STARTER_YEARLY_PRICE!,
limits: { projects: 5, events: 10_000 },
},
pro: {
monthly: process.env.STRIPE_PRO_MONTHLY_PRICE!,
yearly: process.env.STRIPE_PRO_YEARLY_PRICE!,
limits: { projects: -1, events: 1_000_000 }, // -1 = unlimited
},
enterprise: {
monthly: process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE!,
yearly: process.env.STRIPE_ENTERPRISE_YEARLY_PRICE!,
limits: { projects: -1, events: -1 },
},
} as const;
export type PlanName = keyof typeof PLANS;
export type BillingInterval = "monthly" | "yearly";
Checkout Session
// app/api/billing/checkout/route.ts
import { NextResponse } from "next/server";
import { stripe, PLANS, type PlanName, type BillingInterval } 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 { plan, interval = "monthly" } = (await req.json()) as {
plan: PlanName;
interval: BillingInterval;
};
if (!PLANS[plan]) {
return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
}
const priceId = PLANS[plan][interval];
// Get or create Stripe customer (idempotent)
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name || undefined,
metadata: { userId: user.id, source: "checkout" },
});
customerId = customer.id;
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
allow_promotion_codes: true,
tax_id_collection: { enabled: true },
subscription_data: {
trial_period_days: user.hasHadTrial ? undefined : 14,
metadata: { userId: user.id, plan },
},
success_url: `${process.env.APP_URL}/dashboard?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
metadata: { userId: user.id },
});
return NextResponse.json({ url: session.url });
}
Subscription Management
Upgrade (Immediate, Prorated)
export async function upgradeSubscription(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currentItem = subscription.items.data[0];
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: "always_invoice", // Charge difference immediately
billing_cycle_anchor: "unchanged", // Keep same billing date
});
}
Downgrade (End of Period, No Proration)
export async function downgradeSubscription(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currentItem = subscription.items.data[0];
// Schedule change for end of current period
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: "none", // No refund
billing_cycle_anchor: "unchanged",
});
}
Preview Proration (Show Before Confirming)
export async function previewProration(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const invoice = await stripe.invoices.createPreview({
customer: subscription.customer as string,
subscription: subscriptionId,
subscription_details: {
items: [{ id: subscription.items.data[0].id, price: newPriceId }],
proration_date: Math.floor(Date.now() / 1000),
},
});
return {
amountDue: invoice.amount_due, // In cents
credit: invoice.total < 0 ? Math.abs(invoice.total) : 0,
lineItems: invoice.lines.data.map(line => ({
description: line.description,
amount: line.amount,
})),
};
}
Cancel (At Period End)
export async function cancelSubscription(subscriptionId: string) {
// Cancel at period end -- user keeps access until their paid period expires
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
export async function reactivateSubscription(subscriptionId: string) {
// Undo pending cancellation
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
}
Webhook Handler (Idempotent)
This is the most critical code in your billing system. Get this right.
// 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 type Stripe from "stripe";
// Idempotency: track processed events to handle Stripe retries
async function isProcessed(eventId: string): Promise<boolean> {
return !!(await db.stripeEvent.findUnique({ where: { id: eventId } }));
}
async function markProcessed(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");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
// Step 1: Verify webhook 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 });
}
// Step 2: Idempotency check
if (await isProcessed(event.id)) {
return NextResponse.json({ received: true, deduplicated: true });
}
// Step 3: Handle events
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 handleSubscriptionChange(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 handlePaymentSucceeded(event.data.object as Stripe.Invoice);
break;
case "invoice.payment_failed":
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
case "customer.subscription.trial_will_end":
await handleTrialEnding(event.data.object as Stripe.Subscription);
break;
default:
// Log unhandled events for monitoring
console.log(`Unhandled webhook: ${event.type}`);
}
await markProcessed(event.id, event.type);
return NextResponse.json({ received: true });
} catch (err) {
console.error(`Webhook processing failed [${event.type}]:`, err);
// Return 500 so Stripe retries. Do NOT mark as processed.
return NextResponse.json({ error: "Processing failed" }, { status: 500 });
}
}
// --- Handler implementations ---
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
if (session.mode !== "subscription") return;
const userId = session.metadata?.userId;
if (!userId) throw new Error("Missing userId in checkout metadata");
// Always re-fetch from Stripe API -- event data may be stale
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 handleSubscriptionChange(subscription: Stripe.Subscription) {
// Find user by subscription ID first, fall back to customer ID
const user = await db.user.findFirst({
where: {
OR: [
{ stripeSubscriptionId: subscription.id },
{ stripeCustomerId: subscription.customer as string },
],
},
});
if (!user) {
console.warn(`No user for subscription ${subscription.id}`);
return; // Don't throw -- this may be a subscription we don't manage
}
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
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.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: {
subscriptionStatus: "canceled",
stripePriceId: null,
stripeCurrentPeriodEnd: null,
cancelAtPeriodEnd: false,
},
});
}
async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
if (!invoice.subscription) return;
await db.user.updateMany({
where: { stripeSubscriptionId: invoice.subscription as string },
data: {
subscriptionStatus: "active",
stripeCurrentPeriodEnd: new Date(invoice.period_end * 1000),
},
});
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
if (!invoice.subscription) return;
await db.user.updateMany({
where: { stripeSubscriptionId: invoice.subscription as string },
data: { subscriptionStatus: "past_due" },
});
// Dunning: send appropriate email based on attempt count
const attemptCount = invoice.attempt_count || 1;
if (attemptCount === 1) {
// First failure: gentle reminder
await sendDunningEmail(invoice.customer_email!, "first_failure");
} else if (attemptCount === 2) {
// Second failure: more urgent
await sendDunningEmail(invoice.customer_email!, "second_failure");
} else if (attemptCount >= 3) {
// Final failure: last chance before cancellation
await sendDunningEmail(invoice.customer_email!, "final_notice");
}
}
async function handleTrialEnding(subscription: Stripe.Subscription) {
// Stripe sends this 3 days before trial ends
const user = await db.user.findFirst({
where: { stripeSubscriptionId: subscription.id },
});
if (user?.email) {
await sendTrialEndingEmail(user.email, subscription.trial_end!);
}
}
Usage-Based Billing
// Report metered usage
export async function reportUsage(
subscriptionItemId: string,
quantity: number,
idempotencyKey?: string,
) {
return stripe.subscriptionItems.createUsageRecord(
subscriptionItemId,
{
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: "increment", // or "set" for absolute values
},
{
idempotencyKey, // Prevent double-counting on retries
}
);
}
// Middleware: track API usage per request
export async function trackApiUsage(userId: string) {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user?.stripeSubscriptionId) return;
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, `${userId}-${Date.now()}`);
}
}
Customer Portal
// app/api/billing/portal/route.ts
export async function POST() {
const user = await getAuthUser();
if (!user?.stripeCustomerId) {
return NextResponse.json({ error: "No billing account" }, { status: 400 });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.APP_URL}/settings/billing`,
});
return NextResponse.json({ url: session.url });
}
Portal configuration (must be done in Stripe Dashboard > Billing > Customer Portal):
- Enable: Update subscription, cancel subscription, update payment method
- Set cancellation flow: show pause option, require reason
- Configure plan change options: which plans can switch to which
Feature Gating
// lib/subscription.ts
import { PLANS, type PlanName } from "./stripe";
export function isSubscriptionActive(user: {
subscriptionStatus: string | null;
stripeCurrentPeriodEnd: Date | null;
}): boolean {
if (!user.subscriptionStatus) return false;
// Active or trialing = full access
if (["active", "trialing"].includes(user.subscriptionStatus)) return true;
// Past due: grace period until period end
if (user.subscriptionStatus === "past_due" && user.stripeCurrentPeriodEnd) {
return user.stripeCurrentPeriodEnd > new Date();
}
// Cancel pending: access until period end
if (user.subscriptionStatus === "cancel_pending" && user.stripeCurrentPeriodEnd) {
return user.stripeCurrentPeriodEnd > new Date();
}
return false;
}
export function getUserPlan(stripePriceId: string | null): PlanName | "free" {
if (!stripePriceId) return "free";
for (const [plan, config] of Object.entries(PLANS)) {
if (config.monthly === stripePriceId || config.yearly === stripePriceId) {
return plan as PlanName;
}
}
return "free";
}
export function canAccess(user: { stripePriceId: string | null }, feature: string): boolean {
const plan = getUserPlan(user.stripePriceId);
const limits = plan === "free" ? { projects: 1, events: 1000 } : PLANS[plan].limits;
// Feature-specific checks
switch (feature) {
case "unlimited_projects": return limits.projects === -1;
case "api_access": return plan !== "free" && plan !== "starter";
default: return plan !== "free";
}
}
SCA (Strong Customer Authentication) Compliance
Required for European customers under PSD2.
// Checkout Sessions handle SCA automatically (3D Secure)
// For existing subscriptions, handle authentication_required:
async function handlePaymentRequiresAction(invoice: Stripe.Invoice) {
if (invoice.payment_intent) {
const pi = await stripe.paymentIntents.retrieve(invoice.payment_intent as string);
if (pi.status === "requires_action") {
// Send email with link to complete authentication
await sendAuthenticationEmail(
invoice.customer_email!,
pi.next_action?.redirect_to_url?.url || `${process.env.APP_URL}/billing/authenticate`
);
}
}
}
Testing with Stripe CLI
# Install and authenticate
brew install stripe/stripe-cli/stripe
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger specific events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.trial_will_end
# Test card numbers
# Success: 4242 4242 4242 4242
# Requires 3D Secure: 4000 0025 0000 3155
# Declined: 4000 0000 0000 0002
# Insufficient funds: 4000 0000 0000 9995
# Expired card: 4000 0000 0000 0069
# View recent events
stripe events list --limit 10
# Inspect a specific event
stripe events retrieve evt_xxx
Database Schema (Prisma)
model User {
id String @id @default(cuid())
email String @unique
name String?
// Stripe fields
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
stripeCurrentPeriodEnd DateTime?
subscriptionStatus String? // trialing, active, past_due, canceled, cancel_pending
cancelAtPeriodEnd Boolean @default(false)
hasHadTrial Boolean @default(false)
}
model StripeEvent {
id String @id // Stripe event ID (evt_xxx)
type String // Event type
processedAt DateTime @default(now())
@@index([type])
}
Common Pitfalls
| Pitfall | Consequence | Prevention |
|---|---|---|
| Trusting webhook event data | Stale data, race conditions | Always re-fetch from Stripe API in handlers |
| No idempotency on webhooks | Double-charges, duplicate records | Track processed event IDs in database |
| Missing metadata on checkout | Cannot link subscription to user | Always pass userId in metadata |
| Proration surprises | Users charged unexpected amounts | Always preview proration before upgrade |
Not handling past_due |
Users lose access without warning | Implement dunning emails on payment failure |
| Skipping trial abuse prevention | Users create multiple accounts for free trials | Store hasHadTrial: true, check on checkout |
| Customer Portal not configured | Portal returns blank page | Enable features in Stripe Dashboard first |
| Webhook endpoint not idempotent | Stripe retries cause duplicate processing | Idempotency table with event ID dedup |
| Not pinning API version | Breaking changes on Stripe updates | Pin apiVersion in client constructor |
Ignoring trial_will_end event |
Users surprised when trial ends | Send reminder email 3 days before |
Related Skills
| Skill | Use When |
|---|---|
| ab-test-setup | Testing pricing page variants and checkout flows |
| analytics-tracking | Tracking checkout and subscription conversion events |
| email-template-builder | Building dunning and billing notification emails |
| api-design-reviewer | Reviewing your billing API endpoints |