| name | stripe-integration-expert |
| description | 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
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",
},
})
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)
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()
let stripeCustomerId = user.stripeCustomerId
if (!stripeCustomerId) {
const customer = await stripe.customers.create({
email: user.email,
name: "username-undefined"
metadata: { userId: user.id },
})
stripeCustomerId = customer.
db..({ : { : user. }, : { stripeCustomerId } })
}
session = stripe...({
: stripeCustomerId,
: ,
: [],
: [{ : priceId, : }],
: ,
: {
: user. ? : ,
: { : user. },
},
: ,
: ,
: { : user. },
})
.({ : session. })
}
Subscription Upgrade/Downgrade
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) {
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: "always_invoice",
billing_cycle_anchor: "unchanged",
})
} else {
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: "none",
billing_cycle_anchor: "unchanged",
})
}
}
export async () {
subscription = stripe..(subscriptionId)
prorationDate = .(.() / )
invoice = stripe..({
: subscription. ,
: subscriptionId,
: [{ : subscription..[]., : newPriceId }],
: prorationDate,
})
{
: invoice.,
prorationDate,
: invoice..,
}
}
Complete Webhook Handler (Idempotent)
import { NextResponse } from "next/server"
import { headers } from "next/headers"
import { stripe } from "@/lib/stripe"
import { db } from "@/lib/db"
import Stripe from "stripe"
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 () {
body = req.()
signature = ().()!
: .
{
event = stripe..(body, signature, process..!)
} (err) {
.(, err)
.({ : }, { : })
}
( (event.)) {
.({ : , : })
}
{
(event.) {
:
(event.. ..)
:
:
(event.. .)
:
(event.. .)
:
(event.. .)
:
(event.. .)
:
.()
}
(event., event.)
.({ : })
} (err) {
.(, err)
.({ : }, { : })
}
}
() {
(session. !== )
userId = session.?.
(!userId) ()
subscription = stripe..(session. )
db..({
: { : userId },
: {
: session. ,
: subscription.,
: subscription..[]..,
: (subscription. * ),
: subscription.,
: ,
},
})
}
() {
user = db..({
: { : subscription. },
})
(!user) {
customer = db..({
: { : subscription. },
})
(!customer) ()
}
db..({
: { : subscription. },
: {
: subscription..[]..,
: (subscription. * ),
: subscription.,
: subscription.,
},
})
}
() {
db..({
: { : subscription. },
: {
: ,
: ,
: ,
: ,
},
})
}
() {
(!invoice.)
attemptCount = invoice.
db..({
: { : invoice. },
: { : },
})
(attemptCount >= ) {
(invoice.!, )
} {
(invoice.!, )
}
}
() {
(!invoice.)
db..({
: { : invoice. },
: {
: ,
: (invoice. * ),
},
})
}
Usage-Based Billing
export async function reportUsage(subscriptionItemId: string, quantity: number) {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: "increment",
})
}
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 ===
)
(meteredItem) {
(meteredItem., )
}
}
}
Customer Portal
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
brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.updated \
--override subscription:customer=cus_xxx
stripe events list --limit 10
Feature Gating Helper
export function isSubscriptionActive(user: { subscriptionStatus: string | null, stripeCurrentPeriodEnd: Date | null }) {
if (!user.subscriptionStatus) return false
if (user.subscriptionStatus === "active" || user.subscriptionStatus === "trialing") return true
if (user.subscriptionStatus === "past_due" && user.stripeCurrentPeriodEnd) {
return user.stripeCurrentPeriodEnd > new Date()
}
return false
}
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 it