Diagnostic Stripe
bank-debit intents stay in processing for over a week
Bank-debit orders divide into two piles and neither is right. Some shipped the moment the customer clicked, and a few of those later bounced. The rest have never shipped at all, because nothing in the code ever looked at them again. The intents are all sitting in processing, which is where an ACH payment is supposed to sit — for a while.
Page GET /v1/payment_intents, keep status == "processing", and read payment_method_types. For us_bank_account, sepa_debit, acss_debit, bacs_debit and au_becs_debit, a few days in that state is settlement working normally.
The number that matters is age against the method's own window: ACH settles in about four business days, SEPA in about five. An intent still processing well past its window is stuck, and one still processing on a card is a different failure entirely.
The problem in plain words
The damage arrives in two directions from the same missing check. Fulfil on processing and you ship before the money exists, so a failed debit weeks later is a loss with the goods already gone. Fulfil only on succeeded but never look again, and the payment settles perfectly and the order sits unshipped until the customer asks.
Most integrations manage both at once, because different code paths made different assumptions. The checkout controller treats anything not-failed as good; the reconciliation job treats anything not-succeeded as pending. Neither is wrong about cards, where processing lasts seconds and is effectively invisible.
Why it happens
processing is a legitimate resting state here, not an error. That is what makes it dangerous. Every other stuck status — requires_payment_method, requires_action — means somebody has to do something. This one means the bank has it and you wait, so there is no wrong-looking value to alert on and no threshold that is right for every method.
The window is per method, so one number cannot cover them all. A single seven-day rule flags healthy SEPA debits while missing ACH debits that have been stuck for a week. The check has to carry a small table of settlement windows and compare each intent against its own.
Nothing polls you. A card payment resolves inside the request that created it, so a great deal of integration code assumes the answer is available at checkout. For a bank debit the answer arrives days later as payment_intent.succeeded or payment_intent.payment_failed, and an integration with no endpoint subscribed to those simply never learns it.
A stuck debit and a slow one look identical from outside. Both are processing. Only the age separates them, which is why this check is arithmetic rather than a status read, and why the classifier takes the current time as an argument.
The fix, as a flow
The script compares each processing intent against the settlement window of its own payment method, because one threshold flags healthy SEPA and misses stuck ACH at the same time.
How to fix it
Filter to the asynchronous methods
payment_method_types tells you what the intent could have been paid with. The bank debits are us_bank_account, sepa_debit, acss_debit, bacs_debit and au_becs_debit; everything else in processing for more than a day is a different problem worth reporting separately.
Compare age against the method's own window
Keep the windows in one small table so they can be argued about in one place. Where an intent lists more than one debit type, use the most generous window of the set rather than the first one, or the check reports settlement as failure.
Corroborate against the charge
The charge for a bank debit sits at status: "pending" for the same period, and payment_method_details carries the method-specific detail. Where a verification problem is the cause, that is where the wording lives.
Fix the reason there is no answer, not just the backlog
A backlog of stuck intents is the symptom of nothing listening. An endpoint subscribed to payment_intent.succeeded, payment_intent.processing and payment_intent.payment_failed is what stops the pile re-forming after you clear it.
Decide what to do with the genuinely dead ones
Cancellation is permitted in processing for bank debits, but only inside a limited window, so very old intents may not be cancellable at all. Those have to be reconciled against the charges rather than tidied through the API.
How to check it worked
Re-run the script. Everything still processing should be inside its own settlement window, with nothing in the stuck or long-stuck buckets.
python3 stripe_bank_debit_processing.py --days 90
# 1,180 processing intent(s): 1,180 settling, 0 stuck, 0 long-stuck, 0 non-debit
The full code
One paginated GET over /v1/payment_intents and no writes — a restricted key with read access to PaymentIntents is enough. The settlement windows live in one table at the top of the file so they can be adjusted in a single place, and the classifier takes both the intent and the clock, which is what makes a nine-day-old ACH debit a test rather than a wait.
"""Report Stripe bank-debit PaymentIntents stuck in processing past settlement.
Read only. One paginated GET and 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_bank_debit_processing")
API = "https://api.stripe.com/v1"
# Calendar days, generous on purpose: the documented settlement times are in
# business days (ACH about four, SEPA about five), so these carry a weekend.
# One number for every method would flag healthy SEPA while missing stuck ACH,
# which is the whole reason this is a table rather than a constant.
SETTLEMENT_DAYS = {
"us_bank_account": 6,
"acss_debit": 6,
"sepa_debit": 7,
"bacs_debit": 5,
"au_becs_debit": 5,
}
def classify(intent, now, grace_days=0):
"""Sort one processing PaymentIntent against its own settlement window.
Pure, and `now` is passed in rather than read, so a nine-day-old ACH debit
is a test case rather than a wait.
`processing` is a legitimate resting state for a bank debit and a fault for
anything else, so the method decides which rule applies. Where an intent
lists several debit types, the most generous window wins: reporting normal
settlement as failure is how a check like this gets switched off.
Returns (state, detail).
"""
if intent.get("status") != "processing":
return ("not_processing", "status %s" % (intent.get("status"),))
types = [t for t in (intent.get("payment_method_types") or [])
if t in SETTLEMENT_DAYS]
age_days = (int(now) - int(intent.get("created") or now)) / 86400.0
if not types:
if age_days < 1:
return ("settling",
"processing on a synchronous method, less than a day old")
return ("non_debit",
"processing for %.1f day(s) on a method with no multi-day "
"settlement: the confirmation never completed" % age_days)
window = max(SETTLEMENT_DAYS[t] for t in types) + grace_days
method = max(types, key=lambda t: SETTLEMENT_DAYS[t])
if age_days <= window:
return ("settling",
"day %.1f of a %d day window for %s" % (age_days, window, method))
if age_days > 30:
return ("long_stuck",
"%.1f day(s) in processing on %s: far past settlement, and past "
"the window in which cancelling is still permitted" % (age_days, method))
return ("stuck",
"%.1f day(s) in processing on %s, window is %d: this is not "
"settlement taking its time" % (age_days, method, window))
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 intents(session, since, cap):
"""Yield PaymentIntents, paginating until Stripe stops or the cap is hit."""
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 data or not page.get("has_more"):
return
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=90,
help="how far back to read intents")
ap.add_argument("--grace-days", type=int, default=0,
help="add this many days to every settlement window")
ap.add_argument("--max-intents", type=int, default=20000,
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())
counts, amounts = {}, {}
processing = 0
for pi in intents(s, now - args.days * 86400, args.max_intents):
state, detail = classify(pi, now, args.grace_days)
if state == "not_processing":
continue
processing += 1
counts[state] = counts.get(state, 0) + 1
amounts[state] = amounts.get(state, 0) + (pi.get("amount") or 0)
if state != "settling":
log.warning("%s %-11s %s", pi.get("id", "pi_?"), state, detail)
stuck = counts.get("stuck", 0)
long_stuck = counts.get("long_stuck", 0)
log.info("%d processing intent(s): %d settling, %d stuck, %d long-stuck, "
"%d non-debit", processing, counts.get("settling", 0), stuck,
long_stuck, counts.get("non_debit", 0))
if stuck or long_stuck:
log.warning(" %d minor unit(s) sitting in processing past settlement",
amounts.get("stuck", 0) + amounts.get("long_stuck", 0))
log.warning(" repair: subscribe an endpoint to payment_intent.succeeded, "
"payment_intent.processing and payment_intent.payment_failed, "
"and gate fulfilment on succeeded only:")
log.warning(" POST %s/webhook_endpoints -d url=... "
"-d enabled_events[]=payment_intent.succeeded", API)
if long_stuck:
log.warning(" %d intent(s) are past the point where cancelling is "
"permitted. Reconcile those against GET %s/charges.",
long_stuck, API)
if counts.get("non_debit"):
log.warning(" %d intent(s) are processing on a method with no "
"multi-day settlement: those are a confirmation that never "
"finished, not a slow bank.", counts["non_debit"])
return 1 if (stuck or long_stuck or counts.get("non_debit")) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe bank-debit PaymentIntents stuck in processing past settlement.
*
* Read only. One paginated GET and 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';
// Calendar days, generous on purpose: the documented settlement times are in
// business days (ACH about four, SEPA about five), so these carry a weekend.
// One number for every method would flag healthy SEPA while missing stuck ACH.
const SETTLEMENT_DAYS = {
us_bank_account: 6,
acss_debit: 6,
sepa_debit: 7,
bacs_debit: 5,
au_becs_debit: 5,
};
/**
* Sort one processing PaymentIntent against its own settlement window. Pure,
* and `now` is passed in rather than read. Where an intent lists several debit
* types the most generous window wins. Returns [state, detail].
*/
export function classify(intent, now, graceDays = 0) {
if (intent.status !== 'processing') return ['not_processing', `status ${intent.status}`];
const types = (intent.payment_method_types ?? []).filter((t) => t in SETTLEMENT_DAYS);
const ageDays = (Number(now) - Number(intent.created ?? now)) / 86400;
if (types.length === 0) {
if (ageDays < 1) {
return ['settling', 'processing on a synchronous method, less than a day old'];
}
return ['non_debit',
`processing for ${ageDays.toFixed(1)} day(s) on a method with no ` +
'multi-day settlement: the confirmation never completed'];
}
const window = Math.max(...types.map((t) => SETTLEMENT_DAYS[t])) + graceDays;
const method = types.reduce(
(a, b) => (SETTLEMENT_DAYS[b] > SETTLEMENT_DAYS[a] ? b : a));
if (ageDays <= window) {
return ['settling', `day ${ageDays.toFixed(1)} of a ${window} day window for ${method}`];
}
if (ageDays > 30) {
return ['long_stuck',
`${ageDays.toFixed(1)} day(s) in processing on ${method}: far past ` +
'settlement, and past the window in which cancelling is still permitted'];
}
return ['stuck',
`${ageDays.toFixed(1)} day(s) in processing on ${method}, window is ` +
`${window}: this is not settlement taking its time`];
}
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* intents(key, since, cap = 20000) {
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 (data.length === 0 || !page.has_more) 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 now = Math.floor(Date.now() / 1000);
const days = Number(process.env.DAYS ?? 90);
const graceDays = Number(process.env.GRACE_DAYS ?? 0);
const counts = {};
const amounts = {};
let processing = 0;
for await (const pi of intents(key, now - days * 86400)) {
const [state, detail] = classify(pi, now, graceDays);
if (state === 'not_processing') continue;
processing += 1;
counts[state] = (counts[state] ?? 0) + 1;
amounts[state] = (amounts[state] ?? 0) + (pi.amount ?? 0);
if (state !== 'settling') {
console.warn(`${pi.id ?? 'pi_?'} ${state.padEnd(11)} ${detail}`);
}
}
const stuck = counts.stuck ?? 0;
const longStuck = counts.long_stuck ?? 0;
console.log(`${processing} processing intent(s): ${counts.settling ?? 0} ` +
`settling, ${stuck} stuck, ${longStuck} long-stuck, ` +
`${counts.non_debit ?? 0} non-debit`);
if (stuck || longStuck) {
console.warn(` ${(amounts.stuck ?? 0) + (amounts.long_stuck ?? 0)} minor ` +
'unit(s) sitting in processing past settlement');
console.warn(' repair: subscribe an endpoint to payment_intent.succeeded, ' +
'payment_intent.processing and payment_intent.payment_failed, ' +
'and gate fulfilment on succeeded only:');
console.warn(` POST ${API}/webhook_endpoints -d url=... ` +
'-d enabled_events[]=payment_intent.succeeded');
}
if (longStuck) {
console.warn(` ${longStuck} intent(s) are past the point where cancelling ` +
`is permitted. Reconcile those against GET ${API}/charges.`);
}
if (counts.non_debit) {
console.warn(` ${counts.non_debit} intent(s) are processing on a method ` +
'with no multi-day settlement: those are a confirmation that ' +
'never finished, not a slow bank.');
}
if (stuck || longStuck || counts.non_debit) 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 tests exist mostly to defend the per-method table. A five-day-old SEPA debit and a nine-day-old ACH debit are the pair that a single threshold gets wrong in both directions, and an intent listing two debit types has to take the longer window or the check reports normal settlement as failure and gets switched off within a week.
from stripe_bank_debit_processing import classify
NOW = 1_700_000_000
DAY = 86400
def intent(types, age_days, status="processing"):
return {
"status": status,
"payment_method_types": types,
"created": NOW - int(age_days * DAY),
}
def test_settled_intents_are_ignored():
state, _ = classify(intent(["us_bank_account"], 30, status="succeeded"), NOW)
assert state == "not_processing"
def test_ach_inside_its_window_is_settling():
state, detail = classify(intent(["us_bank_account"], 3), NOW)
assert state == "settling"
assert "us_bank_account" in detail
def test_sepa_at_five_days_is_still_settling():
# A single seven-day rule would be wrong here in one direction and wrong
# about the ACH case below in the other.
assert classify(intent(["sepa_debit"], 5), NOW)[0] == "settling"
def test_ach_at_nine_days_is_stuck():
state, detail = classify(intent(["us_bank_account"], 9), NOW)
assert state == "stuck"
assert "not settlement taking its time" in detail
def test_several_debit_types_take_the_most_generous_window():
state, _ = classify(intent(["us_bank_account", "sepa_debit"], 6.5), NOW)
assert state == "settling"
def test_a_card_left_processing_is_a_different_failure():
state, detail = classify(intent(["card"], 4), NOW)
assert state == "non_debit"
assert "confirmation never completed" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-bank-debit-processing.mjs';
const NOW = 1700000000;
const DAY = 86400;
const intent = (types, ageDays, status = 'processing') => ({
status,
payment_method_types: types,
created: NOW - Math.round(ageDays * DAY),
});
test('settled intents are ignored', () => {
assert.equal(
classify(intent(['us_bank_account'], 30, 'succeeded'), NOW)[0], 'not_processing');
});
test('ACH inside its window is settling', () => {
const [state, detail] = classify(intent(['us_bank_account'], 3), NOW);
assert.equal(state, 'settling');
assert.match(detail, /us_bank_account/);
});
test('SEPA at five days is still settling', () => {
assert.equal(classify(intent(['sepa_debit'], 5), NOW)[0], 'settling');
});
test('ACH at nine days is stuck', () => {
const [state, detail] = classify(intent(['us_bank_account'], 9), NOW);
assert.equal(state, 'stuck');
assert.match(detail, /not settlement taking its time/);
});
test('several debit types take the most generous window', () => {
assert.equal(
classify(intent(['us_bank_account', 'sepa_debit'], 6.5), NOW)[0], 'settling');
});
test('a card left processing is a different failure', () => {
const [state, detail] = classify(intent(['card'], 4), NOW);
assert.equal(state, 'non_debit');
assert.match(detail, /confirmation never completed/);
});
FAQ
Is processing an error state?
No, and that is what makes it awkward. For ACH, SEPA, BECS, Bacs and pre-authorized debit, processing is where a payment legitimately sits while the bank moves the money. Only the age tells you whether a given intent is settling or stuck.
How long should each method take?
Roughly four business days for ACH and about five for SEPA, with the other debits in the same range. Because the numbers differ, a single threshold flags healthy payments on one method while missing stuck ones on another, so keep a small per-method table instead.
Can I just poll the intent at checkout and be done?
No. The answer does not exist yet at checkout, and no amount of polling in that request will produce it. The result arrives days later as payment_intent.succeeded or payment_intent.payment_failed, so an endpoint subscribed to those is the only way to learn it.
Should I fulfil while the intent is processing?
Only if you are willing to lose the goods when the debit fails, which does happen. Gate fulfilment on succeeded and treat processing as a state your order system understands and displays, rather than one it silently rounds to either good or bad.
Can I cancel a payment that has been processing for months?
Probably not. Cancellation is permitted in processing for bank debits but only inside a limited window, so very old intents have to be reconciled against the charges instead of tidied up through the API.
Related field notes
- PaymentIntents sit in requires_payment_method for weeks
- Session status is complete but payment_status is still unpaid
- Manual-capture holds expire before anyone captures them
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
- ACH Direct Debit — Stripe Docs
- SEPA Direct Debit — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.