Almost every price change is reported as "we lost X% of customers and revenue went up Y%", which is two numbers with no control group and no way to tell the price change apart from the churn that would have happened anyway. This playbook is price-cohort-centric: it dates the change from the price objects themselves, uses the never-migrated legacy accounts as the control, and keeps downgrades, cancellations and proration artefacts in separate columns.
Steps
-
Find the price objects and date the change from the data. Stripe prices are immutable, so a price change always appears as a new price ID plus
active: falseon the superseded one. Callstripe.list_priceswithtype: "recurring"andactive: true, then again withactive: false, paging both fully, and sort bycreatedto locate the change event. Join tostripe.list_productsfor names, falling back to product or price metadata whennicknameis null, and report price IDs when no human name resolves. On Polar callpolar.list_productsandpolar.get_metricsto see which product IDs and metric slugs exist; on Dodo calldodo_payments.list_products. If no clean price boundary exists, group by distinctunit_amountwithin a single currency and state that assumption out loud. -
Build the three populations. For each price of interest,
stripe.list_subscriptionswithprice: <price_id>andstatus: "all", paged onstarting_afteruntil exhausted. This per-price filter is natively supported and is the backbone of the analysis. Classify each account as on-new-price, still-on-legacy-price, or migrated, and record the migration date from the subscription item change or from the first invoice carrying the new amount. On Polar usepolar.list_subscriptionssorted byamountand bystarted_at; on Dodo usedodo_payments.list_subscriptionswithproduct_idand readrecurring_pre_tax_amount. -
Normalise every amount to monthly MRR per currency. Compute
items[].price.unit_amountmultiplied byquantity, divided by 100, divided by the number of months in the interval. Handlerecurring.interval_countexplicitly: a quarterly plan hasinterval_count: 3and a six-monthly plan has 6. Keep currencies apart, because the same tier normally has one price ID per currency and grouping onunit_amountalone merges 29 USD with 29 EUR. -
Pull the cancelled population and compare against the legacy control.
stripe.list_subscriptionshas nocanceled_atfilter. Its description suggests combiningstatus: "canceled"with acanceled_atrange, but the schema accepts onlycreated_gteandcreated_lte, so that parameter does not exist and passing it filters nothing. Usestripe.searchwithresource: "subscriptions"and a query such asstatus:"canceled" AND created>..., page the synthetic_next_pagerow, then filter oncanceled_atandended_atclient side and readcancellation_details.feedback. Compute the migrated cohort's churn rate in the N months after migration minus its own pre-migration baseline, then subtract the same difference computed for the never-migrated legacy cohort over the identical calendar months. On Polar,polar.get_metricswithcanceled_subscriptions_too_expensive,canceled_subscriptions_switched_service,churn_rateandaverage_revenue_per_useratinterval: "month"gives the cleanest reason attribution available on any of the three. On Dodo,dodo_payments.list_subscriptionswithcancel_at_next_billing_date: trueis an early-warning signal the other two lack. -
Separate downgrades from cancellations and size the legacy exposure. Build the account and MRR distribution across tiers before and after the change, so a move from the old top tier to a cheaper new tier is counted as contraction rather than churn. Then sum monthly-normalised MRR on inactive or superseded prices as a share of total MRR, and recompute that same population at current list price: the difference is the uplift a migration project is worth. Pull
stripe.list_invoicesfor migrated subscriptions around the migration date to identify proration lines, and strip them before attributing any revenue change. Optionally callhubspot.search_dealsorhubspot.search_contactsto check whether sales-touched accounts were migrated on different terms from self-serve ones, and narrow the filter window until the result fits one page because the HubSpot tools do not return a usable next cursor. Where billing history is mirrored to a warehouse,postgres.get_schemathenpostgres.querymakes the before-and-after comparison a single query and should be preferred. -
Report. One row per cohort with columns: cohort (new price, legacy, migrated), accounts at start, accounts at end, monthly-normalised MRR at start, MRR at end, ARPU at start, ARPU at end, churned accounts, churn rate, downgraded accounts, expanded accounts, "too expensive" share of cancellations, currency. Add a one-line legacy exposure summary: stranded legacy MRR, its share of total MRR, and its value at current list price. Then deliver one judgement, stated as the difference-in-differences result rather than raw churn: how many points of the migrated cohort's churn sit above both its own baseline and the legacy control, where in the price ladder they concentrate, and what happened to revenue per retained account.
Gotchas
- Stripe prices are immutable, and grandfathered accounts hide behind active: false. Never expect an amount to change on an existing price. Equally, if you pull only
active: trueprices you exclude precisely the legacy population the analysis is about, so pull both states and join them. - Proration makes the change month unreadable. A mid-cycle price change produces proration credits and debits on the next invoice, so summing invoice totals in the change month shows a spike or dip that is pure accounting rather than demand. Strip proration lines before attributing revenue, and compute MRR from subscription items instead of from invoice totals.
- canceled_at is not the churn date, and the canceled_at filter does not exist. Stripe states that for a period-end cancellation
canceled_atreflects the time of the most recent update request, not the end of the subscription period, so useended_atfor when revenue actually stopped. Andcancel_at_period_endmeans the account is still paying and still in current MRR: after a price increase this bucket swells first, so reporting it as churn double counts next month while ignoring it understates the damage. - Annual plans delay the entire signal by up to twelve months. An annual cohort does not feel a price increase until renewal, so a blended view makes the increase look harmless for a year. Report monthly and annual cohorts separately, and say how much of the base has not yet renewed.
- Discounts mean the list price is not the realised price. An account on a new $79 price with a 40% coupon pays $47. Realised ARPU has to net out discounts read from the
discountsarray on subscriptions and invoices, or the measured increase is fiction. Note that Stripe exposes no coupon list tool here, so coupon identity must be read off subscriptions and invoices. - Your control group is not random. Grandfathered accounts are typically the oldest and most committed, so they would have churned less regardless. Present the difference-in-differences as the best available estimate and state the selection bias rather than claiming causality.
- Pagination, test mode and the timezone of the cut.
stripe.list_subscriptionsstill caps at 100 rows per page even with apricefilter, so a popular tier runs to many pages and a partial pull truncates the biggest cohort first. Filter onlivemodeand drop non-nulltest_clock, since pricing is the area people test most. Put the before-and-after boundary in the same timezone as the deploy: Stripe filters are UTC epoch seconds, Polar accepts an IANAtimezone, and a UTC cut against a US-evening launch misassigns a day of signups.