How to capture Stripe cancellation feedback when cancellation_details is null
cancellation_details.reason tells you how a subscription ended, not why. It only ever holds one of four values, none of them the customer's own words, and one of those values (cancellation_requested) covers both a customer clicking "cancel" and an admin canceling the account from the Dashboard. The two fields that actually carry a reason, cancellation_details.feedback and cancellation_details.comment, populate along exactly one path. The customer has to cancel through Stripe's hosted customer portal, with the portal's reason-collection setting on, and then answer the prompt instead of skipping it. Cancel a subscription any other way and both fields come back null, even when reason correctly reads cancellation_requested.
That gap catches teams off guard because "the customer canceled it themselves" and "we captured why" feel like the same fact. They aren't. A subscription canceled by your own app's "Cancel plan" button, which calls the Stripe API directly, looks identical in reason to one canceled through the portal's survey, both say cancellation_requested, but only one of them ever asked the customer anything. Below is what each field actually tells you, two ways to close the gap depending on how your cancel flow is built, and the cancellations no amount of surveying will ever explain.
| Signal | Where it comes from | What it tells you |
|---|---|---|
cancellation_details.reason | Set on every canceled subscription, regardless of how it was canceled | How the subscription ended: customer action, failed payment, a dispute, or an expired test-mode subscription |
cancellation_details.feedback / .comment | Set only when the customer canceled through Stripe's hosted portal survey | Why, in the customer's own words, on the subset of cancellations where someone actually asked |
| Invoice and payment history | Exists on every subscription regardless of cancellation path | Whether a payment-failure cancellation followed one missed charge or three |
What cancellation_details.reason actually tells you
Per the Subscription object reference, reason is an enum with four possible values:
cancellation_requested: canceled explicitly through the API or Dashboardpayment_failed: canceled automatically after payment failure, per your billing settingspayment_disputed: canceled automatically after a dispute was openedcanceled_by_retention_policy: expired automatically because it was a test-mode subscription
Only the last three are unambiguous. cancellation_requested fires for a subscription your own app canceled through the API on the customer's behalf, for one an internal admin canceled from the Dashboard while resolving a billing dispute, and for one the customer walked through Stripe's hosted portal and answered a reason survey. All three produce the identical reason value. If you're building anything that reacts to reason alone, "customer-initiated" and "explained" are not the same claim, and treating them as one is the mistake this whole gap is built on.
Why feedback and comment stay empty
Both fields carry the same precondition in Stripe's own field descriptions. feedback is documented as "the customer submitted reason for why they canceled," and it's an enum of eight fixed values, not free text. comment is documented separately as "additional comments about why the user canceled the subscription." Both descriptions add the same qualifier. They populate "if the subscription was canceled explicitly by the user," through the customer portal's cancellation page. That page shows the same eight reasons plus optional free text on "Other reason," and it only runs when the portal setting is on and the customer is inside the hosted flow.
Call DELETE /v1/subscriptions/{id} directly, which is what a custom in-app cancel button does, and Stripe's own Cancel a subscription reference shows exactly this outcome. The response comes back with "reason": "cancellation_requested" and "comment": null, "feedback": null, no survey step included, because none ran. The customer still canceled. Stripe just never asked them why, because your app's cancel button and Stripe's cancellation survey are two different UIs, and only one of them collects an answer.
The plan nobody knew was in trouble
Tomasz Adeyemi runs retention at Bramyard, an inventory-forecasting tool for grocery and convenience chains, twelve people, a few hundred paying accounts on Stripe Billing. Bramyard's cancel flow is a "Cancel plan" button inside account settings that posts to their own backend, which calls the Stripe API to cancel the subscription immediately. It works, and it always has.
A 40-location grocery chain canceled its $340/month plan on a Tuesday. Tomasz found out from the weekly MRR dashboard, not from anyone on the team, because nothing had flagged the account. He pulled up the subscription in the Stripe Dashboard expecting to find a reason:
Stripe subscription record:
status: canceled·cancellation_details.reason: cancellation_requested·cancellation_details.feedback: null·cancellation_details.comment: null
Reason confirmed the obvious: someone at the account chose to leave. It said nothing about why. Tomasz messaged the account's admin directly and got an answer three days later. The chain had switched to a competitor with built-in vendor invoice matching, a gap Bramyard's roadmap already had queued for the following quarter. Three days is a long time to not know your churn reason was one release away from not happening, and the only reason it took that long is that Bramyard's cancel button never asked.
Capturing the reason despite the gap
Two fixes, depending on which UI cancels the subscription.
If your app calls the Stripe API directly to cancel: ask the reason yourself, in your own UI, before you make that call, then pass it on the same request. The Cancel a subscription endpoint accepts cancellation_details as an optional parameter, so a one-field dropdown in your own cancellation confirmation, wired to cancellation_details[feedback] and cancellation_details[comment] on the same DELETE call, closes the gap completely. This is the fix most teams miss, because the parameter exists on an endpoint they've already been calling for months without noticing it takes more than a subscription ID.
If you'd rather not build your own reason UI at all: send the customer into Stripe's hosted flow instead of canceling from your backend directly. The customer portal's deep-link flows include a subscription_cancel type built for exactly this: pass flow_data[type]=subscription_cancel and the subscription ID when you create the portal session, and the customer lands straight on Stripe's cancellation page, reason survey included, then returns to your return_url when they're done. No custom form to build or maintain, at the cost of the flow briefly leaving your app's UI.
Either fix turns a cancellation_requested cancellation from one with no answer into one with an actual answer, captured the same moment the subscription ends.
What neither fix explains
payment_failed, payment_disputed, and canceled_by_retention_policy aren't customer decisions, so there's no reason to survey for. Nobody chose to leave; a card declined, a chargeback landed, or a test-mode subscription expired on schedule. Chasing a "why" here through a feedback field is chasing something that doesn't exist. What's actually informative is the account's invoice and payment history. A card that failed once and never retried successfully reads differently from one that failed after three prior successful charges, and that distinction lives on the invoices, not on cancellation_details.
A reason field with no memory
Both fixes above are small, real, and worth building regardless of scale. What they don't do is remember anything. A reason captured today sits on that one subscription record; it doesn't know that the same account filed two support tickets last quarter, or that two other accounts canceled with the same comment last month. At a handful of cancellations a month, a person can hold that context in their head. Past that, the reason field is accurate and isolated at the same time.
This is the point we build Modem for. Modem's Stripe integration matches Stripe customers to the people and companies already in Modem and triggers on subscription events including cancellations, so whatever cancellation_details does carry, a reason, a comment, or nothing at all, lands on that account's existing topic history next to its support tickets and Slack threads instead of sitting alone on a subscription record. It does not fix the null gap above; a cancellation_requested cancellation with empty feedback and comment is still empty feedback and comment inside Modem, because that data was never sent by Stripe in the first place. What changes is that the account's other history, including whatever support tickets and Slack threads already exist on it, rides along automatically instead of requiring someone to remember to go look. We build Modem, and that shapes how we're presenting this section. If you're seeing a handful of cancellations a month, the two fixes above cover the job completely on their own, and Modem doesn't change that math. For teams weighing the wider field, the best tools for connecting Stripe revenue to customer feedback compares the options.
Pass the missing field on the call you're already making
If your cancel button calls the Stripe API directly, the fastest fix is a one-field reason prompt in front of it, passed as cancellation_details on the same request. If you'd rather not build that UI, swap the button for a subscription_cancel portal deep link and let Stripe ask instead. Either change is small enough to ship this week, and it turns a null you can't explain into an answer you didn't have to chase down later.
