Covers the non-obvious parts of subscription billing: why your DB — not Stripe — is the access gate, the exact webhook events that matter and which to ignore, idempotency in webhook handlers, and the trial-to-paid transition edge cases most teams discover in production. Skips Stripe account setup — assumes keys exist and a product/price catalog is configured.
Discovery
Before writing anything, answer:
- Billing provider: Stripe (assumed below), Paddle, Lemon Squeezy?
- Plan structure: Single plan with tiers, or multiple distinct products?
- Trial period: Free trial before payment, or paid from day one?
- Cancellation behavior: Cancel immediately, or access until period end?
- Seat-based or flat-rate: Per-user pricing changes metering logic significantly.
- Customer portal: Use Stripe's hosted portal, or build custom upgrade/cancel UI?
Core Patterns
1. Your DB Is the Access Gate — Not Stripe
The trap: checking stripe.subscriptions.retrieve() on every request to verify access.
This is slow, expensive (Stripe API rate limits), and fails open when Stripe is down.
Fix: sync subscription state into your DB via webhooks and gate access against your own DB.
// DB schema — the subscription state you own and query
model Subscription {
id String @id
userId String @unique
stripeCustomerId String @unique
stripeSubId String? @unique
status String // 'trialing' | 'active' | 'past_due' | 'canceled' | 'unpaid'
plan String // 'free' | 'pro' | 'enterprise'
currentPeriodEnd DateTime
cancelAtPeriodEnd Boolean @default(false)
trialEnd DateTime?
}
// Access check — fast, no Stripe call
async function hasActiveSubscription(userId: string): Promise<boolean> {
const sub = await db.subscription.findUnique({ where: { userId } });
if (!sub) return false;
const accessStatuses = new Set(['trialing', 'active']);
// Non-obvious: 'past_due' — grace period, still grant access while retrying payment
// 'unpaid' — retries exhausted, revoke access
return accessStatuses.has(sub.status);
}
2. Checkout Session Creation
// Backend: create Stripe Checkout session
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
router.post('/api/billing/checkout', async (req, res) => {
const { priceId } = req.body;
const userId = req.user.id;
// Upsert Stripe customer — one customer per user, reuse across sessions
let sub = await db.subscription.findUnique({ where: { userId } });
let customerId = sub?.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: req.user.email,
metadata: { userId }, // critical — lets you identify the user in webhooks
});
customerId = customer.id;
await db.subscription.create({
data: { id: customerId, userId, stripeCustomerId: customerId,
status: 'free', plan: 'free',
currentPeriodEnd: new Date() },
});
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
// Non-obvious: include the userId in metadata so the webhook can find the user
// even if the customer lookup fails
subscription_data: {
metadata: { userId },
trial_period_days: 14, // omit if no trial
},
success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/billing/plans`,
});
res.json({ url: session.url });
});
Non-obvious: always attach metadata: { userId } to both the Stripe customer AND the subscription. Webhooks arrive with no session context — you'll need to look up the user from the Stripe object's metadata.
3. Webhook Handler — The Only Events That Matter
Most Stripe webhook tutorials handle every event. In practice, subscriptions only need these:
| Event | What to do |
|---|---|
checkout.session.completed |
Provision access; write stripeSubId, set status: 'trialing' or 'active' |
customer.subscription.updated |
Sync status, plan, currentPeriodEnd, cancelAtPeriodEnd |
customer.subscription.deleted |
Set status: 'canceled' |
invoice.payment_failed |
Set status: 'past_due'; send dunning email |
invoice.payment_succeeded |
Reset status: 'active'; update currentPeriodEnd |
Everything else (invoice.created, customer.updated, etc.) can be ignored unless you have a specific reason.
// Webhook endpoint
router.post('/api/webhooks/stripe',
express.raw({ type: 'application/json' }), // must be raw bytes for signature verification
async (req, res) => {
const sig = req.headers['stripe-signature']!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return res.status(400).send('Webhook signature verification failed');
}
// Idempotency: Stripe retries webhooks on non-2xx — your handler must be idempotent
// Use the event.id to deduplicate
const alreadyProcessed = await db.webhookEvent.findUnique({ where: { id: event.id } });
if (alreadyProcessed) return res.json({ received: true }); // acknowledge, don't reprocess
await db.webhookEvent.create({ data: { id: event.id, type: event.type } });
try {
await handleStripeEvent(event);
} catch (err) {
// Log but still return 200 — returning 4xx/5xx causes Stripe to retry indefinitely
console.error('Webhook handler error', event.id, err);
}
res.json({ received: true });
}
);
Non-obvious: return 200 even when your handler throws. If you return a 5xx, Stripe retries the webhook up to 3 days — which can cause duplicate processing or spam your error logs. Catch handler errors, log them, still return 200, and alert separately.
4. Webhook Event Handlers
async function handleStripeEvent(event: Stripe.Event) {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode !== 'subscription') break; // ignore one-time checkouts
const sub = await stripe.subscriptions.retrieve(session.subscription as string);
const userId = sub.metadata.userId; // from subscription_data.metadata
await db.subscription.update({
where: { userId },
data: {
stripeSubId: sub.id,
status: sub.status, // 'trialing' or 'active'
plan: getPlanFromPriceId(sub.items.data[0].price.id),
currentPeriodEnd: new Date(sub.current_period_end * 1000), // unix → Date
trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
},
});
break;
}
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
const userId = sub.metadata.userId;
await db.subscription.update({
where: { userId },
data: {
status: sub.status,
plan: getPlanFromPriceId(sub.items.data[0].price.id),
currentPeriodEnd: new Date(sub.current_period_end * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
},
});
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await db.subscription.update({
where: { userId: sub.metadata.userId },
data: { status: 'canceled', plan: 'free' },
});
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const sub = await stripe.subscriptions.retrieve(invoice.subscription as string);
await db.subscription.update({
where: { userId: sub.metadata.userId },
data: { status: 'past_due' },
});
// Trigger dunning email here
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.billing_reason === 'subscription_create') break; // handled by checkout.session.completed
const sub = await stripe.subscriptions.retrieve(invoice.subscription as string);
await db.subscription.update({
where: { userId: sub.metadata.userId },
data: { status: 'active', currentPeriodEnd: new Date(sub.current_period_end * 1000) },
});
break;
}
}
}
// Map Stripe price IDs to your internal plan names — keep this centralized
function getPlanFromPriceId(priceId: string): string {
const map: Record<string, string> = {
[process.env.STRIPE_PRICE_PRO_MONTHLY!]: 'pro',
[process.env.STRIPE_PRICE_PRO_YEARLY!]: 'pro',
[process.env.STRIPE_PRICE_ENTERPRISE!]: 'enterprise',
};
return map[priceId] ?? 'free';
}
5. Trial-to-Paid Transition Edge Cases
Trial end without payment method: if a user starts a trial without a card, Stripe moves the subscription to incomplete_expired when the trial ends — not past_due. Handle this:
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
// 'incomplete_expired' = trial ended, no payment method added
const effectiveStatus = sub.status === 'incomplete_expired' ? 'canceled' : sub.status;
await db.subscription.update({
where: { userId: sub.metadata.userId },
data: { status: effectiveStatus, ... },
});
}
Trial conversion prompt — show a banner N days before trial ends:
function trialDaysRemaining(sub: Subscription): number | null {
if (sub.status !== 'trialing' || !sub.trialEnd) return null;
return Math.ceil((sub.trialEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
}
// In layout component
const daysLeft = trialDaysRemaining(subscription);
if (daysLeft !== null && daysLeft <= 7) {
// Show upgrade banner
}
6. Access Gating by Plan
const PLAN_FEATURES = {
free: { maxProjects: 3, teamMembers: 1, apiAccess: false },
pro: { maxProjects: 50, teamMembers: 10, apiAccess: true },
enterprise: { maxProjects: Infinity, teamMembers: Infinity, apiAccess: true },
} satisfies Record<string, PlanFeatures>;
// Backend middleware — gate by feature, not plan name
function requireFeature(feature: keyof PlanFeatures) {
return async (req: Request, res: Response, next: NextFunction) => {
const sub = await db.subscription.findUnique({ where: { userId: req.user.id } });
const plan = (sub?.status === 'active' || sub?.status === 'trialing') ? sub.plan : 'free';
const features = PLAN_FEATURES[plan as keyof typeof PLAN_FEATURES];
if (!features[feature]) {
return res.status(403).json({ error: 'Upgrade required', feature });
}
next();
};
}
// Non-obvious: gate on feature, not plan name. This lets you add 'apiAccess' to 'free'
// later without hunting down every `plan === 'pro'` check in your codebase.
router.get('/api/v1/data', requireFeature('apiAccess'), getData);
7. Cancellation and Stripe Billing Portal
Cancel at period end (preferred — user keeps access until paid period ends):
router.post('/api/billing/cancel', async (req, res) => {
const sub = await db.subscription.findUnique({ where: { userId: req.user.id } });
if (!sub?.stripeSubId) return res.status(400).json({ error: 'No active subscription' });
// cancel_at_period_end = true means Stripe won't charge again, access continues until end
await stripe.subscriptions.update(sub.stripeSubId, { cancel_at_period_end: true });
await db.subscription.update({
where: { userId: req.user.id },
data: { cancelAtPeriodEnd: true },
});
res.json({ cancelAtPeriodEnd: true, accessUntil: sub.currentPeriodEnd });
});
Stripe Billing Portal — for self-serve plan changes, payment method updates, and invoice history without building custom UI:
router.post('/api/billing/portal', async (req, res) => {
const sub = await db.subscription.findUnique({ where: { userId: req.user.id } });
const session = await stripe.billingPortal.sessions.create({
customer: sub!.stripeCustomerId,
return_url: `${process.env.APP_URL}/billing`,
});
res.json({ url: session.url });
});
Non-obvious: when a user changes plans via the Billing Portal, your app receives a customer.subscription.updated webhook — not a new checkout session. The webhook handler above covers this automatically. No special handling needed.
Output
Produce:
api/billing.ts— checkout session, portal session, cancel endpointsapi/webhooks/stripe.ts— signature verification, idempotency check, event routerlib/subscription.ts—hasActiveSubscription,requireFeature,getPlanFromPriceId,trialDaysRemainingSubscriptionGate.tsx— frontend component that reads plan from context and renders upgrade prompt or children
Flag clearly in comments:
- The
past_duegrace period decision (access granted vs revoked) - Every unix timestamp → Date conversion (Stripe sends unix, not ISO)
- The
billing_reason === 'subscription_create'guard ininvoice.payment_succeeded - Idempotency key usage and why 200 is always returned from the webhook endpoint