Agent skill
stripe-integration-expert
Implement production-grade Stripe integrations for SaaS billing. Covers subscription lifecycle management, checkout sessions, plan upgrades/downgrades with proration, usage-based billing, idempotent webhook handlers, customer portal, dunning, SCA compliance, and local testing with Stripe CLI. Provides patterns for Next.js, Express, and Django.
Install this agent skill to your Project
npx add-skill https://github.com/borghei/Claude-Skills/tree/main/engineering/stripe-integration-expert
Metadata
Additional technical details for this skill
- tier
- POWERFUL
- author
- borghei
- domain
- payments
- updated
- 1773014400
- version
- 1.0.0
- category
- engineering
- frameworks
- stripe-subscriptions, webhook-handling, billing-infrastructure
SKILL.md
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 |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Webhook returns 400 on all events | Webhook signing secret mismatch between environments | Verify STRIPE_WEBHOOK_SECRET matches the endpoint in Stripe Dashboard; use stripe listen output secret for local dev |
| Checkout session redirects to blank page | success_url or cancel_url missing {CHECKOUT_SESSION_ID} template or pointing to wrong domain |
Ensure URLs use APP_URL env var and include the session ID template literal for retrieval |
Subscription shows incomplete status |
First payment requires 3D Secure but was never completed | Handle checkout.session.async_payment_failed and send the customer a link to complete authentication |
| Proration invoice charges full price instead of difference | Using create_prorations instead of always_invoice or not passing existing subscription item ID |
Use always_invoice proration behavior and update the existing items[0].id rather than adding a new line item |
| Usage records return "Cannot create usage record" | Reporting usage on a non-metered price or after subscription cancellation | Confirm the price uses recurring.usage_type: "metered" and the subscription is active before reporting |
| Customer Portal shows no options | Portal configuration not enabled in Stripe Dashboard | Navigate to Stripe Dashboard > Settings > Billing > Customer Portal and enable subscription management features |
| Duplicate webhook processing despite idempotency table | markProcessed called before handler completes, then handler throws on retry |
Move markProcessed to after the handler succeeds (as shown in the webhook handler pattern above) |
Success Criteria
- Webhook reliability: 99.9%+ webhook processing success rate with zero duplicate side effects over a 30-day window
- Checkout conversion: End-to-end checkout flow completes in under 3 seconds (redirect to Stripe and back)
- Idempotency coverage: 100% of webhook handlers are idempotent, verified by replaying the same event ID twice with no state change on the second pass
- Subscription state accuracy: Database subscription status matches Stripe source of truth within 60 seconds of any state change
- SCA compliance: All European payment flows pass 3D Secure challenges without manual intervention or dropped transactions
- Dunning recovery: Automated dunning emails recover at least 30% of failed payments within the retry window (typically 7-21 days)
- Zero hardcoded price IDs: All Stripe price IDs are sourced from environment variables, enabling test/production parity without code changes
Scope & Limitations
This skill covers:
- Stripe Checkout, Subscriptions, and Customer Portal integration for SaaS billing
- Webhook handling with idempotency, signature verification, and retry safety
- Usage-based (metered) billing, proration previews, and plan change workflows
- SCA/3D Secure compliance for European payment regulations (PSD2)
This skill does NOT cover:
- Stripe Connect (marketplace payouts, multi-party payments) -- see platform-specific Stripe Connect documentation
- One-time payment flows without subscriptions (e.g., e-commerce product purchases)
- Tax calculation and remittance (Stripe Tax configuration, VAT/GST filing) -- see
ra-qm-team/compliance skills for regulatory guidance - Payment fraud detection and dispute management (Stripe Radar rules, chargeback workflows) -- see
skill-security-auditorfor security review patterns
Integration Points
| Skill | Integration | Data Flow |
|---|---|---|
| api-design-reviewer | Review billing API endpoints for REST conventions, error handling, and rate limiting | Billing route definitions --> API review checklist --> validated endpoint contracts |
| database-schema-designer | Design and validate the Prisma schema for Stripe customer, subscription, and event tracking tables | Schema requirements --> normalized table design --> migration files |
| observability-designer | Instrument webhook handlers and checkout flows with structured logging, metrics, and alerting | Webhook events --> OpenTelemetry traces --> dashboard alerts on failure spikes |
| env-secrets-manager | Manage Stripe API keys, webhook secrets, and price IDs across dev/staging/production | Secret definitions --> encrypted vault storage --> runtime injection via env vars |
| ci-cd-pipeline-builder | Automate Stripe CLI webhook testing in CI and validate integration before deployment | Test triggers --> stripe listen in CI --> webhook handler assertions |
| runbook-generator | Create operational runbooks for billing incidents: failed webhooks, mass payment failures, subscription reconciliation | Incident scenarios --> step-by-step remediation --> escalation paths |
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
churn-prevention
SaaS churn reduction covering cancel flow design, dynamic save offers, exit survey architecture, dunning sequences, payment recovery, win-back campaigns, and churn impact modeling.
popup-cro
Popup and modal optimization for conversion. Covers exit-intent, slide-ins, banners, timing optimization, frequency capping, audience targeting, compliance, and A/B testing frameworks for lead capture, promotions, and announcements.
competitor-alternatives
Competitor comparison and alternative page creation for SEO and sales enablement. Covers 4 page formats (singular alternative, plural alternatives, vs pages, competitor vs competitor), content architecture, research methodology, and centralized competitor data management.
contract-and-proposal-writer
Generate production-ready business documents including freelance contracts, project proposals, SOWs, NDAs, and MSAs with jurisdiction-aware clauses. Covers US (Delaware), EU (GDPR), UK, and DACH (German law) legal frameworks. Includes contract templates, clause libraries, and DOCX conversion. Use when starting client engagements, writing proposals, drafting partnership agreements, or needing GDPR-compliant data processing addenda.
pricing-strategy
SaaS pricing design and optimization covering value metric selection, tier architecture, price point research, pricing page design, price increase execution, and competitive pricing analysis.
referral-program
Referral and affiliate program design covering referral loop architecture, incentive design, trigger moment optimization, viral coefficient modeling, affiliate program structure, and optimization playbook.
Didn't find tool you were looking for?