| name | stripe-integration-expert |
| description | 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.
|
| license | MIT + Commons Clause |
| metadata | {"version":"1.0.0","author":"borghei","category":"engineering","domain":"payments","tier":"POWERFUL","updated":"2026-03-09T00:00:00.000Z","frameworks":"stripe-subscriptions, webhook-handling, billing-infrastructure"} |
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
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",
typescript: true,
appInfo: {
name: "your-app-name",
version: "1.0.0",
url: "https://yourapp.com",
},
});
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.!,
: { : -, : },
},
: {
: process..!,
: process..!,
: { : -, : - },
},
} ;
= keyof ;
= | ;
Checkout Session
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: }, { : });
}
priceId = [plan][interval];
customerId = user.;
(!customerId) {
customer = stripe..({
: user.,
: user. || ,
: { : user., : },
});
customerId = customer.;
db..({
: { : user. },
: { : customerId },
});
}
session = stripe...({
: customerId,
: ,
: [],
: [{ : priceId, : }],
: ,
: { : },
: {
: user. ? : ,
: { : user., plan },
},
: ,
: ,
: { : user. },
});
.({ : session. });
}
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",
billing_cycle_anchor: "unchanged",
});
}
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];
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: "none",
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,
credit: invoice.total < 0 ? Math.abs(invoice.total) : 0,
lineItems: invoice.lines.data.map( => ({
: line.,
: line.,
})),
};
}
Cancel (At Period End)
export async function cancelSubscription(subscriptionId: string) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
export async function reactivateSubscription(subscriptionId: string) {
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.
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";
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 () {
body = req.();
signature = ().();
(!signature) {
.({ : }, { : });
}
: .;
{
event = stripe..(
body, signature, process..!
);
} (err) {
.(, err);
.({ : }, { : });
}
( (event.)) {
.({ : , : });
}
{
(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. },
{ : subscription. },
],
},
});
(!user) {
.();
;
}
db..({
: { : user. },
: {
: subscription.,
: subscription..[]..,
: (subscription. * ),
: subscription.,
: subscription.,
},
});
}
() {
db..({
: { : subscription. },
: {
: ,
: ,
: ,
: ,
},
});
}
() {
(!invoice.) ;
db..({
: { : invoice. },
: {
: ,
: (invoice. * ),
},
});
}
() {
(!invoice.) ;
db..({
: { : invoice. },
: { : },
});
attemptCount = invoice. || ;
(attemptCount === ) {
(invoice.!, );
} (attemptCount === ) {
(invoice.!, );
} (attemptCount >= ) {
(invoice.!, );
}
}
() {
user = db..({
: { : subscription. },
});
(user?.) {
(user., subscription.!);
}
}
Usage-Based Billing
export async function reportUsage(
subscriptionItemId: string,
quantity: number,
idempotencyKey?: string,
) {
return stripe.subscriptionItems.createUsageRecord(
subscriptionItemId,
{
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: "increment",
},
{
idempotencyKey,
}
);
}
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..(
item..?. ===
);
(meteredItem) {
(meteredItem., , );
}
}
Customer Portal
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
import { PLANS, type PlanName } from "./stripe";
export function isSubscriptionActive(user: {
subscriptionStatus: string | null;
stripeCurrentPeriodEnd: Date | null;
}): boolean {
if (!user.subscriptionStatus) return false;
if (["active", "trialing"].includes(user.subscriptionStatus)) return true;
if (user.subscriptionStatus === "past_due" && user.stripeCurrentPeriodEnd) {
return user.stripeCurrentPeriodEnd > new Date();
}
if (user.subscriptionStatus === "cancel_pending" && user.stripeCurrentPeriodEnd) {
return user.stripeCurrentPeriodEnd > new Date();
}
return false;
}
export (): | {
(!stripePriceId) ;
( [plan, config] .()) {
(config. === stripePriceId || config. === stripePriceId) {
plan ;
}
}
;
}
(): {
plan = (user.);
limits = plan === ? { : , : } : [plan].;
(feature) {
: limits. === -;
: plan !== && plan !== ;
: plan !== ;
}
}
SCA (Strong Customer Authentication) Compliance
Required for European customers under PSD2.
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") {
await sendAuthenticationEmail(
invoice.customer_email!,
pi.next_action?.redirect_to_url?.url || `${process.env.APP_URL}/billing/authenticate`
);
}
}
}
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.trial_will_end
stripe events list --limit 10
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-auditor for 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 |