Diagnostic Stripe
current_deadline passes before anyone collects the fields
Nine sellers lost payouts on the same Monday. All nine had been processing happily for months, all nine had payouts_enabled: true on Sunday night, and all nine had the same Unix timestamp sitting in requirements.current_deadline since the middle of the previous month. Nothing read it. It is a number, not a flag, and every check anyone had written asked yes-or-no questions.
requirements.current_deadline is a Unix timestamp: the moment Stripe starts enforcing whatever is in requirements.currently_due. Until it arrives the account is completely normal — enabled, processing, paying out — which is why a boolean check finds nothing to say.
Paginate GET /v1/accounts?limit=100, keep accounts with a non-null current_deadline and a non-empty currently_due, and turn the timestamp into two things a person can act on: days remaining, so the list has an order, and the calendar date, so accounts sharing a date show up as the cohort they are. The deadline is the only advance warning Stripe gives you, and it is only useful as a schedule.
The problem in plain words
The distinctive shape here is the clustering. Deadlines are set when an account crosses a processing threshold, and sellers who signed up in the same month cross the same threshold in the same month, so the dates land on top of each other. What arrives is not one broken account on a random Tuesday; it is a batch, all with the same missing field, all of whom were fine yesterday, all writing in at once to a support team sized for the usual trickle.
And it is the good accounts. Crossing a threshold means the seller is doing well. The list of accounts about to be disabled is roughly the list of accounts you least want to disable, and the fields being asked for are usually small — a tax id, a business URL, one director's date of birth — things a two-minute email would have collected six weeks earlier.
Why it happens
A timestamp is not an alert. Every other field on requirements is a list or a string that reads as true or false: is anything due, is there a disabled reason. current_deadline is a number that is meaningful only in relation to today, so a check has to do arithmetic before it has anything to report. Checks that do no arithmetic report nothing, which is indistinguishable from all clear.
The deadline is a minimum over things you cannot enumerate. It is the earliest deadline across every requested capability plus risk requirements that are not exposed to you. You cannot reconstruct it from the fields you can see, and you cannot assume the fields in currently_due are the ones that set it. The timestamp is the only place that information surfaces at all.
It appears quietly and it can move. A deadline is set the moment a threshold is crossed, with no event dedicated to announcing it and no change to any capability. It can also come forward. A monthly report is not a substitute for reading the current value.
Once it passes, this is a different problem. After the deadline the fields move into past_due and the capability is already gone; you are no longer scheduling work, you are running an incident. That state has its own note, and this check should hand off to it rather than pretend a passed deadline is still a warning.
The fix, as a flow
The script turns one timestamp into two things a person can act on: days remaining, so the list has an order, and the calendar date, so the accounts that will fail together appear together.
How to fix it
Convert the timestamp to days remaining before anything else
Sorting by current_deadline ascending gives the list an order, and days remaining next to each account gives it urgency. This is the entire transformation that makes the field usable: from a number nobody reads into a queue with the nearest date at the top.
Group by calendar date, not just by account
Accounts that share a date will fail together and can be collected together. One email template, one batch of onboarding links, one afternoon. Seeing "6 accounts due 12 March" is what turns this from a per-account chore into one scheduled piece of work.
Separate a deadline with nothing due from a deadline with fields due
An account can carry a deadline while currently_due is empty — typically because Stripe is verifying documents it already has. There is nothing to collect and no email to send. Reporting it alongside accounts that need chasing adds noise to the one list that has to stay short.
Hand off anything already past the date
A deadline in the past with fields still outstanding means the capability is already disabled. Report it in its own bucket, with a different word, so nobody reads an incident as a task with a week left on it.
Collect eventually_due when you send the link
collection_options[fields]=eventually_due on the account link asks for everything Stripe will ever want rather than only what is due now. Collecting currently_due clears this deadline and leaves the account free to acquire another one at the next threshold, which is how the same seller ends up in this report three times.
How to check it worked
Re-run the script. Nothing should be enforced, and anything still listed should carry a date far enough out to be scheduled rather than chased.
python3 stripe_current_deadline.py
# 412 account(s): 0 enforced, 0 inside 14 days, 3 scheduled across 2 date(s)
The full code
One paginated GET against /v1/accounts, read-only. Both interesting functions are pure and both take the clock as an argument: the classifier that turns a timestamp into an urgency, and the one that turns it into a calendar date. Time-dependent code that reads the clock itself cannot be tested, and a deadline report that is wrong about dates is worse than no report.
"""Turn requirements.current_deadline into a dated queue of connected accounts.
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_current_deadline")
API = "https://api.stripe.com/v1"
DAY = 86400
def days_left(requirements, now):
"""Whole days from `now` to requirements.current_deadline. None if unset.
Pure, and the clock is an argument. Negative means the deadline has passed.
"""
deadline = (requirements or {}).get("current_deadline")
if deadline is None:
return None
return int((deadline - now) // DAY)
def cohort_day(deadline):
"""The UTC calendar date a deadline falls on, as YYYY-MM-DD, or None.
Deadlines cluster: accounts that crossed a processing threshold in the same
month share a date, and grouping by that date is what turns a list of account
ids into one scheduled piece of work.
"""
if deadline is None:
return None
return time.strftime("%Y-%m-%d", time.gmtime(deadline))
def horizon(account, now, window=14):
"""Classify one account's current deadline. Pure. Returns (state, detail).
The states exist to separate three different jobs: an incident that has
already happened, a batch of accounts to chase this week, and work to put in
the calendar. An account with a deadline and nothing outstanding is a fourth
thing: Stripe verifying what it already holds, with nothing for you to do.
"""
reqs = account.get("requirements") or {}
due = [f for f in (reqs.get("currently_due") or []) if f]
left = days_left(reqs, now)
if left is None:
if due:
return ("undated",
"%d field(s) currently due with no deadline set yet: real work, "
"no date to plan it around" % len(due))
return ("clear", "no deadline and nothing currently due")
when = cohort_day(reqs.get("current_deadline"))
if left < 0:
if due:
return ("enforced",
"deadline passed %d day(s) ago on %s with %d field(s) still due: "
"these have moved into past_due and the capability is already off"
% (-left, when, len(due)))
return ("passed",
"deadline passed on %s with nothing outstanding: it was met" % when)
if not due:
return ("verifying",
"deadline %s in %d day(s) with nothing currently due: Stripe is "
"checking what it already has, so there is nothing to collect"
% (when, left))
if left <= window:
return ("urgent",
"%d day(s) left, due %s, %d field(s): %s"
% (left, when, len(due), ", ".join(due[:4])))
return ("scheduled",
"%d day(s) left, due %s, %d field(s): %s"
% (left, when, len(due), ", ".join(due[:4])))
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):
"""Yield connected accounts, paginating until Stripe stops or the cap is hit."""
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("--window-days", type=int, default=14,
help="how many days out counts as urgent rather than scheduled")
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())
rows = []
counts = {}
scanned = 0
for acct in accounts(s, args.max_accounts):
scanned += 1
state, detail = horizon(acct, now, args.window_days)
counts[state] = counts.get(state, 0) + 1
if state in ("clear", "passed"):
continue
reqs = acct.get("requirements") or {}
rows.append((days_left(reqs, now), cohort_day(reqs.get("current_deadline")),
acct.get("id", "acct_?"), state, detail))
# Nearest deadline first; undated accounts last, since they are work you know
# about with a date you do not.
rows.sort(key=lambda r: (r[0] is None, r[0] if r[0] is not None else 0))
for _left, _day, acct_id, state, detail in rows:
log.warning("%s %-10s %s", acct_id, state, detail)
calendar = {}
for _left, day, _id, state, _detail in rows:
if day and state in ("urgent", "scheduled"):
calendar[day] = calendar.get(day, 0) + 1
log.info("%d account(s): %d enforced, %d inside %d days, %d scheduled across "
"%d date(s)", scanned, counts.get("enforced", 0), counts.get("urgent", 0),
args.window_days, counts.get("scheduled", 0), len(calendar))
for day in sorted(calendar):
log.info(" %s %d account(s) fall due together", day, calendar[day])
if counts.get("enforced"):
log.warning(" the enforced accounts are already disabled: read past_due, not "
"currently_due, and treat them as an incident")
if counts.get("urgent") or counts.get("scheduled"):
log.warning(" repair: for each account, create an onboarding link and email it:")
log.warning(" POST %s/account_links with account={id}, "
"type=account_onboarding, refresh_url, return_url,", API)
log.warning(" collection_options[fields]=eventually_due "
"(eventually_due, so the account does not come back next quarter)")
if counts.get("undated"):
log.warning(" the undated accounts have fields due and no deadline yet: "
"collect now rather than waiting for a date to appear")
return 1 if (counts.get("enforced") or counts.get("urgent")
or counts.get("undated")) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Turn requirements.current_deadline into a dated queue of connected accounts.
*
* 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;
/** Whole days from `now` to requirements.current_deadline. Null if unset. */
export function daysLeft(requirements, now) {
const deadline = (requirements ?? {}).current_deadline;
if (deadline === null || deadline === undefined) return null;
return Math.floor((deadline - now) / DAY);
}
/**
* The UTC calendar date a deadline falls on, as YYYY-MM-DD, or null.
* Deadlines cluster, and grouping by the date is what turns a list of account
* ids into one scheduled piece of work.
*/
export function cohortDay(deadline) {
if (deadline === null || deadline === undefined) return null;
return new Date(deadline * 1000).toISOString().slice(0, 10);
}
/**
* Classify one account's current deadline. Pure. Returns [state, detail].
* The states separate an incident that already happened, a batch to chase this
* week, work for the calendar, and a deadline with nothing to collect.
*/
export function horizon(account, now, window = 14) {
const reqs = account.requirements ?? {};
const due = (reqs.currently_due ?? []).filter(Boolean);
const left = daysLeft(reqs, now);
if (left === null) {
if (due.length) {
return ['undated',
`${due.length} field(s) currently due with no deadline set yet: real work, ` +
'no date to plan it around'];
}
return ['clear', 'no deadline and nothing currently due'];
}
const when = cohortDay(reqs.current_deadline);
if (left < 0) {
if (due.length) {
return ['enforced',
`deadline passed ${-left} day(s) ago on ${when} with ${due.length} field(s) ` +
'still due: these have moved into past_due and the capability is already off'];
}
return ['passed', `deadline passed on ${when} with nothing outstanding: it was met`];
}
if (!due.length) {
return ['verifying',
`deadline ${when} in ${left} day(s) with nothing currently due: Stripe is ` +
'checking what it already has, so there is nothing to collect'];
}
const detail = `${left} day(s) left, due ${when}, ${due.length} field(s): ` +
due.slice(0, 4).join(', ');
return [left <= window ? 'urgent' : 'scheduled', detail];
}
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 now = Math.floor(Date.now() / 1000);
const windowDays = 14;
const counts = new Map();
const rows = [];
let scanned = 0;
for await (const acct of accounts(key)) {
scanned += 1;
const [state, detail] = horizon(acct, now, windowDays);
counts.set(state, (counts.get(state) ?? 0) + 1);
if (state === 'clear' || state === 'passed') continue;
const reqs = acct.requirements ?? {};
rows.push({
left: daysLeft(reqs, now),
day: cohortDay(reqs.current_deadline),
id: acct.id ?? 'acct_?',
state,
detail,
});
}
// Nearest deadline first; undated accounts last, since they are work you know
// about with a date you do not.
rows.sort((a, b) => (a.left === null) - (b.left === null) || a.left - b.left);
for (const r of rows) console.warn(`${r.id} ${r.state.padEnd(10)} ${r.detail}`);
const calendar = new Map();
for (const r of rows) {
if (r.day && (r.state === 'urgent' || r.state === 'scheduled')) {
calendar.set(r.day, (calendar.get(r.day) ?? 0) + 1);
}
}
const enforced = counts.get('enforced') ?? 0;
const urgent = counts.get('urgent') ?? 0;
const undated = counts.get('undated') ?? 0;
console.log(`${scanned} account(s): ${enforced} enforced, ${urgent} inside ` +
`${windowDays} days, ${counts.get('scheduled') ?? 0} scheduled across ` +
`${calendar.size} date(s)`);
for (const day of [...calendar.keys()].sort()) {
console.log(` ${day} ${calendar.get(day)} account(s) fall due together`);
}
if (enforced) {
console.warn(' the enforced accounts are already disabled: read past_due, not ' +
'currently_due, and treat them as an incident');
}
if (urgent || counts.get('scheduled')) {
console.warn(' repair: for each account, create an onboarding link and email it:');
console.warn(` POST ${API}/account_links with account={id}, ` +
'type=account_onboarding, refresh_url, return_url,');
console.warn(' collection_options[fields]=eventually_due ' +
'(eventually_due, so the account does not come back next quarter)');
}
if (undated) {
console.warn(' the undated accounts have fields due and no deadline yet: ' +
'collect now rather than waiting for a date to appear');
}
if (enforced || urgent || undated) 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
Both pure functions take the clock, so the tests pin real timestamps and assert on real dates. The cases that matter are the boundaries: a deadline one day inside the window against one day outside it, a deadline that has already passed, and a deadline attached to an account with nothing to collect — the last of which must not appear on a chase list, because a chase list with nothing to chase on it stops being read.
from stripe_current_deadline import cohort_day, days_left, horizon
# 2026-01-01T00:00:00Z, so every assertion below is about a date a human can check.
JAN1 = 1767225600
def account(deadline=None, due=()):
return {"id": "acct_1",
"requirements": {"current_deadline": deadline,
"currently_due": list(due)}}
def test_a_missing_deadline_is_not_a_date_far_away():
assert days_left({"current_deadline": None}, JAN1) is None
assert days_left({}, JAN1) is None
def test_days_left_counts_whole_days_and_goes_negative():
assert days_left({"current_deadline": JAN1 + 10 * 86400}, JAN1) == 10
assert days_left({"current_deadline": JAN1 + 86399}, JAN1) == 0
assert days_left({"current_deadline": JAN1 - 86400}, JAN1) == -1
def test_cohort_day_groups_by_utc_date():
# Two accounts an hour apart on the same UTC day are one cohort; the third
# is a separate batch, and separate is the whole point of the grouping.
assert cohort_day(JAN1) == "2026-01-01"
assert cohort_day(JAN1 + 3600) == "2026-01-01"
assert cohort_day(JAN1 + 86400) == "2026-01-02"
assert cohort_day(None) is None
def test_inside_the_window_is_urgent_and_outside_it_is_scheduled():
urgent, detail = horizon(account(JAN1 + 13 * 86400, ["company.tax_id"]), JAN1)
assert urgent == "urgent"
assert "company.tax_id" in detail
assert "2026-01-14" in detail
later, _ = horizon(account(JAN1 + 40 * 86400, ["company.tax_id"]), JAN1)
assert later == "scheduled"
def test_a_passed_deadline_with_fields_due_is_an_incident_not_a_warning():
state, detail = horizon(account(JAN1 - 3 * 86400, ["company.tax_id"]), JAN1)
assert state == "enforced"
assert "3 day(s) ago" in detail
assert "already off" in detail
def test_a_deadline_with_nothing_due_asks_nobody_for_anything():
state, detail = horizon(account(JAN1 + 5 * 86400, []), JAN1)
assert state == "verifying"
assert "nothing to collect" in detail
def test_fields_due_with_no_deadline_are_still_work():
state, detail = horizon(account(None, ["business_profile.url"]), JAN1)
assert state == "undated"
assert "no date" in detail
def test_a_healthy_account_is_clear():
assert horizon(account(None, []), JAN1)[0] == "clear"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { cohortDay, daysLeft, horizon } from './stripe-current-deadline.mjs';
// 2026-01-01T00:00:00Z, so every assertion below is about a date a human can check.
const JAN1 = 1767225600;
const account = (deadline = null, due = []) => ({
id: 'acct_1',
requirements: { current_deadline: deadline, currently_due: due },
});
test('a missing deadline is not a date far away', () => {
assert.equal(daysLeft({ current_deadline: null }, JAN1), null);
assert.equal(daysLeft({}, JAN1), null);
});
test('days left counts whole days and goes negative', () => {
assert.equal(daysLeft({ current_deadline: JAN1 + 10 * 86400 }, JAN1), 10);
assert.equal(daysLeft({ current_deadline: JAN1 + 86399 }, JAN1), 0);
assert.equal(daysLeft({ current_deadline: JAN1 - 86400 }, JAN1), -1);
});
test('cohort day groups by utc date', () => {
// Two accounts an hour apart on the same UTC day are one cohort; the third is
// a separate batch, and separate is the whole point of the grouping.
assert.equal(cohortDay(JAN1), '2026-01-01');
assert.equal(cohortDay(JAN1 + 3600), '2026-01-01');
assert.equal(cohortDay(JAN1 + 86400), '2026-01-02');
assert.equal(cohortDay(null), null);
});
test('inside the window is urgent and outside it is scheduled', () => {
const [urgent, detail] = horizon(account(JAN1 + 13 * 86400, ['company.tax_id']), JAN1);
assert.equal(urgent, 'urgent');
assert.match(detail, /company.tax_id/);
assert.match(detail, /2026-01-14/);
assert.equal(horizon(account(JAN1 + 40 * 86400, ['company.tax_id']), JAN1)[0],
'scheduled');
});
test('a passed deadline with fields due is an incident not a warning', () => {
const [state, detail] = horizon(account(JAN1 - 3 * 86400, ['company.tax_id']), JAN1);
assert.equal(state, 'enforced');
assert.match(detail, /3 day\(s\) ago/);
assert.match(detail, /already off/);
});
test('a deadline with nothing due asks nobody for anything', () => {
const [state, detail] = horizon(account(JAN1 + 5 * 86400, []), JAN1);
assert.equal(state, 'verifying');
assert.match(detail, /nothing to collect/);
});
test('fields due with no deadline are still work', () => {
const [state, detail] = horizon(account(null, ['business_profile.url']), JAN1);
assert.equal(state, 'undated');
assert.match(detail, /no date/);
});
test('a healthy account is clear', () => {
assert.equal(horizon(account(null, []), JAN1)[0], 'clear');
});
FAQ
What sets a current_deadline in the first place?
Crossing a threshold. Stripe asks for more verification as an account processes more volume or gets closer to a payout milestone, and the deadline is the date that new information starts being enforced. It is set silently: no capability changes, no disabled_reason appears, and the account keeps working normally right up to the date.
Why does the deadline not match the fields I can see?
Because it is the earliest deadline across every requested capability plus risk requirements that are not exposed on the account object. You cannot reconstruct it from currently_due, and you should not assume the fields you can see are the ones that set it. Collect everything listed and re-read the object rather than reasoning about which field owns the date.
Is this the same as future_requirements.current_deadline?
No, and both exist on the same account. This one is on the requirements hash and covers fields that are due now, with a date at which they start being enforced. The other is on a separate hash of fields Stripe has not started asking for yet, and has its own deadline for when they migrate across. A note on that hash is linked below.
Should I alert on every account with a deadline?
No, only on those that also have something in currently_due. An account can carry a deadline with an empty currently_due while Stripe verifies documents it already holds, and nobody can act on that. Filtering it out is what keeps the list short enough to be read every morning.
Does this need a live secret key?
No. A restricted key with read access to Connected accounts is enough. The script reads one paginated list, sorts it by date and prints the account link call for you to run; it cannot create links, update accounts or collect anything.
Related field notes
- requirements.past_due has already disabled the payouts
- future_requirements will revoke a capability on a date
- Accounts stall at details_submitted false after link expiry
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.
- Handling verification with the API — Stripe Docs
- The Account object — Stripe API reference
- Create an account link — Stripe API reference
- Hosted onboarding — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.