Skip to content

Diagnostic Stripe

paymentIntents sit in requires_payment_method for weeks

The Payments page shows a long tail of incomplete payments that never resolve into anything. Checkout starts and successful payments have drifted apart by a factor nobody can explain, and the gap grows every week. Most of those intents were never going to succeed: they were created before the customer had done anything, and nothing ever went back to them.

Read-only key Python and Node.js Tests included
Server cabinets
Photo by Eric Stoynov on Unsplash
The short answer

Paginate GET /v1/payment_intents and count the ones older than seven days still sitting at requires_payment_method or requires_confirmation. Anything over roughly 30% of the intents in that window means the integration is creating intents it does not intend to confirm.

Split the stale ones on whether last_payment_error is null. Null means no payment was ever attempted, which is a page-load creation problem. Populated means the customer tried and was declined, and nothing offered them a retry.

The problem in plain words

requires_payment_method is the state a PaymentIntent is born in, and the state it returns to after every failed confirmation. Those two facts make it the single least informative status on the object: a brand new intent and a twice-declined one look identical unless you read last_payment_error as well.

Because it is also a perfectly valid resting state, nothing anywhere complains. Stripe does not expire these, does not emit an event about them, and does not surface them outside the incomplete filter on the Payments page. They accumulate for as long as the integration has existed, and the first person to notice is usually whoever is trying to reconcile checkout analytics against payment volume and cannot make the numbers meet.

Payment pageloadsintent createdVisitor neverpaysno card enteredrequires_payment_methodthe birth statusNothing cancelsitno expiryStale foreverreports drift
Creating the intent before the customer acts makes the stale pile grow with traffic rather than with failures.

Why it happens

The intent is created on page load instead of at confirm time. This is the common one, and it used to be the documented pattern. Every visitor who reaches the payment step gets an intent whether or not they ever type a card number, so the stale pile grows in proportion to traffic rather than to failures.

Nothing retries after a decline. A declined confirmation puts the intent back at requires_payment_method with the reason in last_payment_error. If the UI shows a generic failure and starts over with a fresh intent, the old one stays behind forever and the customer never sees the actual message from their bank.

Manual confirmation that never happens. With confirmation_method: manual the intent lands at requires_confirmation and waits for a server-side confirm call. A background job that crashes, or a queue that drops the message, leaves the intent one API call short of completion with nothing to indicate it.

Nobody cancels anything. Cancellation is a deliberate write, and most integrations have no code path that performs it. The default behaviour of the whole system is to keep every dead intent indefinitely.

The fix, as a flow

The script scans intents old enough to have a verdict and splits the open ones on a single field, last_payment_error, because that field is what separates a customer who never tried from a customer who tried and was turned down.

GET /v1/payment_intentsolder than 7 days, openNo last_payment_errorcreated on page loadlast_payment_error setno retry was offeredrequires_confirmationyour server never confirmed
Three buckets that share one status and need three different fixes.

How to fix it

Scan intents older than seven days

Seven days is well past any real checkout session, including the ones where somebody genuinely came back the next morning. Anything still unconfirmed at that age is not going to be.

Split never-attempted from declined

The last_payment_error field decides which of the two problems you have, and they have entirely different fixes. Do not read the totals before reading the split; a large never-attempted bucket and a large declined bucket look the same in a headline count and lead you to opposite conclusions.

Read the decline codes on the declined bucket

last_payment_error.code and decline_code tell you whether these are genuine issuer declines, a card that expired, or something your own configuration caused. A pile of one specific code is a configuration problem wearing a decline's clothes.

Count requires_confirmation separately

These are not customer behaviour at all. Every one of them is a server-side confirm call that your code owed Stripe and never made, so the count is a direct measure of a broken job rather than of checkout friction.

Move intent creation behind the pay button

Creating the intent when the customer submits, rather than when the page renders, removes the never-attempted bucket entirely. For the declined bucket, reuse the same intent for the retry and show last_payment_error.message instead of a generic failure.

How to check it worked

Re-run the script against a window that begins after the change shipped. The stale share in the new window should drop toward zero even while the historical backlog is untouched.

