Hook and context
When your SaaS platform lets users switch plans, the billing gets complicated fast. Trials, upgrades and downgrades: lessons from production is a day-to-day reality that surprises most teams on their first run—unexpected invoice timing, proration math that doesn't match the customer's expectation, or a free trial that never converts because you forgot to collect a card upfront. We've learned these lessons the hard way across e-commerce and subscription platforms, and we're sharing what actually works.
Free trials—trial_period_days with or without a card
Stripe's free trial feature is deceptively simple: add trial_period_days to your subscription creation, and Stripe won't charge until the trial ends. But the decision of whether to collect a payment method upfront matters more than the documentation suggests.
No card upfront (trial_period_days alone). This approach maximizes signup conversion. Users enter their email and a password, hit "Start free trial," and they're in. Stripe creates a subscription in trialing status. At the end of the trial period, one of two things happens: either you've collected their card by then (via a modal, email, or onboarding flow), or the subscription transitions to incomplete and fails to charge.
The catch: if you don't collect a card before the trial ends, your success rate on that first payment plummets. We've seen conversion drop 40–60% when users have to fill in payment details after a week of free use. They've moved on. Set clear reminders at day 3 and day 7 of the trial, not on day 8.
Card upfront (trial_period_days + default_payment_method). You ask for the card during signup. Conversion is lower—typically 15–25% drop compared to card-free trials—but the trial-to-paid conversion is higher because the friction is already gone. Your team can focus on activation metrics, not payment collection.
// Trial with card upfront
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
trial_period_days: 14,
default_payment_method: paymentMethodId,
// Stripe will attempt payment on day 15
});
// Trial without card upfront
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
trial_period_days: 14,
// No default_payment_method set
// You must collect a card before day 15
});
In production, we recommend asking why you're running a trial. For a PLG (product-led growth) play where you need volume, skip the card. For a B2B platform with longer sales cycles, collect the card—users expect it, and you filter out flaky signups early.
One more detail: set payment_behavior to error_if_incomplete when creating a trial-without-card subscription. This forces the payment to fail rather than silently retrying in the background, letting you handle the failure gracefully and send a re-collection email immediately.
Upgrades—prorate immediately for fairness
An upgrade is when a customer moves to a higher-priced plan mid-billing cycle. The moment they click "Upgrade now," they expect to be charged the difference and switched immediately. Don't make them wait.
Stripe's proration_behavior is your control here. There are three main modes: create_prorations (default), always_invoice, and none.
create_prorations: Stripe calculates what the customer has already paid for the current cycle, what they owe on the new plan, and either charges them the difference or credits them. If an upgrade is cheap, they might owe $5. If a downgrade happens mid-cycle, they get a $20 credit toward the next invoice. This is the fairest option.
always_invoice: Always charge immediately, even if the customer paid for the old plan five days ago. You're essentially double-charging them this cycle and crediting next cycle. It works, but feels unfair and confuses support.
none: No proration. The upgrade applies only at the next renewal date. Users hate this for upgrades—they're not getting what they paid for yet.
For upgrades, use create_prorations and set billing_cycle_anchor carefully. If your user is mid-cycle (say, day 12 of 30), the new plan takes effect today, and the next billing date is day 30 minus 18 days of overlap. Let Stripe do the math.
// Upgrade to a higher plan, charge immediately
const updatedSubscription = await stripe.subscriptions.update(
subscriptionId,
{
items: [
{
id: existingItemId,
deleted: true, // Remove old plan
},
{
price: newHigherPriceId, // Add new plan
},
],
proration_behavior: 'create_prorations',
// Stripe invoices immediately with the prorated difference
}
);
// Inspect the invoice to see what they're charged
const invoices = await stripe.invoices.list({
subscription: subscriptionId,
limit: 1,
});
console.log('Upgrade charge:', invoices.data[0].amount_due);
A real-world scenario: a customer on the $29/month Starter plan upgrades to $99/month Pro on day 10 of 30. They've used 10 days of Starter (roughly $9.67). Pro for 20 days costs roughly $65. They owe about $55 (the difference). Stripe handles this in one update call.
If you want to show them the charge before they confirm, use the preview pattern—see the "Preview" section below.
Downgrades—often schedule at period end
Downgrades are the opposite of upgrades. A customer moves to a lower-priced plan, and you have a choice: charge them the prorated refund now, or let them keep access to the higher plan until renewal, then switch them.
Most teams schedule downgrades at the end of the billing period. Why? Because prorating a downgrade mid-cycle creates a credit, and credits confuse users. If they get a $15 credit, they assume they can use it anywhere. Some expect it as cash back; others think it never expires. This is a support headache.
The production pattern: When a customer requests a downgrade, create a new subscription starting at the next billing date, set the old one to cancel at renewal, and show them exactly when the change takes effect. No surprise credits, no proration complexity.
// Downgrade at period end
const currentSubscription = await stripe.subscriptions.retrieve(subscriptionId);
const renewalDate = new Date(currentSubscription.current_period_end * 1000);
// Schedule the downgrade for the renewal date
const updatedSubscription = await stripe.subscriptions.update(
subscriptionId,
{
items: [
{
id: existingItemId,
price: newLowerPriceId,
},
],
proration_behavior: 'none', // Don't prorate; apply at renewal
billing_cycle_anchor: currentSubscription.current_period_end,
}
);
// Notify the user
console.log(`Your plan will downgrade on ${renewalDate.toDateString()}`);
If a customer insists on an immediate downgrade with a credit, honor it—but use the preview invoice to show them the exact credit amount before you process it. Then document it in your support system so there's no confusion next month.
Preview—show the next invoice before confirming
Before a customer upgrades, downgrades, or takes any plan change, show them what they'll be charged. Stripe's invoice preview API is built for this.
Use invoices.retrieveUpcoming with the proposed subscription changes to preview the next invoice. This removes surprises and cuts support tickets by 30–40% in our experience.
// Preview an upgrade without applying it
const upcomingInvoice = await stripe.invoices.retrieveUpcoming({
customer: customerId,
subscription: subscriptionId,
subscription_items: [
{
id: existingItemId,
deleted: true,
},
{
price: newHigherPriceId,
},
],
// Include proration calculation
});
console.log('Upcoming charge:', upcomingInvoice.amount_due);
console.log('Due date:', new Date(upcomingInvoice.due_date * 1000));
// User sees this in the UI before confirming the upgrade
In the frontend, show this preview in a modal or summary panel: "Upgrade to Pro: $55 due today. Your next regular charge of $99 is on [date]." Make it prominent and use plain language. Don't hide it in small print.
For downgrades with credits, preview that too: "Downgrade to Starter: $15 credit applied to your account. Your next charge is $29 on [date]." This sets expectations and prevents the "Where's my refund?" emails.
Common mistakes
Not collecting a card before the trial ends. We said this above, but it's worth repeating. A 14-day trial with no card collection means a 50% payment failure rate if you don't actively push card collection in week two. Set up in-app notifications, emails, and a checkout modal. Treat it as a conversion funnel.
Surprising customers with prorated charges. If you use create_prorations for upgrades, show the preview first. Don't charge them $47.50 on a Tuesday and explain it in a follow-up email. They'll refund and churn.
Downgrading immediately with a credit and not explaining it. Credits are an accounting concept, not a user concept. "You have a $15 balance" means nothing to most customers. Instead, show: "Your next charge is $29, and we've applied a $15 credit, so you'll pay $14 on [date]."
Not handling subscription status transitions. When a trial ends and payment fails, your subscription moves to incomplete or past_due. You need webhooks listening for invoice.payment_failed to send a retry email or re-collect the card. If you ignore this, those failed trials are lost revenue.
Forgetting to listen to plan change events in production. Use webhooks (customer.subscription.updated) to log plan changes, send confirmation emails, and update your own database. Don't rely on the API response alone—it can get lost in network noise.
Key takeaways
- Free trials without a card increase signup volume but require aggressive card collection before the trial ends. Card-upfront trials have lower signup rates but convert better to paying customers.
- Use
proration_behavior: 'create_prorations'for upgrades and always show the customer a preview of the charge before they confirm. This is table stakes for fair, transparent billing. - Schedule downgrades at the billing period end to avoid confusion with mid-cycle credits. When credits are unavoidable, show them explicitly in the next invoice preview.
- Invoice previews are your best defense against billing complaints. Use
retrieveUpcomingto show customers exactly what they'll pay before any plan change goes live. - Listen to webhook events for
invoice.payment_failed,customer.subscription.updated, andinvoice.payment_succeeded. These are your signal to send notifications, update analytics, and handle edge cases like incomplete subscriptions.






Comments
Loading comments…