How to tell voluntary churn from involuntary churn before you triage feedback
Check cancellation_details.reason on the subscription before you read anything else about the cancellation. If it comes back payment_failed, payment_disputed, or canceled_by_retention_policy, nobody decided to leave; a card declined, a chargeback landed, or a test-mode subscription expired on schedule. That's not product feedback, and reading it as if it were will point your team at a problem that doesn't exist. Only cancellation_requested means an actual person chose to cancel, and even then the field only tells you that a decision happened, not what drove it.
That's the whole answer to whether a given cancellation belongs in your feedback pipeline: reason sorts the event into "somebody left on purpose" or "something failed," and only the first bucket is worth a product team's time. Below is what each value actually means, a worked case of what happens when the two get piped into the same review, and the filter to run before either one reaches a triage queue.
cancellation_details.reason decides which ones even get to enter the pipeline; the rest are billing noise wearing a churn label.What cancellation_details.reason actually separates
Per Stripe's Subscription object reference, reason is a nullable enum with four possible values:
cancellation_requested: canceled explicitly, through the API or the Dashboardpayment_failed: canceled automatically after payment failure, per your billing settingspayment_disputed: canceled automatically after a dispute was openedcanceled_by_retention_policy: a test-mode subscription that expired on schedule
Only the first one is a human decision. The other three are billing mechanics: a retry schedule ran out, a bank reversed a charge, or a sandbox subscription hit its data-retention limit. None of them carry a customer's intent, because no customer made a choice in any of those three cases. A meaningful share of all subscription cancellations arrive this way rather than through a deliberate cancel click, which is the reason involuntary churn gets its own name in the billing literature instead of being folded into churn generally. One roundup of SaaS payment gateway features lists dunning and payment-retry logic as one feature among several, sitting apart from the self-serve portals and subscription pausing that its other features pitch at the friction driving voluntary cancellations instead.
cancellation_requested still isn't the whole story. It fires identically whether the customer walked through Stripe's hosted cancellation survey, clicked a "Cancel plan" button in your own app that calls the Stripe API directly, or an internal admin canceled the account from the Dashboard while resolving a billing question. By default, only the survey path populates cancellation_details.feedback and .comment with an actual reason; a direct API cancel leaves both null unless your own app asks the customer first and passes the answer as the cancellation_details parameter on that same DELETE call, which Stripe's cancel endpoint accepts but most integrations never set. The mechanics of that gap, and how to close it on either path, are covered in capturing Stripe cancellation feedback when cancellation_details is null; this guide is about the filter that runs before that one, deciding which cancellations are worth reading for a reason at all.
Why the mix-up costs you a real signal
A feedback backlog only works if the items in it are comparable. Once involuntary churn and voluntary churn share a queue, every count is wrong in the same direction. The "why did this account leave" pile gets padded with payment failures that have nothing to say about the product, and the two or three cancellations that actually named a missing feature get diluted into noise. A product lead scanning twelve cancellations a month and seeing eight labeled payment_failed reasonably concludes churn is mostly a dunning problem. If those eight never should have been in that pile in the first place, the actual signal, four voluntary cancellations with a real reason attached, was sitting right there the whole time, just outnumbered two to one by events that were never feedback.
The month Thistlewick's churn review was mostly noise
Thistlewick sells subscription scheduling software to dog-walking and pet-sitting businesses, nine people total, and Doireann Vitale handles finance and ops there. Every month she exports canceled Stripe subscriptions to a spreadsheet and forwards it to the product team as "why customers left this month."
In October the sheet had fourteen rows. Doireann hadn't been filtering by anything beyond status: canceled, so all fourteen went into the export exactly as Stripe returned them. Nine of the fourteen carried cancellation_details.reason: payment_failed, expired cards that never got updated after three retry attempts. Two more were canceled_by_retention_policy, leftover test subscriptions from a demo environment nobody had cleaned up. That left three real cancellations, one with feedback: missing_features and a comment about needing route-optimization for walkers handling more than one neighborhood, and two with feedback: null because the customer had canceled through Thistlewick's own in-app button rather than Stripe's survey.
Product lead, replying to the sheet: Fourteen cancellations feels like a lot this month. Anything jump out?
Doireann: Not really, most of these are just expired cards. One person mentioned route optimization for multi-neighborhood walkers, that's probably worth a look.
The product lead's honest read, before Doireann's reply, was that October looked like a bad churn month. It wasn't. It was a normal month for failed payments, plus one specific, actionable piece of feedback that nearly got lost in a spreadsheet where it was outnumbered eleven to one, by the nine payment_failed rows and two canceled_by_retention_policy rows that had nothing to do with the product.
The filter to run before the review, not during it
The rule fits in one pass over cancellation_details, applied before anything gets forwarded anywhere:
reasonispayment_failed,payment_disputed, orcanceled_by_retention_policy→ route to billing or dunning, not to product. This is involuntary churn by definition; there's no customer intent in it to read.reasoniscancellation_requestedandfeedbackis populated → this is real feedback. Read the enum value and thecommentfield, and it's worth a line in the product review.reasoniscancellation_requestedandfeedbackis null → a customer chose to leave, but nothing captured why. That's still worth counting as a voluntary loss, just not one with a documented reason attached; closing that specific gap is the portal survey or API parameter fix covered separately.
Run that filter first, and the review that follows only ever contains cancellations where a person actually made a decision, which is the only kind a product roadmap should be reacting to.
The ceiling on a three-line filter
The three-line rule above is enough for a team seeing a handful of cancellations a month, and it costs nothing to add to whatever export already runs. It stops being enough once volume or the number of people reading the output grows past what one person can carry in their head:
- Nobody dedupes the reasons. Three accounts citing
missing_featuresfor the same gap read as three separate rows in a spreadsheet, not one counted pattern, unless someone is cross-referencing comments by hand every month. - The account's other history doesn't travel with the row. Thistlewick's route-optimization cancellation might have had two prior support tickets about the same limitation. A spreadsheet export of Stripe data alone has no way to know that, and someone has to remember to go check.
- The filter itself has to be maintained. It's three conditionals today; Stripe adds a new
reasonvalue or a team ships a new cancel path, and the filter is wrong until someone notices and updates it.
None of that is a filtering problem exactly. It's what happens when a rule with no memory gets applied to a growing pile of events, one export at a time.
Where Modem picks this up
This is the point we build Modem for. Modem's Stripe integration triggers on subscription events as distinct types, including cancellations and failed payments separately, so a payment_failed event never lands in the same lane as an actual cancellation in the first place, without anyone maintaining a conditional to keep them apart. It also closes the dedup gap the filter can't touch: Modem groups feedback into topics, so three accounts citing missing_features for the same gap surface as one counted topic instead of three unrelated rows nobody cross-referenced by hand. When a real cancellation does carry a reason, it lands on that customer's existing record next to their support tickets and Slack history rather than sitting alone on a subscription object, so Thistlewick's route-optimization comment would have shown up already grouped with whatever tickets that account had filed about the same limitation, not eleven-to-one outnumbered in an export nobody had time to sort by hand.
Modem is the company writing this guide, so read the pitch above with that bias priced in. Below a handful of cancellations a month, the reason-field filter above does the whole job on its own, and Modem doesn't change that math. For the wider field of tools that connect billing events to feedback, see the best tools for connecting Stripe revenue to customer feedback.
This is a five-minute change, not a new process
Nothing above requires new tooling. It's one field you're already pulling out of Stripe, checked before the export goes anywhere, not a process anyone has to remember to run. Add it to whatever spreadsheet or Slack message already carries cancellations this month, and the pile stops mixing billing noise with the two or three rows a product team actually needs to read.
