How to View All Your Stripe Cancellation Reason Submissions
Stripe’s billing portal collects cancellation reasons when customers cancel subscriptions. The data is there. The problem is seeing it. There is no single page in Stripe to view all cancellation reasons, no way to filter them, no aggregate breakdown, and no CSV export that includes them. You are left clicking into individual subscriptions and hovering over a tiny info icon.
This recipe shows how to pipe Stripe cancellation data into Forminit using a webhook handler. Every cancellation reason, customer comment, email, and plan name lands in your Forminit inbox automatically. You get a searchable dashboard, field-level analytics with reason breakdowns, Slack and email notifications, status tracking, and CSV export. Setup takes about 10 minutes.
Why Stripe’s cancellation data is hard to use
Stripe collects cancellation feedback through the Customer Portal. When a customer cancels, they can pick from eight predefined reasons and optionally leave a comment. Stripe stores this in the cancellation_details object on the subscription:
{
"cancellation_details": {
"comment": "Switching to a competitor that has bulk export",
"feedback": "missing_features",
"reason": "cancellation_requested"
}
}
The eight feedback values are: too_expensive, missing_features, unused, too_complex, low_quality, customer_service, switched_service, and other.
The data collection works fine. The problem is what comes after:
- No aggregate view. To see a cancellation reason, you must navigate to Billing → Subscriptions → Canceled, click into the specific subscription, and hover over the info icon. There is no list, no chart, no summary.
- No export. When you export canceled subscriptions as CSV, the cancellation reason and comment fields are not included. You get customer IDs, amounts, and dates, but not the feedback.
- No notifications. Stripe does not send you an email or Slack message when a customer provides a cancellation reason. You have to remember to check.
- No filtering. You cannot filter canceled subscriptions by reason. Finding all customers who said “too expensive” requires clicking through every cancellation individually.
- Sigma requires SQL. Stripe Sigma can query cancellation data, but it is a paid add-on that requires writing SQL. It is not a dashboard.
Teams that want to actually use this data typically end up paying $58–250/month for analytics platforms like Baremetrics or retention tools like Churnkey. That works, but it is expensive for what amounts to collecting and displaying form data — which is exactly what Forminit does.
What you will build
- A webhook handler that listens for Stripe subscription cancellations
- Automatic extraction of the cancellation reason, comment, customer email, and plan name
- Submission to Forminit where every cancellation becomes a structured, searchable entry
- A dashboard with field-level analytics showing reason breakdowns, submission trends, and geographic data
- Slack, Discord, and email notifications for every churn event
- CSV export for quarterly or monthly analysis
Four implementation options are included: Node.js (Express), Python (Flask), Next.js (API route), and Cloudflare Workers.
Prerequisites
- A Forminit account (Pro plan or above for API key access)
- A Stripe account with active subscriptions
- The Stripe billing portal cancellation page enabled with feedback collection turned on
How the data flows
Customer cancels in Stripe Billing Portal
→ Picks reason + leaves optional comment
→ Stripe fires `customer.subscription.updated` webhook
→ Your webhook handler extracts cancellation_details
→ POST to Forminit with structured fields
→ View in Forminit inbox, analytics, Slack, CSV
Your webhook handler is the only piece you build. It receives the Stripe event, extracts the relevant data, and forwards it to Forminit. Forminit handles storage, notifications, analytics, and export.
Form blocks reference
The webhook handler maps Stripe cancellation data to Forminit form blocks. Using typed blocks means each field is filterable, searchable, and visible in analytics.
| Block type | Field name | What it captures |
|---|---|---|
| Sender | fi-sender-email | Customer’s email address from Stripe |
| Sender | fi-sender-fullName | Customer’s name from Stripe |
| Radio | fi-radio-cancellationReason | The feedback value from Stripe’s eight reasons |
| Text | fi-text-comment | Free-text comment the customer left |
| Text | fi-text-plan | Subscription plan or product name |
| Text | fi-text-stripeCustomerId | Stripe customer ID for cross-referencing |
| Text | fi-text-stripeSubscriptionId | Stripe subscription ID for cross-referencing |
The radio block is the key choice here. Forminit’s analytics page shows response counts and percentages for radio, select, and checkbox fields. By sending the cancellation reason as a radio block, you get an automatic breakdown: 40% “It’s too expensive,” 25% “I need more features,” 15% “I no longer need it,” and so on. No spreadsheet required.
Step 1: Create the Forminit form
- Log in to your Forminit dashboard
- Create a new form. Name it something like “Stripe Churn Feedback”
- Copy the Form ID — you will need it in your webhook handler
- Go to the form’s settings and set the authentication mode to Protected
- Copy the API Key — your webhook handler will use this to authenticate
Since submissions come from your server (not a browser), protected mode is the right choice. It allows 5 requests per second and keeps the form locked down to authenticated requests only.
Step 2: Set up the Stripe webhook
In your Stripe Dashboard:
- Click Add endpoint
- Enter your webhook handler URL (e.g.,
https://yourapp.com/api/stripe-webhook) - Select the event
customer.subscription.updated - Copy the webhook signing secret (
whsec_...) — you will use this to verify events
You only need the customer.subscription.updated event. When a customer cancels through the billing portal, Stripe updates the subscription with cancellation_details before the subscription actually ends. This means you capture the reason immediately, not when the subscription period expires.
Step 3: Write the webhook handler
Inside your Stripe webhook handler, after you verify the event signature and extract the cancellation data, submit it to Forminit using a plain HTTP request. The examples below show only the Forminit submission part — plug this into your existing webhook handler wherever you process customer.subscription.updated events.
Node.js
// ... your Stripe webhook handler verifies the event and extracts cancellation data
const subscription = event.data.object;
const feedback = subscription.cancellation_details?.feedback;
const comment = subscription.cancellation_details?.comment;
// Skip if no cancellation feedback
if (!feedback) return;
// Fetch customer and product details from Stripe
const customer = await stripe.customers.retrieve(subscription.customer);
const item = subscription.items.data[0];
const product = item ? await stripe.products.retrieve(item.price.product) : null;
// Map Stripe's feedback enum to readable labels
const reasonLabels = {
too_expensive: "It's too expensive",
missing_features: 'I need more features',
switched_service: 'I found an alternative',
unused: 'I no longer need it',
customer_service: 'Customer service was less than expected',
low_quality: 'Quality was less than expected',
too_complex: 'Ease of use was less than expected',
other: 'Other reason',
};
// Submit to Forminit
const response = await fetch('https://forminit.com/f/YOUR_FORM_ID', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': process.env.FORMINIT_API_KEY,
},
body: JSON.stringify({
blocks: [
{
type: 'sender',
properties: {
email: customer.email,
fullName: customer.name || '',
},
},
{
type: 'radio',
name: 'cancellationReason',
value: reasonLabels[feedback] || feedback,
},
{
type: 'text',
name: 'comment',
value: comment || '',
},
{
type: 'text',
name: 'plan',
value: product?.name || '',
},
{
type: 'text',
name: 'stripeCustomerId',
value: customer.id,
},
{
type: 'text',
name: 'stripeSubscriptionId',
value: subscription.id,
},
],
}),
});
const result = await response.json();
if (result.success) {
console.log('Churn feedback logged:', result.submission.hashId);
} else {
console.error('Forminit submission failed:', result.message);
}
Python
# ... your Stripe webhook handler verifies the event and extracts cancellation data
import requests
import os
subscription = event["data"]["object"]
cancellation = subscription.get("cancellation_details") or {}
feedback = cancellation.get("feedback")
# Skip if no cancellation feedback
if not feedback:
return
# Fetch customer and product details from Stripe
customer = stripe.Customer.retrieve(subscription["customer"])
item = subscription["items"]["data"][0] if subscription["items"]["data"] else None
product_name = ""
if item:
product = stripe.Product.retrieve(item["price"]["product"])
product_name = product.get("name", "")
reason_labels = {
"too_expensive": "It's too expensive",
"missing_features": "I need more features",
"switched_service": "I found an alternative",
"unused": "I no longer need it",
"customer_service": "Customer service was less than expected",
"low_quality": "Quality was less than expected",
"too_complex": "Ease of use was less than expected",
"other": "Other reason",
}
# Submit to Forminit
response = requests.post(
"https://forminit.com/f/YOUR_FORM_ID",
headers={
"Content-Type": "application/json",
"X-API-KEY": os.environ["FORMINIT_API_KEY"],
},
json={
"blocks": [
{
"type": "sender",
"properties": {
"email": customer.get("email", ""),
"fullName": customer.get("name", ""),
},
},
{
"type": "radio",
"name": "cancellationReason",
"value": reason_labels.get(feedback, feedback),
},
{
"type": "text",
"name": "comment",
"value": cancellation.get("comment", ""),
},
{
"type": "text",
"name": "plan",
"value": product_name,
},
{
"type": "text",
"name": "stripeCustomerId",
"value": customer["id"],
},
{
"type": "text",
"name": "stripeSubscriptionId",
"value": subscription["id"],
},
],
},
)
result = response.json()
if result.get("success"):
print(f"Churn feedback logged: {result['submission']['hashId']}")
else:
print(f"Forminit submission failed: {result.get('message')}")
Next.js (App Router)
// app/api/stripe-webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
// ... verify Stripe webhook signature and parse the event
const subscription = event.data.object as Stripe.Subscription;
const feedback = subscription.cancellation_details?.feedback;
const comment = subscription.cancellation_details?.comment;
if (!feedback) {
return NextResponse.json({ received: true });
}
const customer = (await stripe.customers.retrieve(
subscription.customer as string
)) as Stripe.Customer;
const item = subscription.items.data[0];
const product = item ? await stripe.products.retrieve(item.price.product as string) : null;
const reasonLabels: Record<string, string> = {
too_expensive: 'Too expensive',
missing_features: 'Missing features',
switched_service: 'Switched to another service',
unused: 'Not using it',
customer_service: 'Customer service',
low_quality: 'Low quality',
too_complex: 'Too complex',
other: 'Other',
};
// Submit to Forminit
const response = await fetch('https://forminit.com/f/YOUR_FORM_ID', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': process.env.FORMINIT_API_KEY!,
},
body: JSON.stringify({
blocks: [
{
type: 'sender',
properties: {
email: customer.email || '',
fullName: customer.name || '',
},
},
{
type: 'radio',
name: 'cancellationReason',
value: reasonLabels[feedback] || feedback,
},
{
type: 'text',
name: 'comment',
value: comment || '',
},
{
type: 'text',
name: 'plan',
value: product?.name || '',
},
{
type: 'text',
name: 'stripeCustomerId',
value: customer.id,
},
{
type: 'text',
name: 'stripeSubscriptionId',
value: subscription.id,
},
],
}),
});
const result = await response.json();
if (!result.success) {
console.error('Forminit submission failed:', result.message);
return NextResponse.json({ error: 'Failed to log cancellation' }, { status: 500 });
}
return NextResponse.json({ received: true });
}
Cloudflare Workers
// src/index.ts
export interface Env {
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
FORMINIT_API_KEY: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// ... verify Stripe webhook signature and parse the event
const subscription = event.data.object;
const feedback = subscription.cancellation_details?.feedback;
const comment = subscription.cancellation_details?.comment;
if (!feedback) {
return Response.json({ received: true });
}
// Fetch customer and product details from Stripe
const customer = await stripe.customers.retrieve(subscription.customer);
const item = subscription.items.data[0];
const product = item ? await stripe.products.retrieve(item.price.product) : null;
const reasonLabels: Record<string, string> = {
too_expensive: 'Too expensive',
missing_features: 'Missing features',
switched_service: 'Switched to another service',
unused: 'Not using it',
customer_service: 'Customer service',
low_quality: 'Low quality',
too_complex: 'Too complex',
other: 'Other',
};
// Submit to Forminit
const response = await fetch('https://forminit.com/f/YOUR_FORM_ID', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': env.FORMINIT_API_KEY,
},
body: JSON.stringify({
blocks: [
{
type: 'sender',
properties: {
email: customer.email || '',
fullName: customer.name || '',
},
},
{
type: 'radio',
name: 'cancellationReason',
value: reasonLabels[feedback] || feedback,
},
{
type: 'text',
name: 'comment',
value: comment || '',
},
{
type: 'text',
name: 'plan',
value: product?.name || '',
},
{
type: 'text',
name: 'stripeCustomerId',
value: customer.id,
},
{
type: 'text',
name: 'stripeSubscriptionId',
value: subscription.id,
},
],
}),
});
const result = await response.json();
if (!result.success) {
return Response.json({ error: 'Failed to log cancellation' }, { status: 500 });
}
return Response.json({ received: true });
},
};
Step 4: Test with Stripe CLI
Before deploying, test locally using the Stripe CLI:
# Forward Stripe events to your local server
stripe listen --forward-to localhost:3000/api/stripe-webhook
# In another terminal, trigger a test cancellation event
stripe trigger customer.subscription.updated
Check your Forminit inbox. You should see a new submission with the cancellation reason, customer details, and plan information.
For a more realistic test, create a test subscription in Stripe’s test mode, then cancel it through the customer portal with a reason selected. The webhook will fire and the cancellation data will appear in Forminit within seconds.
Step 5: Set up notifications
Once the webhook handler is deployed and cancellations are flowing into Forminit, configure notifications so your team sees churn feedback without checking the dashboard.
Email notifications
In your Forminit form settings, go to Notifications and add team email addresses. Every cancellation triggers an email with the reason, comment, customer name, and plan. You can customize the email template to highlight the fields that matter most.
Slack notifications
Connect your form to a Slack channel under Integrations → Slack. Each cancellation posts a message with the customer name, reason, and comment. This works well for teams that want real-time visibility without opening another dashboard.
Discord notifications
Same as Slack — connect under Integrations → Discord and choose the channel. Every cancellation posts automatically.
Step 6: Use the inbox and analytics
Submission inbox
Every cancellation reason is now a submission in your Forminit inbox. This is the single page that Stripe does not provide.
- Filter by date to see cancellations from this week, this month, or a custom range
- Filter by status — new cancellations land as “open.” Mark them “in-progress” when you are following up, “done” when handled, “cancelled” if irrelevant
- Star important cancellations for priority follow-up
- Internal notes — add context to each cancellation without the customer seeing it. “Reached out, offered 20% discount” or “Feature they requested is shipping next month”
- Search — find all cancellations from a specific customer, plan, or containing a keyword in the comment
- CSV export — download all cancellation data for spreadsheet analysis, pivot tables, or quarterly reviews
Analytics dashboard
This is where Forminit’s analytics make the real difference. Go to the Analytics page for your churn feedback form.
Cancellation reason breakdown. Because the reason is sent as a fi-radio-cancellationReason field, the analytics page automatically shows a breakdown of all responses with counts and percentages. You can see at a glance that 38% of cancellations are “Too expensive,” 22% are “Missing features,” and 18% are “Not using it.” No SQL, no Sigma, no spreadsheet formulas.
Submission trends. The analytics page shows daily, weekly, and monthly submission counts. A spike in cancellations is immediately visible. You can set custom date ranges to compare month-over-month churn volume.
Geographic data. Forminit auto-captures geolocation from the submitting server’s IP. While this shows your server’s location for webhook submissions, if you forward the customer’s IP via setUserInfo, you get country-level breakdown of where your churning customers are located.
Device and browser data. Less relevant for webhook submissions, but available if you add setUserInfo with the customer’s last known user agent.
Forwarding with webhooks
If you want the data in your own database or data warehouse too, set up a Forminit webhook under Integrations → Webhooks. Every submission triggers a POST to your URL with the full structured data. This lets you use Forminit as the readable dashboard while also feeding your internal analytics pipeline.
What this costs
| Approach | Monthly cost | What you get |
|---|---|---|
| Stripe dashboard only | $0 | Click into each subscription individually. No aggregate view, no export, no alerts |
| Stripe Sigma | ~$10+ | SQL queries against cancellation data. No dashboard, requires SQL knowledge |
| Baremetrics | From $58 | Full subscription analytics. Recently added native cancellation reason import |
| Churnkey | From $250 | Custom cancel flows, retention offers, A/B testing, reason analytics |
| ProsperStack | From $200 | Custom cancel flows, exit surveys, real-time reporting |
| Forminit | $19 | Inbox dashboard, analytics with reason breakdown, Slack/email alerts, CSV export, webhooks |
Forminit is not a replacement for Churnkey or ProsperStack if you want dynamic retention offers, A/B-tested cancel flows, or automated discount presentations. Those platforms replace the entire cancellation experience. Forminit solves a narrower problem: making the cancellation feedback that Stripe already collects visible, searchable, and actionable. For most teams, that is the part that actually matters.
Going further
Enrich with subscription duration
Add a fi-number-subscriptionMonths field to see how long customers stayed before canceling. Calculate it from subscription.start_date:
const startDate = new Date(subscription.start_date * 1000);
const now = new Date();
const months = Math.round((now - startDate) / (1000 * 60 * 60 * 24 * 30));
// Add to your Forminit submission
'fi-number-subscriptionMonths': months,
Forward customer IP for geolocation
If your application tracks the customer’s last known IP, pass it to Forminit for geographic analytics:
forminit.setUserInfo({
ip: customer.metadata.last_ip || undefined,
});
This enables the country and city breakdown in Forminit’s analytics, showing you where your churning customers are located.
Track cancellation trends with Forminit webhooks
Set up a Forminit webhook that forwards cancellation data to your own API or data warehouse. This gives you the best of both worlds: Forminit as the human-readable dashboard, and your own database for custom queries and long-term trend analysis.
Frequently asked questions
Does this change my Stripe cancellation flow?
No. Your customers continue canceling through the Stripe billing portal exactly as before. The portal’s cancellation page, reason selection, and comment field all stay the same. This recipe only adds a webhook handler that listens for those cancellations and forwards the data to Forminit.
What are Stripe’s eight cancellation reasons?
Stripe’s billing portal offers these predefined feedback options: 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, and Other reason. These map to the feedback enum values (too_expensive, missing_features, switched_service, unused, customer_service, too_complex, low_quality, other) on the cancellation_details object. You cannot customize or add to these options in Stripe’s portal.
Can I add custom cancellation questions beyond Stripe’s eight?
Not through Stripe’s billing portal — those eight reasons are fixed. If you want custom questions, you would need to build your own cancellation page that collects additional feedback before calling the Stripe API to cancel the subscription. That is a different pattern from this recipe, which focuses on capturing the data Stripe already collects.
What happens if the webhook handler fails?
Stripe retries failed webhook deliveries for up to 72 hours with exponential backoff. If your handler returns a non-2xx status code, Stripe will retry the event. This means a temporary outage in your webhook handler will not cause data loss — the events will be delivered when your handler comes back online.
Can I use the Forminit free plan for this?
The free plan does not include API key authentication, which is required for server-side submissions. The Pro plan at $19/month includes 5 forms with 3,000 submissions per month and API key access. For most SaaS products, that is more than enough to cover cancellation volume.
How do I backfill existing cancellation data?
You can write a one-time script that fetches all canceled subscriptions from the Stripe API and submits each one to Forminit. Use the Stripe List Subscriptions endpoint with status=canceled and iterate through the results. Be mindful of Forminit’s rate limit (5 requests per second with an API key) and add appropriate delays between submissions.
Further reading
- Forminit form blocks reference — all field types and naming conventions
- Forminit JavaScript SDK — server-side submission with Node.js
- Forminit Python SDK — server-side submission with Python
- Forminit Next.js integration — proxy handler and API routes
- Forminit Cloudflare Workers integration — edge deployment
- Stripe Cancel Subscriptions — Stripe’s cancellation documentation
- Stripe Webhooks — setting up and verifying webhooks
- Stripe Customer Portal — configuring the cancellation page