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.
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.
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.
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.
"""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())
/**
* 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.
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"
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
- requires_action intents pile up at the 3DS step
- Cancel abandoned payment intents
- 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
- The PaymentIntent object — Stripe API reference
- List PaymentIntents — 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.