Skip to content

Diagnostic Stripe

no Billing Portal configuration, so portal sessions 400

The Manage subscription button worked all through development. On the day it goes live it throws a 500, and the log underneath it reads No configuration provided and your default configuration has not been created. Nothing about the deploy changed the portal code, because the thing that is missing was never in the code.

Read-only key Python and Node.js Tests included
White box on white table
Photo by Kelli McClintock on Unsplash
The short answer

Run GET /v1/billing_portal/configurations?limit=100 with the live key. The failure condition is an empty data array, or no element with is_default == true and active == true. A test-mode key that returns configurations proves nothing whatsoever about live.

Cross-check the exposure with GET /v1/subscriptions?status=active&limit=100. Active subscribers plus zero configurations means every portal click on the site is currently returning 400 to your server and a 500 to the customer.

The problem in plain words

This one is unusual in that the error message is genuinely good. Stripe tells you exactly what is wrong and exactly how to fix it. The problem is where the message ends up: inside a 400 from the API, caught by your generic error handler, turned into a 500, and shown to the customer as "something went wrong". Nobody reads the Stripe text until somebody goes looking in the logs, which happens after the support ticket, which happens after the customer has already tried three times.

And it will not have shown up before then. The portal integration is a handful of lines — create a session for a customer, redirect to session.url — and it works perfectly in test mode, because somebody clicked through the portal settings screen at some point while building it. That click saved a test-mode default configuration. Live mode has its own, and it does not exist.

Portal builttested in testmodeSettings savedtest mode onlyLive deployno code differenceSession create400sno defaultconfigurationGeneric 500shownStripe messageswallowed
The default configuration is created by a Dashboard save, so nothing in the repository records that live mode never had one.

Why it happens

The default configuration is created by a Dashboard save, not by an API call. Every other part of a Stripe integration comes into existence because your code created it. This one comes into existence because a human opened a settings page and pressed a button. There is no line in your repository that would remind you, and no deploy step that would carry it across.

Test and live are separate accounts for this purpose. Configurations do not sync between modes, exactly like webhook endpoints and API keys. The mental model of "I set that up already" is true and refers to the wrong mode.

Omitting configuration is the documented, ordinary way to call the API. POST /v1/billing_portal/sessions with just a customer id is the example in every tutorial, and it falls back to the account default. That fallback is invisible until the thing it falls back to is missing, at which point the call fails on a parameter your code never mentioned.

Nothing degrades — it is all or nothing. There is no partial portal. Either a usable default exists and every customer can manage their subscription, or it does not and every single click fails. That makes it a total outage of a feature, and total outages of secondary features are exactly what monitoring misses.

The fix, as a flow

The script reads the configurations in whichever mode the key belongs to and counts who can press the button, because the same missing default is a ticket on one account and an outage on another.

GETbilling_portal/configurationswith the live key, plussubscriptionsDefault and activesessions resolveNone, no subscriberswaiting to breakNone, live subscribersevery click 400s nowActive but no defaultpass configuration=bpc_
Both flags matter. A default that is inactive passes a check that counts the array and still fails every call.

How to fix it

List configurations with the live key specifically

GET /v1/billing_portal/configurations?limit=100. Run it with the key the production server actually uses. Running it with a test key is the same mistake that caused the incident, made a second time while investigating it.

Look for a default that is also active

Both flags matter. is_default decides whether a session created without a configuration parameter can find it; active decides whether it can be used at all. A configuration that is default but inactive satisfies a naive check and still fails every call.

Measure who is affected

GET /v1/subscriptions?status=active&limit=100. Every active subscriber is somebody who can press the button. That count is the difference between "fix before the next release" and "fix now", and it belongs in the incident note.

Create the default, once

Save the portal settings at dashboard.stripe.com/settings/billing/portal for live mode and the /test/ equivalent for test. Or create one through the API and pass its id explicitly as configuration=bpc_... on every session, which has the advantage of being visible in your repository.

Log the Stripe error message, not a generic one

Stripe's message named the problem precisely. Whatever swallowed it will swallow the next one too. Surface error.message from the Stripe response into your logs and this class of failure stops costing an afternoon each time.

How to check it worked

Re-run the script against the live key. A default configuration that is active is all this check wants to see.

python3 stripe_portal_configuration.py
# configured  default configuration bpc_1MrTdC is active; portal sessions resolve

The full code

