Stripe Billing
Disambiguation: this skill = Next.js App Router + Server Actions. For Express/Node backends see
saas-billing.
Production patterns for Stripe billing that handle the edge cases tutorials skip. Subscription lifecycle, usage-based billing, webhook idempotency, EU VAT, and price migrations.
Critical principle: Webhooks are your source of truth, not API responses. Always design for eventual consistency.
Clover API invariants (this skill pins 2025-09-30.clover)
2025-09-30.clover is the first release of the Clover major line (majors run Acacia, Basil, Clover, Dahlia; breaking changes are cumulative, so Clover inherits Basil's). The first three invariants below were introduced in 2025-03-31.basil; the flexible billing_mode default is Clover's own change. The code below depends on these, so if you bump the version, re-verify at https://docs.stripe.com/changelog:
- Initial subscription payment secret lives at
latest_invoice.confirmation_secret.client_secret, NOTlatest_invoice.payment_intent. Expandlatest_invoice.confirmation_secret. (expand: ['latest_invoice.payment_intent']returns nothing on this version.) - Billing period fields moved to the subscription item: use
sub.items.data[0].current_period_end/current_period_start.sub.current_period_endno longer exists.billing_cycle_anchorstays on the subscription. - Legacy usage-based billing is removed:
aggregate_usageandbilling_thresholdsare gone;UsageRecord/UsageRecordSummaryendpoints are deleted. A metered price MUST reference a Billing Meter viarecurring.meter. Report usage withbilling.meterEvents.create. (Changelog: https://docs.stripe.com/changelog/basil/2025-03-31/deprecate-legacy-usage-based-billing) billing_mode: { type: 'flexible' }is the default for new subscriptions and is what enablesconfirmation_secret; set it explicitly so behavior is stable across version bumps.
1. Setup
npm install stripe@19.1 @stripe/stripe-js @stripe/react-stripe-js
// lib/stripe.ts — server-side only
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// Pin a specific Stripe API version and the matching SDK release (stripe-node 19.0-19.1
// pins 2025-09-30.clover; 19.2 moved to 2025-10-29.clover). Bump both together,
// deliberately: newer majors (stripe 21+) pin the Dahlia line (2026-06-24.dahlia is
// current GA) and their TypeScript types reject older apiVersion literals.
apiVersion: '2025-09-30.clover',
typescript: true,
maxNetworkRetries: 3,
timeout: 20000,
});
// lib/stripe-client.ts — browser-safe
import { loadStripe } from '@stripe/stripe-js';
let stripePromise: ReturnType<typeof loadStripe>;
export function getStripe() {
if (!stripePromise) stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
return stripePromise;
}
2. Subscription Lifecycle
Create subscription
// app/api/subscribe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// requestId is sent by the client and held stable across retries (e.g. crypto.randomUUID()
// generated once when the form mounts). It is the basis for idempotency keys below.
const { priceId, paymentMethodId, requestId } = await req.json();
if (!priceId || !paymentMethodId || !requestId) {
return NextResponse.json({ error: 'Missing priceId, paymentMethodId, or requestId' }, { status: 400 });
}
try {
let user = await db.user.findUniqueOrThrow({
where: { id: session.user.id },
select: { stripeCustomerId: true, email: true },
});
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email!,
metadata: { userId: session.user.id },
payment_method: paymentMethodId,
invoice_settings: { default_payment_method: paymentMethodId },
}, { idempotencyKey: `cust-create-${session.user.id}` });
customerId = customer.id;
await db.user.update({ where: { id: session.user.id }, data: { stripeCustomerId: customerId } });
} else {
await stripe.paymentMethods.attach(paymentMethodId, { customer: customerId });
await stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
}
// Prevent duplicate subscriptions. "active" alone is not enough — trialing/past_due/
// incomplete/paused are all live states that would conflict. Treat anything not
// fully ended as occupying the slot. (incomplete_expired and canceled are terminal.)
const existing = await stripe.subscriptions.list({ customer: customerId, status: 'all', limit: 100 });
const LIVE = new Set(['active', 'trialing', 'past_due', 'incomplete', 'paused', 'unpaid']);
if (existing.data.some((s) => LIVE.has(s.status))) {
return NextResponse.json({ error: 'Active subscription exists. Use the change-plan endpoint.' }, { status: 409 });
}
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete',
billing_mode: { type: 'flexible' }, // enables confirmation_secret (Basil-line)
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.confirmation_secret'], // NOT latest_invoice.payment_intent on this API
metadata: { userId: session.user.id },
automatic_tax: { enabled: true },
}, {
// Stable across retries: a network timeout that retries this POST won't create a 2nd sub.
idempotencyKey: `sub-create-${session.user.id}-${priceId}-${requestId}`,
});
const invoice = subscription.latest_invoice as Stripe.Invoice | null;
// $0 invoices, fully-trial subs, and PM-on-file flows can settle with no client_secret.
// Don't assume one exists — let the client skip confirmation when it's null.
const clientSecret = invoice?.confirmation_secret?.client_secret ?? null;
return NextResponse.json({
subscriptionId: subscription.id,
clientSecret, // null ⇒ nothing to confirm client-side
status: subscription.status, // 'active' | 'trialing' | 'incomplete' | ...
});
} catch (err) {
if (err instanceof Stripe.errors.StripeCardError) {
return NextResponse.json({ error: err.message }, { status: 402 });
}
console.error('Subscription creation failed:', err);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}
Upgrade / Downgrade
// app/api/subscription/change-plan/route.ts
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { newPriceId, requestId } = await req.json();
const user = await db.user.findUniqueOrThrow({
where: { id: session.user.id },
select: { stripeSubscriptionId: true },
});
if (!user.stripeSubscriptionId) {
return NextResponse.json({ error: 'No active subscription' }, { status: 400 });
}
const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId, {
expand: ['items.data.price'],
});
// Don't try to classify upgrade vs downgrade from unit_amount: that breaks for tiered/
// metered prices, unit_amount_decimal, different intervals, coupons, and multi-item subs.
// Instead, map your priceIds to a plan rank you control, and always settle the proration
// immediately. If the proration nets to a charge, the client may need to confirm it.
const PLAN_RANK: Record<string, number> = {
[process.env.STRIPE_PRICE_PRO!]: 1,
[process.env.STRIPE_PRICE_TEAM!]: 2,
};
const currentItem = subscription.items.data[0];
const currentPrice = currentItem.price as Stripe.Price;
const isUpgrade = (PLAN_RANK[newPriceId] ?? 0) > (PLAN_RANK[currentPrice.id] ?? 0);
const updated = await stripe.subscriptions.update(subscription.id, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: 'always_invoice',
// Upgrades may owe money now (SCA) → default_incomplete so we can confirm.
// Downgrades/credits → pending_if_incomplete keeps the sub active and applies the change.
payment_behavior: isUpgrade ? 'default_incomplete' : 'pending_if_incomplete',
expand: ['latest_invoice.confirmation_secret'],
metadata: { previousPriceId: currentPrice.id, changeType: isUpgrade ? 'upgrade' : 'downgrade' },
}, {
idempotencyKey: `sub-change-${subscription.id}-${newPriceId}-${requestId}`,
});
const invoice = updated.latest_invoice as Stripe.Invoice | null;
return NextResponse.json({
subscription: updated.id,
status: updated.status,
// Present only when the change created an open invoice needing confirmation.
clientSecret: invoice?.confirmation_secret?.client_secret ?? null,
});
}
Cancel (at period end)
// app/api/subscription/cancel/route.ts
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { reason, feedback } = await req.json();
const user = await db.user.findUniqueOrThrow({
where: { id: session.user.id },
select: { stripeSubscriptionId: true },
});
// Cancel at period end — user keeps access until billing cycle ends
const subscription = await stripe.subscriptions.update(user.stripeSubscriptionId!, {
cancel_at_period_end: true,
metadata: { cancelReason: reason, cancelFeedback: feedback, canceledAt: new Date().toISOString() },
});
// Basil-line: period end is on the subscription item, not the subscription.
const periodEnd = subscription.items.data[0].current_period_end;
await db.cancellation.create({
data: {
userId: session.user.id, reason, feedback,
effectiveDate: new Date(periodEnd * 1000),
},
});
return NextResponse.json({
cancelAt: new Date(periodEnd * 1000).toISOString(),
});
}
Reactivate
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const user = await db.user.findUniqueOrThrow({
where: { id: session.user.id },
select: { stripeSubscriptionId: true },
});
const sub = await stripe.subscriptions.retrieve(user.stripeSubscriptionId!);
if (sub.cancel_at_period_end) {
// Still in billing period — just undo cancellation
const reactivated = await stripe.subscriptions.update(sub.id, {
cancel_at_period_end: false,
metadata: { reactivatedAt: new Date().toISOString() },
});
return NextResponse.json({ status: reactivated.status });
}
if (sub.status === 'canceled') {
return NextResponse.json({ error: 'Subscription expired. Create a new one.' }, { status: 410 });
}
return NextResponse.json({ error: 'Cannot reactivate' }, { status: 400 });
}
3. Usage-Based Billing (Billing Meters)
Legacy usage records are removed (see Basil-line invariants above). The model is now: a Billing Meter (mtr_...) aggregates meter events you send with billing.meterEvents.create; a metered price references that meter via recurring.meter. Stripe rate-limits meter calls, so buffer locally and flush in batches.
Two correctness rules the naive tutorial misses:
- Never
KEYSin production — it blocks Redis O(n) over the whole keyspace. UseSCAN. - Don't read-then-
DEL— increments written betweenHGETandDELare silently lost. Atomically drain the counter (LuaGETDEL-style) so a concurrentHINCRBYlands in the next window instead of vanishing. And send a deterministicidentifierso a retried flush is de-duplicated by Stripe rather than double-billed.
// lib/usage-metering.ts
import { stripe } from './stripe';
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
// Atomically read+reset a window's quantity. Returns the drained value as a string.
// HSET to 0 (rather than DEL) keeps the key alive only for the TTL it already has;
// any HINCRBY racing this script either ran before (counted here) or after (next window).
const DRAIN = `
local q = redis.call('HGET', KEYS[1], 'quantity')
if q then redis.call('HSET', KEYS[1], 'quantity', 0) end
return q or '0'
`;
export class UsageMeter {
constructor(private readonly flushIntervalMs = 60_000) {}
/** event_name is the Billing Meter's event_name (e.g. 'api_requests'). */
async recordUsage(eventName: string, customerId: string, quantity: number): Promise<void> {
const window = String(Math.floor(Date.now() / 60_000) * 60);
const key = `usage:${eventName}:${customerId}:${window}`;
await redis.hincrby(key, 'quantity', quantity);
await redis.expire(key, 7200); // outlive a flush window even if the flusher is down
}
async flush(): Promise<void> {
let cursor = '0';
do {
// SCAN, not KEYS. COUNT is a hint; loop until cursor wraps to '0'.
const [next, keys] = await redis.scan(cursor, 'MATCH', 'usage:*', 'COUNT', 500);
cursor = next;
for (const key of keys) {
// key = usage:<eventName>:<customerId>:<window>; customerId itself contains no ':'.
const parts = key.split(':');
const window = parts.pop()!;
const customerId = parts.pop()!;
const eventName = parts.slice(1).join(':');
const drained = (await redis.eval(DRAIN, 1, key)) as string;
const value = parseInt(drained, 10);
if (!value) continue;
try {
await stripe.billing.meterEvents.create({
event_name: eventName,
payload: { stripe_customer_id: customerId, value: String(value) },
timestamp: parseInt(window, 10), // bucket start; must be within the meter's window
// Deterministic per (customer, event, window): a retried flush is ignored by Stripe
// instead of double-counted. Stripe de-dupes meter events by identifier.
identifier: `${customerId}:${eventName}:${window}`,
});
} catch (err) {
// Flush failed AFTER draining → put it back so the next tick retries.
// Same identifier means Stripe still de-dupes if the event actually landed.
await redis.hincrby(key, 'quantity', value);
console.error(`Usage flush failed for ${eventName}/${customerId}@${window}:`, err);
}
}
} while (cursor !== '0');
}
start(): NodeJS.Timeout {
return setInterval(() => this.flush(), this.flushIntervalMs);
}
}
export const meter = new UsageMeter();
At real scale, prefer a durable outbox over Redis-only buffering: write each usage delta to a
usage_eventstable (or stream) and have a worker drain it to Stripe, marking rows sent. Redis buffering trades a small loss window for latency; an outbox gives you replay and an audit trail. The deterministicidentifierabove is what makes either approach safe to retry.
Create the meter + metered price
Inline, runnable setup. Run once per environment (test and live have separate mtr_/price_ IDs). Replace the placeholder env names with your own.
// scripts/setup-metered-prices.ts — run with: npx tsx scripts/setup-metered-prices.ts
import { stripe } from '../lib/stripe';
async function setup() {
// 1. The meter aggregates events named 'api_requests'. customer_mapping + value keys
// MUST match the payload sent in recordUsage (stripe_customer_id / value).
const m = await stripe.billing.meters.create({
display_name: 'API Requests',
event_name: 'api_requests',
default_aggregation: { formula: 'sum' }, // sum | count | last
value_settings: { event_payload_key: 'value' },
customer_mapping: { type: 'by_id', event_payload_key: 'stripe_customer_id' },
});
const product = await stripe.products.create({ name: 'API Access' });
// 2. $29/mo flat base (licensed, not metered).
const base = await stripe.prices.create({
product: product.id, unit_amount: 2900, currency: 'usd',
recurring: { interval: 'month' },
});
// 3. Metered, graduated-tier usage price. NOTE the required recurring.meter — and
// NO aggregate_usage (removed on the Basil line; aggregation lives on the meter).
const usage = await stripe.prices.create({
product: product.id, currency: 'usd',
recurring: { interval: 'month', usage_type: 'metered', meter: m.id },
billing_scheme: 'tiered', tiers_mode: 'graduated',
tiers: [
{ up_to: 10_000, unit_amount: 0 }, // first 10k included
{ up_to: 100_000, unit_amount_decimal: '0.2' }, // $0.002/req
{ up_to: 'inf', unit_amount_decimal: '0.1' }, // $0.001/req volume discount
],
});
console.log('Meter:', m.id, 'Base:', base.id, 'Usage:', usage.id);
// Subscribe a customer to BOTH prices: items: [{ price: base.id }, { price: usage.id }]
}
setup().catch((e) => { console.error(e); process.exit(1); });
4. Webhook Handler — Production Grade
Idempotency must be durable and atomic, not Redis-only. A Redis flush would let old events replay and re-mutate billing state, and two concurrent deliveries of the same event can both pass a GET before either SETEX. Use the database as the source of truth with a unique constraint on the event id, and claim before processing:
// schema.prisma
model ProcessedWebhook {
eventId String @id // Stripe event.id — unique constraint = the idempotency lock
type String
processedAt DateTime @default(now())
}
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
export async function POST(req: NextRequest) {
const body = await req.text(); // raw body — required for signature verification
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Claim the event durably and atomically BEFORE doing work. The unique PK makes a
// duplicate delivery (or a concurrent one) fail the insert → we ack 200 and skip.
try {
await db.processedWebhook.create({ data: { eventId: event.id, type: event.type } });
} catch (err) {
if (isUniqueViolation(err)) return NextResponse.json({ received: true, duplicate: true });
throw err;
}
try {
await handleEvent(event);
return NextResponse.json({ received: true });
} catch (err) {
// Roll back the claim so Stripe's retry can reprocess. Stripe retries 5xx with backoff.
await db.processedWebhook.delete({ where: { eventId: event.id } }).catch(() => {});
console.error(`Webhook error [${event.type}]:`, err);
return NextResponse.json({ error: 'Handler failed' }, { status: 500 });
}
}
// Prisma unique-constraint code is P2002; adapt for your driver.
function isUniqueViolation(err: unknown): boolean {
return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'P2002';
}
async function handleEvent(event: Stripe.Event) {
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
// A sub can have multiple items (e.g. base + metered). Pick the licensed plan item,
// not blindly items.data[0], or a metered add-on can shadow the real plan.
const planMap: Record<string, string> = {
[process.env.STRIPE_PRICE_PRO!]: 'pro',
[process.env.STRIPE_PRICE_TEAM!]: 'team',
};
const planItem = sub.items.data.find((i) => planMap[i.price.id]) ?? sub.items.data[0];
// Basil-line: period end is on the item, not the subscription.
const periodEnd = planItem.current_period_end;
// Ordering: events can arrive out of order. event.created is the authoritative clock —
// ignore an update older than what we last applied so a late 'created' can't clobber
// a newer 'updated'. (Store lastEventAt alongside the subscription row.)
await db.user.updateMany({
where: {
stripeCustomerId: sub.customer as string,
OR: [{ lastEventAt: null }, { lastEventAt: { lt: new Date(event.created * 1000) } }],
},
data: {
stripeSubscriptionId: sub.id,
subscriptionStatus: sub.status, // raw Stripe status; entitlement derived separately (§10)
planId: planMap[planItem.price.id] ?? 'unknown',
currentPeriodEnd: new Date(periodEnd * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
lastEventAt: new Date(event.created * 1000),
},
});
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await db.user.update({
where: { stripeCustomerId: sub.customer as string },
data: { subscriptionStatus: 'canceled', stripeSubscriptionId: null, planId: null },
});
break;
}
case 'invoice.payment_succeeded': {
const inv = event.data.object as Stripe.Invoice;
await db.invoice.upsert({
where: { stripeInvoiceId: inv.id },
create: {
stripeInvoiceId: inv.id,
stripeCustomerId: inv.customer as string,
amount: inv.amount_paid,
currency: inv.currency,
status: 'paid',
pdfUrl: inv.invoice_pdf,
paidAt: new Date(inv.status_transitions.paid_at! * 1000),
},
update: { status: 'paid' },
});
break;
}
case 'invoice.payment_failed': {
const inv = event.data.object as Stripe.Invoice;
// Dunning emails based on attempt count
if (inv.attempt_count === 1) {
await sendEmail(inv.customer_email!, 'payment-failed-soft', { url: inv.hosted_invoice_url });
} else if (inv.attempt_count >= 3) {
await sendEmail(inv.customer_email!, 'payment-failed-final', { url: inv.hosted_invoice_url });
}
await db.user.update({
where: { stripeCustomerId: inv.customer as string },
data: { subscriptionStatus: 'past_due' },
});
break;
}
case 'customer.subscription.trial_will_end': {
const sub = event.data.object as Stripe.Subscription;
const cust = await stripe.customers.retrieve(sub.customer as string) as Stripe.Customer;
await sendEmail(cust.email!, 'trial-ending', {
trialEnd: new Date(sub.trial_end! * 1000).toLocaleDateString(),
});
break;
}
}
}
async function sendEmail(to: string, template: string, data: Record<string, any>) {
console.log(`[email] ${template} → ${to}`, data);
}
5. Customer Portal
// app/api/billing/portal/route.ts
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const user = await db.user.findUniqueOrThrow({
where: { id: session.user.id },
select: { stripeCustomerId: true },
});
const portal = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId!,
return_url: `${process.env.NEXT_PUBLIC_URL}/settings/billing`,
});
return NextResponse.json({ url: portal.url });
}
Portal configuration
Configure the portal once per environment (run with npx tsx scripts/configure-portal.ts). Re-running create makes a new default configuration each time, so update the existing default if present to keep this idempotent:
// scripts/configure-portal.ts
import Stripe from 'stripe';
import { stripe } from '../lib/stripe';
const params: Stripe.BillingPortal.ConfigurationCreateParams = {
business_profile: {
headline: 'Manage your subscription',
privacy_policy_url: 'https://example.com/privacy',
terms_of_service_url: 'https://example.com/terms',
},
features: {
subscription_update: {
enabled: true,
default_allowed_updates: ['price', 'quantity'],
proration_behavior: 'always_invoice',
products: [{
product: process.env.STRIPE_PRODUCT_ID!,
prices: [process.env.STRIPE_PRICE_PRO!, process.env.STRIPE_PRICE_TEAM!],
}],
},
subscription_cancel: {
enabled: true,
mode: 'at_period_end',
cancellation_reason: {
enabled: true,
options: ['too_expensive', 'missing_features', 'switched_service', 'unused', 'other'],
},
},
payment_method_update: { enabled: true },
invoice_history: { enabled: true },
},
};
async function main() {
const existing = await stripe.billingPortal.configurations.list({ is_default: true, limit: 1 });
const cfg = existing.data[0]
? await stripe.billingPortal.configurations.update(existing.data[0].id, params)
: await stripe.billingPortal.configurations.create(params);
console.log('Portal configuration:', cfg.id);
}
main().catch((e) => { console.error(e); process.exit(1); });
6. Stripe Tax for EU VAT
Tax handling is jurisdiction-specific and changes often. The patterns below are wiring, not tax advice — confirm registration thresholds, rates, place-of-supply, and exemption rules with a tax professional for every country you sell in. Stripe Tax only files where you've registered and configured a registration in the Dashboard.
Prerequisites: enable Stripe Tax in the Dashboard and add a registration per jurisdiction; otherwise automatic_tax: { enabled: true } computes $0. Set tax behavior on every Price (tax_behavior: 'exclusive' to add tax on top, 'inclusive' if the listed price already contains it) — automatic_tax cannot compute on a price whose tax_behavior is unspecified.
// When creating customers, collect address for tax. validate_location:'deferred'
// validates lazily at the first taxable transaction (vs 'immediately', which throws on bad input).
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id },
tax: { validate_location: 'deferred' },
address: {
country: billingAddress.country,
postal_code: billingAddress.postalCode,
city: billingAddress.city,
line1: billingAddress.line1,
},
});
// Location validation can fail later (Stripe can't resolve the address to a tax jurisdiction).
// Surface it instead of silently billing untaxed: on customer.updated, inspect
// customer.tax.automatic_tax — 'unrecognized_location' / 'failed' means prompt the user to fix
// their address before the next invoice finalizes.
case 'customer.updated': {
const c = event.data.object as Stripe.Customer;
if (c.tax?.automatic_tax && c.tax.automatic_tax !== 'supported') {
await flagAddressForReview(c.id, c.tax.automatic_tax); // your app concern
}
break;
}
// B2B reverse charge: validate the buyer's VAT number.
if (vatNumber) {
try {
await stripe.customers.createTaxId(customer.id, {
type: 'eu_vat',
value: vatNumber, // e.g. 'DE123456789' — country prefix + digits
});
// Verified asynchronously — listen for customer.tax_id.updated webhook.
} catch (err) {
// Malformed value (wrong format) throws here; an invalid-but-well-formed number
// fails later via the webhook. Don't grant exemption on this success path alone.
console.error('VAT id rejected at creation:', err);
}
}
// In webhook handler — only exempt AFTER verification succeeds:
case 'customer.tax_id.updated': {
const taxId = event.data.object as Stripe.TaxId;
if (taxId.verification?.status === 'verified') {
// 'reverse' = EU reverse-charge (B2B cross-border): Stripe zero-rates VAT and notes it.
await stripe.customers.update(taxId.customer as string, { tax_exempt: 'reverse' });
} else if (taxId.verification?.status === 'failed') {
await stripe.customers.update(taxId.customer as string, { tax_exempt: 'none' });
}
break;
}
B2C vs B2B: B2C in the EU is always taxed at the customer's rate (destination principle, no threshold for digital services). B2B with a verified VAT id in a different member state is reverse-charged (tax_exempt: 'reverse'); same-country B2B is still taxed normally. Non-EU caveats: US sales tax is economic-nexus based (per-state thresholds, not VAT), the UK is post-Brexit standalone (separate registration), and many countries have their own digital-goods rules — register per jurisdiction in the Dashboard before relying on automatic tax there.
7. Adaptive Pricing
Adaptive Pricing presents your single home-currency Price to international buyers in their local currency at Stripe-managed FX, so you don't maintain a Price per currency. It is not the same as multi-currency Prices (where you set explicit currency_options and bear FX yourself) — pick one model, not both, for a given Price.
- Where it's configured: Dashboard → Settings → Adaptive Pricing (account-level toggle). There is no per-request API flag — once enabled, eligible Stripe-hosted Checkout and the Pricing Table present localized amounts automatically.
- When it applies: Stripe-hosted Checkout Sessions / Payment Links / Pricing Tables, when Stripe can geolocate the buyer and the presentment currency differs from the Price currency. It does not apply to a self-hosted Payment Element flow like §1's direct-subscription path — that path always charges in the Price's currency. If you need localized presentment there, either move that flow to Checkout or define explicit
currency_optionson the Price. - Limitations: settlement/payout is still in your account currency (buyers see local, you receive home minus FX); not all currencies/payment methods are eligible; it composes with Stripe Tax but the taxable amount follows the presentment currency. Interaction with coupons/trials can vary — verify in your account at https://docs.stripe.com/payments/checkout/adaptive-pricing.
- How to test: open a Checkout Session from a non-home locale (VPN or a browser
Accept-Language/IP in another country) and confirm the displayed currency. Do this in test mode before enabling in live; don't confuse a localized presentment amount with having created a second Price.
8. Recovery, Reconciliation & Test Clocks
Webhooks are the source of truth, but deliveries can be missed (downtime, a handler bug, a dropped 5xx). Add a periodic reconciliation sweep, and use Test Clocks to exercise time-dependent flows deterministically.
Nightly reconciliation (catch drift / missed events)
// scripts/reconcile.ts — run on a daily cron
import { stripe } from '../lib/stripe';
import { db } from '../lib/db';
async function reconcile() {
// stripe-node list results are async-iterable and auto-paginate across pages.
for await (const sub of stripe.subscriptions.list({ status: 'all', limit: 100 })) {
const periodEnd = sub.items.data[0].current_period_end; // item, not subscription (Basil-line)
await db.user.updateMany({
where: { stripeCustomerId: sub.customer as string },
data: {
subscriptionStatus: sub.status,
currentPeriodEnd: new Date(periodEnd * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
},
});
}
}
reconcile().catch((e) => { console.error(e); process.exit(1); });
To re-drive missed events instead of polling state, list events and replay them through the same handler: stripe.events.list({ type: 'customer.subscription.*' }) (Stripe retains events for 30 days) — the ProcessedWebhook claim in §4 makes replay safe and idempotent.
Test Clocks (simulate renewals, trials, dunning)
Test Clocks let you fast-forward time so renewals, trial_will_end, and failed-payment retries fire on demand instead of waiting real days. Attach the customer to the clock at creation.
// scripts/test-clock-renewal.ts (test mode only)
import { stripe } from '../lib/stripe';
async function run() {
const clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000),
name: 'renewal test',
});
const customer = await stripe.customers.create({
test_clock: clock.id, // must be set at creation; can't attach later
payment_method: 'pm_card_visa',
invoice_settings: { default_payment_method: 'pm_card_visa' },
});
const sub = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: process.env.TEST_PRICE_ID! }],
trial_period_days: 7,
});
// Jump to just after trial end → trial_will_end (3 days prior) + first renewal invoice fire.
await stripe.testHelpers.testClocks.advance({
id: clock.id,
frozen_time: Math.floor(Date.now() / 1000) + 8 * 24 * 3600,
});
// Poll clock.status until 'ready', then assert your webhook handler updated the DB.
console.log('Advanced clock for', sub.id);
}
run().catch((e) => { console.error(e); process.exit(1); });
Method names are stripe-node
testHelpers.testClocks.*; the underlying API is/v1/test_helpers/test_clocks. Test Clocks are test-mode only and a clock can advance at most ~2 years total.
9. Price Migration
Grandfather existing customers
// scripts/price-migration.ts — run with: npx tsx scripts/price-migration.ts
import { stripe } from '../lib/stripe';
// Use env vars, not literals: test and live have different price IDs.
const OLD_PRICE = process.env.STRIPE_PRICE_OLD!;
const NEW_PRICE = process.env.STRIPE_PRICE_NEW!;
async function migrate(grandfatherBefore: Date) {
let startingAfter: string | undefined;
let migrated = 0, skipped = 0, alreadyDone = 0;
while (true) {
const subs = await stripe.subscriptions.list({
price: OLD_PRICE, status: 'active', limit: 100,
...(startingAfter && { starting_after: startingAfter }),
});
for (const sub of subs.data) {
// Idempotent: re-runs (after a crash mid-batch) skip subs already migrated.
if (sub.metadata.migratedFrom === OLD_PRICE) { alreadyDone++; continue; }
if (new Date(sub.created * 1000) < grandfatherBefore) {
await stripe.subscriptions.update(sub.id, {
metadata: { grandfathered: 'true', originalPrice: OLD_PRICE },
});
skipped++;
continue;
}
// Find the item on the OLD price (don't assume items.data[0]).
const item = sub.items.data.find((i) => i.price.id === OLD_PRICE) ?? sub.items.data[0];
await stripe.subscriptions.update(sub.id, {
items: [{ id: item.id, price: NEW_PRICE }],
proration_behavior: 'none', // 'none' = no mid-cycle charge; new price applies next cycle
metadata: { migratedFrom: OLD_PRICE, migratedAt: new Date().toISOString() },
}, {
idempotencyKey: `migrate-${sub.id}-${NEW_PRICE}`,
});
migrated++;
await new Promise((r) => setTimeout(r, 50)); // gentle rate limiting
}
if (!subs.has_more) break;
startingAfter = subs.data[subs.data.length - 1].id;
}
console.log(`Done. Migrated: ${migrated}, Grandfathered: ${skipped}, Already: ${alreadyDone}`);
}
// Grandfather anyone who subscribed before this date; migrate everyone newer.
migrate(new Date('2026-01-01')).catch((e) => { console.error(e); process.exit(1); });
Schedule future price change
async function schedulePriceChange(subscriptionId: string, newPriceId: string, effectiveDate: Date) {
const schedule = await stripe.subscriptionSchedules.create({ from_subscription: subscriptionId });
await stripe.subscriptionSchedules.update(schedule.id, {
phases: [
{
items: [{ price: schedule.phases[0].items[0].price as string }],
start_date: schedule.phases[0].start_date,
end_date: Math.floor(effectiveDate.getTime() / 1000),
},
{
items: [{ price: newPriceId }],
start_date: Math.floor(effectiveDate.getTime() / 1000),
},
],
});
}
10. Testing
# Listen for webhooks locally
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed
Test card numbers
4242424242424242 — Success
4000000000003220 — 3D Secure required
4000000000009995 — Insufficient funds
4000000000000341 — Attach succeeds, charge fails
4000002500003155 — SCA required (EU)
Integration tests
These hit Stripe's live test-mode API. Note the assertions: a freshly created subscription is rarely 'active' synchronously — with pm_card_visa and no SCA it settles to active, but a card requiring SCA stays incomplete until confirmed. Assert against the set of acceptable states, not a single value. For time-dependent behavior (renewals, dunning), use Test Clocks (§8).
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { stripe } from '../lib/stripe';
const SETTLED = new Set(['active', 'trialing']);
describe('Billing', () => {
let customerId: string | undefined;
let subscriptionId: string | undefined;
beforeAll(async () => {
const customer = await stripe.customers.create({
email: `test-${Date.now()}@example.com`,
payment_method: 'pm_card_visa',
invoice_settings: { default_payment_method: 'pm_card_visa' },
});
customerId = customer.id;
});
it('creates subscription', async () => {
const sub = await stripe.subscriptions.create({
customer: customerId!,
items: [{ price: process.env.TEST_PRICE_ID! }],
payment_behavior: 'default_incomplete',
billing_mode: { type: 'flexible' },
expand: ['latest_invoice.confirmation_secret'],
});
subscriptionId = sub.id;
// No-SCA test card settles immediately; SCA cards stay 'incomplete' pending confirmation.
expect(['active', 'trialing', 'incomplete']).toContain(sub.status);
});
it('upgrades', async () => {
const sub = await stripe.subscriptions.retrieve(subscriptionId!);
const updated = await stripe.subscriptions.update(subscriptionId!, {
items: [{ id: sub.items.data[0].id, price: process.env.TEST_PRICE_PRO_ID! }],
proration_behavior: 'always_invoice',
});
expect(updated.items.data[0].price.id).toBe(process.env.TEST_PRICE_PRO_ID);
});
it('cancels at period end', async () => {
const updated = await stripe.subscriptions.update(subscriptionId!, { cancel_at_period_end: true });
expect(updated.cancel_at_period_end).toBe(true);
expect([...SETTLED, 'past_due', 'incomplete']).toContain(updated.status);
});
// Best-effort cleanup; guard each call so a failed setup step can't throw here.
afterAll(async () => {
if (subscriptionId) await stripe.subscriptions.cancel(subscriptionId).catch(() => {});
if (customerId) await stripe.customers.del(customerId).catch(() => {});
});
});
11. Frontend Checkout
Prerequisites for this self-hosted Payment Element flow (vs. Stripe-hosted Checkout):
- Wrap this form in
<Elements stripe={getStripe()} options={{ clientSecret }}>(the deferred-intent variant usesoptions={{ mode, amount, currency }});clientSecretis theconfirmation_secret.client_secretreturned by §2's subscribe route. - The parent generates a
requestIdonce (useState(() => crypto.randomUUID())) and POSTs it withpriceId/paymentMethodIdso the server's idempotency key is stable across retries. clientSecretmay benull(trial-only or $0 first invoice → nothing to confirm). Skip confirmation and treat it as success.- This path charges in the Price's currency — it does not get Adaptive Pricing (§7). For localized presentment, use Stripe-hosted Checkout instead.
- SCA/3D Secure is handled by
confirmPayment+redirect: 'if_required': cards needing a challenge redirect toreturn_url; on return, read the status from the URL'spayment_intent_client_secretand reconcile via webhook.
'use client';
import { useState } from 'react';
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js';
export function CheckoutForm({ clientSecret, onSuccess }: { clientSecret: string | null; onSuccess: () => void }) {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) return;
setLoading(true);
setError(null);
// No secret ⇒ trial/$0 invoice: subscription is already active, nothing to confirm.
if (!clientSecret) { onSuccess(); setLoading(false); return; }
const { error: submitErr } = await elements.submit();
if (submitErr) { setError(submitErr.message ?? 'Validation failed'); setLoading(false); return; }
const { error: confirmErr } = await stripe.confirmPayment({
elements, clientSecret,
confirmParams: { return_url: `${window.location.origin}/billing/success` },
redirect: 'if_required',
});
if (confirmErr) { setError(confirmErr.message ?? 'Payment failed'); setLoading(false); return; }
onSuccess();
setLoading(false);
};
return (
<form onSubmit={handleSubmit}>
<PaymentElement options={{ layout: 'tabs' }} />
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<button type="submit" disabled={!stripe || loading}
className="mt-4 w-full rounded-lg bg-blue-600 px-4 py-2 text-white disabled:opacity-50">
{loading ? 'Processing...' : 'Subscribe'}
</button>
</form>
);
}
12. Common Pitfalls
- Not handling
requires_action— SCA/3D Secure means subscriptions can beincomplete. Always handle the client-secret confirmation flow; anullsecret means nothing to confirm. - Trusting API over webhooks — Payment can fail asynchronously. Sync state from webhooks; reconcile nightly (§8) for missed deliveries.
- Reading
subscription.current_period_end— On the Basil line it moved to the item:sub.items.data[0].current_period_end. The old field is gone and reads asundefined. - Expanding
latest_invoice.payment_intent— Returns nothing on this API. Expandlatest_invoice.confirmation_secretand readconfirmation_secret.client_secret. - Using
aggregate_usage/UsageRecord— Removed. Create a Billing Meter, reference it viarecurring.meter, report withbilling.meterEvents.create(§3). - Skipping idempotency keys — Pass a stable
idempotencyKeyon every write (create/update/migrate). A retried POST then can't double-charge or double-create. - Redis-only webhook dedup — A flush replays old events and concurrent deliveries race. Claim the
event.idin a durable, unique-constrained table before processing (§4). - Only testing with US cards —
4000002500003155triggers SCA. Test EU/SCA flows and use Test Clocks for renewals/dunning. - Ignoring
invoice.payment_failed— Failed payments are a large share of involuntary churn. Implement dunning emails onattempt_count. - Hardcoding price IDs — Use env vars. Test and live have different IDs.
- Calling
subscriptions.cancel()directly — Immediately revokes access. Usecancel_at_period_end: true.