Say a customer on your $19/month Starter plan emails on the 16th: "can I move to Pro today? I need the export thing." You open Stripe, swap the price on their subscription, and reply "done".
What did they just pay? Nothing yet, most likely. And on the 1st they'll get an invoice for $64 instead of $49, and you'll get an email asking why.
That's proration. It's not a bug, it's Stripe doing exactly what the default says. But when you have under 50 subscribers, every one of these plan changes is a support thread with your name on it, so it's worth knowing what each switch actually charges before you click. This is the version I'd want: upgrades, downgrades, monthly to annual, the four traps, and how to check the number before the customer sees it. Not tax or accounting advice.
All numbers below use a 30-day month to keep the math readable. Stripe prorates to the second, so real invoices will be a few cents off.
The default: credit the old plan, charge the new one, bill it later
Stripe's proration docs describe it as two lines. When a plan changes mid-period, the customer gets a credit for the unused time on the old price, and a charge for the remaining time on the new one.
For the Starter to Pro upgrade on day 15:
| Line | Amount |
|---|---|
| Unused time on Starter ($19 x 15/30) | -$9.50 |
| Remaining time on Pro ($49 x 15/30) | +$24.50 |
| Net proration | +$15.00 |
The part people miss is when that $15 gets billed. The default proration_behavior is create_prorations, which creates those two lines as pending items and puts them on the next invoice. So on the 1st the customer sees $49 for the new month plus the $15 adjustment. $64. Correct, but nobody expects it.
You have three settings per update:
-
create_prorations(default): calculate the adjustment, bill it on the next renewal. -
always_invoice: calculate it and invoice it right now. The customer pays $15 today and $49 on the 1st. Much easier to explain. -
none: no adjustment. They pay the full new price from the next invoice. There's no setting that turns prorations off for a subscription for good, you passnoneon every request that would prorate.
For upgrades at this size I'd use always_invoice. "You paid $15 today for the rest of this month, normal $49 from the 1st" is one sentence in a reply. "Your next invoice includes a proration adjustment" is a follow-up email.
Downgrades: the credit doesn't go back to their card
Now flip it. A Pro customer drops to Starter on day 15:
| Line | Amount |
|---|---|
| Unused time on Pro | -$24.50 |
| Remaining time on Starter | +$9.50 |
| Net proration | -$15.00 |
With the default, that -$15 lands on the next invoice: $19 minus $15, so they pay $4 next month. Stripe's docs are clear that negative prorations aren't automatically refunded. The credit reduces what they owe next, it doesn't go back to the card. If they cancel before the next invoice, you have a credit sitting on a customer who's gone, and you're deciding manually whether to refund it.
Most tiny SaaS don't need any of that for a downgrade. The cleaner policy is "you keep Pro until the end of the month you paid for, then you're on Starter". No credit, no $4 invoice, nothing to explain.
In Stripe that's a subscription schedule, not a normal update. The documented pattern is two calls: create a schedule from_subscription, which gives you one phase matching the current period, then update it with a second phase on the cheaper price. The second phase starts when the current one ends.
Two things the docs warn about that are easy to get wrong:
- When you update the schedule, pass the whole current phase again (price, quantity, discounts, metadata). Stripe unsets anything you leave out.
- Once a schedule is attached, change the subscription through the Schedules API, not the Subscriptions API. Some direct changes can get overwritten at the next phase transition. Store the schedule ID next to the subscription ID.
If you use Customer Portal, there's a "Manage downgrades" setting that does the schedule for you at the end of the billing period. It only works between prices on the same product, and it creates a schedule you then need to respect the same way. I went through the portal settings I'd start with in turning on Stripe Customer Portal before 50 customers, the plan-switch part pairs with this.
Monthly to annual: the one that bills today
This is the switch you actually want people to make, and it behaves differently from the other two. Stripe's guide on changing prices says when the old and new price have the same billing period, the billing dates stay the same. When the period changes, like monthly to yearly, the new price is billed at the new interval starting the day of the change.
So say a Starter customer on day 10 switches to a $190/year plan. On a classic-mode subscription:
| Line | Amount |
|---|---|
| Unused time on monthly Starter ($19 x 20/30) | -$12.67 |
| Annual Starter, starting today | +$190.00 |
| Charged today | $177.33 |
And their renewal date moves to today, next year. If you send them a "you're on annual now" note, say that, because the date they're used to just changed.
If you pass none here, the API reference says there's no credit for the unused monthly time, but the billing date still resets and they still get charged the full $190 today. So they pay for 20 days twice. Leave proration on for this one.
There's a catch if your subscriptions are on Stripe's newer billing mode, which I'll get to below: there, the billing cycle anchor is never reset automatically. If you want "annual starts today and charges today", ask for the anchor reset explicitly on the update and preview the result first.
I'm biased here, I think an annual option belongs on the pricing page way earlier than most founders add it. I made that case in this piece on adding an annual plan before you think you need one. But the switch itself is the part that generates support email, so test it before you promote it.
Four traps I'd check before the first plan change
1. Pass the subscription item ID or you'll bill both plans. The change-price docs say this twice for a reason: if you update the subscription with a new price but don't specify which item it replaces, Stripe adds the new price as a second item. Now the customer is on Starter and Pro at once. Same page: changing the price resets the quantity to 1 unless you pass it again. If you sell seats, a 5-seat team becomes a 1-seat team.
2. Unpaid invoice plus a plan change can credit time they never paid for. Stripe calculates prorations assuming earlier invoices will eventually be paid. If a customer is past_due and downgrades, the credit for "unused" time on the expensive plan can be for time that was never paid. The docs suggest proration_behavior=none when the latest invoice is unpaid, and voiding the old invoice if you bill again, so they don't pay twice.
3. Charging now doesn't mean the change waits for the money. With always_invoice, if the card fails, the update still goes through and the subscription goes past_due. So the customer has Pro and you have a failed invoice. If you'd rather the upgrade only happen once it's paid, use payment_behavior=pending_if_incomplete. Stripe holds the change as a pending update and applies it when payment succeeds. It expires after 23 hours (or earlier, at the trial end or period end if those come first), and then the invoice is voided.
4. Discounts and prorations don't stack the way you'd guess. Stripe calculates proration amounts from the discounted price, but the proration lines themselves are marked not discountable. So if someone is on a launch coupon and upgrades, the credit and the charge both already reflect the coupon, and the coupon won't knock anything more off those lines.
Check which billing mode your subscriptions are on
This one is easy to miss. Stripe now has two billing modes, classic and flexible, and they prorate differently. Per Stripe's comparison page:
- In classic mode, a credit proration is based on the item's current price. Their example: upgrade with
none, then downgrade 10 days later, and classic credits time on a price the customer never paid. Flexible credits what was actually billed. - Classic resets the billing anchor automatically when you switch to a different interval. Flexible never does unless you ask.
Which one you get depends on how the subscription was created. For API version 2025-09-30.clover and later the default is flexible. Earlier versions default to classic. Subscriptions made in the Dashboard (and Payment Links and pricing tables made there) use the default set in your billing settings. You can migrate a subscription from classic to flexible, but not back.
So before you write any plan-change code, open one real subscription and look at billing_mode. A lot of older proration examples online assume classic.
Preview the number before the customer sees it
Stripe lets you preview the next invoice with the change applied, without changing anything. Pass a proration_date so the preview and the real update use the same second, otherwise the amount drifts a little between the two calls.
PRORATION_DATE=$(date +%s)
curl https://api.stripe.com/v1/invoices/create_preview \
-u "$STRIPE_SECRET_KEY:" \
-d customer=cus_123 \
-d subscription=sub_123 \
-d "subscription_details[items][0][id]=si_123" \
-d "subscription_details[items][0][price]=price_pro_monthly" \
-d "subscription_details[proration_date]=$PRORATION_DATE" \
> preview.json
# just the proration lines, in dollars
jq -r '.lines.data[]
| select(.parent.subscription_item_details.proration == true)
| "\(.amount / 100)\t\(.description)"' preview.json
jq '.amount_due / 100' preview.json
With the Starter to Pro example, the first jq prints the -9.5 and 24.5 lines, and the second prints 64, because the preview shows the next invoice under the default behavior (adjustment plus the new month). If you're going to use always_invoice, the proration lines are the part that gets charged today.
Then send the real update with the same proration_date:
curl https://api.stripe.com/v1/subscriptions/sub_123 \
-u "$STRIPE_SECRET_KEY:" \
-d "items[0][id]=si_123" \
-d "items[0][price]=price_pro_monthly" \
-d proration_behavior=always_invoice \
-d "proration_date=$PRORATION_DATE"
Run all three switches once in test mode with a test clock, so you've seen each one end to end before a real customer asks.
What I'd actually set up under 50 subscribers
- Upgrades:
always_invoice, pluspending_if_incompleteif you only want to unlock after payment. - Downgrades: at period end, through a schedule (or the portal's "Manage downgrades").
- Monthly to annual: proration on, explicit anchor reset if you're on flexible, preview first.
- Your app's access follows the webhook, not your "done" reply. Upgrade unlocks when the subscription update lands, downgrade locks when the next phase starts.
- One short confirmation email after every change: old plan, new plan, what they paid today, what the next invoice will be and when. If you don't have one yet, this breakdown of the billing emails that cut support tickets covers the plan-change one.
That's most of it. None of this is hard, it's just that the default does something reasonable that nobody explains to the customer.
If you handle plan changes on Stripe already: do you prorate downgrades and let the credit show up on the next invoice, or make everyone wait until the end of the period they paid for?
Top comments (1)
The $64 invoice surprise is the one that generates the angriest support tickets, and it usually arrives two weeks after the change, so the customer has already forgotten they asked for it. The always_invoice point is worth dwelling on: the proration charge and the access change land on different rails in Stripe, which is exactly why pending_if_incomplete matters for upgrades. On downgrades, letting the credit sit on the next invoice is the safer default under 50 customers, because refunding the proration to the card creates a second ledger entry that tends to resurface in disputes months later. The billing_mode note is the one most people will miss; anyone copy-pasting proration examples written before flexible mode shipped should read the comparison page first.