Skip to content

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.

Read-only key Python and Node.js Tests included
Red tag with "sale 50%" text on black background
Photo by Sasun Bughdaryan on Unsplash
The short answer

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.

Customer paysby ACHintent goesprocessingCheckout checksoncenot succeeded yetNo endpointsubscribedresult neverarrivesBank settlesdays laternobody islisteningOrder nevershipscustomer asksweeks on
Processing is where an ACH payment is supposed to sit, so there is no wrong looking value anywhere to alert on.

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.

status processingplus payment_method_types andcreatedinside the windowsettling normallypast the windowstuck, chase the mandateover 30 dayscancelling no longer permittedcard, over a dayconfirmation never finished
Age is the only thing separating a slow debit from a dead one, so the check is arithmetic rather than a status read.

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.

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_bank_debit_processing.py
"""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())
stripe-bank-debit-processing.mjs
/**
 * 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.

test_stripe_bank_debit_processing.py
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
stripe-bank-debit-processing.test.mjs
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

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.