Skip to content

Diagnostic Stripe

SetupIntents are created but never confirmed by the client

Support keeps hearing the same thing: they added a card, the page said it worked, and the next invoice failed anyway. In the API there are four hundred SetupIntents sitting at requires_confirmation, created over three months, none of which ever produced a mandate or a PaymentMethod on a Customer.

Read-only key Python and Node.js Tests included
Red 'buy now!' button on a computer keyboard.
Photo by Money Knack on Unsplash
The short answer

Page GET /v1/setup_intents?created[lt]={now-24h} and bucket status. Anything still at requires_payment_method, requires_confirmation or requires_action a day after creation is stuck rather than in progress. Above roughly 20% of the window, that is a broken confirm path, not user abandonment.

Which bucket dominates names the bug. requires_confirmation means stripe.confirmSetup() was never called. requires_action means the 3DS handoff started and the browser never came back, which is usually a missing or wrong return_url. requires_payment_method means the customer never got as far as entering details, or the confirm failed — read last_setup_error.code to tell those apart.

The problem in plain words

A SetupIntent has the same lifecycle as a PaymentIntent, and creating one server-side is only the first step. The card is not saved until the client confirms, the customer completes any authentication the issuer asks for, and the intent reaches succeeded. Until then there is no mandate, and without a mandate every later off-session charge has nothing to authorise against.

What makes it silent is that the failure happens in the browser and the browser is where the success message lives. A frontend that closes the modal on the network response rather than on setupIntent.status tells the customer their card is saved while the intent is still at requires_confirmation. Your server never hears about it either, because the failure produced no event: an intent that is never confirmed generates nothing to subscribe to.

Server createsintentreturns a clientsecretClient neverconfirmserror or closedtabUI says savedon the networkresponseNo mandateexistsstatus stays stuckRenewal failsnothing to charge
The failure happens in the browser, which is also where the success message lives. The server hears nothing either way.

Why it happens

Creating and confirming are separate calls, and only one of them is on your server. The server-side create returns a client secret and a 200, which is the part your logs and your tests cover. The confirm lives in the browser, where a JavaScript exception, a closed tab or an early redirect ends the flow with no trace on your side.

The client secret makes success look like it already happened. Once the create call returns, there is a real object with a real id, and it is tempting to record "card added" against it. That object will sit unconfirmed forever, and it will still be readable through the API a year later, looking exactly like the ones that worked.

A missing return_url only breaks the authenticated subset. With 3DS, the customer leaves your page and needs somewhere to come back to. If that landing page is missing or does not finish the flow, the intents that required authentication freeze at requires_action while the ones that did not sail through. The result is a bug that reproduces for some cards, some banks and some countries, which is the hardest kind to be believed about.

Some abandonment is real and you have to allow for it. People do open a card form and walk away. That is why the useful signal is a ratio over a window rather than a raw count, and why the buckets matter: ordinary abandonment lands overwhelmingly in requires_payment_method, while a pile at requires_confirmation is your code.

The fix, as a flow

The script ages the window by a day, buckets the three unresolved statuses, and lets the dominant bucket name the defect rather than reporting one undifferentiated pile.

GET /v1/setup_intentscreated over a day ago, by statusAll resolvedthe confirm path worksUnder a fifth stuckordinary drop offMostly requires_confirmationconfirmSetup never runsMostly requires_actionthe return_url landing page
People really do abandon card forms. The bucket the stuck ones land in is what separates that from a defect.

How to fix it

Give them a day before judging

GET /v1/setup_intents?limit=100&created[lt]={now-24h}. An intent created ten minutes ago at requires_confirmation is a customer still typing. A day later it is never going to resolve on its own.

Bucket by status and take the ratio

Count the three unresolved statuses against everything created in the window. Under about a fifth is ordinary drop-off from a card form. Above it, something in the confirm path is broken and the ratio is the fastest way to tell those apart without reading any frontend code.

Let the dominant bucket point at the bug

requires_confirmation means the confirm call never happened. requires_action means it happened and the customer never came back, so look at next_action.type and at your return_url. requires_payment_method means no usable card was ever attached, which is either abandonment or a confirm that failed.

Read the errors on the failures

last_setup_error.code separates the causes: setup_intent_authentication_failure is the customer failing 3DS, setup_intent_setup_attempt_expired is an attempt that timed out. An empty last_setup_error across the whole stuck population means nothing was ever attempted, which points back at the client.

Confirm properly and drive persistence from the webhook

