Skip to content

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.

Read-only key Python and Node.js Tests included
A network switch
Photo by Dimitri Karastelev on Unsplash
The short answer

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.

Confirm calledcard needs 3DSrequires_actionnext_actionpopulatedClient doesnothingreturn_url is 404Customer leavesno error shownIntent frozenno money moves
The authorization is never attempted, so there is no decline code and no failure event. The intent simply stops.

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.

GET /v1/payment_intentsstatus and next_action, agedUnder 24 hours oldin flight, leave itOver 24 hours oldhandoff is brokenEmpty next_actionclient was never told
Only one of these three is a broken integration, and the other two would bury it if they were counted together.

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.

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

test_stripe_requires_action.py
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"
stripe-requires-action.test.mjs
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

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.