python3 stripe_stale_intents.py --days 14
# 312 intent(s) older than 7d: 4 stale (1%) - 0 never-attempted, 4 declined, 0 unconfirmed

The full code

One paginated GET against PaymentIntents, no writes — a restricted key with read access to PaymentIntents is all it needs. The classifier is pure and takes the clock as an argument, because the interesting behaviour is entirely about which bucket a given object falls into, and that should be readable without tracing a request loop.

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

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_stale_intents")

API = "https://api.stripe.com/v1"
STALE_SECONDS = 7 * 86400
OPEN_STATUSES = ("requires_payment_method", "requires_confirmation")


def classify(intent, now, stale_after=STALE_SECONDS):
    """Classify one PaymentIntent. Pure, so the rules can be tested without a network.

    Returns (state, detail). The split that matters is `last_payment_error`:
    null means nothing was ever attempted, populated means the customer tried and
    was declined. The two look identical in a status count and need opposite fixes.
    """
    status = intent.get("status")
    if status not in OPEN_STATUSES:
        return ("other", "status %r, not an open intent" % (status,))
    created = intent.get("created")
    if not isinstance(created, int):
        return ("unknown", "no created timestamp, so the intent cannot be aged")
    days = int((now - created) // 86400)
    if now - created < stale_after:
        return ("recent", "%s, %dd old, still plausibly live" % (status, days))
    if status == "requires_confirmation":
        return ("unconfirmed",
                "%dd old: confirmation_method is manual and the server never "
                "called confirm" % days)
    err = intent.get("last_payment_error") or {}
    if err:
        reason = err.get("decline_code") or err.get("code") or "no code given"
        return ("declined",
                "%dd old: last attempt was declined (%s) and nothing offered a retry"
                % (days, reason))
    return ("never-attempted",
            "%dd old: created but no payment method was ever attached" % days)


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, until, cap):
    """Yield PaymentIntents created in [since, until), up to `cap` of them."""
    seen = 0
    params = {"limit": 100, "created[gte]": since, "created[lt]": until}
    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-days", type=int, default=7,
                    help="age at which an open intent counts as stale")
    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())
    stale_after = args.stale_days * 86400
    since = now - args.days * 86400
    until = now - stale_after  # only intents old enough to have a verdict

    counts = {}
    codes = {}
    examples = []
    scanned = 0

    for pi in payment_intents(s, since, until, args.max_intents):
        scanned += 1
        state, detail = classify(pi, now, stale_after)
        counts[state] = counts.get(state, 0) + 1
        if state == "declined":
            err = pi.get("last_payment_error") or {}
            code = err.get("decline_code") or err.get("code") or "unknown"
            codes[code] = codes.get(code, 0) + 1
        if state in ("never-attempted", "declined", "unconfirmed") and len(examples) < 10:
            examples.append((pi["id"], detail))

    never = counts.get("never-attempted", 0)
    declined = counts.get("declined", 0)
    unconfirmed = counts.get("unconfirmed", 0)
    stale = never + declined + unconfirmed

    for pid, detail in examples:
        log.warning("%s  %s", pid, detail)

    share = (100.0 * stale / scanned) if scanned else 0.0
    log.info("%d intent(s) older than %dd: %d stale (%.0f%%) - "
             "%d never-attempted, %d declined, %d unconfirmed",
             scanned, args.stale_days, stale, share, never, declined, unconfirmed)

    for code, n in sorted(codes.items(), key=lambda kv: -kv[1]):
        log.warning("  decline %-28s %d", code, n)

    if share > 30:
        log.warning("  over 30%% of intents in this window never went anywhere")
    if never:
        log.warning("  repair: create the PaymentIntent when the customer submits, "
                    "not when the payment page renders")
    if declined:
        log.warning("  repair: retry on the same intent and show "
                    "last_payment_error.message rather than a generic failure")
    if unconfirmed:
        log.warning("  repair: find the job that owes Stripe "
                    "POST %s/payment_intents/{id}/confirm and fix it", API)
    if stale:
        log.warning("  to clear the backlog: POST %s/payment_intents/{id}/cancel "
                    "-d cancellation_reason=abandoned", API)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
