Diagnostic Stripe
3DS handoff breaks and requires_action intents pile up
Card volume from Europe and India reads lower than your traffic says it should. Nothing fails. There are no declines to look at, no errors in the logs, and no support tickets, because from the customer's side the page simply did nothing. The intents stopped at requires_action and stayed there.
Paginate GET /v1/payment_intents and count the ones whose status is "requires_action" with a created timestamp older than 24 hours. An intent in that state is waiting on the customer's bank, and 24 hours is far longer than any real authentication takes.
Bucket the results by next_action.type. use_stripe_sdk failing points at the client never calling confirmPayment; redirect_to_url failing points at a return_url that does not resolve, or a redirect blocked inside an iframe or an in-app webview.
The problem in plain words
An intent at requires_action is not a failure and Stripe does not treat it as one. The authorization was never attempted, so there is no decline code, no last_payment_error, and no payment_intent.payment_failed event to subscribe to. The object just sits in a non-terminal state with a next_action nobody ever acted on.
That makes it invisible to every dashboard you already look at. Conversion reports built on succeeded payments show a dip with no cause attached. Fraud reports show nothing, because Radar never saw a charge. The only place the failure is written down is the intent itself, and nothing in a normal integration reads intents that did not succeed.
Why it happens
The client never handles the returned status. A server-confirmed flow returns an intent that needs stripe.handleNextAction({clientSecret}). Code that checks only for succeeded and otherwise shows a spinner leaves the customer looking at a page that will never change.
The return_url does not exist. The bank's redirect flow sends the customer back to a URL you supplied at confirm time. If that route was renamed, or points at a staging host, or returns a page that does not re-retrieve the intent by client_secret, the customer lands on something broken after authenticating successfully. The money is one API round trip away and never gets collected.
The redirect is blocked by the frame it runs in. Issuer authentication pages set X-Frame-Options. Launching 3DS inside a cross-origin iframe, or inside an in-app browser that refuses third-party redirects, produces a blank frame rather than an error. This is why the problem tends to look regional: SCA applies to European cards, and RBI mandates step-up authentication in India, so those are the cards that reach the step at all.
Nothing expires loudly. The intent stays valid, so there is no cleanup job that trips over it and no alert that fires. It accumulates.
The fix, as a flow
The script reads every PaymentIntent in the window and asks one question of each: is it waiting on the customer's bank, and if so, for how long. The age separates a customer reading a prompt from a handoff that was never wired up.
How to fix it
Count the intents currently frozen at the authentication step
Anything at requires_action for more than 24 hours is not a customer who is still deciding. Run this over a 30-day window so you can see whether the pile started on a particular day, which is usually a deploy.
Bucket them by next_action.type
The distribution is the diagnosis. If everything failing is redirect_to_url, the problem is the return trip. If it is use_stripe_sdk, the client never called into the SDK at all. A mix of both usually means one shared code path in front of the two.
Look for intents with no next_action at all
requires_action with an empty next_action is a different bug: the intent is waiting for something the client was never told to do. Nothing on the customer's side can complete it, so these are dead on arrival rather than abandoned.
Open the return_url yourself
Take the return_url from a recent confirm and request it directly. It should be a real page on the live host that reads payment_intent_client_secret from the query string and re-retrieves the intent. A 404, a redirect to a login wall, or a page that ignores the parameter each produce exactly the symptom above.
Cross-check the charges for authentication_required
GET /v1/charges with outcome.reason of authentication_required is the related off-session failure: a saved card that needed a step-up when nobody was present to give one. It is a different fix, but the same root cause of assuming authentication never happens.
How to check it worked
Re-run the script after the client change ships. The abandoned count should stop growing; existing ones do not clear themselves, so compare against a fresh window rather than the total.
python3 stripe_requires_action.py --days 2
# scanned 214 intent(s): 0 abandoned, 3 in-flight, 0 with no next_action
The full code
The script makes one paginated GET against PaymentIntents and no writes — a restricted key with read access to PaymentIntents is enough, and is what you should give it. The clock is passed into the classifier rather than read inside it, so the ageing rule is testable at a pinned timestamp instead of being true only on the day you wrote the test.
"""Report Stripe PaymentIntents abandoned at the authentication step.
Read only. One paginated GET, no writes: give this a RESTRICTED key with read
access to 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 time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_requires_action")
API = "https://api.stripe.com/v1"
STALE_SECONDS = 24 * 3600
def classify(intent, now, stale_after=STALE_SECONDS):
"""Classify one PaymentIntent. Pure, so the rules can be tested without a network.
Returns (state, detail). `now` is a unix timestamp passed in rather than read
here, so the ageing rule can be tested against a pinned clock.
"""
status = intent.get("status")
if status != "requires_action":
return ("other", "status %r, not waiting on authentication" % (status,))
created = intent.get("created")
if not isinstance(created, int):
return ("unknown", "no created timestamp, so the intent cannot be aged")
action = (intent.get("next_action") or {}).get("type")
if not action:
return ("no-next-action",
"requires_action with an empty next_action: the client was never "
"told what to do, so nothing can finish this")
hours = int((now - created) // 3600)
if now - created < stale_after:
return ("in-flight",
"%s, %dh old, still inside the window a customer plausibly needs"
% (action, hours))
return ("abandoned",
"%s, %dh old: the customer left the authentication step and never came back"
% (action, hours))
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 payment_intents(session, since, cap):
"""Yield PaymentIntents created since `since`, newest first, up to `cap`."""
seen = 0
params = {"limit": 100, "created[gte]": since}
while True:
page = get(session, "/payment_intents", **params)
data = page.get("data", [])
for pi in data:
yield pi
seen += 1
if seen >= cap:
return
if not page.get("has_more") or not data:
return
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=30,
help="how far back to scan (default 30)")
ap.add_argument("--stale-hours", type=int, default=24,
help="age at which requires_action counts as abandoned")
ap.add_argument("--max-intents", type=int, default=5000,
help="stop paginating after this many intents")
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})
now = int(time.time())
since = now - args.days * 86400
stale_after = args.stale_hours * 3600
counts = {}
by_action = {}
examples = []
scanned = 0
for pi in payment_intents(s, since, args.max_intents):
scanned += 1
state, detail = classify(pi, now, stale_after)
counts[state] = counts.get(state, 0) + 1
if state in ("abandoned", "no-next-action"):
action = (pi.get("next_action") or {}).get("type") or "none"
by_action[action] = by_action.get(action, 0) + 1
if len(examples) < 10:
examples.append((pi["id"], detail))
abandoned = counts.get("abandoned", 0)
in_flight = counts.get("in-flight", 0)
headless = counts.get("no-next-action", 0)
for pid, detail in examples:
log.warning("%s %s", pid, detail)
log.info("scanned %d intent(s): %d abandoned, %d in-flight, %d with no next_action",
scanned, abandoned, in_flight, headless)
if by_action:
for action, n in sorted(by_action.items(), key=lambda kv: -kv[1]):
log.warning(" %-24s %d", action, n)
waiting = abandoned + in_flight
if waiting:
# Not the true abandonment rate: Stripe does not report which succeeded
# intents passed through requires_action on their way, so the honest
# denominator here is the intents sitting at the step right now.
log.info(" %.0f%% of the intents at the authentication step are stalled",
100.0 * abandoned / waiting)
if abandoned or headless:
log.warning(" repair: handle the returned status on the client, e.g. "
"await stripe.confirmPayment({elements, confirmParams: {return_url}})")
log.warning(" repair: for server-confirmed flows call "
"stripe.handleNextAction({clientSecret}) with the returned secret")
log.warning(" check: request the return_url directly and confirm it "
"re-retrieves the intent by client_secret")
log.warning(" check: stop launching 3DS inside a cross-origin iframe")
log.warning(" to close out the dead ones: POST %s/payment_intents/{id}/cancel "
"-d cancellation_reason=abandoned", API)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe PaymentIntents abandoned at the authentication step.
*
* Read only. One paginated GET, no writes: give this a RESTRICTED key with read
* access to PaymentIntents. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
const STALE_SECONDS = 24 * 3600;
/**
* Classify one PaymentIntent. Pure, so the rules can be tested without a network.
* `now` is a unix timestamp passed in, so the ageing rule can be tested at a
* pinned clock rather than only on the day the test was written.
*/
export function classify(intent, now, staleAfter = STALE_SECONDS) {
const status = intent.status;
if (status !== 'requires_action') {
return ['other', `status ${JSON.stringify(status)}, not waiting on authentication`];
}
const created = intent.created;
if (!Number.isInteger(created)) {
return ['unknown', 'no created timestamp, so the intent cannot be aged'];
}
const action = intent.next_action?.type;
if (!action) {
return ['no-next-action',
'requires_action with an empty next_action: the client was never told ' +
'what to do, so nothing can finish this'];
}
const hours = Math.floor((now - created) / 3600);
if (now - created < staleAfter) {
return ['in-flight',
`${action}, ${hours}h old, still inside the window a customer plausibly needs`];
}
return ['abandoned',
`${action}, ${hours}h old: the customer left the authentication step and never came back`];
}
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* paymentIntents(key, since, cap) {
let seen = 0;
const params = { limit: 100, 'created[gte]': since };
for (;;) {
const page = await get(key, '/payment_intents', params);
const data = page.data ?? [];
for (const pi of data) {
yield pi;
seen += 1;
if (seen >= cap) return;
}
if (!page.has_more || data.length === 0) return;
params.starting_after = data[data.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 days = Number(process.env.DAYS ?? 30);
const staleAfter = Number(process.env.STALE_HOURS ?? 24) * 3600;
const now = Math.floor(Date.now() / 1000);
const since = now - days * 86400;
const counts = new Map();
const byAction = new Map();
const examples = [];
let scanned = 0;
for await (const pi of paymentIntents(key, since, 5000)) {
scanned += 1;
const [state, detail] = classify(pi, now, staleAfter);
counts.set(state, (counts.get(state) ?? 0) + 1);
if (state === 'abandoned' || state === 'no-next-action') {
const action = pi.next_action?.type ?? 'none';
byAction.set(action, (byAction.get(action) ?? 0) + 1);
if (examples.length < 10) examples.push([pi.id, detail]);
}
}
const abandoned = counts.get('abandoned') ?? 0;
const inFlight = counts.get('in-flight') ?? 0;
const headless = counts.get('no-next-action') ?? 0;
for (const [id, detail] of examples) console.warn(`${id} ${detail}`);
console.log(`scanned ${scanned} intent(s): ${abandoned} abandoned, ` +
`${inFlight} in-flight, ${headless} with no next_action`);
for (const [action, n] of [...byAction].sort((a, b) => b[1] - a[1])) {
console.warn(` ${action.padEnd(24)} ${n}`);
}
const waiting = abandoned + inFlight;
if (waiting) {
// Not the true abandonment rate: Stripe does not report which succeeded
// intents passed through requires_action, so the honest denominator is the
// intents sitting at the step right now.
const pct = Math.round((100 * abandoned) / waiting);
console.log(` ${pct}% of the intents at the authentication step are stalled`);
}
if (abandoned || headless) {
console.warn(' repair: handle the returned status on the client, e.g. ' +
'await stripe.confirmPayment({elements, confirmParams: {return_url}})');
console.warn(' repair: for server-confirmed flows call ' +
'stripe.handleNextAction({clientSecret}) with the returned secret');
console.warn(' check: request the return_url directly and confirm it ' +
're-retrieves the intent by client_secret');
console.warn(' check: stop launching 3DS inside a cross-origin iframe');
console.warn(` to close out the dead ones: POST ${API}/payment_intents/{id}/cancel ` +
'-d cancellation_reason=abandoned');
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
Two cases carry the note. An intent one hour into requires_action is a customer reading a bank prompt, and calling that broken would bury the real signal under normal traffic. An intent at requires_action with an empty next_action is not abandoned at all — nobody could have completed it — and folding it into the abandoned count sends you to look at the wrong layer.
from stripe_requires_action import classify
NOW = 1_800_000_000
def pi(status="requires_action", age_h=48, action="redirect_to_url"):
out = {"status": status, "created": NOW - age_h * 3600}
if action is not None:
out["next_action"] = {"type": action}
return out
def test_old_requires_action_is_abandoned():
state, detail = classify(pi(age_h=48), NOW)
assert state == "abandoned"
assert "redirect_to_url" in detail
def test_recent_requires_action_is_not_abandoned():
# A customer reading a bank prompt is not a broken integration.
state, _ = classify(pi(age_h=1), NOW)
assert state == "in-flight"
def test_empty_next_action_is_its_own_state():
# Nobody could have completed this one, so it is a different bug.
state, detail = classify(pi(age_h=48, action=None), NOW)
assert state == "no-next-action"
assert "never" in detail
def test_other_statuses_are_left_alone():
assert classify(pi(status="succeeded"), NOW)[0] == "other"
def test_missing_created_is_not_silently_healthy():
assert classify({"status": "requires_action"}, NOW)[0] == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-requires-action.mjs';
const NOW = 1800000000;
function pi({ status = 'requires_action', ageH = 48, action = 'redirect_to_url' } = {}) {
const out = { status, created: NOW - ageH * 3600 };
if (action !== null) out.next_action = { type: action };
return out;
}
test('old requires_action is abandoned', () => {
const [state, detail] = classify(pi({ ageH: 48 }), NOW);
assert.equal(state, 'abandoned');
assert.match(detail, /redirect_to_url/);
});
test('recent requires_action is not abandoned', () => {
assert.equal(classify(pi({ ageH: 1 }), NOW)[0], 'in-flight');
});
test('empty next_action is its own state', () => {
const [state, detail] = classify(pi({ ageH: 48, action: null }), NOW);
assert.equal(state, 'no-next-action');
assert.match(detail, /never/);
});
test('other statuses are left alone', () => {
assert.equal(classify(pi({ status: 'succeeded' }), NOW)[0], 'other');
});
test('missing created is not silently healthy', () => {
assert.equal(classify({ status: 'requires_action' }, NOW)[0], 'unknown');
});
FAQ
How long should a PaymentIntent stay in requires_action?
Minutes. The customer is being shown a bank prompt or redirected to an issuer page, and either finishes or gives up within one session. Twenty-four hours is a deliberately generous threshold that no genuine authentication reaches, so anything past it is a customer who was never able to complete the step.
Why does this only affect European and Indian cards?
Because those are the cards that reach the authentication step at all. SCA applies to card payments in the European Economic Area and the UK, and the Reserve Bank of India mandates step-up authentication. A broken 3DS handoff is invisible on US traffic that mostly frictionlessly authorizes, which is why the regional split in your conversion numbers is the first clue.
Is requires_action with no next_action the same problem?
No. An intent waiting for action with nothing populated in next_action cannot be completed by the customer at all, because the client was never given anything to do. It usually means the confirm call did not go through the path that populates it. Treat it as a separate bug from an abandoned redirect.
Can I just cancel the stuck intents and move on?
You can, with cancellation_reason set to abandoned, and it will tidy your reporting. It does not recover the payment and it does not stop the next one from stalling. Fix the client handoff first, then clear the backlog, or you will be clearing it again next month.
Does this script need a live secret key?
No. A restricted key with read access to PaymentIntents covers every call it makes. It never confirms, cancels, or captures anything, so if the key leaks the worst case is that somebody learns the shape of your payment volume.
Related field notes
- Orders stuck at requires_action after 3DS
- Intents sitting in requires_payment_method for weeks
- Declined card leaves the order stuck pending
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 PaymentIntent lifecycle — Stripe Docs
- 3D Secure authentication — Stripe Docs
- The PaymentIntent object — Stripe API reference
- API keys — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.