Churn reported as a single number silently merges two unrelated businesses: customers who decided to leave, and customers who still want the product but whose card failed. This audit never recomputes the MRR bridge. It sits underneath it and asks where in the retry sequence the money stopped moving, which decline codes caused it, and which named accounts are still recoverable today.
Steps
-
Discover the billing surface before measuring anything. Establish which provider is authoritative, then learn this account's own vocabulary. Call
stripe.list_priceswithtype: "recurring"andstripe.list_productsto build a price to plan-name map, becausenicknameis frequently null and plan identity often lives in product metadata. Callpolar.get_metricswith a one month window to see which of the 55 metric slugs this workspace actually populates. Do not assume status values are in use: pull the status distribution first, since the terminal state after retries is a merchant setting. -
Pull the live dunning pool and price it.
stripe.list_subscriptionswithstatus: "past_due",limit: 100, paging onstarting_afteruntil exhausted, then repeat forstatus: "unpaid"andstatus: "paused". On Polar usepolar.list_subscriptionswithstatus: ["past_due", "unpaid"]. On Dodo usedodo_payments.list_subscriptionswithstatus: "on_hold", the documented failed-renewal state, plusstatus: "failed". Sum monthly-normalised amount per currency: divide annual by 12 and quarterly by theinterval_countmonths. -
Compute the first-attempt failure rate over renewals only. Stripe's definition is the percentage of subscription payment volume that failed on the first attempt, and its scope note is explicit: recurring subscription payments only, excluding the first invoice payment following a trial (docs.stripe.com/billing/revenue-recovery/recovery-analytics). Pull
stripe.list_invoiceswithstatus: "open"andcreated_gte/created_ltecovering the period plus a 60 day lookback, then again withstatus: "uncollectible"for the written-off ones. Keepattempt_count,next_payment_attempt,subscription,total,currencyandbilling_reason, and drop first invoices. Report failure rate by volume and by count, because they diverge when failures concentrate in large accounts. -
Compute the recovery rate with an in-recovery carve-out. Recovery rate is volume recovered by any means after a failure, divided by volume that failed. Payments still inside the retry window are neither recovered nor lost, so exclude them from the denominator and show them as a separate "in recovery" line. Stripe's recommended default retry policy is 8 tries within 2 weeks, with custom schedules capped at 3 retries, so compare the observed
attempt_countdistribution and retry span against that. Price the gap as (0.55 minus your observed rate) multiplied by failed MRR, using Stripe's published 55% average recovery figure. -
Date the realised involuntary churn correctly.
stripe.list_subscriptionshas nocanceled_atfilter. Its own tool description suggests pairingstatus: "canceled"with acanceled_atrange, but the input schema accepts onlycreated_gteandcreated_lte, so that instruction cannot be followed and attempting it will not filter anything. Usestripe.searchwithresource: "subscriptions"and a query such asstatus:"canceled" AND created>..., paging the synthetic_next_pagerow, then filter oncanceled_atandended_atclient side. A termination with no successful final payment and no explicit cancel action is involuntary; a termination withcancellation_detailspopulated is voluntary. On Polar read the per-reason cancellation metrics directly throughpolar.get_metrics. -
Segment decline reasons, then check whether the customer is still there. Use
stripe.list_payment_intentsover the window and readlast_payment_error.decline_code, rather thanstripe.list_charges, which has no status filter and would mean paging the whole charge volume.dodo_payments.list_paymentswithstatus: "failed"returnserror_codedirectly and is the best decline surface of the three;polar.list_paymentscovers the Polar case. Split retryable soft declines (insufficient funds, generic decline, do not honor, processing errors) from Stripe's published hard decline list: incorrect_number, lost_card, pickup_card, stolen_card, revocation_of_authorization, revocation_of_all_authorizations, authentication_required, highest_risk_level, transaction_not_allowed. Then join the dunning pool to recent product activity withposthog.queryor a mirrored billing table viapostgres.queryafter callingpostgres.get_schema. An account in dunning that still logs in daily is recoverable; one dark for 30 days is voluntary churn wearing a card-decline costume. -
Report. One row per month with columns: month, renewal volume attempted, first-attempt failed volume, failure rate by volume, failure rate by count, recovered volume, recovery rate, volume still in recovery, written-off volume, involuntary churn rate, voluntary churn rate, involuntary share of total churn. Add a second table of the top accounts in dunning now: customer, plan, monthly amount, currency, status, attempt count, last decline code, days since last product activity. Then deliver one judgement: whether the loss is a card-capture problem (hard declines dominate, fix account updater and pre-dunning) or a retry-timing problem (soft declines dominate, fix the retry schedule), with the priced gap against the 55% benchmark.
Gotchas
- Minor currency units, exactly once. Stripe, Polar and Dodo all return integer amounts in the currency's smallest unit. Divide by 100 once, never twice, and never for zero-decimal currencies such as JPY and KRW, where dividing understates by 100x. Never sum across currencies: only
dodo_payments.list_ledger_entriesexposes ausd_equivalent_amount, so for Stripe and Polar report per currency or state the FX source and date. - The canceled_at filter does not exist.
stripe.list_subscriptionsdocuments astatuspluscanceled_atrange pattern that its schema does not support. Passing it does nothing and the result silently covers subscriptions by creation date instead, which looks plausible and is wrong. All churn-timing slices must go throughstripe.searchor be filtered client side oncanceled_atandended_at. - past_due, unpaid, canceled and incomplete_expired are config artefacts. Stripe's terminal behaviour after exhausted retries is a merchant setting: cancel, mark unpaid, leave past_due, or pause. The same reality therefore appears as three different statuses across two accounts.
incomplete_expiredis separate again: Stripe moves a subscription there if the first invoice is unpaid within 23 hours, and it is terminal. Read the distribution before interpreting any single status, and note that Dodo spells itcancelledwith two Ls where Stripe spells itcanceled. - attempt_count rises without a new charge, and one invoice spawns many charges. On a hard decline Stripe keeps scheduling retries and keeps incrementing
attempt_count, but the payment only executes once a new payment method exists, so no Charge object is created. Counting charges undercounts attempts and countingattempt_countovercounts real attempts. Use failed invoices for revenue at risk and failed attempts for operational volume, and never add the two. - The retry window straddles the month cut, and the cut itself is UTC. The in-recovery pool means current-month and previous-month recovery rates stay provisional for up to two months, so label them. Stripe filters are Unix epoch seconds in UTC, Polar accepts an IANA
timezoneonpolar.get_metrics, and Dodo takes ISO timestamps. A UTC month boundary reshuffles which failures land in which month for a US-based team. - Trial first charges are a different population. Stripe's recovery analytics deliberately exclude the first invoice after a trial, so including it here turns a trial or card-capture problem into a fake dunning problem. Keep that cohort out and send it to the trial-conversion-audit playbook.
- Test data and invisible pauses. Filter Stripe objects on
livemodeand drop anything with a non-nulltest_clock, because dunning is the most test-clocked part of a billing integration. Separately,pause_collectionleaves the subscription status unchanged, so a paused-collection account looksactivewhile generating nothing. India-issued cards are never auto-retried by Stripe, so an India-heavy base carries a structurally higher unrecovered rate that no retry config fixes.