Don’t get burned, make your Stripe MRR boring and correct

MRR and the dynamics of its components are the metrics investors lean on to judge an early-stage company’s growth and health (often before they read anything else in the update). Unfortunately, MRR is also the easiest metric to get subtly wrong. It looks trivial: sum the subscriptions, chart it, done. Then… a refund dents a month it shouldn’t have touched, or an ordinary upgrade books itself as churn plus reactivation. Since the number looks correct, no one notices, and a quarter passes. But it’s wrong, and it can steer you toward the wrong conclusions. We’ve been through this on dozens of client projects, analyzed every version of MRR calculations, and want to make sure you have a source of truth that makes the number boring, explainable, and safe to put in front of a board.
This guide is for engineers building or maintaining billing integration with Stripe, especially if you need MRR in your own dashboard/warehouse and want to understand it in detail. We’ll focus on fixed-price subscriptions: how to calculate their monthly value, track changes, and reconcile the results. The examples use Ruby, but the same Stripe objects and rules apply in any language.
One rule: normalize everything to monthly
Monthly recurring revenue or MRR is your current recurring subscription revenue as a monthly amount. Stripe’s starting point is subscriptions with a status of active or past_due (including those with overdue payments).
Its standard calculation excludes taxes, free plans, and metered usage; trial items are excluded while their trial lasts, and discount treatment depends on your reporting settings.
For a fixed-price subscription, normalization means converting its recurring charge to a monthly equivalent. A $1,200/year plan contributes $100 in MRR, a $300/quarter plan contributes $100, and a $100/month plan contributes $100. ARR is MRR multiplied by 12.
Weekly plans need an explicit conversion convention because calendar months don’t contain a fixed number of weeks. One reasonable approach is to assume 52 weeks per year: multiply the weekly amount by 52 ÷ 12, or about 4.33 weeks per month. Under that convention, a $30/week plan contributes $130 in MRR. Document your choice and apply it consistently; if you need to match Stripe’s dashboard exactly, verify that your conversion produces the same results.
Whatever the billing interval, MRR is a snapshot of your subscription run rate at a particular moment. It describes the monthly value of subscriptions continuing on their current terms, rather than the cash collected during a month. A customer who pays $1,200 in January still contributes $100 in MRR in July, (as long as the subscription remains active and unchanged) even though no payment arrives from them that month.
Where MRR moves in a subscription’s life
These five stages help explain when MRR changes:
-
Creation. A new subscription usually starts as
incomplete, waiting on its first payment, ortrialing, and neither counts toward MRR. -
Activation. When the status flips to
active, MRR counting starts. The trigger is the status change, not the payment: a converting trial counts from the flip even if its first invoice later fails. If this is the customer’s first paid subscription, that flip is new business; if they’ve paid before and are coming back from zero, it’s reactivation. Stripe’s own MRR behaves the same way. -
Renewal. It moves nothing. An invoice paid every cycle is a cash event confirming a run rate you already counted.
-
Modification. Prices, quantities, items, and discounts change mid-life. This is where expansion and contraction live, and where most implementations go wrong.
-
Termination. An immediate cancellation is churn now. Setting
cancel_at_period_endchanges nothing today: the subscription stays active until the period closes andcustomer.subscription.deletedfires. Track scheduled churn as a separate leading indicator; don’t subtract it early. A failed renewal isn’t churn either: the subscription goespast_dueand keeps counting while dunning runs, dropping out only if it ends incanceledorunpaid.
The resulting five movement categories give you the identity your chart must satisfy:
$$\Delta\mathrm{MRR} = \mathrm{new} + \mathrm{reactivation} + \mathrm{expansion} - \mathrm{contraction} - \mathrm{churn}$$
Add an FX term if you convert multiple billing currencies into one reporting currency (Stripe’s growth chart carries one explicitly). If your movements don’t sum to your delta, apart from rounding, there’s an error in the data or calculation.
Read subscription state instead of charges
Why not just sum successful charges? They mix recurring revenue with one-time fees, taxes, prorations, and annual prepays.
Payment timing and refunds can change cash flow without changing MRR.
Subscription objects carry the recurring terms: items, prices, billing intervals, and quantities. Normalize those, accounting for status, trials, discounts, and pricing rules.
These webhooks help identify MRR movements:
| Event | What it can mean for MRR |
|---|---|
customer.subscription.created | Usually nothing yet: incomplete or trialing subscriptions don’t count. |
customer.subscription.updated | The workhorse: activation (trialing to active), expansion or contraction through item changes, and scheduled cancels. Diff previous_attributes to see what moved. |
customer.subscription.deleted | Churn. |
customer.subscription.paused / .resumed | Exit and re-entry via the paused status. |
invoice.payment_failed | Start of dunning. MRR holds. |
Note what’s not on that list: invoice.paid, charge.succeeded, and payout events. Those report cash movement, not run-rate change. Nothing in your MRR pipeline should subscribe to them.
Four strategies to change a subscription
The same commercial change (say, moving a customer to a bigger plan) can be expressed several ways in Stripe’s API. Your chart should classify the underlying MRR change consistently.
1. Update in place
To change a customer’s plan, update the existing subscription item:
Stripe::Subscription.update(
subscription_id,
items: [{ id: item_id, price: new_price_id }]
)
The same items array handles quantity changes, removals, and additions in one call. The subscription keeps its ID and billing schedule throughout, so every change lands as one clean, attributable delta: a simple expansion or contraction.
Two nuances: removing one of three items is a contraction, not churn, because the customer’s aggregate stays positive; and under tiered pricing, a quantity change may move quantity without moving MRR.
Stripe’s event stream models this explicitly; see the Sigma table below.
A separate setting, proration_behavior, decides how the money for a mid-cycle change is collected. create_prorations, the default, adds an adjustment line to the next invoice; always_invoice charges the difference immediately; and none skips the adjustment. None of them affect MRR. Whichever you pick, the run rate moves the moment the items change, by the full difference between the old and new price.
Another important setting, payment_behavior specifies how the status of the payment affects the subscription status. Note: if you want to support 3DS flows you need to choose between allow_incomplete and pending_if_incomplete. The main difference between the two is that the first one moves subscription status to past_due on failure, still adjusting the expansion, so you need to deal with that manually, and the second one just cancels the update without affecting the MRR.
Our advice: make this your default.
This is what we ship in every billing integration, because each change stays on one subscription as a single attributable movement. Use payment_behavior: "pending_if_incomplete"; without it, the item updates immediately, and a bounced upgrade invoice books an expansion that no cash backs.
2. Cancel and recreate
The tempting choice to “just start fresh”, is easy to implement, and it also wrecks the chart. Every state-derived system, Stripe’s included, sees exactly what you did: churn plus new business. Gross churn inflates, gross new inflates, net stays the same, and your logo-churn slide is now fiction.
Operationally, you also reset the billing cycle anchor, invite an immediate charge, and sever the subscription’s history. The item-level variant has the same disease in miniature:
items: [
{ id: old_item, deleted: true },
{ price: new_price }
]
The advice we give every client wiring up Stripe is to modify in place with items: [{ id: old_item, price: new_price }], and save cancel-and-recreate for contracts that are truly new: a new legal entity, a new currency, or a deal renegotiated from scratch.
3. Pause and resume
Stripe has two unrelated pauses. pause_collection stops invoicing but leaves the status active, so Stripe’s MRR (and any status-derived MRR) just keeps counting it. Decide whether paused-collection revenue is real. We exclude it: a deliberate, documented divergence from Stripe’s chart.
The paused status (a trial that ended with no payment method, if you’ve configured that behavior) drops out of the active set on its own and needs no special handling.
4. Coupons and discounts
A permanent discount reduces MRR, so adding one counts as contraction and removing one counts as expansion. Stripe treats a reduced discount as expansion too, and always subtracts permanent recurring discounts from dashboard MRR.
One-time and fixed-duration discounts are controlled by a dashboard toggle that’s off by default. Whichever treatment you pick, mirror your dashboard’s configuration, or accept a permanent unexplained discrepancy between the two numbers.
Things that look like MRR but aren’t
| Thing | Moves MRR? | Why |
|---|---|---|
| Proration line items | No | Cash correction for a mid-cycle change. The run-rate delta was already counted at update time; proration line items exist only on invoices. |
| Free trials | No | trialing is excluded; MRR counts from the flip to active. |
| One-time charges | No | They aren’t recurring. |
| Taxes | No | Tax isn’t revenue. Tax-inclusive prices count in full; strip tax yourself if that matters. |
| Refunds and disputes | No | They’re payment-level events. Churn only if you also cancel. |
Failed payment / past_due | No, keeps counting | Dunning isn’t churn until the subscription ends in canceled or unpaid. |
| Metered, usage-based items | No | Unpredictable isn’t recurring. Stripe excludes them, and your baseline should too. |
| Multi-currency | Converts, doesn’t move | Keep per-currency sums, convert at one consistent daily rate, and expect drift versus Stripe’s estimated FX. |
What you’ll actually see in Stripe
So far, we’ve talked theory. Now, here’s how these movements look in real Stripe output. A mid-cycle upgrade from a $10 to a $20 plan with default proration produces an updated subscription item, with the new price effective immediately. On the next invoice, it produces two extra lines: a credit for unused time on the old price and a charge for the remaining time on the new one, roughly -$5 and +$10 at mid-cycle, each flagged proration: true.
The Billing overview’s MRR moves at update time by the full +$10, not the prorated cash. A converting trial arrives as customer.subscription.updated with status active and trialing in previous_attributes. A failed renewal shows invoice.payment_failed plus a past_due badge, while the dashboard keeps the subscription in MRR through dunning.
For audits, Stripe shows its work in Sigma / Stripe Data: the subscription_item_change_events table, and its fresher _v2_beta sibling, log one row per item change with an mrr_change amount in the item’s currency:
| Event type | Meaning |
|---|---|
ACTIVE_START / ACTIVE_END | An item began or stopped contributing to MRR. |
ACTIVE_UPGRADE / ACTIVE_DOWNGRADE | An item’s MRR rose or fell through a price or quantity change. |
ACTIVE_QUANTITY_INCREASE / _DECREASE | Quantity moved, but MRR did not, as can happen with tiered pricing. |
If a movement in your system can’t be matched to rows here, one of you is wrong, and now you can find out which.
One caveat before you go matching: the same event doesn’t hit everywhere at once. The dashboard runs on Stripe’s freshest pipeline; Sigma and warehouse exports trail it, each of which is on its own clock:
| Surface | Data appears after (typical) |
|---|---|
| Billing overview charts | ~1 hour |
| Revenue-tab report downloads | ~1 day |
Sigma: API-backed tables (subscriptions, invoices, and so on) | ~3 hours |
Sigma: subscription_item_change_events | Up to 24 hours |
Sigma: subscription_item_change_events_v2_beta | ~3 hours, or ~7 hours via Data Pipeline to your warehouse |
Recent rows aren’t final either, as Stripe can retroactively adjust the current and previous day’s MRR events, and its own guidance is to let data settle for up to 48 hours before consuming it incrementally. If a movement hasn’t shown up yet, the data may still be catching up. Compare the same dates, and check today’s numbers again once the data has settled.
Putting it to work: getting MRR right
Now, let’s put all of this to work. The code below is conceptual, with a few common cases from the Ruby side of a billing integration. The goal is to feed Stripe the parameters that make every movement land on your chart as what it commercially was.
Subscription updates
We already have active subscriptions. There are different options for what we can do with them:
subscription_id = "sub_..."
# Upgrade to a new price/quantity.
Stripe::Subscription.update(
subscription_id,
items: [{ id: current_item_id, price: new_price_id, quantity: new_quantity }],
proration_behavior: "always_invoice",
payment_behavior: "pending_if_incomplete"
)
# Downgrade the subscription.
# Usually a scheduled change: the customer keeps what they paid for until the period ends.
# Updates the active schedule or creates a new one.
subscription = Stripe::Subscription.retrieve(subscription_id)
schedule = current_schedule(subscription)
Stripe::SubscriptionSchedule.update(
schedule.id,
phases: [
{
start_date: schedule.current_phase.start_date,
end_date: schedule.current_phase.end_date,
items: [{ price: current_price_id, quantity: current_quantity }]
},
{
start_date: schedule.current_phase.end_date,
items: [{ price: downgraded_price_id, quantity: downgraded_quantity }]
}
]
)
def current_schedule(subscription)
return Stripe::SubscriptionSchedule.create(from_subscription: subscription.id) unless subscription.schedule
schedule = Stripe::SubscriptionSchedule.retrieve(subscription.schedule)
return schedule if schedule_active?(schedule)
Stripe::SubscriptionSchedule.release(schedule.id) if schedule.current_phase
Stripe::SubscriptionSchedule.create(from_subscription: subscription.id)
end
def schedule_active?(schedule)
phase = schedule&.phases&.first
current_phase = schedule&.current_phase
return false unless phase && current_phase
phase.start_date == current_phase.start_date && phase.end_date == current_phase.end_date
end
Trust, but reconcile
Your number and Stripe’s Billing overview should agree closely, but reconcile on data older than 48 hours. Recent days are still settling on Stripe’s side, and Sigma tables lag the dashboard by hours to a day. Compare per currency before any FX conversion, and expect every gap to trace to a documented policy difference: your pause_collection stance, discount toggles, or Stripe’s estimated FX rates.
For correctness, use Stripe’s test clocks. Create a clock-attached customer, advance time through trial, activation, upgrade, dunning, and cancellation, and assert that each transition produced exactly one movement of the right kind. Then add the assertion that guards everything:
$$\mathrm{new} + \mathrm{reactivation} + \mathrm{expansion} - \mathrm{contraction} - \mathrm{churn} = \Delta\mathrm{MRR}$$
If you’re building your billing stack right now and would rather skip the trial and error, this is exactly the kind of engineering we do for startups every week. Drop us a line—we’ll help you make MRR the most boring, trustworthy number in your investor update.


