How to route Stripe customer portal cancellation reasons to your product team
Turning on Stripe's customer portal cancellation reasons gets you exactly one thing: a value stored on the subscription object. A customer picks "too expensive" or "missing features" from Stripe's list, maybe adds a sentence of free text, and that answer sits in cancellation_details until someone goes looking for it. Stripe does not email it to your product team, post it to Slack, or open a ticket. Turning the setting on is a data-capture decision, not a routing one.
That's the honest answer to "does it go anywhere on its own": no. The reason lives next to the subscription, visible on that customer's record in the Dashboard and queryable in Sigma, and it stays there until you write something that reads it and puts it somewhere a human will see it. Below is what Stripe actually gives you, how to build that read-and-route step yourself, and the point past which a webhook and a Slack message stop being enough.
What the portal's cancellation reasons actually are
When you enable "collect a cancellation reason" in the customer portal settings, Stripe shows the canceling customer a fixed list. Per Stripe's docs, you choose which of these your customers see:
- It's too expensive
- I need more features
- I found an alternative
- I no longer need it
- Customer service was less than expected
- Ease of use was less than expected
- Quality was less than expected
- Other reason
Pick "Other reason" and the customer can add free text. That's the whole capture surface: one selection from a fixed list, plus an optional comment. No account context, no follow-up question, nothing conditional.
Where the answer actually lands
The reason writes into cancellation_details, an object on the Subscription resource with three parts worth knowing:
cancellation_details.feedback: an enum matching the eight portal options (too_expensive,missing_features,switched_service,unused,customer_service,too_complex,low_quality,other)cancellation_details.comment: the free-text field, populated only when the customer wrote somethingcancellation_details.reason: a separate enum describing why the subscription was canceled at the system level (cancellation_requested,payment_failed,payment_disputed,canceled_by_retention_policy), which tells you whether a human chose to leave versus a card declining
Stripe's own docs point to three places to find this: the subscription's detail page in the Dashboard, a Sigma query, or the customer.subscription.updated webhook event. That third one is the only automated path, and it requires you to already have a webhook endpoint listening.
A concrete case: the reason nobody read
Here's a hypothetical that plays out at a lot of small SaaS teams: a nine-person shipment-tracking company for freight brokers, call it Ferrytail, turns on Stripe's cancellation reasons and never builds anything to read them. Odalys Marín runs product there.
An account on the $180/month plan canceled last Tuesday. The portal recorded cancellation_details.feedback: "missing_features" with the comment: "We needed multi-carrier rate comparison and kept getting told it was on the roadmap. Went with a competitor that has it." That sentence is exactly the kind of thing a product team wants in front of them the day it's written, not three months later during a churn post-mortem.
Odalys found it by accident, scrolling the customer's subscription page while investigating an unrelated billing question. Nobody on the product team had seen it. The account had also filed two support tickets about multi-carrier rates in the prior quarter, and no one connected them to the cancellation, because the tickets lived in the help desk and the cancellation reason lived in Stripe.
Building the webhook route yourself
The DIY version is a real, buildable thing, and it's worth building even if you outgrow it. The shape:
- Register a webhook endpoint subscribed to
customer.subscription.updated(andcustomer.subscription.deleted, since an immediate cancellation, one not scheduled for period end, fires that event instead, carrying the samecancellation_detailsobject). - Verify the Stripe signature on every request before trusting the payload.
- Check for a populated
cancellation_details.feedback. Mostsubscription.updatedevents are unrelated to cancellation (plan changes, coupon updates, quantity edits), so filter on the field actually being set rather than reacting to every event. - Look up the customer on the event (
customerID) to pull their name, plan, and any account metadata you store. - Post it somewhere a person will read it. A Slack message to
#product-cancellationswith the reason, comment, plan, and a link to the Stripe Dashboard record is the minimum viable version.
That's a small script, not a project, and for a while it works. The product team sees every cancellation reason the day it happens instead of never.
Where the webhook-and-Slack version stops working
The failure mode isn't the webhook breaking. It's what a raw feed of Slack messages can't do as volume grows past a handful a month:
- Nothing dedupes across reasons. Five accounts citing
missing_featuresfor the same missing capability read as five separate Slack messages, not one counted pattern, unless someone is manually tracking a spreadsheet on the side. - The account's other history doesn't ride along. Odalys's example account had two prior support tickets about the exact feature. A Slack post with the cancellation reason alone doesn't carry that, and someone has to remember to go look, which is the same failure that let the first reason go unread.
- There's no revenue or plan weighting. "Too expensive" from a $29/month account and "too expensive" from your largest enterprise renewal look identical in a chat message, and prioritizing between them means cross-referencing billing by hand.
- Retention and churn diagnosis compete for the same channel. Once cancellation reasons and other feedback both post to Slack, the volume makes both easy to scroll past.
None of that is a webhook problem. It's what happens when a routing script hands off a fact with no memory of what else is true about that customer. For the general shape of this ceiling, see how to give AI agents customer context and, on the revenue-weighting piece specifically, prioritizing feedback by what the requester pays.
Where Modem picks this up
This is the point we build for. Modem connects to Stripe as a documented integration and reads the same cancellation_details data your webhook would, but it doesn't stop at forwarding a message. The cancellation reason lands as a topic on that company's record, sitting next to the support tickets, Slack threads, and sales-call notes already attached to the same account. Odalys's example wouldn't have been a lone Slack post; it would have shown up already linked to the two prior tickets about multi-carrier rates, with the count and the plan attached. Multiple accounts citing the same reason merge into one counted topic instead of scattering across separate messages, which is the context graph idea applied specifically to churn.
Modem is what we build, so read that pitch knowing where it came from. If a Slack ping with the raw reason is genuinely enough for your team's size, the webhook above does that job on its own, and you don't need more than that until the volume or the dedupe problem shows up.
Start with the checkbox, then the webhook
If you haven't turned on cancellation reasons yet, do that first. It's a checkbox in the customer portal settings, no code required. If you have and nothing reads them, the webhook route above is a single afternoon of work: one endpoint, one filter on cancellation_details.feedback, one Slack message. That alone turns an invisible field into something your product team actually sees the day it's written, which is the whole gap this guide is about closing.
