Skip to content

Diagnostic Stripe

dunning ran out of retries and no attempt is scheduled

A card expired in May. Stripe retried it eight times over two weeks, each attempt failed, and then it stopped, which is exactly what it is supposed to do. Nothing announced the end of that sequence. The invoice is still open, the customer still has access, and the last human to look at the account was the one who set up the subscription.

Read-only key Python and Node.js Tests included
Text
Photo by Tamanna Rumee on Unsplash
The short answer

List GET /v1/invoices?status=open&collection_method=charge_automatically and read two fields together. attempt_count is how many times Stripe has tried; next_payment_attempt is when it will try again, and null means never.

A high attempt_count with next_payment_attempt: null and amount_remaining above zero is a finished dunning sequence: Stripe has given up and no further attempt exists anywhere. A high count with an attempt still scheduled is the other signature, the hard decline, where retries are queued but only execute once a new payment method appears.

The problem in plain words

Dunning is the one part of billing that is genuinely automatic, so it gets trusted completely. The retries run, most of them succeed eventually, and the recovered revenue shows up without anyone doing anything. That is a real feature and it works.

What is missing is the ending. When the last retry fails, Stripe stops attempting and sets next_payment_attempt to null. There is no separate "we gave up" state on the invoice: the status is still open, identical to an invoice issued twenty minutes ago that is about to be paid. Unless the end-of-dunning subscription action was configured, the subscription does not change either, so the customer keeps their access, keeps renewing, and accumulates further unpaid invoices behind the first one. Six months later, someone finds an account with nine open invoices and no way to tell when the relationship actually ended.

Card expiresrenewal declinesSmart Retriesrun8 tries, 2 weeksLast retryfailsnext_payment_attemptnullStatus stillopenno event for theendingAccesscontinuesinvoices keepstacking
The last failure looks exactly like the earlier ones. What marks the end is an attempt that is never scheduled, and absence fires no event.

Why it happens

The end of dunning is an absence, not an event. Every failed attempt produces an invoice.payment_failed event, including the last one, and the last one looks exactly like the others. The only thing that distinguishes it is that no further attempt gets scheduled, and there is no event for a thing that did not happen.

attempt_count alone is misleading. On a hard decline — lost_card, stolen_card, transaction_not_allowed, authentication_required — Stripe still increments the count and still schedules attempts, but those attempts do not really execute against the dead card; they wait for a new payment method. A count of nine with a future attempt means the customer needs an email, not that the system is still trying. The pair of fields distinguishes the two; either one on its own does not.

A low count with nothing scheduled is a configuration problem, not a customer problem. If dunning stopped after one or two attempts, either Smart Retries is off, or an end-of-dunning action already ran. Both are worth knowing, and both look like the exhausted case if you only check next_payment_attempt.

Access is decoupled from payment by default. The action Stripe takes when dunning ends — cancel, mark unpaid, leave alone — is a Dashboard setting, and the default leaves the subscription running. That is a sensible default for a business that wants to keep customers through a payment problem, and a costly one for a business that forgot the setting exists.

The fix, as a flow

The script reads attempt_count and next_payment_attempt together, because the same attempt count means Stripe has given up or that a customer needs to send a new card, depending entirely on the second field.

GET /v1/invoicesstatus=open, charge_automaticallyFew tries, one scheduleddunning is runningMany tries, none scheduledexhausted, decideMany tries, one scheduledhard decline, ask for a cardZero tries, none schedulednever charged at all
Four states out of two fields, and each one goes to a different person.

How to fix it

Page open invoices for charge_automatically only

Manually collected invoices have no retry schedule at all, so including them fills the report with rows where next_payment_attempt is legitimately null. Those are a receivable problem with a different repair, covered separately.

Read attempt_count and next_payment_attempt as a pair

Four states come out of two fields, and they need four different responses. Retries still running: wait. Retries exhausted: the relationship needs a decision. Retries scheduled but the count is high: email the customer for a new card. Nothing ever attempted: look at the integration, not the customer.

Check that Smart Retries is actually on

A pile of invoices that stopped after one or two attempts is a settings problem, not a customer-quality problem. Dashboard, then Billing, then Revenue recovery, then Retries: the default schedule is eight attempts over two weeks, and it recovers materially more than a fixed schedule does.

Set the end-of-dunning action while you are there

Decide now what should happen when retries run out: cancel the subscription, mark it unpaid, or leave it. Any of the three is defensible. Not choosing means the default runs, and the default is the one that keeps giving away the product.

