Diagnostic Stripe
future_requirements will revoke a capability on a date
Fourteen connected accounts stopped taking payments on the same Thursday morning. All of them were fully verified. None of them had anything in requirements the night before, and the monitor that reads requirements every hour never made a sound. The fields that disabled them had been sitting on the same objects for six weeks, in a different hash, with the date printed on it.
Read future_requirements as well as requirements. It is a separate hash with the same shape, holding fields Stripe will start enforcing later. It does not affect capabilities and its entries do not appear in requirements, so every check written against requirements is blind to it by design.
At future_requirements.current_deadline the entries migrate into requirements, and anything unmet disables the capability immediately. Paginate GET /v1/accounts?limit=100, keep the accounts where controller.requirement_collection == "application", and sort those with a non-empty future_requirements.currently_due by their deadline.
The problem in plain words
The shape of this failure is what makes it expensive. It is not one account breaking on a random day, it is a cohort breaking together, because the deadline is set by a change Stripe is rolling out and every affected account gets the same date. A support team sized for the usual trickle of verification problems gets a month's worth in one morning, from customers who were all working perfectly the day before.
And the accounts that break are the good ones. These are verified, processing, established sellers — the accounts nobody watches, because everything about them has been green for a year. The stalled and half-onboarded accounts that get all the monitoring attention are unaffected.
Why it happens
The hash is invisible by design. future_requirements exists precisely so that upcoming changes do not disturb a working account. That is the right behaviour and it is also why nothing surfaces it: no capability changes, no disabled_reason appears, no event that your handler branches on fires with a status change in it.
The migration is the moment of failure, not the collection. Fields do not gradually become required. At current_deadline they move into requirements and, if unmet, take the capability down in the same instant. There is no intermediate state you can alert on, so the only warning available is the one you have to go and look for.
It only applies to accounts you are responsible for. controller.requirement_collection tells you who collects: "stripe" means Stripe chases the account owner directly and handles the update, "application" means you do. Alerting on future requirements for Stripe-collected accounts produces noise you cannot act on, and the field is the only way to tell them apart.
A deadline can also be absent. Entries can appear in future_requirements.currently_due before any date is set. That is a real state and it is not benign: it means work is coming and you cannot yet say when. Code that sorts on current_deadline and assumes a number quietly drops those accounts.
The fix, as a flow
The script reads the hash that does not affect anything yet, then sorts by the date on which it will, because the only useful form of this warning is an ordered list with days remaining next to each account.
How to fix it
Filter to the accounts whose requirements you collect
controller.requirement_collection == "application". Everything else is Stripe's to chase, and including it turns a short actionable list into a long one that gets ignored.
Read future_requirements, then sort by current_deadline
The list you want is ordered by date, with days remaining next to each account. That converts a warning into a schedule, and a schedule is the only form of this information anybody acts on before the deadline rather than after.
Give the undated accounts their own bucket
Entries with no current_deadline yet are work you know about with a date you do not. Sorting them last is fine; dropping them because the sort key is null is how they end up in the cohort that breaks.
Collect the future fields in the same session as everything else
For hosted onboarding, pass collection_options[future_requirements]=include when creating the account link. That asks for today's fields and the upcoming ones in one visit, which is one email to the seller instead of two.
Rehearse the migration in a sandbox before it happens
Creating a sandbox account with the email jenny+enforce_future_requirements@example.com forces every known future requirement into requirements immediately. That is the only way to see what your own product does on the morning of the deadline while there is still time to change it.
How to check it worked
Re-run the script. Nothing should be overdue, and anything still listed should carry a deadline far enough out to be scheduled rather than chased.
python3 stripe_future_requirements.py
# 412 account(s): 0 overdue, 0 due within 14 days, 6 scheduled, 2 undated
The full code
One paginated GET against /v1/accounts, read-only, and no second call at all — the future hash comes back on the list response. The classifier is pure and takes the account plus the current time, because the entire value of this check is the ordering it produces, and an ordering is only testable if the clock is an argument rather than something the function reaches for itself.
"""Report connected accounts whose future_requirements will disable a capability.
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_future_requirements")
API = "https://api.stripe.com/v1"
DAY = 86400
def verdict(account, now, soon_days=14):
"""Classify one account's future_requirements. Pure: `now` is an argument.
Returns (state, detail). The states separate the three things a reader has to
do differently: nothing, schedule it, or do it this week. Accounts whose
requirements Stripe collects are excluded up front rather than reported as
healthy, because their state is not yours to act on either way.
"""
controller = account.get("controller") or {}
if controller.get("requirement_collection") != "application":
return ("stripe-managed",
"Stripe collects for this account and handles the update itself")
fr = account.get("future_requirements") or {}
past = fr.get("past_due") or []
due = fr.get("currently_due") or []
eventually = fr.get("eventually_due") or []
deadline = fr.get("current_deadline")
if past:
return ("overdue",
"%d future field(s) already past due (%s)" % (len(past), ", ".join(past)))
if due:
if deadline is None:
return ("undated",
"%d future field(s) with no deadline set yet (%s)"
% (len(due), ", ".join(due)))
days = (deadline - now) / float(DAY)
if days <= 0:
return ("overdue",
"the deadline passed %.1f day(s) ago; these fields are moving "
"into requirements now" % (-days,))
if days <= soon_days:
return ("due-soon",
"%d future field(s) in %.1f day(s) (%s)"
% (len(due), days, ", ".join(due)))
return ("scheduled",
"%d future field(s) in %.1f day(s)" % (len(due), days))
if eventually:
return ("eventual",
"%d field(s) Stripe will want at a later threshold" % len(eventually))
return ("clear", "no future 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 paginate(session, path, limit):
"""Walk a list endpoint, stopping once `limit` objects have been yielded."""
seen = 0
params = {"limit": 100}
while True:
page = get(session, path, **params)
data = page.get("data", [])
for obj in data:
yield obj
seen += 1
if seen >= limit:
return
if not page.get("has_more") or not data:
return
params["starting_after"] = data[-1]["id"]
ORDER = {"overdue": 0, "due-soon": 1, "scheduled": 2, "undated": 3, "eventual": 4}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-accounts", type=int, default=500,
help="stop after this many connected accounts")
ap.add_argument("--soon-days", type=int, default=14,
help="a deadline inside this many days is urgent")
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()
total = 0
rows = []
for acct in paginate(s, "/accounts", args.max_accounts):
total += 1
state, detail = verdict(acct, now, args.soon_days)
if state in ("clear", "stripe-managed"):
continue
fr = acct.get("future_requirements") or {}
rows.append((ORDER.get(state, 9),
fr.get("current_deadline") or float("inf"),
state, acct["id"], detail))
rows.sort()
for _, _, state, acct_id, detail in rows:
log.warning("%-11s %s %s", state, acct_id, detail)
log.warning(" repair: POST %s/accounts/%s with the future field(s) before "
"the deadline", API, acct_id)
log.warning(" hosted: create an account link with "
"collection_options[future_requirements]=include")
counts = {}
for _, _, state, _, _ in rows:
counts[state] = counts.get(state, 0) + 1
log.info("%d account(s): %d overdue, %d due within %d days, %d scheduled, %d undated",
total, counts.get("overdue", 0), counts.get("due-soon", 0),
args.soon_days, counts.get("scheduled", 0), counts.get("undated", 0))
return 1 if counts.get("overdue") or counts.get("due-soon") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report connected accounts whose future_requirements will disable a capability.
*
* 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';
const DAY = 86400;
/**
* Classify one account's future_requirements. Pure: `now` is an argument, so the
* ordering this produces can be tested against a fixed clock.
*/
export function verdict(account, now, soonDays = 14) {
const controller = account.controller ?? {};
if (controller.requirement_collection !== 'application') {
return ['stripe-managed',
'Stripe collects for this account and handles the update itself'];
}
const fr = account.future_requirements ?? {};
const past = fr.past_due ?? [];
const due = fr.currently_due ?? [];
const eventually = fr.eventually_due ?? [];
const deadline = fr.current_deadline;
if (past.length) {
return ['overdue',
`${past.length} future field(s) already past due (${past.join(', ')})`];
}
if (due.length) {
if (deadline === null || deadline === undefined) {
return ['undated',
`${due.length} future field(s) with no deadline set yet (${due.join(', ')})`];
}
const days = (deadline - now) / DAY;
if (days <= 0) {
return ['overdue',
`the deadline passed ${(-days).toFixed(1)} day(s) ago; these fields are ` +
'moving into requirements now'];
}
if (days <= soonDays) {
return ['due-soon',
`${due.length} future field(s) in ${days.toFixed(1)} day(s) (${due.join(', ')})`];
}
return ['scheduled', `${due.length} future field(s) in ${days.toFixed(1)} day(s)`];
}
if (eventually.length) {
return ['eventual',
`${eventually.length} field(s) Stripe will want at a later threshold`];
}
return ['clear', 'no future 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();
}
async function* paginate(key, path, limit) {
let seen = 0;
const params = { limit: 100 };
for (;;) {
const page = await get(key, path, params);
const data = page.data ?? [];
for (const obj of data) {
yield obj;
if (++seen >= limit) return;
}
if (!page.has_more || data.length === 0) return;
params.starting_after = data[data.length - 1].id;
}
}
const ORDER = { overdue: 0, 'due-soon': 1, scheduled: 2, undated: 3, eventual: 4 };
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 soonDays = 14;
const now = Date.now() / 1000;
let total = 0;
const rows = [];
for await (const acct of paginate(key, '/accounts', 500)) {
total += 1;
const [state, detail] = verdict(acct, now, soonDays);
if (state === 'clear' || state === 'stripe-managed') continue;
const fr = acct.future_requirements ?? {};
rows.push([ORDER[state] ?? 9, fr.current_deadline ?? Infinity, state, acct.id, detail]);
}
rows.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const counts = {};
for (const [, , state, id, detail] of rows) {
counts[state] = (counts[state] ?? 0) + 1;
console.warn(`${state.padEnd(11)} ${id} ${detail}`);
console.warn(` repair: POST ${API}/accounts/${id} with the future field(s) ` +
'before the deadline');
console.warn(' hosted: create an account link with ' +
'collection_options[future_requirements]=include');
}
console.log(`${total} account(s): ${counts.overdue ?? 0} overdue, ` +
`${counts['due-soon'] ?? 0} due within ${soonDays} days, ` +
`${counts.scheduled ?? 0} scheduled, ${counts.undated ?? 0} undated`);
process.exitCode = (counts.overdue || counts['due-soon']) ? 1 : 0;
}
// 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 clock is a parameter, which is what makes the interesting cases cheap to write: the same account is scheduled six weeks out and due-soon five days out, and the test says so directly instead of mocking time. The two cases that actually bite are also pinned here — an account Stripe collects for is never reported, and future entries with a null deadline are their own state rather than a sort key that evaluates to zero.
from stripe_future_requirements import verdict
NOW = 1_700_000_000
APP = {"requirement_collection": "application"}
def account(**future):
return {"controller": APP, "future_requirements": future}
def test_stripe_collected_accounts_are_never_reported():
# Stripe chases the owner and applies the update itself, so an alert here is
# noise nobody can act on.
acct = {"controller": {"requirement_collection": "stripe"},
"future_requirements": {"currently_due": ["id_number"],
"current_deadline": NOW + 3600}}
assert verdict(acct, NOW)[0] == "stripe-managed"
def test_a_distant_deadline_is_scheduled():
state, _ = verdict(account(currently_due=["id_number"],
current_deadline=NOW + 42 * 86400), NOW)
assert state == "scheduled"
def test_the_same_account_is_urgent_inside_the_window():
state, detail = verdict(account(currently_due=["id_number"],
current_deadline=NOW + 5 * 86400), NOW)
assert state == "due-soon"
assert "id_number" in detail
def test_an_elapsed_deadline_is_overdue_not_merely_urgent():
state, _ = verdict(account(currently_due=["id_number"],
current_deadline=NOW - 86400), NOW)
assert state == "overdue"
def test_future_entries_without_a_deadline_are_their_own_state():
state, _ = verdict(account(currently_due=["id_number"], current_deadline=None), NOW)
assert state == "undated"
def test_eventually_due_alone_is_not_urgent_and_not_silent():
assert verdict(account(eventually_due=["id_number"]), NOW)[0] == "eventual"
def test_an_empty_future_hash_is_clear():
assert verdict(account(), NOW)[0] == "clear"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-future-requirements.mjs';
const NOW = 1700000000;
const account = (future) => ({
controller: { requirement_collection: 'application' },
future_requirements: future,
});
test('stripe collected accounts are never reported', () => {
const acct = {
controller: { requirement_collection: 'stripe' },
future_requirements: { currently_due: ['id_number'], current_deadline: NOW + 3600 },
};
assert.equal(verdict(acct, NOW)[0], 'stripe-managed');
});
test('a distant deadline is scheduled', () => {
const acct = account({ currently_due: ['id_number'],
current_deadline: NOW + 42 * 86400 });
assert.equal(verdict(acct, NOW)[0], 'scheduled');
});
test('the same account is urgent inside the window', () => {
const acct = account({ currently_due: ['id_number'],
current_deadline: NOW + 5 * 86400 });
const [state, detail] = verdict(acct, NOW);
assert.equal(state, 'due-soon');
assert.match(detail, /id_number/);
});
test('an elapsed deadline is overdue not merely urgent', () => {
const acct = account({ currently_due: ['id_number'], current_deadline: NOW - 86400 });
assert.equal(verdict(acct, NOW)[0], 'overdue');
});
test('future entries without a deadline are their own state', () => {
const acct = account({ currently_due: ['id_number'], current_deadline: null });
assert.equal(verdict(acct, NOW)[0], 'undated');
});
test('eventually_due alone is not urgent and not silent', () => {
assert.equal(verdict(account({ eventually_due: ['id_number'] }), NOW)[0], 'eventual');
});
test('an empty future hash is clear', () => {
assert.equal(verdict(account({}), NOW)[0], 'clear');
});
FAQ
What is the difference between requirements and future_requirements?
requirements is what is being enforced now, and unmet entries there disable capabilities. future_requirements is what will be enforced later; it does not affect capabilities and its entries do not appear in requirements until the deadline passes. They are two hashes with the same shape and completely different urgency.
What happens at current_deadline exactly?
The entries in future_requirements move into requirements. Anything still unmet at that moment disables the capabilities that depend on it, with no intermediate warning state. That is why the deadline is the alert and the migration is not.
Why should I skip accounts where requirement_collection is stripe?
Because Stripe collects those fields from the account owner directly and applies the update. You cannot submit them and you will not be told when they are satisfied, so listing those accounts adds volume to a report without adding anything to do.
What does an empty current_deadline with fields in currently_due mean?
That Stripe knows what it will need and has not set a date yet. It is genuinely coming, so it belongs in the report, but it cannot be sorted alongside the dated ones. Give it its own bucket rather than treating a null as zero or dropping it.
How do I test my handling before a real deadline arrives?
Create a sandbox account with the email jenny+enforce_future_requirements@example.com. Stripe forces all known future requirements straight into requirements for that account, which reproduces the morning of the deadline while you still have time to change what your product does.
Related field notes
- requirements.past_due has already disabled the payouts
- A Person's currently_due blocks the whole account
- transfers capability is inactive so every transfer 400s
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.
- Handle verification updates — Stripe Docs
- The Account object — Stripe API reference
- Handling verification with the API — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.