Skip to content

Diagnostic Stripe

requirements.past_due has already disabled the payouts

There is a monitor. It runs daily, it reads every connected account, and it alerts when requirements.currently_due is not empty. It has been green for a month. A seller's payouts stopped eleven days ago and the monitor never said a word about it, because the field that would have told it apart from routine housekeeping is a different array on the same object.

Read-only key Python and Node.js Tests included
Server cooling fans
Photo by Winston Chen on Unsplash
The short answer

Read requirements.past_due, not just requirements.currently_due. past_due is a strict subset of currently_due, so an account whose payouts Stripe has already disabled looks identical to one with paperwork outstanding if you only measure the length of the larger array.

The three states that matter are separate fields: past_due non-empty means already broken, currently_due with a near requirements.current_deadline means about to break on a known date, and eventually_due alone means nothing is wrong yet. Confirm the first with disabled_reason == "requirements.past_due" and payouts_enabled == false.

The problem in plain words

The insidious part is that the monitor is not broken. It fires correctly, on a real field, with a sensible threshold. It just cannot distinguish the two situations that share that field, and the difference between them is a seller who needs a reminder email and a seller whose money has stopped moving.

What that produces in practice is alert fatigue that arrives in the worst possible order. Most accounts have something in currently_due most of the time, so the alert is noisy, so the team raises the threshold or mutes it, and the one account per month that is genuinely disabled comes in on the same channel as the eighty that are merely untidy. The signal was always there; it was averaged away.

Thresholdcrossedcurrent_deadlinesetFields gouncollectedstill incurrently_dueDeadline passesfields move topast_duePayoutsdisabledpayouts_enabledfalseMonitor staysgreenone array, onecount
The monitor is not broken. It fires on a real field, and that field cannot tell a warning apart from a disabled account.

Why it happens

The arrays nest, and the nesting is not obvious. eventually_due contains currently_due contains past_due. A field moves inward over time as deadlines pass. Because every past_due field is also in currently_due, a check on the outer array is technically triggered by the inner state and therefore feels like it covers it. It does not: it just cannot say which one it saw.

The deadline is invisible until it is not. current_deadline is the earliest deadline across every requested capability plus any risk requirements you cannot see. It is set the moment a volume threshold is crossed, which is a good thing happening to the seller, and it gives a real window. A boolean check discards that window entirely and turns a scheduled piece of work into a surprise.

Cohorts break together. Deadlines are usually driven by processing thresholds, and sellers who onboarded in the same month cross them in the same month. The failure mode is not one account; it is fourteen accounts on a Tuesday, all with the same missing tax ID field, all of which could have been collected weeks earlier from the same list.

Nothing in your own code fails. Payouts are automatic. When Stripe disables them, no request of yours errors, because you were not making one. The balance simply stops moving, and a balance that stops moving looks exactly like a quiet week until someone counts the days.

The fix, as a flow

The script reads the requirement arrays innermost first, because past_due sits inside currently_due and a length check on the outer array reports an already broken account as routine paperwork.

GET /v1/accountspast_due, currently_due,current_deadlineNo requirementscleareventually_due onlynot urgentDeadline inside 14 dayscollect the cohort nowpast_due not emptycapabilities already off
Already broken, breaks on a known date, and nothing wrong yet are three different answers with three different response times.

How to fix it

Measure past_due separately from currently_due

These are two different alerts with two different response times. past_due is an incident: capabilities that depend on those fields are already off. currently_due without past_due is a task. Reporting them on one line, with one count, guarantees the incident is read as a task.

Sort the rest by current_deadline ascending

The accounts closest to their deadline are the ones worth an email today. An ordered list with days remaining next to each account is actionable in a way that an unordered set of account ids is not, and it lets you collect a whole cohort in one pass before any of them break.

Confirm the damage with payouts_enabled and disabled_reason

requirements.past_due being non-empty and disabled_reason reading requirements.past_due should agree with payouts_enabled: false. When they disagree, believe the requirement arrays: the capability that was disabled may not be the one driving the top-level flag.

Get the per-capability breakdown before you collect anything

GET /v1/accounts/{id}/capabilities returns requirements.past_due per capability. This matters when only one capability is affected, since the account-level arrays flatten several capabilities together and you can end up chasing a field that is blocking a capability the seller does not use.

Collect eventually_due, not currently_due

An onboarding link with collection_options[fields]=currently_due clears today's problem and leaves the account to re-enter this state at the next threshold. Asking for eventually_due collects everything Stripe will ever want in one session, which is one email to the seller instead of three.

How to check it worked

Re-run the script. No account should be in past-due, and anything left should carry a deadline far enough out to be scheduled rather than chased.