Work the exhausted list by value

For the ones worth recovering, collect a new card, set it as the subscription's default_payment_method, and then pay the invoice. Doing it in the other order pays the invoice with the same failing card. For the rest, mark them uncollectible so the receivable figure means something.

How to check it worked

Re-run the script. Every remaining open invoice should have an attempt scheduled, or a count low enough that dunning is genuinely still in progress.

python3 stripe_dunning_exhausted.py
# clear           0 invoice(s) with dunning stopped

The full code

One paginated GET against /v1/invoices and no writes — a restricted key with read access to Invoices is enough. The classifier is pure and takes the two fields plus the balance, because the whole value of this check is in telling exhausted apart from stalled: one needs a decision about the customer, the other needs an email asking for a card, and they are indistinguishable in the Dashboard.

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_dunning_exhausted.py
"""Report Stripe invoices where dunning has stopped and no attempt is scheduled.

Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Invoices. The repair is printed, never performed, because paying an
invoice moves money and marking one uncollectible writes off revenue.
"""
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_dunning_exhausted")

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

# The default Smart Retries schedule is eight attempts over two weeks. Four is a
# deliberately conservative floor: past it, a sequence that has stopped has
# stopped for a reason rather than by coincidence.
EXHAUSTED_ATTEMPTS = 4


