Stripe integration expert

Production-grade Stripe integrations: subscriptions with trials and proration, one-time payments, usage-based billing, checkout sessions, idempotent webhook handlers, customer portal, and invoicing.

How to use it

Claude Code
  1. Run the line below. It pulls the whole folder into ~/.claude/skills/stripe-integration-expert, including the files SKILL.md points to.
  2. Describe your job in plain words. Claude Code follows the skill from there.
Claude Code — installs the whole folder, not just SKILL.md
npx degit alirezarezvani/claude-skills/engineering-team/skills/stripe-integration-expert#main ~/.claude/skills/stripe-integration-expert

For one project only, change the path to .claude/skills/stripe-integration-expert. This skill also uses Next.js, NextResponse.json, req.json — copying SKILL.md alone won't be enough. See the folder on GitHub.

Claude (web or desktop app)
  1. On this page open ⋯ → Download .md.
  2. Save it as SKILL.md in a folder, zip the folder, then Customize → Skills → + → Create skill → Upload a skill.
  3. Pick the file and Save. Claude shows the name and description and runs a security scan.
  4. Check the skill is switched on.
  5. Start a new chat and describe your job in plain words. The AI follows the skill from there.
ChatGPT or another app
  1. ChatGPT: make a Project and paste it into Instructions.
  2. Neither? Paste it at the top of a new chat — it works for that chat.
Not working?
  • Check which app you pasted it into — the steps above name the right one.
  • Some skills need the paid tier of Claude or ChatGPT.
Step-by-step guide with screenshots · Ask in the forum

Paste into Claude, ChatGPT or Cursor.

Source of Stripe integration expert

