Most trial conversion numbers answer the wrong question because they treat every non-conversion as a buying decision. A trial whose first charge failed is not a verdict on the product, it is a billing failure, and conflating the two is the central error here. This audit cohorts on trial start, requires a settled payment before counting a conversion, and splits non-conversion into buckets that lead to different fixes.
Steps
-
Resolve plan and trial identity before building any cohort.
stripe.list_priceswithtype: "recurring"andactive: true, paged fully, to build a map of price tounit_amount,recurring.interval,recurring.interval_countandrecurring.trial_period_days. Pair withstripe.list_productsfor readable names, falling back to product or price metadata whennicknameis null. On Polar, callpolar.get_metricsonce to confirm which trial slugs this workspace populates. On Dodo, calldodo_payments.list_productsand readtrial_daysoff the subscription rather than the plan. Never infer trial length from the plan alone: a subscription can override the price's trial period. -
Build the cohort by trial start date.
stripe.list_subscriptionswithstatus: "all"andcreated_gte/created_ltebracketing the cohort month plus the full trial length, paged onstarting_afteruntil exhausted. Keeptrial_start,trial_end,status,cancel_at_period_end,canceled_at,cancellation_details,latest_invoiceanditems. On Polar usepolar.list_subscriptions; on Dodo usedodo_payments.list_subscriptionswithcreated_at_gte/created_at_lte. Separately pullstatus: "trialing"for the live pipeline and value it as pipeline, not revenue. -
Require a settled payment for the numerator. A status change is not a conversion. For each cohort member, confirm a real paid first invoice via
stripe.list_invoiceswithsubscription: <id>andstatus: "paid", or a succeeded intent viastripe.list_payment_intents. Run this only against the ambiguous subset, not the whole cohort, to keep page counts sane. On Polar,polar.list_orderswithstatus: ["paid"]serves the same purpose, andpolar.get_customer_stateresolves single accounts under discussion. -
Decompose non-conversion into four buckets. Explicit cancel during trial:
cancel_at_period_endset whiletrialing, or cancelled beforetrial_end. No payment method: Stripe statuspaused, which it reaches only when a trial ends without a payment method. Attempted and failed first charge:incomplete, orpast_dueimmediately aftertrial_end, withattempt_countat 1 or more. Hard expired:incomplete_expired, reached when the first invoice goes unpaid for 23 hours, which is terminal. Read decline reasons fromdodo_payments.list_paymentswithstatus: "failed"and itserror_code, frompolar.list_payments, or fromlast_payment_error.decline_codeonstripe.list_payment_intents. Buckets two and three are recoverable and are the entire reason this audit exists. -
Measure time to convert, trial length and abuse. Distribution of days from
trial_startto first settled payment, so trial length can be tuned to where the mass actually sits. Compare conversion across the trial lengths genuinely in use, detected fromtrial_starttotrial_enddeltas rather than assumed, and treat a mid-period trial length change as a series break. Screen for repeat trials on the same email domain or card fingerprint and for trials that reachedtrial_endwith zero product usage: usestripe.searchwithresource: "customers"orresource: "subscriptions"for metadata and name matching, andposthog.queryorpostgres.query(afterpostgres.get_schema) for the activation side. Keep the abuse bucket out of the honest conversion rate rather than deleting it. -
Report. One row per trial start cohort with columns: cohort month, trials started, converted with settled payment, conversion rate, cancelled in trial, ended with no payment method, failed first charge, hard expired, abuse-flagged, median days to convert, converted MRR, recoverable MRR in the failed and no-payment-method buckets. Then deliver one judgement: state the conversion rate against the model-matched Growth Unhinged benchmark (8% median overall, 30% when a card is required up front), and name how many points of the shortfall are a billing failure rather than a buying decision.
Gotchas
- A failed first charge is not a non-conversion. This is the trap the whole playbook exists for. Stripe's revenue recovery analytics explicitly exclude the first invoice payment following a trial, so these failures appear in neither the conversion funnel nor the dunning dashboard. Report them as their own line and price them, because they convert with an email, not with a product change.
- Trial subscriptions emit $0.00 invoices. Counting invoices to count conversions counts every trial twice, once at zero and once at the real price. Count distinct subscriptions with a settled non-zero payment instead.
- The 23 hour fuse on incomplete_expired. Stripe moves a subscription to
incomplete_expiredif the first invoice is not paid within 23 hours, and the status is terminal. A cohort pulled the next morning shows these as permanently lost even though an intervention at hour 20 would have worked, so always report the age of the window you pulled. - paused means no payment method, not customer paused. Stripe enters
pausedonly when a trial ends without a payment method. Reading it as a voluntary pause hides a pure collections problem. Separately,pause_collectiondoes not change status at all, so those accounts still readactive. - Cohort on trial start, and watch the timezone cut. Conversions lag the cohort by the trial length, so a conversion-dated numerator over a start-dated denominator mechanically breaks the newest month. Stripe filters are UTC epoch seconds, Polar takes an IANA
timezone, Dodo takes ISO timestamps, so a trial starting at 23:00 UTC on the last day of the month lands in a different cohort for a US team. - Minor units, interval_count and multi-currency. Amounts are integer minor units, so divide by 100 once and not at all for zero-decimal currencies such as JPY. Monthly normalisation is
unit_amountdivided by 100 divided by the months in the interval, and forgettinginterval_counttriples a quarterly plan. Never sum trial MRR across currencies without stating the FX source. - Test clocks and status spellings. Trial logic is the most test-clocked part of any billing integration, so filter on
livemodeand drop non-nulltest_clock. Dodo spells the cancelled state with two Ls while Stripe spells itcanceled, and a status comparison written for one silently returns zero rows against the other. On Polar, prefer the pre-computedtrial_monthly_recurring_revenueandcheckouts_conversionslugs over recomputing from raw orders; on a Stripe-only or Dodo-only account state plainly that there is no checkout-step rate available and the measurement starts at the trial subscription.