We've wired up Stripe subscriptions in enough projects to know the official docs give you the happy path and leave you to figure out everything else. What happens when a user upgrades mid-cycle? Does the trial end when they add a card? What actually fires when someone cancels — and is it immediate or at period end? These are the questions that bite you at 2am when a customer emails saying they were charged twice.
This post is about the real implementation — trials, plan changes, cancellations, and the webhook logic that ties it all together. We'll use TypeScript and the latest Stripe Node SDK. Expect actual code you can drop into a Next.js API route.
Setting Up a Subscription with a Trial
The most common setup: user signs up, gets a 14-day free trial, no credit card required. Or maybe card required — your call, but no-card trials convert better in most B2B SaaS. Here's the no-card version using Stripe's payment_behavior and trial_period_days.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
async function createTrialSubscription(customerId: string, priceId: string) {
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
trial_period_days: 14,
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
},
expand: ['latest_invoice.payment_intent'],
});
return subscription;
}
// When creating the customer, store their ID in your DB
async function createStripeCustomer(email: string, userId: string) {
const customer = await stripe.customers.create({
email,
metadata: { userId }, // critical — lets you look them up from webhooks
});
return customer.id;
}That metadata: { userId } on the customer is not optional. When Stripe fires a webhook, you get a customer ID. Without the userId in metadata, you're doing a database lookup by email — which breaks the moment someone changes their email. Store it, thank yourself later.
With payment_behavior: 'default_incomplete', the subscription starts in an incomplete state until payment is confirmed. During the trial period this doesn't matter much, but it prevents charging edge cases when the trial ends and the card gets processed for the first time.
Handling Upgrades and Downgrades Mid-Cycle
This is where most implementations get lazy and just cancel-then-recreate. Don't. Stripe has proration built in, and using it correctly means your customer gets charged fairly — the difference between what they already paid and what the new plan costs, prorated to the day.
async function changePlan(
subscriptionId: string,
newPriceId: string,
prorationBehavior: 'create_prorations' | 'always_invoice' | 'none' = 'create_prorations'
) {
// Get the current subscription to find the item ID
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currentItemId = subscription.items.data[0].id;
const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
items: [
{
id: currentItemId,
price: newPriceId,
},
],
proration_behavior: prorationBehavior,
// For immediate upgrades, invoice now
billing_cycle_anchor: 'unchanged',
});
return updatedSubscription;
}
// Upgrade immediately and charge the difference now
async function upgradeNow(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currentItemId = subscription.items.data[0].id;
// Update with always_invoice to charge immediately
const updated = await stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItemId, price: newPriceId }],
proration_behavior: 'always_invoice',
});
return updated;
}The difference between create_prorations and always_invoice is subtle but important. create_prorations adds credit/debit line items to the next invoice. always_invoice creates and immediately attempts to pay a new invoice for the difference. For upgrades, always_invoice feels more correct — the user gets access to the higher tier immediately and gets charged immediately. For downgrades, create_prorations is kinder — they get a credit on their next bill.
Never cancel and recreate subscriptions to handle plan changes. You lose the billing history, the proration math, and you'll end up double-charging someone eventually. Use subscription update.
Cancellations: Immediate vs. End of Period
There are two ways to cancel, and which you choose matters a lot for UX and for your revenue. cancel_at_period_end: true is almost always what you want. The customer keeps access until the billing period ends, you don't issue a refund, and they can reactivate before it expires.
// Soft cancel — access until end of billing period (recommended)
async function cancelAtPeriodEnd(subscriptionId: string) {
const subscription = await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
// subscription.current_period_end is the Unix timestamp when access ends
return {
subscription,
accessUntil: new Date(subscription.current_period_end * 1000),
};
}
// Hard cancel — immediate, no refund issued automatically
async function cancelImmediately(subscriptionId: string) {
const subscription = await stripe.subscriptions.cancel(subscriptionId);
return subscription;
}
// Let them reactivate before the period ends
async function reactivateCancelledSubscription(subscriptionId: string) {
const subscription = await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
return subscription;
}In your database, you should store a cancel_at_period_end flag and the current_period_end timestamp. Your access control logic then checks: is the subscription active OR is it cancelled-but-not-yet-expired? This is what separates apps that feel polished from apps that lock people out the second they hit cancel.
Webhooks: The Part Everyone Gets Wrong
Your UI can do the optimistic updates, but your database should be driven by webhooks. Stripe will retry failed webhooks for up to 3 days with exponential backoff. That means your webhook handler needs to be idempotent — processing the same event twice should have the same result as processing it once.
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Handle idempotently — check if we've already processed this event
const processed = await hasEventBeenProcessed(event.id);
if (processed) {
return NextResponse.json({ received: true });
}
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscriptionToDatabase(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionDeleted(subscription);
break;
}
case 'customer.subscription.trial_will_end': {
// Fires 3 days before trial ends — send a reminder email
const subscription = event.data.object as Stripe.Subscription;
await sendTrialEndingEmail(subscription);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentSucceeded(invoice);
break;
}
}
await markEventAsProcessed(event.id);
return NextResponse.json({ received: true });
}
async function syncSubscriptionToDatabase(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
// Look up user by Stripe customer ID
const user = await getUserByStripeCustomerId(customerId);
if (!user) return;
await updateUserSubscription(user.id, {
stripeSubscriptionId: subscription.id,
status: subscription.status,
priceId: subscription.items.data[0].price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
trialEnd: subscription.trial_end
? new Date(subscription.trial_end * 1000)
: null,
});
}That hasEventBeenProcessed check requires a table in your database that stores processed event IDs. Simple, but without it you will process the same payment.succeeded event twice on retry and credit someone's account twice. We know because we did it.
- customer.subscription.updated fires for almost everything — trial conversions, plan changes, cancellations, payment failures. It's your most important event.
- customer.subscription.trial_will_end fires 3 days before the trial ends. Use it to send a reminder email with a clear CTA to add a card.
- invoice.payment_failed means their card declined. Don't immediately revoke access — Stripe has Smart Retries. Give them a grace period and send dunning emails.
- customer.subscription.deleted means it's actually over. This is when you revoke access, not when cancel_at_period_end is set to true.
The Trial Conversion Flow
When a trial ends and there's no payment method, the subscription moves to status: 'past_due' or 'unpaid' depending on your Stripe settings. You should handle this explicitly rather than waiting for the subscription.deleted event.
The cleanest flow for no-card trials: when the user decides to upgrade during the trial, use Stripe's SetupIntent to collect card details without charging, attach it to the customer, then set it as the default payment method on the subscription. Stripe will then automatically charge it when the trial ends.
// Step 1: Create a SetupIntent to collect card without charging
async function createSetupIntent(customerId: string) {
const setupIntent = await stripe.setupIntents.create({
customer: customerId,
payment_method_types: ['card'],
usage: 'off_session', // Card will be charged automatically in future
});
return setupIntent.client_secret;
}
// Step 2: After SetupIntent confirms on the frontend, set as default
async function attachPaymentMethodToSubscription(
subscriptionId: string,
paymentMethodId: string
) {
// Attach to customer
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const customerId = subscription.customer as string;
await stripe.paymentMethods.attach(paymentMethodId, {
customer: customerId,
});
// Set as default for the subscription
await stripe.subscriptions.update(subscriptionId, {
default_payment_method: paymentMethodId,
});
// Also set as default for the customer (for future subscriptions)
await stripe.customers.update(customerId, {
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
}One thing that trips people up: when you collect a card during the trial and want to end the trial immediately (user says "start now, don't wait"), you can pass trial_end: 'now' to subscriptions.update. This converts the trial immediately and charges the customer.
Storing Subscription State in Your Database
Don't query Stripe on every request to check if a user has an active subscription. Cache it in your database and keep it fresh via webhooks. Here's roughly what your subscriptions table should look like:
// Drizzle schema, but the shape is the same for Prisma or raw SQL
import { pgTable, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';
export const subscriptionStatusEnum = pgEnum('subscription_status', [
'trialing',
'active',
'canceled',
'incomplete',
'incomplete_expired',
'past_due',
'unpaid',
'paused',
]);
export const subscriptions = pgTable('subscriptions', {
id: text('id').primaryKey(), // Stripe subscription ID
userId: text('user_id').notNull().references(() => users.id),
stripeCustomerId: text('stripe_customer_id').notNull(),
status: subscriptionStatusEnum('status').notNull(),
priceId: text('price_id').notNull(),
currentPeriodStart: timestamp('current_period_start').notNull(),
currentPeriodEnd: timestamp('current_period_end').notNull(),
cancelAtPeriodEnd: boolean('cancel_at_period_end').default(false).notNull(),
canceledAt: timestamp('canceled_at'),
trialStart: timestamp('trial_start'),
trialEnd: timestamp('trial_end'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Helper to check if user has active access
export function hasActiveAccess(subscription: typeof subscriptions.$inferSelect): boolean {
const now = new Date();
if (subscription.status === 'trialing') return true;
if (subscription.status === 'active') return true;
// Cancelled but still within the paid period
if (
subscription.status === 'canceled' &&
subscription.cancelAtPeriodEnd &&
subscription.currentPeriodEnd > now
) {
return true;
}
return false;
}That hasActiveAccess function is doing real work. The canceled-but-still-within-period check is critical. When someone cancels and cancel_at_period_end is true, Stripe immediately sets the status to... still 'active'. It only moves to 'canceled' at period end. But if they cancel and you're storing the flag, you want to know they've opted out while still showing them they have access until the date. This logic handles both the Stripe status and your own cancel flag.
Stripe's subscription status is 'active' even when cancel_at_period_end is true. Always check both the status AND your cancel_at_period_end flag when displaying subscription state to users.
The Billing Portal: Just Use It
Stripe's hosted billing portal handles upgrade, downgrade, cancel, reactivate, update payment method, and download invoices. The only reason to build your own is if you need deeply custom UI or specific flows it doesn't support. For most apps, the 20 minutes to configure the portal in your Stripe dashboard is 20 minutes well spent. Here's how to generate a session:
async function createBillingPortalSession(customerId: string, returnUrl: string) {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
return session.url;
}
// In your Next.js API route
export async function GET(req: NextRequest) {
const user = await getCurrentUser();
if (!user?.stripeCustomerId) {
return NextResponse.redirect('/pricing');
}
const url = await createBillingPortalSession(
user.stripeCustomerId,
`${process.env.NEXT_PUBLIC_URL}/settings/billing`
);
return NextResponse.redirect(url);
}If you're starting a new SaaS and don't want to wire all this up from scratch, our templates on peal.dev have this entire Stripe subscription flow pre-built — trials, webhooks, billing portal, the database schema, all of it. Worth a look before you spend a weekend reinventing it.
The Edge Cases Worth Knowing
- If a payment fails during trial-to-paid conversion, the subscription goes to 'past_due'. You should handle this in your webhook and send a payment failure email with a link to update their card.
- Stripe retries failed payments 3-4 times over several days (configurable in your dashboard under Subscriptions > Smart Retries). Don't cancel access on the first failure.
- When upgrading from a monthly to an annual plan, billing_cycle_anchor matters. Setting it to 'now' resets the billing cycle to today. 'unchanged' keeps the existing cycle date.
- Free plans are best handled outside Stripe — don't create a $0 subscription just to track free users. Store a plan field in your users table and only create Stripe subscriptions when money is involved.
- Coupons and promotions: use promotion_codes on the checkout session, not discount directly on the subscription. Promotion codes give you better tracking and can be customer-redeemable.
The last piece of advice: test your entire billing flow in Stripe's test mode with their test card numbers before you go live. Use 4242 4242 4242 4242 for success, 4000 0000 0000 0341 for payment failures, and 4000 0000 0000 3220 for 3D Secure. Run through the full lifecycle — sign up, trial end, upgrade, cancel, reactivate. The hour you spend doing this will save you from a very unhappy customer email on a Sunday morning.