stripe-stale-intents.mjs
/**
 * Report Stripe PaymentIntents that were created and never confirmed.
 *
 * 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 = 7 * 86400;
const OPEN_STATUSES = ['requires_payment_method', 'requires_confirmation'];

/**
 * Classify one PaymentIntent. Pure, so the rules can be tested without a network.
 * The split that matters is last_payment_error: null means nothing was ever
 * attempted, populated means the customer tried and was declined. The two look
 * identical in a status count and need opposite fixes.
 */
export function classify(intent, now, staleAfter = STALE_SECONDS) {
  const status = intent.status;
  if (!OPEN_STATUSES.includes(status)) {
    return ['other', `status ${JSON.stringify(status)}, not an open intent`];
  }
  const created = intent.created;
  if (!Number.isInteger(created)) {
    return ['unknown', 'no created timestamp, so the intent cannot be aged'];
  }
  const days = Math.floor((now - created) / 86400);
  if (now - created < staleAfter) {
    return ['recent', `${status}, ${days}d old, still plausibly live`];
  }
  if (status === 'requires_confirmation') {
    return ['unconfirmed',
      `${days}d old: confirmation_method is manual and the server never called confirm`];
  }
  const err = intent.last_payment_error;
  if (err) {
    const reason = err.decline_code ?? err.code ?? 'no code given';
    return ['declined',
      `${days}d old: last attempt was declined (${reason}) and nothing offered a retry`];
  }
  return ['never-attempted',
    `${days}d old: created but no payment method was ever attached`];
}

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, until, cap) {
  let seen = 0;
  const params = { limit: 100, 'created[gte]': since, 'created[lt]': until };
  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 staleDays = Number(process.env.STALE_DAYS ?? 7);
  const staleAfter = staleDays * 86400;
  const now = Math.floor(Date.now() / 1000);
  const since = now - days * 86400;
  const until = now - staleAfter; // only intents old enough to have a verdict

  const counts = new Map();
  const codes = new Map();
  const examples = [];
  let scanned = 0;

  for await (const pi of paymentIntents(key, since, until, 5000)) {
    scanned += 1;
    const [state, detail] = classify(pi, now, staleAfter);
    counts.set(state, (counts.get(state) ?? 0) + 1);
    if (state === 'declined') {
      const err = pi.last_payment_error ?? {};
      const code = err.decline_code ?? err.code ?? 'unknown';
      codes.set(code, (codes.get(code) ?? 0) + 1);
    }
    if (['never-attempted', 'declined', 'unconfirmed'].includes(state) && examples.length < 10) {
      examples.push([pi.id, detail]);
    }
  }

  const never = counts.get('never-attempted') ?? 0;
  const declined = counts.get('declined') ?? 0;
  const unconfirmed = counts.get('unconfirmed') ?? 0;
  const stale = never + declined + unconfirmed;

  for (const [id, detail] of examples) console.warn(`${id}  ${detail}`);

  const share = scanned ? Math.round((100 * stale) / scanned) : 0;
  console.log(`${scanned} intent(s) older than ${staleDays}d: ${stale} stale (${share}%) - ` +
              `${never} never-attempted, ${declined} declined, ${unconfirmed} unconfirmed`);

  for (const [code, n] of [...codes].sort((a, b) => b[1] - a[1])) {
    console.warn(`  decline ${code.padEnd(28)} ${n}`);
  }

  if (share > 30) console.warn('  over 30% of intents in this window never went anywhere');
  if (never) {
    console.warn('  repair: create the PaymentIntent when the customer submits, ' +
                 'not when the payment page renders');
  }
  if (declined) {
    console.warn('  repair: retry on the same intent and show ' +
                 'last_payment_error.message rather than a generic failure');
  }
  if (unconfirmed) {
    console.warn(`  repair: find the job that owes Stripe POST ${API}` +
                 '/payment_intents/{id}/confirm and fix it');
  }
  if (stale) {
    console.warn(`  to clear the backlog: 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

The classifier exists to keep two buckets apart that Stripe reports with the same status. A stale intent with no last_payment_error is a page-load creation bug; a stale intent with one is a missing retry. The tests pin that split, and pin that requires_confirmation is never folded in with either of them, because it is your server's omission rather than the customer's.

test_stripe_stale_intents.py
from stripe_stale_intents import classify

NOW = 1_800_000_000
DAY = 86400


def pi(status="requires_payment_method", age_d=30, err=None):
    out = {"status": status, "created": NOW - age_d * DAY}
    if err is not None:
        out["last_payment_error"] = err
    return out


def test_old_intent_with_no_error_was_never_attempted():
    state, detail = classify(pi(age_d=30), NOW)
    assert state == "never-attempted"
    assert "no payment method" in detail


def test_old_intent_with_an_error_is_a_missing_retry():
    # Same status, opposite fix: this customer tried and was turned down.
    state, detail = classify(pi(age_d=30, err={"decline_code": "insufficient_funds"}), NOW)
    assert state == "declined"
    assert "insufficient_funds" in detail


def test_requires_confirmation_is_the_servers_omission():
    state, detail = classify(pi(status="requires_confirmation", age_d=30), NOW)
    assert state == "unconfirmed"
    assert "confirm" in detail


def test_a_two_day_old_intent_is_still_live():
    assert classify(pi(age_d=2), NOW)[0] == "recent"


def test_succeeded_intents_are_not_counted():
    assert classify(pi(status="succeeded"), NOW)[0] == "other"
stripe-stale-intents.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-stale-intents.mjs';

const NOW = 1800000000;
const DAY = 86400;

function pi({ status = 'requires_payment_method', ageD = 30, err = null } = {}) {
  const out = { status, created: NOW - ageD * DAY };
  if (err !== null) out.last_payment_error = err;
  return out;
}

test('old intent with no error was never attempted', () => {
  const [state, detail] = classify(pi({ ageD: 30 }), NOW);
  assert.equal(state, 'never-attempted');
  assert.match(detail, /no payment method/);
});

test('old intent with an error is a missing retry', () => {
  const [state, detail] = classify(
    pi({ ageD: 30, err: { decline_code: 'insufficient_funds' } }), NOW);
  assert.equal(state, 'declined');
  assert.match(detail, /insufficient_funds/);
});

test('requires_confirmation is the server omission', () => {
  const [state, detail] = classify(pi({ status: 'requires_confirmation', ageD: 30 }), NOW);
  assert.equal(state, 'unconfirmed');
  assert.match(detail, /confirm/);
});

test('a two day old intent is still live', () => {
  assert.equal(classify(pi({ ageD: 2 }), NOW)[0], 'recent');
});

test('succeeded intents are not counted', () => {
  assert.equal(classify(pi({ status: 'succeeded' }), NOW)[0], 'other');
});

FAQ

Is it wrong to create a PaymentIntent on page load?

It is not wrong, but it costs you a permanent record for every visitor who never pays, and it makes your incomplete-payment count a measure of traffic rather than of failure. Creating the intent when the customer submits keeps the object count proportional to actual attempts, which is what makes the remaining stale ones worth investigating.

Do stale intents cost money or hold funds?

No. An intent at requires_payment_method has never touched a card, so nothing is authorized and nothing is held. The cost is entirely in reporting: they distort conversion figures and they bury the small number of intents that represent a real broken flow.

What is the difference between requires_payment_method and requires_confirmation?

The first means no usable payment method is attached, which is where every intent starts and where it returns after a decline. The second only appears when confirmation_method is manual: a payment method is attached and Stripe is waiting for your server to call confirm. One is about the customer, the other is about your code.

Should I cancel old intents automatically?

Cancelling is safe for genuinely dead intents and sets cancellation_reason so the history stays readable. Do it as a deliberate, rate-limited job over a fixed age threshold rather than as a side effect of the check, and fix the creation pattern first so the job has less to do each week.

Why does this script only look at intents older than the stale threshold?

Because a younger intent has no verdict yet, and including it would drag the stale percentage down by however much traffic you had today. Bounding the scan at now minus seven days makes the ratio a property of the integration instead of a property of the hour you ran it.

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.