Show the full text477 lines
namedescription
stripe-integration-expertProduction-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. Use when integrating Stripe for the first time, debugging webhook reliability issues, migrating from a different payment provider, or adding usage-based billing to an existing subscription product.

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 it
1---
2name: "stripe-integration-expert"
3description: "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. Use when integrating Stripe for the first time, debugging webhook reliability issues, migrating from a different payment provider, or adding usage-based billing to an existing subscription product."
4---
5 
6# Stripe Integration Expert
7 
8**Tier:** POWERFUL
9**Category:** Engineering Team
10**Domain:** Payments / Billing Infrastructure
11 
12---
13 
14## Overview
15 
16Implement 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.
17 
18---
19 
20## Core Capabilities
21 
22- Subscription lifecycle management (create, upgrade, downgrade, cancel, pause)
23- Trial handling and conversion tracking
24- Proration calculation and credit application
25- Usage-based billing with metered pricing
26- Idempotent webhook handlers with signature verification
27- Customer portal integration
28- Invoice generation and PDF access
29- Full Stripe CLI local testing setup
30 
31---
32 
33## When to Use
34 
35- Adding subscription billing to any web app
36- Implementing plan upgrades/downgrades with proration
37- Building usage-based or seat-based billing
38- Debugging webhook delivery failures
39- Migrating from one billing model to another
40 
41---
42 
43## Subscription Lifecycle State Machine
44 
45```
46FREE_TRIAL ──paid──► ACTIVE ──cancel──► CANCEL_PENDING ──period_end──► CANCELED
47 │ │ │
48 │ downgrade reactivate
49 │ ▼ │
50 │ DOWNGRADING ──period_end──► ACTIVE (lower plan) │
51 │ │
52 └──trial_end without payment──► PAST_DUE ──payment_failed 3x──► CANCELED
53 │
54 payment_success
55 │
56 ▼
57 ACTIVE
58```
59 
60### DB subscription status values:
61`trialing | active | past_due | canceled | cancel_pending | paused | unpaid`
62 
63---
64 
65## Stripe Client Setup
66 
67```typescript
68// lib/stripe.ts
69import Stripe from "stripe"
70 
71export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
72 apiVersion: "2024-04-10",
73 typescript: true,
74 appInfo: {
75 name: "myapp",
76 version: "1.0.0",
77 },
78})
79 
80// Price IDs by plan (set in env)
81export const PLANS = {
82 starter: {
83 monthly: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID!,
84 yearly: process.env.STRIPE_STARTER_YEARLY_PRICE_ID!,
85 features: ["5 projects", "10k events"],
86 },
87 pro: {
88 monthly: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
89 yearly: process.env.STRIPE_PRO_YEARLY_PRICE_ID!,
90 features: ["Unlimited projects", "1M events"],
91 },
92} as const
93```
94 
95---
96 
97## Checkout Session (Next.js App Router)
98 
99```typescript
100// app/api/billing/checkout/route.ts
101import { NextResponse } from "next/server"
102import { stripe } from "@/lib/stripe"
103import { getAuthUser } from "@/lib/auth"
104import { db } from "@/lib/db"
105 
106export async function POST(req: Request) {
107 const user = await getAuthUser()
108 if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
109 
110 const { priceId, interval = "monthly" } = await req.json()
111 
112 // Get or create Stripe customer
113 let stripeCustomerId = user.stripeCustomerId
114 if (!stripeCustomerId) {
115 const customer = await stripe.customers.create({
116 email: user.email,
117 name: "username-undefined"
118 metadata: { userId: user.id },
119 })
120 stripeCustomerId = customer.id
121 await db.user.update({ where: { id: user.id }, data: { stripeCustomerId } })
122 }
123 
124 const session = await stripe.checkout.sessions.create({
125 customer: stripeCustomerId,
126 mode: "subscription",
127 payment_method_types: ["card"],
128 line_items: [{ price: priceId, quantity: 1 }],
129 allow_promotion_codes: true,
130 subscription_data: {
131 trial_period_days: user.hasHadTrial ? undefined : 14,
132 metadata: { userId: user.id },
133 },
134 success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
135 cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
136 metadata: { userId: user.id },
137 })
138 
139 return NextResponse.json({ url: session.url })
140}
141```
142 
143---
144 
145## Subscription Upgrade/Downgrade
146 
147```typescript
148// lib/billing.ts
149export async function changeSubscriptionPlan(
150 subscriptionId: string,
151 newPriceId: string,
152 immediate = false
153) {
154 const subscription = await stripe.subscriptions.retrieve(subscriptionId)
155 const currentItem = subscription.items.data[0]
156 
157 if (immediate) {
158 // Upgrade: apply immediately with proration
159 return stripe.subscriptions.update(subscriptionId, {
160 items: [{ id: currentItem.id, price: newPriceId }],
161 proration_behavior: "always_invoice",
162 billing_cycle_anchor: "unchanged",
163 })
164 } else {
165 // Downgrade: apply at period end, no proration
166 return stripe.subscriptions.update(subscriptionId, {
167 items: [{ id: currentItem.id, price: newPriceId }],
168 proration_behavior: "none",
169 billing_cycle_anchor: "unchanged",
170 })
171 }
172}
173 
174// Preview proration before confirming upgrade
175export async function previewProration(subscriptionId: string, newPriceId: string) {
176 const subscription = await stripe.subscriptions.retrieve(subscriptionId)
177 const prorationDate = Math.floor(Date.now() / 1000)
178 
179 const invoice = await stripe.invoices.retrieveUpcoming({
180 customer: subscription.customer as string,
181 subscription: subscriptionId,
182 subscription_items: [{ id: subscription.items.data[0].id, price: newPriceId }],
183 subscription_proration_date: prorationDate,
184 })
185 
186 return {
187 amountDue: invoice.amount_due,
188 prorationDate,
189 lineItems: invoice.lines.data,
190 }
191}
192```
193 
194---
195 
196## Complete Webhook Handler (Idempotent)
197 
198```typescript
199// app/api/webhooks/stripe/route.ts
200import { NextResponse } from "next/server"
201import { headers } from "next/headers"
202import { stripe } from "@/lib/stripe"
203import { db } from "@/lib/db"
204import Stripe from "stripe"
205 
206// Processed events table to ensure idempotency
207async function hasProcessedEvent(eventId: string): Promise<boolean> {
208 const existing = await db.stripeEvent.findUnique({ where: { id: eventId } })
209 return !!existing
210}
211 
212async function markEventProcessed(eventId: string, type: string) {
213 await db.stripeEvent.create({ data: { id: eventId, type, processedAt: new Date() } })
214}
215 
216export async function POST(req: Request) {
217 const body = await req.text()
218 const signature = headers().get("stripe-signature")!
219 
220 let event: Stripe.Event
221 try {
222 event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
223 } catch (err) {
224 console.error("Webhook signature verification failed:", err)
225 return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
226 }
227 
228 // Idempotency check
229 if (await hasProcessedEvent(event.id)) {
230 return NextResponse.json({ received: true, skipped: true })
231 }
232 
233 try {
234 switch (event.type) {
235 case "checkout.session.completed":
236 await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
237 break
238 
239 case "customer.subscription.created":
240 case "customer.subscription.updated":
241 await handleSubscriptionUpdated(event.data.object as Stripe.Subscription)
242 break
243 
244 case "customer.subscription.deleted":
245 await handleSubscriptionDeleted(event.data.object as Stripe.Subscription)
246 break
247 
248 case "invoice.payment_succeeded":
249 await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice)
250 break
251 
252 case "invoice.payment_failed":
253 await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice)
254 break
255 
256 default:
257 console.log(`Unhandled event type: ${event.type}`)
258 }
259 
260 await markEventProcessed(event.id, event.type)
261 return NextResponse.json({ received: true })
262 } catch (err) {
263 console.error(`Error processing webhook ${event.type}:`, err)
264 // Return 500 so Stripe retries — don't mark as processed
265 return NextResponse.json({ error: "Processing failed" }, { status: 500 })
266 }
267}
268 
269async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
270 if (session.mode !== "subscription") return
271 
272 const userId = session.metadata?.userId
273 if (!userId) throw new Error("No userId in checkout session metadata")
274 
275 const subscription = await stripe.subscriptions.retrieve(session.subscription as string)
276 
277 await db.user.update({
278 where: { id: userId },
279 data: {
280 stripeCustomerId: session.customer as string,
281 stripeSubscriptionId: subscription.id,
282 stripePriceId: subscription.items.data[0].price.id,
283 stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
284 subscriptionStatus: subscription.status,
285 hasHadTrial: true,
286 },
287 })
288}
289 
290async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
291 const user = await db.user.findUnique({
292 where: { stripeSubscriptionId: subscription.id },
293 })
294 if (!user) {
295 // Look up by customer ID as fallback
296 const customer = await db.user.findUnique({
297 where: { stripeCustomerId: subscription.customer as string },
298 })
299 if (!customer) throw new Error(`No user found for subscription ${subscription.id}`)
300 }
301 
302 await db.user.update({
303 where: { stripeSubscriptionId: subscription.id },
304 data: {
305 stripePriceId: subscription.items.data[0].price.id,
306 stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
307 subscriptionStatus: subscription.status,
308 cancelAtPeriodEnd: subscription.cancel_at_period_end,
309 },
310 })
311}
312 
313async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
314 await db.user.update({
315 where: { stripeSubscriptionId: subscription.id },
316 data: {
317 stripeSubscriptionId: null,
318 stripePriceId: null,
319 stripeCurrentPeriodEnd: null,
320 subscriptionStatus: "canceled",
321 },
322 })
323}
324 
325async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
326 if (!invoice.subscription) return
327 const attemptCount = invoice.attempt_count
328 
329 await db.user.update({
330 where: { stripeSubscriptionId: invoice.subscription as string },
331 data: { subscriptionStatus: "past_due" },
332 })
333 
334 if (attemptCount >= 3) {
335 // Send final dunning email
336 await sendDunningEmail(invoice.customer_email!, "final")
337 } else {
338 await sendDunningEmail(invoice.customer_email!, "retry")
339 }
340}
341 
342async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) {
343 if (!invoice.subscription) return
344 
345 await db.user.update({
346 where: { stripeSubscriptionId: invoice.subscription as string },
347 data: {
348 subscriptionStatus: "active",
349 stripeCurrentPeriodEnd: new Date(invoice.period_end * 1000),
350 },
351 })
352}
353```
354 
355---
356 
357## Usage-Based Billing
358 
359```typescript
360// Report usage for metered subscriptions
361export async function reportUsage(subscriptionItemId: string, quantity: number) {
362 await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
363 quantity,
364 timestamp: Math.floor(Date.now() / 1000),
365 action: "increment",
366 })
367}
368 
369// Example: report API calls in middleware
370export async function trackApiCall(userId: string) {
371 const user = await db.user.findUnique({ where: { id: userId } })
372 if (user?.stripeSubscriptionId) {
373 const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId)
374 const meteredItem = subscription.items.data.find(
375 (item) => item.price.recurring?.usage_type === "metered"
376 )
377 if (meteredItem) {
378 await reportUsage(meteredItem.id, 1)
379 }
380 }
381}
382```
383 
384---
385 
386## Customer Portal
387 
388```typescript
389// app/api/billing/portal/route.ts
390import { NextResponse } from "next/server"
391import { stripe } from "@/lib/stripe"
392import { getAuthUser } from "@/lib/auth"
393 
394export async function POST() {
395 const user = await getAuthUser()
396 if (!user?.stripeCustomerId) {
397 return NextResponse.json({ error: "No billing account" }, { status: 400 })
398 }
399 
400 const portalSession = await stripe.billingPortal.sessions.create({
401 customer: user.stripeCustomerId,
402 return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
403 })
404 
405 return NextResponse.json({ url: portalSession.url })
406}
407```
408 
409---
410 
411## Testing with Stripe CLI
412 
413```bash
414# Install Stripe CLI
415brew install stripe/stripe-cli/stripe
416 
417# Login
418stripe login
419 
420# Forward webhooks to local dev
421stripe listen --forward-to localhost:3000/api/webhooks/stripe
422 
423# Trigger specific events for testing
424stripe trigger checkout.session.completed
425stripe trigger customer.subscription.updated
426stripe trigger invoice.payment_failed
427 
428# Test with specific customer
429stripe trigger customer.subscription.updated \
430 --override subscription:customer=cus_xxx
431 
432# View recent events
433stripe events list --limit 10
434 
435# Test cards
436# Success: 4242 4242 4242 4242
437# Requires auth: 4000 0025 0000 3155
438# Decline: 4000 0000 0000 9995
439# Insufficient funds: 4000 0000 0000 9995
440```
441 
442---
443 
444## Feature Gating Helper
445 
446```typescript
447// lib/subscription.ts
448export function isSubscriptionActive(user: { subscriptionStatus: string | null, stripeCurrentPeriodEnd: Date | null }) {
449 if (!user.subscriptionStatus) return false
450 if (user.subscriptionStatus === "active" || user.subscriptionStatus === "trialing") return true
451 // Grace period: past_due but not yet expired
452 if (user.subscriptionStatus === "past_due" && user.stripeCurrentPeriodEnd) {
453 return user.stripeCurrentPeriodEnd > new Date()
454 }
455 return false
456}
457 
458// Middleware usage
459export async function requireActiveSubscription() {
460 const user = await getAuthUser()
461 if (!isSubscriptionActive(user)) {
462 redirect("/billing?reason=subscription_required")
463 }
464}
465```
466 
467---
468 
469## Common Pitfalls
470 
471- **Webhook delivery order not guaranteed** — always re-fetch from Stripe API, never trust event data alone for DB updates
472- **Double-processing webhooks** — Stripe retries on 500; always use idempotency table
473- **Trial conversion tracking** — store `hasHadTrial: true` in DB to prevent trial abuse
474- **Proration surprises** — always preview proration before upgrade; show user the amount before confirming
475- **Customer portal not configured** — must enable features in Stripe dashboard under Billing → Customer portal settings
476- **Missing metadata on checkout** — always pass `userId` in metadata; can't link subscription to user without it
477 

Discussion

Alternatives

Also in Services & APIsSee all 533 in Development →