Here is the formula everybody ranks for:
NRR = (Starting MRR + Expansion − Contraction − Churn) ÷ Starting MRR
Take the cohort of customers you had on day one of the period. Add what they upgraded into. Subtract what they downgraded out of and what they cancelled. Divide by where they started.
Above 100% means that group of customers is worth more today than it was at the start — without a single new signup.
That is the definition. Now the part the glossary pages skip.
The formula is four inputs, and Stripe hands you one
Starting MRR is the easy one. Fetch active subscriptions, normalise every price to a monthly amount, sum it. Annual plans divide by twelve, quarterly by three, weekly multiply by about 4.345.
The other three are not lookups. They are reconstructions.
Expansion and contraction are per-subscription deltas. You need to know that customer 4,110 went from two seats to five on the 14th, and that customer 812 dropped a $99 add-on on the 22nd. Stripe knows both. It just does not have an endpoint called /expansion.
Churn is the one that quietly breaks the most spreadsheets. The obvious move is to list subscriptions with status=canceled and filter by date. That returns the wrong week, every week — because created on a cancelled subscription is when it started, not when it ended. A subscription that began in March and died last Tuesday will not appear in your "last 7 days" filter at all.
The correct source is the event log: customer.subscription.deleted events inside your window. Different endpoint, different mental model, and the reason a lot of hand-rolled churn queries report a suspiciously stable zero.
The gap where a confident number appears
Now put yourself in the position of an AI assistant asked "what was our NRR last month?"
It can reach Stripe. It can pull subscriptions and invoices. It can see that the formula requires expansion and contraction across a fixed cohort. What it cannot do is find those two numbers sitting in a field somewhere.
So it does what a helpful assistant does. It produces a figure.
Not maliciously — the number will be plausible. It will be in the range you'd expect. It will sit in the report next to four figures that are completely real, which is exactly what makes it dangerous. You will not catch it by reading, because it does not look wrong. It looks like the others.
Then somebody puts it in a board deck.
What a defensible number actually requires
Two things, and only the second one is unusual.
The first is that a machine does the arithmetic. Not the model — actual code, in a real language, reading real rows. Arithmetic performed inside prose is where a grounded report quietly stops being grounded, because nobody can re-run a sentence.
The second is a checker that reads the finished report and blocks it.
That is the design of the Stripe AI Churn Analysis & Revenue Report template on jurniti. Five read-only Stripe calls come back. Python computes every rate, total, and delta, and stamps each one with the call it came from and the method used. The agent reads that bundle and writes the prose — it never divides.
Then a grounding check pulls every figure out of the draft and compares it against the bundle. A number that is not in there does not get posted:
UNGROUNDED: these figures are not in the facts bundle:
line 12: 118% → post blocked
The posting script runs that check itself rather than trusting whoever called it, so there is no version of the workflow where you skip the gate by forgetting.
Here is the honest part. The template does not compute NRR. Cohort-level expansion and contraction tracking is not in the kit today. So when you ask it for net revenue retention, it does not approximate — it reports the metric as unavailable and says why.
What it does compute, each one sourced: MRR, active subscribers, churned MRR and count, notice-given MRR and count, past-due MRR and count, collected, refunded, invoices paid, invoices retrying, gross MRR churn, logo churn, and ARPU.
A tool that tells you which number it cannot give you is worth more than one that gives you all of them.
Churn rate formula, without the benchmark theatre
Two churn rates matter and they answer different questions.
Gross MRR churn = churned MRR ÷ (active MRR + churned MRR), for the period. This is money. It ignores logo count entirely, which is correct — losing one $4,000 account is not the same event as losing one $29 account, even though both are "a customer."
Logo churn = cancelled subscriptions ÷ (active + cancelled). This is count. Run both, because the gap between them is the diagnosis: high logo churn with low MRR churn means your small accounts are leaving, which is a pricing or onboarding story, not a retention crisis.
As for what is a good churn rate — the honest answer is that the benchmark posts are close to useless. Self-serve SMB products routinely sit at 3-5% monthly logo churn and survive. Annual enterprise contracts would call 1% monthly a fire. Your comparison set is your own trend line from eight weeks ago, not a median someone published about a different business.
And note what NRR alone hides. NRR can print 115% while you are bleeding logos, because expansion from the accounts that stayed papers over the ones that left. That is why gross revenue retention exists — it counts only the losses and is capped at 100%. Report both or report neither.
Involuntary churn is a different problem wearing the same costume
Some of your churn is not a decision. It is an expired card.
Failed payments surface in Stripe as past_due and unpaid subscriptions with invoices still retrying. Lumping them into your churn number is a category error, because the fix is completely different: a customer who chose to leave needs a conversation, and a customer whose Visa expired needs an email with a link.
The template splits them. Its at-risk list is built from a rule rather than a judgement call — a subscription is at risk when it has given notice (cancel_at_period_end) or when payment is failing (past_due). Then each of those customers is matched to your PostHog data by email to answer one question: are they still using the product?
That is the tiebreaker. A past-due account logging in daily needs an invoice fixed. A past-due account nobody has seen in three weeks needs a phone call today. Same Stripe status, opposite response, and you cannot tell them apart from billing data alone.
What you actually get
Fork it, paste a restricted, read-only Stripe key, and type /pulse.
The read lands in Slack: MRR, what churned, who gave notice, whose card is failing, and what to do first. Every figure carries its source. Anything retrieval could not reach is listed under "Not available" instead of quietly becoming a zero.
The key has to be restricted, by the way. Hand it a full sk_ secret key and it refuses before making a single call — a reporting agent has no business holding credentials that can write to your billing system.
Before you trust it with anything, run bin/selftest.py. Fourteen checks, offline, no credentials. It tries to slip a fabricated figure past the gate and confirms the gate stops it, then hands the collector a writable key and confirms it refuses.
That is the whole pitch. Not that the numbers are right — that you can check them.
No free trial and no free tier. Forking provisions a real paid microVM, and the first purchase carries a 30-day money-back guarantee. Your Stripe key, your Slack workspace, your model spend, all on a box that is yours.