Diagnostic Stripe
billing portal can't cancel, so customers charge back instead
Your dispute rate is drifting up and the reasons cluster on one code: subscription_canceled. These are not fraudsters. They are customers who wanted to stop paying, could not find a way to do it, emailed support, waited, and then used the one cancellation mechanism that always works — their bank.
Read GET /v1/billing_portal/configurations?limit=100 and check features.subscription_cancel.enabled on the default configuration. It defaults to false, so a portal created without asking for it has no cancel button. Check features.payment_method_update.enabled at the same time, for the same reason.
Then price it: GET /v1/disputes?created[gte]=<now-180d> and count reason == "subscription_canceled" as a share of all disputes. That share is what the missing button costs, in money and in dispute rate.
The problem in plain words
This is the rare integration problem where the failure is not an error anywhere. The portal works. Customers open it, see their invoices, update their address, and find no way to leave. Every API call returns 200. The only trace is downstream, in a dispute reason code that nobody maps back to a feature flag.
It is distinct from having no portal at all, which is the portal sessions 400 case: that one throws, gets noticed, and gets fixed within a day. This one never throws. It quietly converts a customer who was leaving anyway into a chargeback, and chargebacks count against you in a way that voluntary cancellations never do.
Why it happens
The feature defaults to off. features.subscription_cancel.enabled is false unless somebody explicitly turned it on, in the Dashboard or in the create call. Nobody notices an absent button while building the portal, because the person building it is not trying to cancel anything.
Turning it off feels like retention. Making cancellation hard is a strategy some teams choose on purpose. It works, in the sense that fewer people cancel through the portal. It does not work in the sense that matters: the customer still stops paying, only now it happens through a dispute that costs a fee, counts toward your dispute rate, and cannot be won.
A dispute is not a refund. Stripe's own dispute-prevention guidance says an in-app cancellation button is often the best solution because it does not require the cardholder to wait. Waiting is what turns an ordinary cancellation into a chargeback, and a chargeback is a fee plus a mark against the account, permanently.
Card update matters for the same reason. If payment_method_update is off too, a customer whose card expired cannot fix it and cannot leave. Both roads lead to support, and one of them leads to the bank.
The fix, as a flow
The script reads one feature flag and then prices it, because a portal with no cancel button is an opinion until the disputes that name it are counted against the disputes that do not.
How to fix it
Find the configuration your code actually uses
GET /v1/billing_portal/configurations?limit=100. If your session-create call passes an explicit configuration, check that one; if it does not, the one that matters is the one with is_default == true and active == true. If there is no configuration at all, that is a different note and a louder failure.
Read features.subscription_cancel.enabled
false is the finding. While you are there, read mode and cancellation_reason: at_period_end keeps the service running to the end of the paid period, and the reason prompt is the only structured churn data you will ever get for free.
Read features.payment_method_update.enabled too
A portal that can cancel but cannot update a card sends every expired-card customer to support anyway. The two features fail into the same queue and it is worth fixing them in the same change.
Count the disputes that name it
GET /v1/disputes?created[gte]=<now-180d>&limit=100, paginated. Count reason == "subscription_canceled" against the total. A material share is the direct cost of the missing button, and it is the number that gets the change approved.
Turn cancellation on, and collect the reason
Enable subscription_cancel with mode=at_period_end, enable the cancellation reason prompt, and enable payment_method_update. You lose the customers you were going to lose anyway, and you keep the fee, the dispute rate and the explanation of why they left.
How to check it worked
Re-run the script. The configuration your code uses should report cancellation available, and the dispute share should fall over the following months.
python3 stripe_portal_cancel_disabled.py --days 180
# self-serve bpc_1MrTdC: cancel at_period_end, card update on
The full code
Two GETs and no writes — a restricted key with read access to the Customer Portal and Disputes is enough. The classifier is pure and takes one configuration object plus two dispute counts, because the same disabled flag is a design opinion on an account with no disputes and a measurable bill on one with a wall of subscription_canceled.
"""Report a Stripe Billing Portal configuration with cancellation switched off.
Read only. Two GETs and no writes: give this a RESTRICTED key with read access to
the Customer Portal and Disputes. The repair is printed, never performed, because
this script holds a credential to a live payments account.
"""
import argparse
import logging
import os
import sys
import time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_portal_cancel_disabled")
API = "https://api.stripe.com/v1"
def verdict(configuration, cancel_disputes=0, total_disputes=0):
"""Classify one portal configuration. Pure, so it is testable offline.
`cancel_disputes` is the number of disputes in the window with reason
subscription_canceled and `total_disputes` every dispute in the same window.
Returns (state, detail).
"""
features = ((configuration or {}).get("features") or {})
cancel = features.get("subscription_cancel") or {}
update = features.get("payment_method_update") or {}
config_id = (configuration or {}).get("id", "<no id>")
enabled = cancel.get("enabled")
if enabled is None:
return ("unknown",
"%s does not report features.subscription_cancel.enabled; read the "
"configuration rather than assuming either way" % config_id)
if not enabled:
if cancel_disputes:
share = 100.0 * cancel_disputes / total_disputes if total_disputes else 0.0
return ("cancel-off-disputed",
"%s has no cancel button, and %d of %d dispute(s) in the window "
"(%.1f%%) are subscription_canceled"
% (config_id, cancel_disputes, total_disputes, share))
return ("cancel-off",
"%s has no cancel button: the fastest exit a customer has is their "
"bank" % config_id)
if not update.get("enabled"):
return ("update-off",
"%s can cancel but cannot update a card, so an expired card still "
"goes to support" % config_id)
if not (cancel.get("cancellation_reason") or {}).get("enabled"):
return ("no-reason",
"%s cancels at %s and collects no cancellation reason: the churn "
"data is free and is being discarded"
% (config_id, cancel.get("mode") or "an unspecified point"))
return ("self-serve",
"%s: cancel %s, card update on" % (config_id, cancel.get("mode") or "on"))
def get(session, path, params=None):
r = session.get(API + path, params=params or {}, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from Stripe: the key is wrong, or is for the other mode")
r.raise_for_status()
return r.json()
def configurations(session):
"""Every portal configuration in whichever mode the key is for."""
out = []
params = {"limit": 100}
while True:
page = get(session, "/billing_portal/configurations", params)
data = page.get("data", [])
out.extend(data)
if not data or not page.get("has_more"):
break
params["starting_after"] = data[-1]["id"]
return out
def dispute_counts(session, since, cap):
"""(disputes citing a cancelled subscription, all disputes) since `since`."""
cancel = total = 0
params = {"created[gte]": since, "limit": 100}
while True:
page = get(session, "/disputes", params)
data = page.get("data", [])
for d in data:
total += 1
if d.get("reason") == "subscription_canceled":
cancel += 1
if not data or not page.get("has_more") or total >= cap:
break
params["starting_after"] = data[-1]["id"]
return cancel, total
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=180,
help="dispute window to price the missing button over")
ap.add_argument("--max-disputes", type=int, default=2000,
help="stop paginating after this many disputes")
args = ap.parse_args()
key = os.environ.get("STRIPE_API_KEY")
if not key:
log.error("set STRIPE_API_KEY (use a restricted, read-only key)")
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + key})
configs = configurations(s)
if not configs:
log.warning("no portal configuration exists at all, which is a louder "
"failure: every portal session create returns 400")
return 1
since = int(time.time()) - args.days * 86400
cancel_disputes, total_disputes = dispute_counts(s, since, args.max_disputes)
bad = 0
for config in configs:
if not config.get("active"):
continue
state, detail = verdict(config, cancel_disputes, total_disputes)
line = "%-20s %s" % (state, detail)
if state == "self-serve":
log.info(line)
continue
bad += 1
log.warning(line)
if state in ("cancel-off", "cancel-off-disputed", "unknown"):
log.warning(" repair: POST %s/billing_portal/configurations/%s "
"-d \"features[subscription_cancel][enabled]=true\" "
"-d \"features[subscription_cancel][mode]=at_period_end\" "
"-d \"features[subscription_cancel]"
"[cancellation_reason][enabled]=true\"",
API, config.get("id"))
if state in ("update-off", "cancel-off", "cancel-off-disputed"):
log.warning(" and: POST %s/billing_portal/configurations/%s "
"-d \"features[payment_method_update][enabled]=true\"",
API, config.get("id"))
log.info("%d active configuration(s), %d needing attention", len(configs), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report a Stripe Billing Portal configuration with cancellation switched off.
*
* Read only. Two GETs and no writes: give this a RESTRICTED key with read access
* to the Customer Portal and Disputes. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
/**
* Classify one portal configuration. Pure, so it is testable offline.
* A missing enabled flag is unknown, not false.
*/
export function verdict(configuration, cancelDisputes = 0, totalDisputes = 0) {
const features = (configuration ?? {}).features ?? {};
const cancel = features.subscription_cancel ?? {};
const update = features.payment_method_update ?? {};
const id = (configuration ?? {}).id ?? '<no id>';
const enabled = cancel.enabled;
if (enabled === null || enabled === undefined) {
return ['unknown',
`${id} does not report features.subscription_cancel.enabled; read the ` +
'configuration rather than assuming either way'];
}
if (!enabled) {
if (cancelDisputes) {
const share = totalDisputes ? (100 * cancelDisputes / totalDisputes) : 0;
return ['cancel-off-disputed',
`${id} has no cancel button, and ${cancelDisputes} of ${totalDisputes} ` +
`dispute(s) in the window (${share.toFixed(1)}%) are subscription_canceled`];
}
return ['cancel-off',
`${id} has no cancel button: the fastest exit a customer has is their bank`];
}
if (!update.enabled) {
return ['update-off',
`${id} can cancel but cannot update a card, so an expired card still goes ` +
'to support'];
}
if (!(cancel.cancellation_reason ?? {}).enabled) {
return ['no-reason',
`${id} cancels at ${cancel.mode ?? 'an unspecified point'} and collects no ` +
'cancellation reason: the churn data is free and is being discarded'];
}
return ['self-serve', `${id}: cancel ${cancel.mode ?? 'on'}, card update on`];
}
async function get(key, path, params = {}) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 401) {
throw new Error('401 from Stripe: the key is wrong, or is for the other mode');
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
export async function configurations(key) {
const out = [];
const params = { limit: 100 };
for (;;) {
const page = await get(key, '/billing_portal/configurations', params);
const data = page.data ?? [];
out.push(...data);
if (data.length === 0 || !page.has_more) break;
params.starting_after = data[data.length - 1].id;
}
return out;
}
export async function disputeCounts(key, since, cap = 2000) {
let cancel = 0; let total = 0;
const params = { 'created[gte]': since, limit: 100 };
for (;;) {
const page = await get(key, '/disputes', params);
const data = page.data ?? [];
for (const d of data) {
total += 1;
if (d.reason === 'subscription_canceled') cancel += 1;
}
if (data.length === 0 || !page.has_more || total >= cap) break;
params.starting_after = data[data.length - 1].id;
}
return { cancel, total };
}
async function main() {
const key = process.env.STRIPE_API_KEY;
if (!key) {
console.error('set STRIPE_API_KEY (use a restricted, read-only key)');
process.exitCode = 2;
return;
}
const configs = await configurations(key);
if (configs.length === 0) {
console.warn('no portal configuration exists at all, which is a louder ' +
'failure: every portal session create returns 400');
process.exitCode = 1;
return;
}
const days = Number(process.argv[2] ?? 180);
const since = Math.floor(Date.now() / 1000) - days * 86400;
const { cancel, total } = await disputeCounts(key, since);
let bad = 0;
for (const config of configs) {
if (!config.active) continue;
const [state, detail] = verdict(config, cancel, total);
const line = `${state.padEnd(20)} ${detail}`;
if (state === 'self-serve') { console.log(line); continue; }
bad += 1;
console.warn(line);
if (state === 'cancel-off' || state === 'cancel-off-disputed' || state === 'unknown') {
console.warn(` repair: POST ${API}/billing_portal/configurations/${config.id} ` +
'-d "features[subscription_cancel][enabled]=true" ' +
'-d "features[subscription_cancel][mode]=at_period_end" ' +
'-d "features[subscription_cancel][cancellation_reason][enabled]=true"');
}
if (state === 'update-off' || state === 'cancel-off' || state === 'cancel-off-disputed') {
console.warn(` and: POST ${API}/billing_portal/configurations/${config.id} ` +
'-d "features[payment_method_update][enabled]=true"');
}
}
console.log(`${configs.length} active configuration(s), ${bad} needing attention`);
process.exitCode = bad ? 1 : 0;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The split worth pinning is between a portal with no cancel button on an account with no disputes and the same portal on an account where a sixth of the disputes say subscription_canceled. Identical configuration, and only one of them has a number attached. The last test is the usual one: a missing enabled flag is something you did not read, not something you know is off.
from stripe_portal_cancel_disabled import verdict
FULL = {"id": "bpc_1", "features": {
"subscription_cancel": {"enabled": True, "mode": "at_period_end",
"cancellation_reason": {"enabled": True}},
"payment_method_update": {"enabled": True}}}
NO_CANCEL = {"id": "bpc_2", "features": {
"subscription_cancel": {"enabled": False},
"payment_method_update": {"enabled": True}}}
def test_a_portal_that_cancels_and_asks_why_is_done():
state, detail = verdict(FULL, 0, 40)
assert state == "self-serve"
assert "at_period_end" in detail
def test_cancellation_off_with_no_disputes_is_still_the_finding():
state, detail = verdict(NO_CANCEL, 0, 0)
assert state == "cancel-off"
assert "their bank" in detail
def test_cancellation_off_with_disputes_naming_it_is_priced():
state, detail = verdict(NO_CANCEL, 7, 42)
assert state == "cancel-off-disputed"
assert "16.7%" in detail
def test_cancel_on_but_card_update_off_still_sends_people_to_support():
config = {"id": "bpc_3", "features": {
"subscription_cancel": {"enabled": True, "mode": "immediately",
"cancellation_reason": {"enabled": True}},
"payment_method_update": {"enabled": False}}}
assert verdict(config)[0] == "update-off"
def test_cancelling_without_asking_why_throws_away_the_churn_data():
config = {"id": "bpc_4", "features": {
"subscription_cancel": {"enabled": True, "mode": "at_period_end"},
"payment_method_update": {"enabled": True}}}
state, detail = verdict(config)
assert state == "no-reason"
assert "at_period_end" in detail
def test_a_missing_enabled_flag_is_not_read_as_off():
# Reporting a cancel button as missing on a portal that has one sends somebody
# to fix a configuration that is already correct.
assert verdict({"id": "bpc_5", "features": {"subscription_cancel": {}}})[0] == "unknown"
assert verdict(None)[0] == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-portal-cancel-disabled.mjs';
const FULL = { id: 'bpc_1', features: {
subscription_cancel: { enabled: true, mode: 'at_period_end',
cancellation_reason: { enabled: true } },
payment_method_update: { enabled: true } } };
const NO_CANCEL = { id: 'bpc_2', features: {
subscription_cancel: { enabled: false },
payment_method_update: { enabled: true } } };
test('a portal that cancels and asks why is done', () => {
const [state, detail] = verdict(FULL, 0, 40);
assert.equal(state, 'self-serve');
assert.match(detail, /at_period_end/);
});
test('cancellation off with no disputes is still the finding', () => {
const [state, detail] = verdict(NO_CANCEL, 0, 0);
assert.equal(state, 'cancel-off');
assert.match(detail, /their bank/);
});
test('cancellation off with disputes naming it is priced', () => {
const [state, detail] = verdict(NO_CANCEL, 7, 42);
assert.equal(state, 'cancel-off-disputed');
assert.match(detail, /16\.7%/);
});
test('cancel on but card update off still sends people to support', () => {
const config = { id: 'bpc_3', features: {
subscription_cancel: { enabled: true, mode: 'immediately',
cancellation_reason: { enabled: true } },
payment_method_update: { enabled: false } } };
assert.equal(verdict(config)[0], 'update-off');
});
test('cancelling without asking why throws away the churn data', () => {
const config = { id: 'bpc_4', features: {
subscription_cancel: { enabled: true, mode: 'at_period_end' },
payment_method_update: { enabled: true } } };
const [state, detail] = verdict(config);
assert.equal(state, 'no-reason');
assert.match(detail, /at_period_end/);
});
test('a missing enabled flag is not read as off', () => {
assert.equal(verdict({ id: 'bpc_5', features: { subscription_cancel: {} } })[0],
'unknown');
assert.equal(verdict(null)[0], 'unknown');
});
FAQ
Does letting customers cancel themselves increase churn?
It increases visible cancellations and reduces disputes. The customer who wanted to leave leaves either way; the only variable is whether that exit costs you a dispute fee and a mark on your dispute rate, or a row in a churn report with a reason attached.
What is the subscription_canceled dispute reason?
It is the reason a cardholder gives when they say they cancelled and were charged anyway, or could not cancel. A cluster of them is the clearest evidence that the exit path in your product does not work, whether that is a missing portal button or a support queue people gave up on.
Should cancellation take effect immediately or at period end?
at_period_end is the usual choice: the customer keeps what they paid for, you keep the revenue for the current period, and there is no partial-refund argument. Immediate cancellation is right when the service is metered or when access itself is the thing being disputed.
Why does the script check payment_method_update as well?
Because it fails into the same queue. A customer whose card expired cannot pay and cannot leave, so they contact support for one of two reasons and get the same wait either way. The two flags are almost always turned on in the same change.
What if my code passes an explicit configuration id?
Then check that configuration rather than the default. The script classifies every active configuration on the account for exactly this reason: an account can have a perfectly configured default and pass the id of an older one that has cancellation switched off.
Related field notes
- No Billing Portal configuration, so portal sessions 400
- A wall of cancel_at_period_end subscriptions nobody noticed
- Dispute activity is above the 0.75% excessive threshold
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- The portal configuration object — Stripe API reference
- Update a portal configuration — Stripe API reference
- Set up the customer portal — Stripe Docs
- Dispute monitoring programs — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.