Diagnostic Stripe
subscriptions frozen on requires_action 3DS authentication
European signups convert worse than everyone else's and support has nothing to go on. The card is good, the customer entered it correctly, and the bank was ready to approve the payment as soon as somebody answered a question. Nobody asked them the question. The invoice is still open, waiting.
Read the incomplete subscriptions and look at the intent behind the first invoice. On API versions before 2025-03-31.basil: GET /v1/subscriptions?status=incomplete&limit=100&expand[]=data.latest_invoice.payment_intent, and flag rows where latest_invoice.payment_intent.status is "requires_action". On Basil and later the intent moved: GET /v1/invoices?status=open&limit=100&expand[]=data.payments.data.payment.payment_intent reaches the same field.
Sort what you find by the intent status. requires_action is an unanswered 3DS challenge. requires_payment_method with a decline code is an ordinary card failure and a different repair. Treating both as "the subscription did not activate" is what hides the first behind the second.
The problem in plain words
An unanswered authentication is not a decline. The issuer did not refuse the payment; it asked for a step-up and is waiting. So there is no failure_code to group by, no failed-payment event, and nothing in a dunning report. The invoice sits at open and the subscription at incomplete, which is exactly what a subscription looks like for the ordinary few seconds between creation and payment.
Retries do not save it either. authentication_required is on Stripe's hard-decline list, so the smart-retry schedule will not turn an unanswered challenge into a payment. Attempts get scheduled and go nowhere, because nothing about a retry produces the challenge screen the customer never saw.
The distribution is what makes it get misread. SCA applies to cards in the European Economic Area and the UK, and India mandates step-up authentication, so this only bites part of your traffic. From a dashboard it looks like certain markets convert badly, which is a plausible enough story to survive several quarters.
Why it happens
The challenge has to be shown, and the code shows it only if it was written to. Handling a subscription signup means checking the returned intent status and calling stripe.handleNextAction with the client secret when it is requires_action. Code that branches on succeeded and treats everything else as an error leaves a customer looking at a spinner while a real payment waits behind it.
Nobody emails them either. Stripe can send the customer a link to the Hosted Invoice Page where the challenge can be completed, but the automatic collection reminders have to be turned on, and the invoice.payment_action_required event has to be handled by someone. Neither is on by default, so a customer who leaves the page has no route back to a payment that is still open.
The field moved between API versions. Before 2025-03-31.basil the intent hangs off invoice.payment_intent. On Basil and later it is reached through the invoice's payments collection. A check written against one shape reports every subscription as unreadable on the other, which looks enough like a clean result to be believed.
The two ways a first invoice fails need different repairs. An unanswered challenge is a client-side handoff bug and the money is still collectable. A declined card is a payment problem and needs a new card. Both leave the subscription incomplete, and a check that only reports the status cannot tell you which conversation to have.
It is silently on a clock. The subscription is not going to wait indefinitely; a first invoice that stays unpaid expires and takes the signup with it. That deadline runs whether or not anybody knows the challenge is outstanding.
The fix, as a flow
The script reads down to the PaymentIntent behind the first invoice and handles both field layouts, because a lookup that only knows one of them reports the whole account as unreadable and that reads like good news.
How to fix it
Expand down to the PaymentIntent, and handle both shapes
The subscription status alone tells you nothing about the cause. Read the intent behind the first invoice, and write the lookup so it finds the field on either side of the Basil change rather than reporting the whole account as unreadable on one of them.
Split requires_action from requires_payment_method
This is the entire diagnosis. requires_action means a bank challenge was never shown and the payment is still live. requires_payment_method with a decline code means the card failed. The first is a bug in your checkout, the second is an email asking for a different card.
Read next_action to see what was supposed to happen
use_stripe_sdk means the client was expected to call into Stripe.js and did not. redirect_to_url means the customer should have been sent to the issuer. An empty next_action on a requires_action intent is a third thing again: nothing was ever prepared for the customer to do.
Check where the affected customers are
If the stuck ones cluster in the EEA, the UK, or India, that is the regulation choosing them and it confirms the reading. It also explains why the problem never reproduced for anyone testing on a US card.
Turn on the recovery path before fixing the code
The repair takes two parts. Enable automatic collection reminders so Stripe mails the Hosted Invoice Page link when a payment needs authentication, which recovers the customers already stuck. Then handle invoice.payment_action_required and pass the client secret to stripe.handleNextAction in the signup flow, so the next one never gets stuck at all.
How to check it worked
Re-run after the handler ships. New incomplete subscriptions should be minutes old and resolving on their own, and nothing should be sitting at requires_action from previous days.
python3 stripe_sca_stuck_subs.py
# 6 incomplete subscription(s): 0 awaiting authentication, 4 declined, 2 unconfirmed
The full code
One paginated GET with an expansion, and no writes — a restricted key with read access to Subscriptions, Invoices and PaymentIntents is enough. The intent lookup is a pure function of its own because the field moved between API versions, and a check that silently reads the wrong shape reports a clean account rather than an error. The classifier then splits the unanswered challenges from the declines, which is the whole point of running it.
"""Report Stripe subscriptions frozen on an unanswered 3DS authentication.
Read only. One paginated GET with an expansion, no writes: give this a
RESTRICTED key with read access to Subscriptions, Invoices and PaymentIntents.
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_sca_stuck_subs")
API = "https://api.stripe.com/v1"
def intent_of(invoice):
"""Find the PaymentIntent on an invoice across the Basil API change. Pure.
Before 2025-03-31.basil the intent hangs off `invoice.payment_intent`. On
Basil and later it is reached through the invoice's `payments` collection.
A check that only knows one shape reads every row as unexpanded on the
other, which looks like a clean account rather than a broken query.
Returns the intent dict, or None.
"""
if not isinstance(invoice, dict):
return None
intent = invoice.get("payment_intent")
if isinstance(intent, dict):
return intent
for payment in ((invoice.get("payments") or {}).get("data") or []):
candidate = (payment.get("payment") or {}).get("payment_intent")
if isinstance(candidate, dict):
return candidate
return None
def verdict(sub):
"""Say why one incomplete subscription never activated. Pure.
The subscription status is the same whichever way the first invoice failed,
so the answer is on the PaymentIntent behind it: an unanswered bank challenge
is a client-side handoff bug with the money still collectable, and a decline
is a card problem that needs a different card.
Returns (state, detail).
"""
status = sub.get("status")
if status != "incomplete":
return ("other", "status %r: not waiting on a first payment" % (status,))
intent = intent_of(sub.get("latest_invoice"))
if intent is None:
return ("unexpanded",
"no PaymentIntent found on the first invoice: expand "
"latest_invoice.payment_intent, or on 2025-03-31.basil and later "
"read payments.data.payment.payment_intent")
pi_status = intent.get("status")
if pi_status == "requires_action":
action = (intent.get("next_action") or {}).get("type")
if not action:
return ("no-next-action",
"the intent wants authentication but nothing was prepared "
"for the customer to do, so nobody can finish this one")
return ("authentication",
"the issuer asked for a challenge (%s) and it was never shown to "
"the customer; the payment is still live" % action)
if pi_status == "requires_payment_method":
error = intent.get("last_payment_error") or {}
code = error.get("decline_code") or error.get("code") or "no code recorded"
return ("declined",
"the card failed (%s): a decline, not an unanswered challenge, so "
"this customer needs a different card" % code)
if pi_status == "requires_confirmation":
return ("unconfirmed",
"the intent was created and never confirmed at all: the client "
"never called confirm, so no bank has seen this payment")
if pi_status == "processing":
return ("settling", "the payment is in flight, nothing to do yet")
return ("other", "payment_intent status %r on an incomplete subscription"
% (pi_status,))
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_subscriptions(session, limit):
seen = 0
params = {"status": "incomplete", "limit": 100,
"expand[]": "data.latest_invoice.payment_intent"}
while True:
page = get(session, "/subscriptions", **params)
rows = page.get("data", [])
for sub in rows:
yield sub
seen += 1
if not page.get("has_more") or not rows or seen >= limit:
break
params["starting_after"] = rows[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-subscriptions", type=int, default=1000,
help="stop paginating after this many subscriptions")
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})
states = {}
scanned = 0
for sub in page_subscriptions(s, args.max_subscriptions):
scanned += 1
state, detail = verdict(sub)
states[state] = states.get(state, 0) + 1
if state in ("authentication", "no-next-action", "unexpanded"):
log.warning("%-15s %s %s", state, sub.get("id", "?"), detail)
elif state == "declined":
log.info("%-15s %s %s", state, sub.get("id", "?"), detail)
if not scanned:
log.info("no incomplete subscriptions")
return 0
log.info("%d incomplete subscription(s): %s", scanned,
", ".join("%d %s" % (n, k) for k, n in sorted(states.items())))
if states.get("unexpanded"):
log.warning("repair: re-run with the expansion that matches your API "
"version; an unreadable row is not a healthy one")
stuck = states.get("authentication", 0) + states.get("no-next-action", 0)
if not stuck:
log.info("nothing is waiting on an unanswered authentication")
return 1 if states.get("unexpanded") else 0
log.warning("repair: Dashboard, Settings, Billing, Automatic collection: turn "
"on reminder emails so Stripe sends the Hosted Invoice Page link "
"when a payment needs authentication")
log.warning("repair: handle invoice.payment_action_required and pass the "
"client secret to stripe.handleNextAction in the signup flow")
log.warning("note: authentication_required is a hard decline, so smart "
"retries will never clear these on their own")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe subscriptions frozen on an unanswered 3DS authentication.
*
* Read only. One paginated GET with an expansion, no writes: give this a
* RESTRICTED key with read access to Subscriptions, Invoices and PaymentIntents.
* The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
/**
* Find the PaymentIntent on an invoice across the Basil API change. Pure.
*
* Before 2025-03-31.basil the intent hangs off `invoice.payment_intent`. On
* Basil and later it is reached through the invoice's `payments` collection.
*/
export function intentOf(invoice) {
if (!invoice || typeof invoice !== 'object') return null;
if (invoice.payment_intent && typeof invoice.payment_intent === 'object') {
return invoice.payment_intent;
}
for (const payment of invoice.payments?.data ?? []) {
const candidate = payment.payment?.payment_intent;
if (candidate && typeof candidate === 'object') return candidate;
}
return null;
}
/**
* Say why one incomplete subscription never activated. Pure.
*
* The subscription status is the same whichever way the first invoice failed,
* so the answer is on the PaymentIntent behind it.
*/
export function verdict(sub) {
if (sub.status !== 'incomplete') {
return ['other', `status ${JSON.stringify(sub.status)}: not waiting on a first payment`];
}
const intent = intentOf(sub.latest_invoice);
if (intent === null) {
return ['unexpanded',
'no PaymentIntent found on the first invoice: expand ' +
'latest_invoice.payment_intent, or on 2025-03-31.basil and later read ' +
'payments.data.payment.payment_intent'];
}
if (intent.status === 'requires_action') {
const action = intent.next_action?.type;
if (!action) {
return ['no-next-action',
'the intent wants authentication but nothing was prepared for the ' +
'customer to do, so nobody can finish this one'];
}
return ['authentication',
`the issuer asked for a challenge (${action}) and it was never shown to ` +
'the customer; the payment is still live'];
}
if (intent.status === 'requires_payment_method') {
const error = intent.last_payment_error ?? {};
const code = error.decline_code ?? error.code ?? 'no code recorded';
return ['declined',
`the card failed (${code}): a decline, not an unanswered challenge, so ` +
'this customer needs a different card'];
}
if (intent.status === 'requires_confirmation') {
return ['unconfirmed',
'the intent was created and never confirmed at all: the client never ' +
'called confirm, so no bank has seen this payment'];
}
if (intent.status === 'processing') {
return ['settling', 'the payment is in flight, nothing to do yet'];
}
return ['other',
`payment_intent status ${JSON.stringify(intent.status)} on an incomplete subscription`];
}
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* pageSubscriptions(key, limit) {
let seen = 0;
const params = {
status: 'incomplete',
limit: 100,
'expand[]': 'data.latest_invoice.payment_intent',
};
for (;;) {
const page = await get(key, '/subscriptions', params);
const rows = page.data ?? [];
for (const sub of rows) { yield sub; seen += 1; }
if (!page.has_more || rows.length === 0 || seen >= limit) break;
params.starting_after = rows[rows.length - 1].id;
}
}
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 states = new Map();
let scanned = 0;
for await (const sub of pageSubscriptions(key, 1000)) {
scanned += 1;
const [state, detail] = verdict(sub);
states.set(state, (states.get(state) ?? 0) + 1);
if (state === 'authentication' || state === 'no-next-action' || state === 'unexpanded') {
console.warn(`${state.padEnd(15)} ${sub.id ?? '?'} ${detail}`);
} else if (state === 'declined') {
console.log(`${state.padEnd(15)} ${sub.id ?? '?'} ${detail}`);
}
}
if (scanned === 0) {
console.log('no incomplete subscriptions');
return;
}
const summary = [...states.entries()].sort().map(([k, n]) => `${n} ${k}`).join(', ');
console.log(`${scanned} incomplete subscription(s): ${summary}`);
if (states.get('unexpanded')) {
console.warn('repair: re-run with the expansion that matches your API version; ' +
'an unreadable row is not a healthy one');
}
const stuck = (states.get('authentication') ?? 0) + (states.get('no-next-action') ?? 0);
if (!stuck) {
console.log('nothing is waiting on an unanswered authentication');
process.exitCode = states.get('unexpanded') ? 1 : 0;
return;
}
console.warn('repair: Dashboard, Settings, Billing, Automatic collection: turn on ' +
'reminder emails so Stripe sends the Hosted Invoice Page link when a payment ' +
'needs authentication');
console.warn('repair: handle invoice.payment_action_required and pass the client ' +
'secret to stripe.handleNextAction in the signup flow');
console.warn('note: authentication_required is a hard decline, so smart retries ' +
'will never clear these on their own');
process.exitCode = 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Two things get pinned here. The intent has to be found on both sides of the Basil change, because a lookup that only knows one shape returns a clean account instead of an error. And an unanswered challenge has to be reported differently from a declined card, since both leave the subscription incomplete and only one of them is still collectable.
from stripe_sca_stuck_subs import intent_of, verdict
def legacy(intent):
return {"id": "sub_1", "status": "incomplete",
"latest_invoice": {"id": "in_1", "payment_intent": intent}}
def basil(intent):
return {"id": "sub_1", "status": "incomplete",
"latest_invoice": {"id": "in_1",
"payments": {"data": [{"payment": {"payment_intent": intent}}]}}}
def test_an_unanswered_challenge_is_named_as_one():
sub = legacy({"status": "requires_action", "next_action": {"type": "use_stripe_sdk"}})
state, detail = verdict(sub)
assert state == "authentication"
assert "use_stripe_sdk" in detail
assert "still live" in detail
def test_the_intent_is_found_on_the_basil_shape_too():
intent = {"status": "requires_action", "next_action": {"type": "redirect_to_url"}}
assert intent_of(basil(intent)["latest_invoice"]) == intent
assert verdict(basil(intent))[0] == "authentication"
def test_a_declined_card_is_not_reported_as_an_authentication_problem():
sub = legacy({"status": "requires_payment_method",
"last_payment_error": {"decline_code": "insufficient_funds"}})
state, detail = verdict(sub)
assert state == "declined"
assert "insufficient_funds" in detail
def test_an_unreadable_invoice_is_not_a_healthy_one():
sub = {"id": "sub_1", "status": "incomplete", "latest_invoice": "in_1"}
state, detail = verdict(sub)
assert state == "unexpanded"
assert "basil" in detail
def test_requires_action_with_nothing_to_do_is_its_own_state():
assert verdict(legacy({"status": "requires_action"}))[0] == "no-next-action"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { intentOf, verdict } from './stripe-sca-stuck-subs.mjs';
const legacy = (payment_intent) => ({
id: 'sub_1', status: 'incomplete',
latest_invoice: { id: 'in_1', payment_intent },
});
const basil = (intent) => ({
id: 'sub_1', status: 'incomplete',
latest_invoice: { id: 'in_1', payments: { data: [{ payment: { payment_intent: intent } }] } },
});
test('an unanswered challenge is named as one', () => {
const [state, detail] = verdict(
legacy({ status: 'requires_action', next_action: { type: 'use_stripe_sdk' } }));
assert.equal(state, 'authentication');
assert.match(detail, /use_stripe_sdk/);
assert.match(detail, /still live/);
});
test('the intent is found on the basil shape too', () => {
const intent = { status: 'requires_action', next_action: { type: 'redirect_to_url' } };
assert.deepEqual(intentOf(basil(intent).latest_invoice), intent);
assert.equal(verdict(basil(intent))[0], 'authentication');
});
test('a declined card is not reported as an authentication problem', () => {
const [state, detail] = verdict(legacy({
status: 'requires_payment_method',
last_payment_error: { decline_code: 'insufficient_funds' },
}));
assert.equal(state, 'declined');
assert.match(detail, /insufficient_funds/);
});
test('an unreadable invoice is not a healthy one', () => {
const [state, detail] = verdict({ id: 'sub_1', status: 'incomplete', latest_invoice: 'in_1' });
assert.equal(state, 'unexpanded');
assert.match(detail, /basil/);
});
test('requires_action with nothing to do is its own state', () => {
assert.equal(verdict(legacy({ status: 'requires_action' }))[0], 'no-next-action');
});
FAQ
Why does the subscription stay incomplete instead of failing?
Because nothing failed. The issuer asked for a 3D Secure challenge and is waiting for an answer, so the PaymentIntent sits at requires_action, the invoice stays open, and the subscription stays incomplete. No decline code is produced, which is why the payment never shows up in a failed-payments report.
Will Stripe's retries eventually push it through?
No. authentication_required is a hard decline as far as retry logic is concerned, so attempts can be scheduled but will not clear the challenge. Nothing about a retry causes the customer's browser to display the bank's authentication screen, and that display is the only thing missing.
Why is it mostly European and Indian customers?
Because those are the cards that reach the challenge. Strong Customer Authentication applies in the European Economic Area and the UK, and India mandates step-up authentication. A broken handoff is invisible on traffic that authorizes without a challenge, so the failure looks like a regional conversion problem rather than a bug.
My expansion returns nothing. Did the field move?
Yes. Before API version 2025-03-31.basil the intent is at latest_invoice.payment_intent. On Basil and later it is reached through the invoice's payments collection. A script written for one version reads every row on the other as unexpanded, so check for that before concluding the account is clean.
Can I rescue the ones that are already stuck?
Often, yes, while the invoice is still open. Turning on automatic collection reminders makes Stripe email the customer a Hosted Invoice Page link where the challenge can be completed and the payment finished. Do that first, then fix the handoff so the next signup never reaches this state.
Related field notes
- 3DS handoff breaks and requires_action intents pile up
- Incomplete subscriptions die silently after 23 hours
- Off-session charges die on authentication_required
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.
- 3D Secure authentication — Stripe Docs
- How subscriptions work — Stripe Docs
- Invoice workflow transitions — Stripe Docs
- Smart Retries — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.