Two GETs and no writes — a restricted key with read access to the Customer Portal and to Subscriptions is enough. The classifier takes the list of configurations and the number of active subscriptions, because the same missing configuration is a ticket on an account with no subscribers and a live outage on one with four hundred.

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_configuration.py
"""Report a missing or unusable Stripe Billing Portal configuration.

Read only. Two GETs and no writes: give this a RESTRICTED key with read access to
the Customer Portal and Subscriptions. 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 requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_portal_configuration")

API = "https://api.stripe.com/v1"

PORTAL_SETTINGS = "https://dashboard.stripe.com/settings/billing/portal"


def verdict(configurations, active_subscriptions=0):
    """Classify the account's portal configuration. Pure, so it is testable offline.

    `configurations` is the list from /v1/billing_portal/configurations and
    `active_subscriptions` the number of customers who can press the button.
    Returns (state, detail).
    """
    configs = list(configurations or [])
    usable = [c for c in configs if c.get("is_default") and c.get("active")]
    if usable:
        return ("configured",
                "default configuration %s is active; portal sessions resolve"
                % usable[0].get("id", "<no id>"))
    if not configs:
        if active_subscriptions:
            return ("erroring",
                    "no portal configuration exists and %d active subscription(s) "
                    "can reach the portal: every session create is failing with 400 "
                    "right now" % active_subscriptions)
        return ("missing",
                "no portal configuration exists. The first session created without "
                "an explicit configuration will fail with 400.")
    active = [c for c in configs if c.get("active")]
    if active:
        return ("explicit-only",
                "%d active configuration(s) but none is the default (%s). A session "
                "created without configuration=... fails with 400."
                % (len(active), ", ".join(c.get("id", "?") for c in active[:3])))
    return ("inactive-default",
            "%d configuration(s) exist and none of them is active, so none can be "
            "used to open the portal" % len(configs))


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 on the account, 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 active_subscription_count(session, cap):
    count = 0
    params = {"status": "active", "limit": 100}
    while True:
        page = get(session, "/subscriptions", params)
        data = page.get("data", [])
        count += len(data)
        if not data or not page.get("has_more") or count >= cap:
            break
        params["starting_after"] = data[-1]["id"]
    return count


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-subscriptions", type=int, default=2000,
                    help="stop counting active subscriptions after this many")
    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
    if key.startswith("sk_test") or key.startswith("rk_test"):
        log.warning("this is a test-mode key: a result here says nothing about live, "
                    "which is where this failure happens")

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + key})

    configs = configurations(s)
    subs = active_subscription_count(s, args.max_subscriptions)
    state, detail = verdict(configs, subs)

    line = "%-16s %s" % (state, detail)
    if state == "configured":
        log.info(line)
        return 0

    log.warning(line)
    log.warning("  %d active subscription(s) can reach the portal", subs)
    log.warning("  repair: save the portal settings once, in this mode, at %s",
                PORTAL_SETTINGS)
    log.warning("  or create one over the API and pass its id explicitly:")
    log.warning("  POST %s/billing_portal/configurations -d "
                "\"features[invoice_history][enabled]=true\" ...", API)
    log.warning("  then POST %s/billing_portal/sessions -d customer=cus_... "
                "-d configuration=bpc_...", API)
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-portal-configuration.mjs
/**
 * Report a missing or unusable Stripe Billing Portal configuration.
 *
 * Read only. Two GETs and no writes: give this a RESTRICTED key with read access
 * to the Customer Portal and Subscriptions. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

const PORTAL_SETTINGS = 'https://dashboard.stripe.com/settings/billing/portal';

/**
 * Classify the account's portal configuration. Pure, so it is testable offline.
 * A default that is not active satisfies a naive check and still fails every call.
 */
export function verdict(configurations, activeSubscriptions = 0) {
  const configs = configurations ?? [];
  const usable = configs.filter((c) => c.is_default && c.active);
  if (usable.length) {
    return ['configured',
      `default configuration ${usable[0].id ?? '<no id>'} is active; portal ` +
      'sessions resolve'];
  }
  if (configs.length === 0) {
    if (activeSubscriptions) {
      return ['erroring',
        `no portal configuration exists and ${activeSubscriptions} active ` +
        'subscription(s) can reach the portal: every session create is failing ' +
        'with 400 right now'];
    }
    return ['missing',
      'no portal configuration exists. The first session created without an ' +
      'explicit configuration will fail with 400.'];
  }
  const active = configs.filter((c) => c.active);
  if (active.length) {
    return ['explicit-only',
      `${active.length} active configuration(s) but none is the default ` +
      `(${active.slice(0, 3).map((c) => c.id ?? '?').join(', ')}). A session ` +
      'created without configuration=... fails with 400.'];
  }
  return ['inactive-default',
    `${configs.length} configuration(s) exist and none of them is active, so ` +
    'none can be used to open the portal'];
}

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 activeSubscriptionCount(key, cap = 2000) {
  let count = 0;
  const params = { status: 'active', limit: 100 };
  for (;;) {
    const page = await get(key, '/subscriptions', params);
    const data = page.data ?? [];
    count += data.length;
    if (data.length === 0 || !page.has_more || count >= cap) break;
    params.starting_after = data[data.length - 1].id;
  }
  return count;
}

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;
  }
  if (key.startsWith('sk_test') || key.startsWith('rk_test')) {
    console.warn('this is a test-mode key: a result here says nothing about live, ' +
                 'which is where this failure happens');
  }

  const configs = await configurations(key);
  const subs = await activeSubscriptionCount(key);
  const [state, detail] = verdict(configs, subs);

  const line = `${state.padEnd(16)} ${detail}`;
  if (state === 'configured') { console.log(line); return; }

  console.warn(line);
  console.warn(`  ${subs} active subscription(s) can reach the portal`);
  console.warn(`  repair: save the portal settings once, in this mode, at ${PORTAL_SETTINGS}`);
  console.warn('  or create one over the API and pass its id explicitly:');
  console.warn(`  POST ${API}/billing_portal/configurations -d ` +
               '"features[invoice_history][enabled]=true" ...');
  console.warn(`  then POST ${API}/billing_portal/sessions -d customer=cus_... ` +
               '-d configuration=bpc_...');
  process.exitCode = 1;
}

// 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 two tests worth reading are the ones about configurations that exist and still do not work. A default flag with active false, and a set of perfectly good configurations none of which is the default, both look like success to a check that only counts array length — and both return the same 400 to the customer as having nothing at all.

test_stripe_portal_configuration.py
from stripe_portal_configuration import verdict

DEFAULT = {"id": "bpc_1", "is_default": True, "active": True}
EXPLICIT = {"id": "bpc_2", "is_default": False, "active": True}


def test_an_active_default_is_all_that_is_needed():
    state, detail = verdict([DEFAULT], 400)
    assert state == "configured"
    assert "bpc_1" in detail


def test_no_configuration_with_live_subscribers_is_an_outage():
    state, detail = verdict([], 400)
    assert state == "erroring"
    assert "400" in detail


def test_no_configuration_and_no_subscribers_is_only_waiting_to_break():
    assert verdict([], 0)[0] == "missing"


def test_an_explicit_only_setup_still_fails_without_the_id():
    # Counting the array would call this configured. Sessions created without a
    # configuration parameter still have no default to fall back to.
    state, detail = verdict([EXPLICIT], 400)
    assert state == "explicit-only"
    assert "bpc_2" in detail


def test_an_inactive_default_does_not_count():
    inactive = {"id": "bpc_3", "is_default": True, "active": False}
    assert verdict([inactive], 5)[0] == "inactive-default"
stripe-portal-configuration.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-portal-configuration.mjs';

const DEFAULT = { id: 'bpc_1', is_default: true, active: true };
const EXPLICIT = { id: 'bpc_2', is_default: false, active: true };

test('an active default is all that is needed', () => {
  const [state, detail] = verdict([DEFAULT], 400);
  assert.equal(state, 'configured');
  assert.match(detail, /bpc_1/);
});

test('no configuration with live subscribers is an outage', () => {
  const [state, detail] = verdict([], 400);
  assert.equal(state, 'erroring');
  assert.match(detail, /400/);
});

test('no configuration and no subscribers is only waiting to break', () => {
  assert.equal(verdict([], 0)[0], 'missing');
});

test('an explicit only setup still fails without the id', () => {
  const [state, detail] = verdict([EXPLICIT], 400);
  assert.equal(state, 'explicit-only');
  assert.match(detail, /bpc_2/);
});

test('an inactive default does not count', () => {
  const inactive = { id: 'bpc_3', is_default: true, active: false };
  assert.equal(verdict([inactive], 5)[0], 'inactive-default');
});

FAQ

What exactly does the error say?

No configuration provided and your default configuration has not been created. Provide a configuration or create your default by saving your customer portal settings. It is a 400 from POST /v1/billing_portal/sessions, and it is usually invisible because a generic handler turns it into a 500 before anyone reads it.

Why did it work in test mode?

Because somebody saved the portal settings in test mode while building the feature, and that save is what creates the default configuration. Configurations do not cross modes, exactly like webhook endpoints and keys. Live mode has never had the settings screen saved.

Can I create the default configuration through the API?

You can create configurations through the API, and you can mark one as the default. Many teams prefer to skip the default entirely and pass configuration=bpc_... explicitly on every session, because then the dependency is visible in the repository instead of living in somebody's browser history.

Does a configuration need to be active as well as default?

Yes, and this is the trap. active can be set to false to retire a configuration, and an inactive one cannot be used to open the portal even if it is still flagged as the default. A check that only looks at is_default reports success on an account where every click fails.

How many configurations should an account have?

One is enough for most. More than one is useful when different customer segments should see different features, in which case you pass the right configuration id per session. What you should not have is several configurations and no default, plus code that never passes an id.

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.