def verdict(attempt_count, next_attempt_in_days, amount_remaining):
    """Classify one automatically collected invoice. Pure, so the rules can be tested.

    `next_attempt_in_days` is the time until `next_payment_attempt`, and None when
    that field is null, which is Stripe saying it will not try again. Returns
    (state, detail).
    """
    if not amount_remaining or amount_remaining <= 0:
        return ("nothing_due", "open with amount_remaining 0: no money outstanding")
    if next_attempt_in_days is None:
        if attempt_count >= EXHAUSTED_ATTEMPTS:
            return ("exhausted",
                    "%d attempt(s) and next_payment_attempt is null: dunning is "
                    "over and nothing will collect this" % attempt_count)
        if attempt_count:
            return ("stopped_early",
                    "only %d attempt(s) and nothing scheduled: Smart Retries is "
                    "off, or an end-of-dunning action already ran" % attempt_count)
        return ("never_attempted",
                "0 attempts and nothing scheduled: this invoice was never charged "
                "at all, which is an integration problem rather than a decline")
    if attempt_count >= EXHAUSTED_ATTEMPTS:
        return ("stalled",
                "%d attempt(s) with another in %.1f day(s): on a hard decline the "
                "count keeps rising but nothing collects until a new payment "
                "method is attached" % (attempt_count, next_attempt_in_days))
    return ("retrying",
            "%d attempt(s), next in %.1f day(s): dunning is still running"
            % (attempt_count, next_attempt_in_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 open_invoices(session, limit):
    """Page open invoices that Stripe is supposed to be charging by itself."""
    out = []
    params = {"status": "open", "collection_method": "charge_automatically",
              "limit": 100}
    while True:
        page = get(session, "/invoices", **params)
        data = page.get("data", [])
        out.extend(data)
        if not data or not page.get("has_more") or len(out) >= limit:
            break
        params["starting_after"] = data[-1]["id"]
    return out


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--top", type=int, default=20,
                    help="how many individual invoices to print")
    ap.add_argument("--max-invoices", type=int, default=2000,
                    help="stop paginating after this many open invoices")
    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 = time.time()
    rows = []
    for inv in open_invoices(s, args.max_invoices):
        nxt = inv.get("next_payment_attempt")
        remaining = inv.get("amount_remaining") or 0
        rows.append((
            inv.get("id", "<no id>"),
            inv.get("subscription") or "<no subscription>",
            verdict(inv.get("attempt_count") or 0,
                    None if nxt is None else (nxt - now) / 86400.0,
                    remaining),
            remaining,
            (inv.get("currency") or "").upper(),
        ))

    stopped = [r for r in rows if r[2][0] in
               ("exhausted", "stopped_early", "never_attempted", "stalled")]
    if not stopped:
        log.info("%-15s 0 invoice(s) with dunning stopped", "clear")
        return 0

    stopped.sort(key=lambda r: r[3], reverse=True)
    lost = sum(r[3] for r in stopped)
    log.warning("%-15s %d invoice(s) nothing is collecting, worth %d in minor units",
                "stopped", len(stopped), lost)
    for inv_id, sub, (state, detail), amount, currency in stopped[:args.top]:
        log.warning("  %-15s %s  %d %s  %s", state, inv_id, amount, currency, detail)
        if state in ("exhausted", "stalled"):
            log.warning("      collect a card, then set it on the subscription "
                        "before paying: POST %s/subscriptions/%s "
                        "default_payment_method=<pm>", API, sub)
            log.warning("      then POST %s/invoices/%s/pay", API, inv_id)
    if len(stopped) > args.top:
        log.warning("  ... and %d more", len(stopped) - args.top)
    log.warning("  check the schedule itself: Dashboard, Billing, Revenue "
                "recovery, Retries, and set an end-of-dunning action")
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-dunning-exhausted.mjs
/**
 * Report Stripe invoices where dunning has stopped and no attempt is scheduled.
 *
 * Read only. One paginated GET and no writes: give this a RESTRICTED key with
 * read access to Invoices. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

// The default Smart Retries schedule is eight attempts over two weeks. Four is a
// deliberately conservative floor: past it, a sequence that has stopped has
// stopped for a reason rather than by coincidence.
export const EXHAUSTED_ATTEMPTS = 4;

/**
 * Classify one automatically collected invoice. Pure, so the rules can be tested.
 * `nextAttemptInDays` is null when next_payment_attempt is null, which is Stripe
 * saying it will not try again.
 */
export function verdict(attemptCount, nextAttemptInDays, amountRemaining) {
  if (!amountRemaining || amountRemaining <= 0) {
    return ['nothing_due', 'open with amount_remaining 0: no money outstanding'];
  }
  if (nextAttemptInDays === null || nextAttemptInDays === undefined) {
    if (attemptCount >= EXHAUSTED_ATTEMPTS) {
      return ['exhausted',
        `${attemptCount} attempt(s) and next_payment_attempt is null: dunning is ` +
        'over and nothing will collect this'];
    }
    if (attemptCount) {
      return ['stopped_early',
        `only ${attemptCount} attempt(s) and nothing scheduled: Smart Retries is ` +
        'off, or an end-of-dunning action already ran'];
    }
    return ['never_attempted',
      '0 attempts and nothing scheduled: this invoice was never charged at all, ' +
      'which is an integration problem rather than a decline'];
  }
  if (attemptCount >= EXHAUSTED_ATTEMPTS) {
    return ['stalled',
      `${attemptCount} attempt(s) with another in ${nextAttemptInDays.toFixed(1)} ` +
      'day(s): on a hard decline the count keeps rising but nothing collects ' +
      'until a new payment method is attached'];
  }
  return ['retrying',
    `${attemptCount} attempt(s), next in ${nextAttemptInDays.toFixed(1)} day(s): ` +
    'dunning is still running'];
}

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 openInvoices(key, limit = 2000) {
  const out = [];
  const params = { status: 'open', collection_method: 'charge_automatically', limit: 100 };
  for (;;) {
    const page = await get(key, '/invoices', params);
    const data = page.data ?? [];
    out.push(...data);
    if (data.length === 0 || !page.has_more || out.length >= limit) break;
    params.starting_after = data[data.length - 1].id;
  }
  return out;
}

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 = Date.now() / 1000;
  const rows = (await openInvoices(key)).map((inv) => {
    const nxt = inv.next_payment_attempt;
    const remaining = inv.amount_remaining ?? 0;
    return {
      id: inv.id ?? '<no id>',
      sub: inv.subscription ?? '<no subscription>',
      amount: remaining,
      currency: (inv.currency ?? '').toUpperCase(),
      state: verdict(inv.attempt_count ?? 0,
        nxt === null || nxt === undefined ? null : (nxt - now) / 86400,
        remaining),
    };
  });

  const stopped = rows.filter((r) => ['exhausted', 'stopped_early', 'never_attempted', 'stalled']
    .includes(r.state[0]));
  if (stopped.length === 0) {
    console.log(`${'clear'.padEnd(15)} 0 invoice(s) with dunning stopped`);
    return;
  }

  stopped.sort((a, b) => b.amount - a.amount);
  const lost = stopped.reduce((a, r) => a + r.amount, 0);
  console.warn(`${'stopped'.padEnd(15)} ${stopped.length} invoice(s) nothing is collecting, worth ${lost} in minor units`);
  for (const r of stopped.slice(0, 20)) {
    const [state, detail] = r.state;
    console.warn(`  ${state.padEnd(15)} ${r.id}  ${r.amount} ${r.currency}  ${detail}`);
    if (state === 'exhausted' || state === 'stalled') {
      console.warn('      collect a card, then set it on the subscription before ' +
                   `paying: POST ${API}/subscriptions/${r.sub} default_payment_method=<pm>`);
      console.warn(`      then POST ${API}/invoices/${r.id}/pay`);
    }
  }
  if (stopped.length > 20) console.warn(`  ... and ${stopped.length - 20} more`);
  console.warn('  check the schedule itself: Dashboard, Billing, Revenue recovery, ' +
               'Retries, and set an end-of-dunning action');
  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

Every test here is about the pair of fields rather than either one, because that is where the check earns its keep. The same attempt_count of nine means “Stripe has given up” with a null next attempt and “the customer needs to send a new card” with a scheduled one, and those go to different people.

test_stripe_dunning_exhausted.py
from stripe_dunning_exhausted import verdict


def test_dunning_still_running_is_not_a_finding():
    state, detail = verdict(2, 1.5, 9900)
    assert state == "retrying"
    assert "still running" in detail


def test_high_count_with_nothing_scheduled_is_exhausted():
    state, detail = verdict(8, None, 9900)
    assert state == "exhausted"
    assert "next_payment_attempt is null" in detail


def test_high_count_with_an_attempt_scheduled_is_a_hard_decline():
    # Same count, opposite meaning: retries are queued but only execute once a
    # new payment method appears, so this one needs an email, not a decision.
    assert verdict(8, 2.0, 9900)[0] == "stalled"
    assert verdict(3, None, 9900)[0] == "stopped_early"
    assert verdict(4, None, 9900)[0] == "exhausted"


def test_never_attempted_is_an_integration_problem():
    state, detail = verdict(0, None, 9900)
    assert state == "never_attempted"
    assert "integration problem" in detail


def test_a_settled_balance_is_not_dunning():
    assert verdict(8, None, 0)[0] == "nothing_due"
stripe-dunning-exhausted.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-dunning-exhausted.mjs';

test('dunning still running is not a finding', () => {
  const [state, detail] = verdict(2, 1.5, 9900);
  assert.equal(state, 'retrying');
  assert.match(detail, /still running/);
});

test('high count with nothing scheduled is exhausted', () => {
  const [state, detail] = verdict(8, null, 9900);
  assert.equal(state, 'exhausted');
  assert.match(detail, /next_payment_attempt is null/);
});

test('high count with an attempt scheduled is a hard decline', () => {
  assert.equal(verdict(8, 2.0, 9900)[0], 'stalled');
  assert.equal(verdict(3, null, 9900)[0], 'stopped_early');
  assert.equal(verdict(4, null, 9900)[0], 'exhausted');
});

test('never attempted is an integration problem', () => {
  const [state, detail] = verdict(0, null, 9900);
  assert.equal(state, 'never_attempted');
  assert.match(detail, /integration problem/);
});

test('a settled balance is not dunning', () => {
  assert.equal(verdict(8, null, 0)[0], 'nothing_due');
});

FAQ

How many times does Stripe retry a failed invoice?

The Smart Retries default is eight attempts spread over two weeks, timed by a model rather than a fixed interval. You can switch to a fixed schedule or turn retries off entirely, which is why a pile of invoices that stopped after one or two attempts is worth checking against the settings before blaming the cards.

What exactly does next_payment_attempt: null mean?

That Stripe has no further attempt planned for that invoice. On an open, automatically collected invoice with a balance, it means the retry sequence is finished. It is the only signal that dunning ended, because the invoice status stays open and the final failure event looks like every earlier one.

Why would attempt_count be high while an attempt is still scheduled?

Hard declines. On lost_card, stolen_card, transaction_not_allowed or authentication_required, retrying the same card cannot succeed, so the attempts wait for a new payment method to be attached. The count keeps rising and the schedule stays populated, which reads as a working system and is actually a stalled one.

Does the subscription get cancelled when retries run out?

Only if you configured it to. The end-of-dunning action is a Dashboard setting with options to cancel, mark the subscription unpaid, or do nothing, and doing nothing is the default. That is why unpaid customers keep their access and keep generating new invoices behind the unpaid one.

Why set the payment method on the subscription before paying the invoice?

Because paying it without doing that charges whatever failed last time. Attach the new payment method, set it as the subscription's default so future renewals use it too, and then pay the invoice. In the other order you get one more decline and an unchanged renewal.

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.