Claude-Skills stripe-integration-expert
install
source · Clone the upstream repo
git clone https://github.com/borghei/Claude-Skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/borghei/Claude-Skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/engineering/stripe-integration-expert" ~/.claude/skills/borghei-claude-skills-stripe-integration-expert && rm -rf "$T"
manifest:
engineering/stripe-integration-expert/SKILL.mdsource content
Stripe Integration Expert
The agent builds production-grade Stripe integrations for SaaS billing: subscription lifecycle management with trials and proration, idempotent webhook handlers, usage-based metered billing, Checkout sessions, Customer Portal, dunning recovery, and SCA/3D Secure compliance. Provides patterns for Next.js, Express, and Django with emphasis on real-world edge cases.
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 in metadata |
| Proration surprises | Users charged unexpected amounts | Always preview proration before upgrade |
Not handling | Users lose access without warning | Implement dunning emails on payment failure |
| Skipping trial abuse prevention | Users create multiple accounts for free trials | Store , 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 in client constructor |
Ignoring 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 matches the endpoint in Stripe Dashboard; use output secret for local dev |
| Checkout session redirects to blank page | or missing template or pointing to wrong domain | Ensure URLs use env var and include the session ID template literal for retrieval |
Subscription shows status | First payment requires 3D Secure but was never completed | Handle and send the customer a link to complete authentication |
| Proration invoice charges full price instead of difference | Using instead of or not passing existing subscription item ID | Use proration behavior and update the existing 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 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 | called before handler completes, then handler throws on retry | Move 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
compliance skills for regulatory guidancera-qm-team/ - Payment fraud detection and dispute management (Stripe Radar rules, chargeback workflows) -- see
for security review patternsskill-security-auditor
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 --> 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 |