python3 stripe_requirements_past_due.py
# 412 account(s): 0 past due, 3 with a deadline inside 14 days

The full code

One paginated GET against /v1/accounts — a restricted key with read access to Connected accounts covers it. The classifier takes the requirements object and the current time and returns one of five states, because the entire point of this check is that already broken, breaks on a date and nothing wrong yet are three different answers that a length check on one array collapses into one.

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_requirements_past_due.py
"""Separate connected accounts that are already disabled from ones merely due.

Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Connected accounts. 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_requirements_past_due")

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

# A deadline further out than this is a scheduled task; inside it, an email today.
NEAR_DEADLINE_DAYS = 14


def classify(requirements, now, near_days=NEAR_DEADLINE_DAYS):
    """Sort one account's requirements object. Pure, so the nesting can be tested.

    eventually_due contains currently_due contains past_due, so the arrays are
    read innermost first. Returns (state, detail).
    """
    reqs = requirements or {}
    past = [f for f in (reqs.get("past_due") or []) if f]
    current = [f for f in (reqs.get("currently_due") or []) if f]
    pending = [f for f in (reqs.get("pending_verification") or []) if f]
    eventual = [f for f in (reqs.get("eventually_due") or []) if f]
    deadline = reqs.get("current_deadline")

    if past:
        return ("past-due",
                "%d field(s) past the deadline, so the capabilities that need them "
                "are already off: %s" % (len(past), ", ".join(past[:4])))

    if current:
        if isinstance(deadline, (int, float)):
            days = (deadline - now) / 86400.0
            if days < 0:
                return ("overdue",
                        "current_deadline passed %.1f days ago with %d field(s) "
                        "still due: expect past_due next" % (-days, len(current)))
            if days <= near_days:
                return ("deadline",
                        "%d field(s) due and current_deadline is %.1f days away: %s"
                        % (len(current), days, ", ".join(current[:4])))
            return ("due",
                    "%d field(s) due, %.1f days of deadline left"
                    % (len(current), days))
        return ("due",
                "%d field(s) currently due with no deadline set yet"
                % len(current))

    if pending:
        return ("pending",
                "%d field(s) submitted and under verification: nothing to collect"
                % len(pending))

    if eventual:
        return ("eventual",
                "%d field(s) eventually due, none of them urgent" % len(eventual))

    return ("clear", "no outstanding requirements")


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 accounts(session, cap):
    seen = 0
    params = {"limit": 100}
    while True:
        page = get(session, "/accounts", **params)
        data = page.get("data", [])
        for acct in data:
            yield acct
            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("--near-days", type=int, default=NEAR_DEADLINE_DAYS,
                    help="treat a deadline inside this many days as urgent")
    ap.add_argument("--max-accounts", type=int, default=5000,
                    help="stop paginating after this many accounts")
    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 = {}
    urgent = []
    scanned = 0

    for acct in accounts(s, args.max_accounts):
        scanned += 1
        state, detail = classify(acct.get("requirements"), now, args.near_days)
        counts[state] = counts.get(state, 0) + 1
        if state in ("past-due", "overdue", "deadline"):
            deadline = (acct.get("requirements") or {}).get("current_deadline") or 0
            urgent.append((deadline, acct.get("id", "acct_?"), state, detail,
                           acct.get("payouts_enabled")))

    # Soonest deadline first: this list is a work queue, not a report.
    for deadline, acct_id, state, detail, payouts in sorted(urgent):
        log.warning("%s  %-9s payouts_enabled=%s  %s",
                    acct_id, state, payouts, detail)

    log.info("%d account(s): %d past due, %d with a deadline inside %d days",
             scanned, counts.get("past-due", 0) + counts.get("overdue", 0),
             counts.get("deadline", 0), args.near_days)

    if counts.get("past-due") or counts.get("overdue"):
        log.warning("  repair: per-capability detail first, since the account level "
                    "arrays flatten several capabilities together:")
        log.warning("  GET %s/accounts/{id}/capabilities", API)
        log.warning("  repair: update the account with every string listed in "
                    "requirements.past_due, or send an onboarding account link")
    if counts.get("deadline") or counts.get("due"):
        log.warning("  repair: collect eventually_due rather than currently_due so "
                    "the account does not re-enter this state at the next threshold")
    return 1 if (counts.get("past-due") or counts.get("overdue")
                 or counts.get("deadline")) else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-requirements-past-due.mjs
/**
 * Separate connected accounts that are already disabled from ones merely due.
 *
 * Read only. One paginated GET and no writes: give this a RESTRICTED key with
 * read access to Connected accounts. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

// A deadline further out than this is a scheduled task; inside it, an email today.
export const NEAR_DEADLINE_DAYS = 14;

/**
 * Sort one account's requirements object. Pure, so the nesting can be tested.
 * eventually_due contains currently_due contains past_due, so the arrays are read
 * innermost first. Returns [state, detail].
 */
export function classify(requirements, now, nearDays = NEAR_DEADLINE_DAYS) {
  const reqs = requirements ?? {};
  const past = (reqs.past_due ?? []).filter(Boolean);
  const current = (reqs.currently_due ?? []).filter(Boolean);
  const pending = (reqs.pending_verification ?? []).filter(Boolean);
  const eventual = (reqs.eventually_due ?? []).filter(Boolean);
  const deadline = reqs.current_deadline;

  if (past.length) {
    return ['past-due',
      `${past.length} field(s) past the deadline, so the capabilities that need ` +
      `them are already off: ${past.slice(0, 4).join(', ')}`];
  }

  if (current.length) {
    if (typeof deadline === 'number') {
      const days = (deadline - now) / 86400;
      if (days < 0) {
        return ['overdue',
          `current_deadline passed ${(-days).toFixed(1)} days ago with ` +
          `${current.length} field(s) still due: expect past_due next`];
      }
      if (days <= nearDays) {
        return ['deadline',
          `${current.length} field(s) due and current_deadline is ` +
          `${days.toFixed(1)} days away: ${current.slice(0, 4).join(', ')}`];
      }
      return ['due',
        `${current.length} field(s) due, ${days.toFixed(1)} days of deadline left`];
    }
    return ['due', `${current.length} field(s) currently due with no deadline set yet`];
  }

  if (pending.length) {
    return ['pending',
      `${pending.length} field(s) submitted and under verification: nothing to collect`];
  }

  if (eventual.length) {
    return ['eventual', `${eventual.length} field(s) eventually due, none of them urgent`];
  }

  return ['clear', 'no outstanding requirements'];
}

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* accounts(key, cap = 5000) {
  let seen = 0;
  const params = { limit: 100 };
  for (;;) {
    const page = await get(key, '/accounts', params);
    const data = page.data ?? [];
    for (const acct of data) {
      yield acct;
      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 nearDays = Number(process.env.NEAR_DAYS ?? NEAR_DEADLINE_DAYS);
  const now = Math.floor(Date.now() / 1000);
  const counts = new Map();
  const urgent = [];
  let scanned = 0;

  for await (const acct of accounts(key)) {
    scanned += 1;
    const [state, detail] = classify(acct.requirements, now, nearDays);
    counts.set(state, (counts.get(state) ?? 0) + 1);
    if (['past-due', 'overdue', 'deadline'].includes(state)) {
      urgent.push([acct.requirements?.current_deadline ?? 0, acct.id ?? 'acct_?',
        state, detail, acct.payouts_enabled]);
    }
  }

  // Soonest deadline first: this list is a work queue, not a report.
  for (const [, id, state, detail, payouts] of urgent.sort((a, b) => a[0] - b[0])) {
    console.warn(`${id}  ${state.padEnd(9)} payouts_enabled=${payouts}  ${detail}`);
  }

  const broken = (counts.get('past-due') ?? 0) + (counts.get('overdue') ?? 0);
  const soon = counts.get('deadline') ?? 0;
  console.log(`${scanned} account(s): ${broken} past due, ${soon} with a deadline ` +
              `inside ${nearDays} days`);

  if (broken) {
    console.warn('  repair: per-capability detail first, since the account level ' +
                 'arrays flatten several capabilities together:');
    console.warn(`  GET ${API}/accounts/{id}/capabilities`);
    console.warn('  repair: update the account with every string listed in ' +
                 'requirements.past_due, or send an onboarding account link');
  }
  if (soon || counts.get('due')) {
    console.warn('  repair: collect eventually_due rather than currently_due so the ' +
                 'account does not re-enter this state at the next threshold');
  }
  if (broken || soon) 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 first test is the one this whole guide exists for: an account with a field in past_due also has that field in currently_due, and a classifier that reads the outer array first reports it as a task. The rest pin the deadline arithmetic, including the case where the deadline has passed but Stripe has not moved the fields yet.

test_stripe_requirements_past_due.py
from stripe_requirements_past_due import classify

NOW = 1800000000
DAY = 86400


def test_past_due_wins_over_the_array_that_contains_it():
    # past_due is a strict subset of currently_due. Reading the outer array first
    # is exactly the bug this check exists to avoid.
    state, detail = classify({
        "past_due": ["company.tax_id"],
        "currently_due": ["company.tax_id", "business_profile.url"],
        "current_deadline": NOW - 3 * DAY,
    }, NOW)
    assert state == "past-due"
    assert "company.tax_id" in detail


def test_near_deadline_is_separated_from_a_distant_one():
    reqs = {"currently_due": ["company.tax_id"], "current_deadline": NOW + 20 * DAY}
    assert classify(reqs, NOW)[0] == "due"
    reqs["current_deadline"] = NOW + 13 * DAY
    assert classify(reqs, NOW)[0] == "deadline"


def test_fourteen_days_is_inside_the_window():
    reqs = {"currently_due": ["x"], "current_deadline": NOW + 14 * DAY}
    assert classify(reqs, NOW)[0] == "deadline"


def test_passed_deadline_without_past_due_is_still_reported():
    # Stripe moves the fields on its own schedule, so there is a gap where the
    # deadline is behind you and past_due is still empty.
    state, detail = classify(
        {"currently_due": ["x"], "current_deadline": NOW - 2 * DAY}, NOW)
    assert state == "overdue"
    assert "expect past_due next" in detail


def test_pending_verification_is_not_work_for_anyone():
    state, _ = classify({"pending_verification": ["individual.id_number"]}, NOW)
    assert state == "pending"


def test_eventually_due_alone_is_not_urgent_and_empty_is_clear():
    assert classify({"eventually_due": ["company.tax_id"]}, NOW)[0] == "eventual"
    assert classify({}, NOW)[0] == "clear"
    assert classify(None, NOW)[0] == "clear"
stripe-requirements-past-due.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-requirements-past-due.mjs';

const NOW = 1800000000;
const DAY = 86400;

test('past due wins over the array that contains it', () => {
  // past_due is a strict subset of currently_due. Reading the outer array first
  // is exactly the bug this check exists to avoid.
  const [state, detail] = classify({
    past_due: ['company.tax_id'],
    currently_due: ['company.tax_id', 'business_profile.url'],
    current_deadline: NOW - 3 * DAY,
  }, NOW);
  assert.equal(state, 'past-due');
  assert.match(detail, /company\.tax_id/);
});

test('near deadline is separated from a distant one', () => {
  const reqs = { currently_due: ['company.tax_id'], current_deadline: NOW + 20 * DAY };
  assert.equal(classify(reqs, NOW)[0], 'due');
  reqs.current_deadline = NOW + 13 * DAY;
  assert.equal(classify(reqs, NOW)[0], 'deadline');
});

test('fourteen days is inside the window', () => {
  assert.equal(
    classify({ currently_due: ['x'], current_deadline: NOW + 14 * DAY }, NOW)[0],
    'deadline');
});

test('passed deadline without past due is still reported', () => {
  // Stripe moves the fields on its own schedule, so there is a gap where the
  // deadline is behind you and past_due is still empty.
  const [state, detail] = classify(
    { currently_due: ['x'], current_deadline: NOW - 2 * DAY }, NOW);
  assert.equal(state, 'overdue');
  assert.match(detail, /expect past_due next/);
});

test('pending verification is not work for anyone', () => {
  assert.equal(classify({ pending_verification: ['individual.id_number'] }, NOW)[0],
    'pending');
});

test('eventually due alone is not urgent and empty is clear', () => {
  assert.equal(classify({ eventually_due: ['company.tax_id'] }, NOW)[0], 'eventual');
  assert.equal(classify({}, NOW)[0], 'clear');
  assert.equal(classify(null, NOW)[0], 'clear');
});

FAQ

What is the difference between past_due and currently_due?

currently_due is everything Stripe needs from the account now. past_due is the part of that list whose deadline has already passed, which is why Stripe has disabled the capabilities depending on it. Every past_due field also appears in currently_due, so the two are not alternatives: past_due is the subset that means the account is already broken.

Where does current_deadline come from?

It is the earliest deadline across every requested capability, including risk requirements you cannot see in the object. Stripe sets it when a threshold is crossed, usually a processing volume one, which means it tends to appear because the seller is doing well. It is a window, and the only way to use it is to sort by it.

Why does payouts_enabled sometimes stay true with fields past due?

Because requirements are per capability. A field past due on a capability the account does not rely on for payouts disables that capability without touching payouts. This is why the per-capability call matters: the account-level arrays flatten several capabilities together and cannot tell you which one is affected.

Should I collect currently_due or eventually_due?

eventually_due, in almost every case. Collecting currently_due clears today's block and leaves the account to hit the same wall at the next threshold, which means another email, another link, and another chance for the seller to ignore it. eventually_due asks for everything once.

Does this need write access?

No. Read access to Connected accounts covers both the list and the per-capability detail. The script sorts accounts into a work queue and prints what to submit; the submission itself is a write, and it belongs in a tool with a much narrower blast radius than a monitor that runs unattended.

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.