On the client, await stripe.confirmSetup() and treat only setupIntent.status === 'succeeded' as success. Implement the return_url landing page. Then save the PaymentMethod when the setup_intent.succeeded event arrives rather than when the browser says so, and cancel dead intents with cancellation_reason=abandoned so the backlog stops growing.

How to check it worked

Re-run against a fresh window after the fix. The ratio is the number to watch: it should drop back under the threshold and stay there, with whatever remains landing in requires_payment_method.

python3 stripe_setup_intents_stuck.py
# clear       all 312 SetupIntent(s) in the window resolved

The full code

One paginated GET against /v1/setup_intents and no writes — a restricted key with read access to SetupIntents is enough. The classifier takes the total and the three stuck counts and is pure, including the rule for what happens when two buckets tie, because a tie-break decided by dictionary ordering is a diagnosis that changes between runs.

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_setup_intents_stuck.py
"""Report Stripe SetupIntents that were created and never confirmed.

Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to SetupIntents. 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_intents_stuck")

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

STUCK_STATUSES = ("requires_payment_method", "requires_confirmation", "requires_action")
BROKEN_RATIO = 0.20   # above this, it is the confirm path rather than abandonment
MIN_AGE_HOURS = 24    # younger than this is a customer still typing


def verdict(total, requires_payment_method, requires_confirmation, requires_action):
    """Classify a window of SetupIntents. Pure, so it can be tested offline.

    Ties are broken in a fixed order rather than by whichever bucket a dict
    happened to yield first: requires_action wins, then requires_confirmation.
    Both are specific code defects, while requires_payment_method is the bucket
    ordinary abandonment also lands in, so it is the least informative of the
    three and should never win a tie.

    Returns (state, detail).
    """
    stuck = requires_payment_method + requires_confirmation + requires_action
    if not total:
        return ("clear", "no SetupIntents created in the window")
    if not stuck:
        return ("clear", "all %d SetupIntent(s) in the window resolved" % total)
    ratio = stuck / float(total)
    pct = ratio * 100
    if ratio < BROKEN_RATIO:
        return ("abandonment",
                "%d of %d SetupIntents (%.0f%%) are stuck, under the %.0f%% that "
                "separates a broken confirm path from ordinary drop-off"
                % (stuck, total, pct, BROKEN_RATIO * 100))
    if requires_action >= requires_confirmation and requires_action >= requires_payment_method:
        return ("return-url",
                "%d of %d (%.0f%%) stuck, mostly at requires_action: the 3DS handoff "
                "starts and never comes back. Check next_action.type and the "
                "return_url landing page." % (stuck, total, pct))
    if requires_confirmation >= requires_payment_method:
        return ("unconfirmed",
                "%d of %d (%.0f%%) stuck, mostly at requires_confirmation: "
                "confirmSetup() is never being called for these."
                % (stuck, total, pct))
    return ("no-payment-method",
            "%d of %d (%.0f%%) stuck at requires_payment_method, above the "
            "abandonment threshold: read last_setup_error.code before blaming "
            "the customers." % (stuck, total, pct))


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):
    """Yield objects from a paginated list endpoint until `limit` is reached."""
    seen = 0
    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:
            return
        params["starting_after"] = data[-1]["id"]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-intents", type=int, default=2000,
                    help="stop paginating after this many SetupIntents")
    ap.add_argument("--min-age-hours", type=float, default=MIN_AGE_HOURS,
                    help="ignore SetupIntents younger than this")
    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})

    cutoff = int(time.time() - args.min_age_hours * 3600)
    buckets = dict.fromkeys(STUCK_STATUSES, 0)
    errors = {}
    next_actions = {}
    total = 0
    params = {"limit": 100, "created[lt]": cutoff}
    for si in page_all(s, "/setup_intents", args.max_intents, **params):
        total += 1
        status = si.get("status")
        if status not in buckets:
            continue
        buckets[status] += 1
        err = (si.get("last_setup_error") or {}).get("code")
        if err:
            errors[err] = errors.get(err, 0) + 1
        action = (si.get("next_action") or {}).get("type")
        if action:
            next_actions[action] = next_actions.get(action, 0) + 1

    state, detail = verdict(total, buckets["requires_payment_method"],
                            buckets["requires_confirmation"],
                            buckets["requires_action"])
    line = "%-18s %s" % (state, detail)
    if state == "clear":
        log.info(line)
        return 0

    log.warning(line)
    for status in STUCK_STATUSES:
        log.warning("  %-24s %d", status, buckets[status])
    for code, count in sorted(errors.items(), key=lambda kv: -kv[1]):
        log.warning("  last_setup_error %-20s %d", code, count)
    for action, count in sorted(next_actions.items(), key=lambda kv: -kv[1]):
        log.warning("  next_action %-25s %d", action, count)
    log.warning("  confirm on the client and treat only 'succeeded' as success:")
    log.warning("  await stripe.confirmSetup({elements, confirmParams: {return_url}})")
    log.warning("  persist from the setup_intent.succeeded webhook, not the browser")
    log.warning("  clear the backlog: POST %s/setup_intents/{id}/cancel "
                "-d cancellation_reason=abandoned", API)
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-setup-intents-stuck.mjs
/**
 * Report Stripe SetupIntents that were created and never confirmed.
 *
 * Read only. One paginated GET and no writes: give this a RESTRICTED key with
 * read access to SetupIntents. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

export const STUCK_STATUSES = [
  'requires_payment_method', 'requires_confirmation', 'requires_action',
];
const BROKEN_RATIO = 0.20; // above this it is the confirm path, not abandonment
const MIN_AGE_HOURS = 24;  // younger than this is a customer still typing

/**
 * Classify a window of SetupIntents. Pure, so it can be tested offline.
 *
 * Ties are broken in a fixed order rather than by whichever bucket an object
 * happened to yield first: requiresAction wins, then requiresConfirmation. Both
 * are specific code defects, while requiresPaymentMethod is the bucket ordinary
 * abandonment also lands in, so it should never win a tie.
 */
export function verdict(total, requiresPaymentMethod, requiresConfirmation, requiresAction) {
  const stuck = requiresPaymentMethod + requiresConfirmation + requiresAction;
  if (!total) return ['clear', 'no SetupIntents created in the window'];
  if (!stuck) return ['clear', `all ${total} SetupIntent(s) in the window resolved`];
  const ratio = stuck / total;
  const pct = (ratio * 100).toFixed(0);
  if (ratio < BROKEN_RATIO) {
    return ['abandonment',
      `${stuck} of ${total} SetupIntents (${pct}%) are stuck, under the ` +
      `${(BROKEN_RATIO * 100).toFixed(0)}% that separates a broken confirm path ` +
      'from ordinary drop-off'];
  }
  if (requiresAction >= requiresConfirmation && requiresAction >= requiresPaymentMethod) {
    return ['return-url',
      `${stuck} of ${total} (${pct}%) stuck, mostly at requires_action: the 3DS ` +
      'handoff starts and never comes back. Check next_action.type and the ' +
      'return_url landing page.'];
  }
  if (requiresConfirmation >= requiresPaymentMethod) {
    return ['unconfirmed',
      `${stuck} of ${total} (${pct}%) stuck, mostly at requires_confirmation: ` +
      'confirmSetup() is never being called for these.'];
  }
  return ['no-payment-method',
    `${stuck} of ${total} (${pct}%) stuck at requires_payment_method, above the ` +
    'abandonment threshold: read last_setup_error.code before blaming the customers.'];
}

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 pageAll(key, path, limit, params = {}) {
  const out = [];
  const q = { limit: 100, ...params };
  for (;;) {
    const page = await get(key, path, q);
    const data = page.data ?? [];
    out.push(...data);
    if (data.length === 0 || !page.has_more || out.length >= limit) return out;
    q.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 cutoff = Math.floor(Date.now() / 1000 - MIN_AGE_HOURS * 3600);
  const buckets = Object.fromEntries(STUCK_STATUSES.map((s) => [s, 0]));
  const errors = new Map();
  const nextActions = new Map();
  let total = 0;

  for (const si of await pageAll(key, '/setup_intents', 2000, { 'created[lt]': cutoff })) {
    total += 1;
    if (!(si.status in buckets)) continue;
    buckets[si.status] += 1;
    const err = si.last_setup_error?.code;
    if (err) errors.set(err, (errors.get(err) ?? 0) + 1);
    const action = si.next_action?.type;
    if (action) nextActions.set(action, (nextActions.get(action) ?? 0) + 1);
  }

  const [state, detail] = verdict(total, buckets.requires_payment_method,
    buckets.requires_confirmation, buckets.requires_action);
  const line = `${state.padEnd(18)} ${detail}`;
  if (state === 'clear') { console.log(line); return; }

  console.warn(line);
  for (const status of STUCK_STATUSES) {
    console.warn(`  ${status.padEnd(24)} ${buckets[status]}`);
  }
  for (const [code, count] of [...errors].sort((a, b) => b[1] - a[1])) {
    console.warn(`  last_setup_error ${code.padEnd(20)} ${count}`);
  }
  for (const [action, count] of [...nextActions].sort((a, b) => b[1] - a[1])) {
    console.warn(`  next_action ${action.padEnd(25)} ${count}`);
  }
  console.warn("  confirm on the client and treat only 'succeeded' as success:");
  console.warn('  await stripe.confirmSetup({elements, confirmParams: {return_url}})');
  console.warn('  persist from the setup_intent.succeeded webhook, not the browser');
  console.warn(`  clear the backlog: POST ${API}/setup_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

The threshold and the tie-break are what the tests are for. Nineteen percent stuck is a card form people walk away from; twenty is a bug, and the function has to agree with itself about which. The tie-break matters just as much: when two buckets are level, the diagnosis has to be the same on every run rather than depending on which status the iteration reached first.

test_stripe_setup_intents_stuck.py
from stripe_setup_intents_stuck import verdict


def test_an_empty_window_is_clear():
    assert verdict(0, 0, 0, 0)[0] == "clear"


def test_everything_resolved_is_clear():
    state, detail = verdict(312, 0, 0, 0)
    assert state == "clear"
    assert "312" in detail


def test_nineteen_percent_is_ordinary_drop_off():
    assert verdict(100, 19, 0, 0)[0] == "abandonment"


def test_twenty_percent_is_a_broken_path():
    # The boundary is inclusive. One percentage point either side is the
    # difference between a report nobody acts on and one that names the bug.
    state, detail = verdict(100, 20, 0, 0)
    assert state == "no-payment-method"
    assert "last_setup_error" in detail


def test_a_pile_at_requires_confirmation_names_the_client():
    state, detail = verdict(100, 5, 40, 2)
    assert state == "unconfirmed"
    assert "confirmSetup" in detail


def test_requires_action_points_at_the_return_url():
    state, detail = verdict(100, 5, 10, 40)
    assert state == "return-url"
    assert "return_url" in detail


def test_a_tie_is_broken_deterministically():
    # Level buckets must not depend on iteration order: requires_action first,
    # then requires_confirmation.
    assert verdict(100, 20, 20, 20)[0] == "return-url"
    assert verdict(100, 20, 20, 0)[0] == "unconfirmed"
stripe-setup-intents-stuck.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-setup-intents-stuck.mjs';

test('an empty window is clear', () => {
  assert.equal(verdict(0, 0, 0, 0)[0], 'clear');
});

test('everything resolved is clear', () => {
  const [state, detail] = verdict(312, 0, 0, 0);
  assert.equal(state, 'clear');
  assert.match(detail, /312/);
});

test('nineteen percent is ordinary drop off', () => {
  assert.equal(verdict(100, 19, 0, 0)[0], 'abandonment');
});

test('twenty percent is a broken path', () => {
  const [state, detail] = verdict(100, 20, 0, 0);
  assert.equal(state, 'no-payment-method');
  assert.match(detail, /last_setup_error/);
});

test('a pile at requires_confirmation names the client', () => {
  const [state, detail] = verdict(100, 5, 40, 2);
  assert.equal(state, 'unconfirmed');
  assert.match(detail, /confirmSetup/);
});

test('requires_action points at the return_url', () => {
  const [state, detail] = verdict(100, 5, 10, 40);
  assert.equal(state, 'return-url');
  assert.match(detail, /return_url/);
});

test('a tie is broken deterministically', () => {
  assert.equal(verdict(100, 20, 20, 20)[0], 'return-url');
  assert.equal(verdict(100, 20, 20, 0)[0], 'unconfirmed');
});

FAQ

What is the difference between requires_confirmation and requires_action?

requires_confirmation means the client never called confirmSetup at all, so nothing was attempted. requires_action means it did, and the issuer asked for authentication that the customer never completed, usually because the 3DS redirect had nowhere to come back to.

Is a stuck SetupIntent the same as a failed one?

No, and that is why it is easy to miss. A failed setup has a last_setup_error and often an event you can subscribe to. An unconfirmed one has neither: it simply sits there, indistinguishable from a customer who is still typing, until you age it.

Why 24 hours before counting one as stuck?

Because a SetupIntent created minutes ago is a card form that is open right now. A day is long enough that nothing legitimate is still in flight and short enough that a daily run catches a regression the day after it ships.

Should I cancel the backlog?

Yes, once you have counted it. POST /v1/setup_intents/{id}/cancel with cancellation_reason=abandoned clears intents that will never resolve, so the ratio on the next run measures current behaviour instead of history. Count first, cancel second.

Why persist from the webhook instead of the browser?

Because the browser is where this fails. A tab closed during the redirect, a JavaScript error after the confirm, a flaky network on the way back: all of them end with a card that really was set up and an application that never recorded it. setup_intent.succeeded arrives regardless.

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.