Diagnostic Stripe
SetupIntents use on_session but you bill off-session
The card saves cleanly. It shows up on the customer, the last four digits are right, and if the customer comes back and pays with it themselves it works every time. The renewal that runs at three in the morning fails with authentication_required, and so does the one after that. The card is saved; it was never authorised for anyone but the customer to use.
Page GET /v1/setup_intents?created[gte]={now-180d} and look for status == "succeeded" together with usage == "on_session" and mandate == null. That combination is a card that was stored with consent for customer-present reuse only. Mirror it on the payment side by counting PaymentIntents with setup_future_usage == "on_session".
The signal that turns it from a style question into a bug is the customer. An on_session save on a customer with an active subscription is a renewal waiting to fail. Confirm with last_payment_error.decline_code == "authentication_required" on the same customers.
The problem in plain words
Two things have to line up for this to bite, and each of them is reasonable on its own. Saving a card with usage: "on_session" is correct if the customer will always be present when you charge it — a stored card in a one-click checkout, say. Billing a subscription off-session is correct too. The bug is the seam between them, and the seam is in two different files written months apart.
It also fails asymmetrically, which delays the diagnosis. Domestic cards from issuers that do not enforce SCA charge fine off-session with no mandate, because nobody checks. European cards and any issuer running strict rules decline. So the report is "renewals fail for some customers", the affected set looks random from the inside, and the first suspicion is always the card rather than the consent recorded when it was saved.
Why it happens
usage is about consent, not about storage. The card is stored either way. What off_session adds is a mandate: a record, shown to the issuer, that the customer agreed to unattended charges by this merchant. on_session creates no such agreement, so an off-session charge later has nothing to present and the issuer asks for authentication that nobody is there to provide.
off_session is already the default, so this is always an active choice. Omitting usage entirely gives you the right behaviour. It gets set to on_session deliberately, usually by someone reading the docs at the moment they are building a customer-present flow, and the same helper is later reused by the subscription code.
The save looks perfect in every tool you have. The SetupIntent succeeds. The PaymentMethod attaches. It appears on the Customer in the Dashboard with the brand, the last four and the expiry. Nothing in that view mentions the mandate, and mandate: null on the SetupIntent is the only place the difference is visible.
The failure arrives one billing cycle later. A monthly subscription puts thirty days between the save and the first unattended charge. By then the deploy is merged, the customer has forgotten the card form, and the decline code points at authentication rather than at consent.
The fix, as a flow
The script reads the consent recorded at save time, not the card, because the credential is stored either way and only the mandate decides whether an unattended charge has anything to show the issuer.
How to fix it
Count the on_session saves over a real window
Page GET /v1/setup_intents?limit=100&created[gte]={now-180d}. Six months rather than thirty days: a card saved in March is what breaks the renewal in September, and a short window will show you a healthy month while the damage sits just outside it.
Check the payment side too
Cards are saved during payment as often as through a dedicated flow. GET /v1/payment_intents and count setup_future_usage == "on_session". It is the same mistake through a different parameter, and a check that only reads SetupIntents misses whichever half of your integration saves cards at checkout.
Intersect with customers who are billed unattended
An on_session card on a customer with no subscription may be exactly right. The same card on a subscribed customer is not. GET /v1/subscriptions?status=active&limit=100 gives you the customer ids to intersect against, and it is that intersection, not the raw count, that belongs in the ticket.
Corroborate with declines that already happened
Count last_payment_error.decline_code == "authentication_required" on recent PaymentIntents. If those declines exist and the customers have on_session saves, the diagnosis is settled. If they exist and nobody has an on_session save, the cause is elsewhere and this note is the wrong one to be reading.
Re-collect consent rather than flipping a flag
You cannot promote an existing on_session save to a mandate after the fact; the customer's agreement is the thing that is missing, and it has to be asked for. Create a fresh SetupIntent with usage=off_session, show mandate text covering what you will charge, how often, and how the amount is determined, and do it before the next renewal rather than after it fails.
How to check it worked
Re-run the script. Every succeeded SetupIntent for a subscribed customer should read off_session, and the authentication_required count should stop rising.
python3 stripe_setup_intent_usage.py
# clear 412 saved card(s), all off_session, 0 authentication_required decline(s)
The full code
Three paginated GETs and no writes — a restricted key with read access to SetupIntents, PaymentIntents and Subscriptions is enough, and is what you should give it. The classifier is pure and its ordering is the interesting part: declines only mean this problem when there are on_session saves to blame them on, and a check that skips that condition will confidently misdiagnose an unrelated 3DS bug.
"""Report Stripe cards saved with usage=on_session but billed off-session.
Read only. Three paginated GETs, no writes: give this a RESTRICTED key with read
access to SetupIntents, PaymentIntents 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 time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_setup_intent_usage")
API = "https://api.stripe.com/v1"
def verdict(succeeded, on_session, on_session_subscribed, auth_required):
"""Classify the account. Pure, so the ordering can be tested without a network.
`on_session` counts saved cards recorded for customer-present reuse only.
`on_session_subscribed` is the subset belonging to customers with an active
subscription, which is the only subset that is unambiguously wrong.
The decline count is deliberately checked second, not first: authentication
failures with no on_session save behind them are a different bug, and reporting
them here sends people to re-collect consent that was already correct.
"""
if not succeeded:
return ("unknown", "no succeeded SetupIntents in the window; nothing to judge")
if on_session_subscribed and auth_required:
return ("declining",
"%d card(s) saved on_session belong to subscribed customers, and "
"%d off-session charge(s) have already failed on "
"authentication_required." % (on_session_subscribed, auth_required))
if on_session_subscribed:
return ("exposed",
"%d card(s) saved on_session belong to customers with an active "
"subscription. Nothing has failed yet; the next renewal is the "
"test." % on_session_subscribed)
if on_session:
return ("review",
"%d of %d saved card(s) used usage=on_session, none of them for a "
"subscribed customer. Correct only if you never charge without the "
"customer present." % (on_session, succeeded))
if auth_required:
return ("elsewhere",
"%d off-session decline(s) on authentication_required, but every "
"saved card is off_session. The mandate is not the cause; look at "
"the charge path." % auth_required)
return ("clear",
"%d saved card(s), all off_session, 0 authentication_required decline(s)"
% succeeded)
def get(session, path, **params):
r = session.get(API + path, params=params, 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 page_all(session, path, limit, **params):
seen = 0
params = dict(params, limit=100)
while True:
page = get(session, path, **params)
data = page.get("data", [])
for obj in data:
yield obj
seen += 1
if not data or not page.get("has_more") or seen >= limit:
break
params["starting_after"] = data[-1]["id"]
def subscribed_customer_ids(session, limit):
ids = set()
for sub in page_all(session, "/subscriptions", limit, status="active"):
cus = sub.get("customer")
if isinstance(cus, dict):
cus = cus.get("id")
if cus:
ids.add(cus)
return ids
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=180,
help="how far back to read SetupIntents and PaymentIntents")
ap.add_argument("--max-intents", type=int, default=5000,
help="stop paginating each list after this many objects")
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})
since = int(time.time()) - args.days * 86400
subscribed = subscribed_customer_ids(s, 2000)
succeeded = on_session = on_session_subscribed = 0
offenders = []
for si in page_all(s, "/setup_intents", args.max_intents,
**{"created[gte]": since}):
if si.get("status") != "succeeded":
continue
succeeded += 1
if si.get("usage") != "on_session":
continue
on_session += 1
cus = si.get("customer")
if isinstance(cus, dict):
cus = cus.get("id")
if cus in subscribed:
on_session_subscribed += 1
if len(offenders) < 10:
offenders.append((si["id"], cus, si.get("mandate")))
pi_on_session = auth_required = 0
for pi in page_all(s, "/payment_intents", args.max_intents,
**{"created[gte]": since}):
if pi.get("setup_future_usage") == "on_session":
pi_on_session += 1
err = pi.get("last_payment_error") or {}
if err.get("decline_code") == "authentication_required":
auth_required += 1
state, detail = verdict(succeeded, on_session + pi_on_session,
on_session_subscribed, auth_required)
line = "%-11s %s" % (state, detail)
if state in ("clear", "unknown"):
log.info(line)
return 0
log.warning(line)
log.warning(" %d SetupIntent(s) and %d PaymentIntent(s) recorded on_session",
on_session, pi_on_session)
for si_id, cus, mandate in offenders:
log.warning(" %s customer=%s mandate=%s", si_id, cus, mandate)
log.warning(" repair: collect fresh consent, then save with the right usage")
log.warning(" POST %s/setup_intents -d customer=cus_XXX -d usage=off_session", API)
log.warning(" when saving during a payment: -d setup_future_usage=off_session")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe cards saved with usage=on_session but billed off-session.
*
* Read only. Three paginated GETs, no writes: give this a RESTRICTED key with
* read access to SetupIntents, PaymentIntents and Subscriptions. The repair is
* printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
/**
* Classify the account. Pure, so the ordering can be tested without a network.
*
* The decline count is deliberately checked second, not first: authentication
* failures with no on_session save behind them are a different bug.
*/
export function verdict(succeeded, onSession, onSessionSubscribed, authRequired) {
if (!succeeded) {
return ['unknown', 'no succeeded SetupIntents in the window; nothing to judge'];
}
if (onSessionSubscribed && authRequired) {
return ['declining',
`${onSessionSubscribed} card(s) saved on_session belong to subscribed ` +
`customers, and ${authRequired} off-session charge(s) have already failed ` +
'on authentication_required.'];
}
if (onSessionSubscribed) {
return ['exposed',
`${onSessionSubscribed} card(s) saved on_session belong to customers with ` +
'an active subscription. Nothing has failed yet; the next renewal is the test.'];
}
if (onSession) {
return ['review',
`${onSession} of ${succeeded} saved card(s) used usage=on_session, none of ` +
'them for a subscribed customer. Correct only if you never charge without ' +
'the customer present.'];
}
if (authRequired) {
return ['elsewhere',
`${authRequired} off-session decline(s) on authentication_required, but ` +
'every saved card is off_session. The mandate is not the cause; look at ' +
'the charge path.'];
}
return ['clear',
`${succeeded} saved card(s), all off_session, 0 authentication_required decline(s)`];
}
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();
}
async function* pageAll(key, path, limit, params = {}) {
let seen = 0;
const p = { ...params, limit: 100 };
for (;;) {
const page = await get(key, path, p);
const data = page.data ?? [];
for (const obj of data) { yield obj; seen += 1; }
if (data.length === 0 || !page.has_more || seen >= limit) break;
p.starting_after = data[data.length - 1].id;
}
}
async function subscribedCustomerIds(key, limit = 2000) {
const ids = new Set();
for await (const sub of pageAll(key, '/subscriptions', limit, { status: 'active' })) {
const cus = typeof sub.customer === 'object' ? sub.customer?.id : sub.customer;
if (cus) ids.add(cus);
}
return ids;
}
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 days = 180;
const since = Math.floor(Date.now() / 1000) - days * 86400;
const subscribed = await subscribedCustomerIds(key);
let succeeded = 0;
let onSession = 0;
let onSessionSubscribed = 0;
const offenders = [];
for await (const si of pageAll(key, '/setup_intents', 5000, { 'created[gte]': since })) {
if (si.status !== 'succeeded') continue;
succeeded += 1;
if (si.usage !== 'on_session') continue;
onSession += 1;
const cus = typeof si.customer === 'object' ? si.customer?.id : si.customer;
if (subscribed.has(cus)) {
onSessionSubscribed += 1;
if (offenders.length < 10) offenders.push([si.id, cus, si.mandate]);
}
}
let piOnSession = 0;
let authRequired = 0;
for await (const pi of pageAll(key, '/payment_intents', 5000, { 'created[gte]': since })) {
if (pi.setup_future_usage === 'on_session') piOnSession += 1;
if (pi.last_payment_error?.decline_code === 'authentication_required') authRequired += 1;
}
const [state, detail] = verdict(succeeded, onSession + piOnSession,
onSessionSubscribed, authRequired);
const line = `${state.padEnd(11)} ${detail}`;
if (state === 'clear' || state === 'unknown') { console.log(line); return; }
console.warn(line);
console.warn(` ${onSession} SetupIntent(s) and ${piOnSession} PaymentIntent(s) recorded on_session`);
for (const [id, cus, mandate] of offenders) {
console.warn(` ${id} customer=${cus} mandate=${mandate}`);
}
console.warn(' repair: collect fresh consent, then save with the right usage');
console.warn(` POST ${API}/setup_intents -d customer=cus_XXX -d usage=off_session`);
console.warn(' when saving during a payment: -d setup_future_usage=off_session');
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 tests pin the ordering, because that is what separates a diagnosis from a guess. authentication_required declines are the loudest number on the page and they only mean this when there are on_session saves behind them; on their own they belong to a different note, and the classifier has to say so rather than claim the credit.
from stripe_setup_intent_usage import verdict
def test_on_session_saves_for_subscribed_customers_with_declines_are_the_diagnosis():
state, detail = verdict(500, 40, 12, 7)
assert state == "declining"
assert "12" in detail and "7" in detail
def test_on_session_saves_for_subscribed_customers_are_flagged_before_anything_fails():
state, detail = verdict(500, 40, 12, 0)
assert state == "exposed"
assert "next renewal" in detail
def test_on_session_saves_with_no_subscribers_are_only_worth_a_look():
# A stored card for a customer-present one-click checkout is legitimately on_session.
state, _ = verdict(500, 40, 0, 0)
assert state == "review"
def test_declines_without_on_session_saves_are_a_different_bug():
state, detail = verdict(500, 0, 0, 31)
assert state == "elsewhere"
assert "not the cause" in detail
def test_all_off_session_and_no_declines_is_clear():
assert verdict(500, 0, 0, 0)[0] == "clear"
def test_an_empty_window_is_not_silently_clear():
assert verdict(0, 0, 0, 0)[0] == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-setup-intent-usage.mjs';
test('on_session saves for subscribed customers with declines are the diagnosis', () => {
const [state, detail] = verdict(500, 40, 12, 7);
assert.equal(state, 'declining');
assert.match(detail, /12/);
assert.match(detail, /7/);
});
test('on_session saves for subscribed customers are flagged before anything fails', () => {
const [state, detail] = verdict(500, 40, 12, 0);
assert.equal(state, 'exposed');
assert.match(detail, /next renewal/);
});
test('on_session saves with no subscribers are only worth a look', () => {
assert.equal(verdict(500, 40, 0, 0)[0], 'review');
});
test('declines without on_session saves are a different bug', () => {
const [state, detail] = verdict(500, 0, 0, 31);
assert.equal(state, 'elsewhere');
assert.match(detail, /not the cause/);
});
test('all off_session and no declines is clear', () => {
assert.equal(verdict(500, 0, 0, 0)[0], 'clear');
});
test('an empty window is not silently clear', () => {
assert.equal(verdict(0, 0, 0, 0)[0], 'unknown');
});
FAQ
What does usage actually change, if the card is stored either way?
It changes what the issuer is told. off_session establishes a mandate recording the customer's agreement to merchant-initiated charges; on_session records consent for reuse while the customer is present. The stored credential is identical. Only the mandate lets a later unattended charge answer the issuer's question about authorisation.
Can I convert an existing on_session card to off_session?
Not by updating the object. The missing piece is the customer's consent, which has to be collected again: create a new SetupIntent with usage=off_session for the same customer and payment method, show mandate text, and confirm it while they are on the page.
Is setup_future_usage=on_session on a PaymentIntent the same mistake?
Yes, through a different parameter. It saves the card during a payment with the same customer-present consent, so a subscription created against that PaymentMethod hits the same wall. The script counts both, because integrations commonly save cards on one path and bill on the other.
Why do only some customers fail?
Because enforcement varies by issuer and by region. Where SCA rules are applied strictly, an off-session charge with no mandate is challenged and there is nobody to complete the challenge. Elsewhere the same charge is approved. That is why the affected set looks random and why the raw decline rate underestimates the exposure.
Does this check need a live secret key?
No. A restricted key with read access to SetupIntents, PaymentIntents and Subscriptions covers every call here, and it cannot move money if it leaks.
Related field notes
- SetupIntents are created but never confirmed by the client
- Off-session charges die on authentication_required
- PaymentMethods are created but never attached to a customer
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 SetupIntent object — Stripe API reference
- Create a SetupIntent — Stripe API reference
- Set up future payments — Stripe Docs
- Strong Customer Authentication — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.