Skip to content

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-only key Python and Node.js Tests included
The word email in tiles
Photo by Miguel Angel Padrinan Alba on Unsplash
The short answer

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.

Customer wantsoutopens the portalNo cancelbuttonfeature off bydefaultEmails supportwaits for a replyDisputes thechargereasonsubscription_canceledFee plusdispute ratecannot be won
Nothing errors anywhere in this sequence. The only trace is a dispute reason code that nobody maps back to a feature flag.

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.

GET/v1/billing_portal/configurationsplus disputes by reasonCancel and reason onself serveCancel on, no reason askedfree churn data lostCancel on, card update offexpired cards go to supportCancel offthe bank is the exitCancel off, disputes cite itpriced already
Cancel and card update fail into the same support queue, so they are worth fixing in the same change.

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_portal_cancel_disabled.py
"""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())
stripe-portal-cancel-disabled.mjs
/**
 * 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.

test_stripe_portal_cancel_disabled.py
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"
stripe-portal-cancel-disabled.test.mjs
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

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.