Skip to content

Diagnostic Stripe

a Person's currently_due blocks the whole account

The seller filled in everything your onboarding form asked for. The account object shows a business name, an address, a tax id and a bank account, and charges_enabled is still false. The one thing in requirements.currently_due is a string that starts person_1Mq and ends .verification.document, and there is no field anywhere in your product that corresponds to it.

Read-only key Python and Node.js Tests included
Holding a card and phone
Photo by Nathana Reboucas on Unsplash
The short answer

For a company account the KYC data does not live on the Account. It lives on Person objects — the representative, the owners, the directors, the executives — and each one carries its own requirements hash. Read them with GET /v1/accounts/{id}/persons?limit=100 and flag any where requirements.currently_due or requirements.past_due is non-empty, or verification.status is not verified.

The account-level entry is a pointer, not a field. person_1MqEZ.verification.document means that person owes that field. Split it on the first dot and you have the id to fetch and the thing to ask for; without that step the requirement is unactionable and stays outstanding indefinitely.

The problem in plain words

What makes this one stick around is that nothing about it looks like a bug. The onboarding form works. The account was created, the details were submitted, the bank account attached. Support looks at the account object, sees every field populated, and concludes Stripe is being slow. Stripe is not being slow: it is waiting on a passport scan from a director whose record your application has never once fetched.

It is also the failure mode that survives a rewrite of the onboarding flow. Teams add fields to the company form, then more fields, then a document upload for the business, and none of it touches the sub-objects where the blocking requirement actually is. The account stays stuck through three rounds of improvements to a form that was never the problem.

Companyonboardsform fullycompletedAccount looksdonedetails_submittedtruecurrently_dueperson_1Mq.verificationNo matchingfieldform has nothingto askcharges_enabledfalseblockedindefinitelyform is rebuilt, requirement stays
Everything the onboarding form knows how to collect is already collected. The outstanding item is on an object the integration never reads.

Why it happens

The requirement names an object you did not know you had. Stripe creates Person records for the representative and for anyone with significant ownership or control. They are separate resources with separate ids, and an integration that only ever calls POST /v1/accounts creates them implicitly and never looks at them again.

The account-level string is deliberately opaque. person_1MqEZ2eZvKYlo2C.verification.document is not a field name, it is a path. Code that renders currently_due straight into a checklist prints that string to the seller, who has no idea what it means either. The dot is the whole trick: everything left of it is a resource to fetch.

One person's paperwork disables the account, not just themselves. Capabilities are granted at the account level and verified against the union of what is outstanding. A single director missing a date of birth stops charges for the entire business, which is why the account boolean and the person record disagree so completely.

Verification status is a third signal that neither array carries. A person can have an empty currently_due and a verification.status of pending, which means Stripe is reviewing something already submitted. Treating that as a missing field sends the seller a link to a form with nothing on it, and they reasonably conclude your product is broken.

The fix, as a flow

The script splits every account level requirement on its first dot before it fetches anything, because the part on the left is a resource id and the part on the right is the field that one human still owes.

GET /accounts/{id}/personsone requirements hash eachpast_due not emptycapabilities already offcurrently_due not emptycollect these fieldsverification pendingunder review, waitverified, nothing dueclear
A person under review needs nothing collected. Sending them a link opens a form with no fields on it.

How to fix it

Resolve every person_ entry on the account first

Take requirements.currently_due and requirements.past_due from the account, keep the entries beginning person_, and split each on the first dot. That gives you the exact list of humans the account is blocked on before you fetch anything, and it tells you which persons matter when an account has nine of them.

List the persons and read each requirements hash

GET /v1/accounts/{id}/persons?limit=100. Every object has its own requirements.currently_due, requirements.past_due and requirements.eventually_due. The account-level arrays flatten these together, so the per-person view is the only place you can say who owes what.

Separate under review from waiting on you

verification.status of pending means submitted and being checked. There is nothing to collect and no email to send. Lumping it in with currently_due produces a support queue full of people who have already done what you asked.

Name the human in whatever you show a support agent

relationship says whether this is the representative, an owner, a director or an executive, and the name fields say who they are. "The director Priya Raman owes a photo id" is a message someone can act on; a person id is a lookup task that gets deferred.

Check the persons for errors too, not only the account

A rejected document produces an entry in requirements.errors on the person, and the account-level array can be empty while that one is not. The codes and what each of them actually asks for are covered in requirements.errors explains the rejected document.

How to check it worked

Re-run the script. No person should report past-due or blocking, and the account's own requirements should no longer contain any person_ entries.

python3 stripe_person_requirements.py
# 412 account(s), 730 person(s), 0 needing attention

The full code

One paginated GET over the accounts and one small GET per account for its persons, both read-only. Two pure functions carry the logic: one splits an account-level requirement string into the person id it points at, and one classifies a person. Keeping the split separate matters because it is the step everyone misses, and it is a one-line rule that is easy to get subtly wrong on entries that are not person references at all.

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_person_requirements.py
"""Report the Persons whose outstanding requirements are blocking an account.

Read only. Paginated GETs 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 requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_person_requirements")

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


def person_ref(entry):
    """Return the Person id an account-level requirement points at, or None.

    Account requirements read like person_1MqEZ.verification.document: the id is
    everything left of the first dot, the rest is the field on that Person. Pure,
    so the parsing is testable without a network. Entries that are ordinary
    account fields come back as None rather than as a bogus id.
    """
    if not isinstance(entry, str) or not entry.startswith("person_"):
        return None
    return entry.split(".", 1)[0]


def verdict(person):
    """Classify one Person object. Pure, so the rules are visible and testable.

    Returns (state, detail). Ordered by what each state costs: a past_due field
    has already disabled something, a currently_due field has not yet, and a
    person Stripe is still reviewing needs nothing collected at all.
    """
    req = person.get("requirements") or {}
    past = req.get("past_due") or []
    due = req.get("currently_due") or []
    status = (person.get("verification") or {}).get("status")

    if past:
        return ("past-due",
                "%d field(s) past due (%s); capabilities that depend on this "
                "person are already off" % (len(past), ", ".join(past)))
    if due:
        return ("blocking",
                "%d field(s) currently due (%s)" % (len(due), ", ".join(due)))
    if status == "pending":
        return ("verifying",
                "submitted and under review; nothing to collect, and a link sent "
                "now opens a form with no fields on it")
    if status == "unverified":
        return ("unverified",
                "not verified and nothing due yet; Stripe asks at a threshold, so "
                "this is the cheap moment to collect it")
    if status == "verified":
        return ("clear", "verified, nothing outstanding")
    return ("unknown", "unrecognised verification status %r" % (status,))


def blocked_on(account):
    """The Person ids the account's own requirements point at, in order seen."""
    req = account.get("requirements") or {}
    out = []
    for entry in (req.get("past_due") or []) + (req.get("currently_due") or []):
        pid = person_ref(entry)
        if pid and pid not in out:
            out.append(pid)
    return out


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"]


def describe(person):
    """A label a support agent can act on: the role if there is one, else a name."""
    rel = person.get("relationship") or {}
    roles = sorted(k for k, v in rel.items() if v is True)
    name = " ".join(x for x in (person.get("first_name"), person.get("last_name")) if x)
    return "/".join(roles) or name or person.get("id", "?")


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("--show-clear", action="store_true",
                    help="also print the persons with nothing outstanding")
    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})

    accounts = people = bad = 0
    for acct in paginate(s, "/accounts", args.max_accounts):
        accounts += 1
        pointed_at = blocked_on(acct)
        for person in paginate(s, "/accounts/%s/persons" % acct["id"], 100):
            people += 1
            state, detail = verdict(person)
            line = "%-10s %s %s (%s)  %s" % (
                state, acct["id"], person["id"], describe(person), detail)
            if state in ("clear", "unverified") and not args.show_clear:
                continue
            if state in ("clear", "verifying", "unverified"):
                log.info(line)
                continue
            bad += 1
            log.warning(line)
            if person["id"] in pointed_at:
                log.warning("  the account's own requirements name this person")
            log.warning("  repair: POST %s/accounts/%s/persons/%s with the field(s) above",
                        API, acct["id"], person["id"])
            log.warning("  for a document, upload it to files.stripe.com with "
                        "purpose=identity_document and set verification[document][front]")

    log.info("%d account(s), %d person(s), %d needing attention", accounts, people, bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-person-requirements.mjs
/**
 * Report the Persons whose outstanding requirements are blocking an account.
 *
 * Read only. Paginated GETs 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';

/**
 * Return the Person id an account-level requirement points at, or null.
 * Account requirements read like person_1MqEZ.verification.document.
 */
export function personRef(entry) {
  if (typeof entry !== 'string' || !entry.startsWith('person_')) return null;
  return entry.split('.')[0];
}

/**
 * Classify one Person object. Pure, so the rules are visible and testable.
 */
export function verdict(person) {
  const req = person.requirements ?? {};
  const past = req.past_due ?? [];
  const due = req.currently_due ?? [];
  const status = person.verification?.status;

  if (past.length) {
    return ['past-due',
      `${past.length} field(s) past due (${past.join(', ')}); capabilities that ` +
      'depend on this person are already off'];
  }
  if (due.length) {
    return ['blocking', `${due.length} field(s) currently due (${due.join(', ')})`];
  }
  if (status === 'pending') {
    return ['verifying',
      'submitted and under review; nothing to collect, and a link sent now opens ' +
      'a form with no fields on it'];
  }
  if (status === 'unverified') {
    return ['unverified',
      'not verified and nothing due yet; Stripe asks at a threshold, so this is ' +
      'the cheap moment to collect it'];
  }
  if (status === 'verified') return ['clear', 'verified, nothing outstanding'];
  return ['unknown', `unrecognised verification status ${JSON.stringify(status)}`];
}

/** The Person ids the account's own requirements point at, in order seen. */
export function blockedOn(account) {
  const req = account.requirements ?? {};
  const out = [];
  for (const entry of [...(req.past_due ?? []), ...(req.currently_due ?? [])]) {
    const pid = personRef(entry);
    if (pid && !out.includes(pid)) out.push(pid);
  }
  return out;
}

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;
  }
}

function describe(person) {
  const rel = person.relationship ?? {};
  const roles = Object.keys(rel).filter((k) => rel[k] === true).sort();
  const name = [person.first_name, person.last_name].filter(Boolean).join(' ');
  return roles.join('/') || name || person.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 showClear = process.argv.includes('--show-clear');

  let accounts = 0, people = 0, bad = 0;
  for await (const acct of paginate(key, '/accounts', 500)) {
    accounts += 1;
    const pointedAt = blockedOn(acct);
    for await (const person of paginate(key, `/accounts/${acct.id}/persons`, 100)) {
      people += 1;
      const [state, detail] = verdict(person);
      const line = `${state.padEnd(10)} ${acct.id} ${person.id} ` +
                   `(${describe(person)})  ${detail}`;
      if ((state === 'clear' || state === 'unverified') && !showClear) continue;
      if (state === 'clear' || state === 'verifying' || state === 'unverified') {
        console.log(line);
        continue;
      }
      bad += 1;
      console.warn(line);
      if (pointedAt.includes(person.id)) {
        console.warn("  the account's own requirements name this person");
      }
      console.warn(`  repair: POST ${API}/accounts/${acct.id}/persons/${person.id} ` +
                   'with the field(s) above');
      console.warn('  for a document, upload it to files.stripe.com with ' +
                   'purpose=identity_document and set verification[document][front]');
    }
  }

  console.log(`${accounts} account(s), ${people} person(s), ${bad} needing attention`);
  process.exitCode = bad ? 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

Two things are worth pinning. The reference parser has to return the id for person_1Mq.verification.document and nothing at all for business_profile.url, because a parser that is generous about the prefix invents person ids that 404. And a person under review has to classify as its own state: it is not clear, and it is not something you can ask anyone to fix.

test_stripe_person_requirements.py
from stripe_person_requirements import blocked_on, person_ref, verdict


def test_person_reference_yields_the_id_before_the_first_dot():
    assert person_ref("person_1MqEZ2eZvKYlo2C.verification.document") == "person_1MqEZ2eZvKYlo2C"


def test_ordinary_account_fields_are_not_person_references():
    assert person_ref("business_profile.url") is None
    assert person_ref("external_account") is None
    assert person_ref(None) is None


def test_past_due_outranks_currently_due():
    # past_due is a subset of currently_due, so the order of these checks is the
    # difference between "already broken" and "some paperwork outstanding".
    state, detail = verdict({"requirements": {"past_due": ["dob.day"],
                                              "currently_due": ["dob.day", "id_number"]}})
    assert state == "past-due"
    assert "dob.day" in detail


def test_currently_due_names_the_fields():
    state, detail = verdict({"requirements": {"currently_due": ["id_number"]}})
    assert state == "blocking"
    assert "id_number" in detail


def test_pending_verification_is_not_something_to_collect():
    state, _ = verdict({"requirements": {}, "verification": {"status": "pending"}})
    assert state == "verifying"


def test_missing_verification_status_is_not_silently_clear():
    assert verdict({})[0] == "unknown"


def test_account_requirements_resolve_to_a_deduplicated_person_list():
    acct = {"requirements": {
        "past_due": ["person_1A.verification.document"],
        "currently_due": ["person_1A.verification.document", "person_1B.dob.day",
                          "business_profile.url"]}}
    assert blocked_on(acct) == ["person_1A", "person_1B"]
stripe-person-requirements.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { blockedOn, personRef, verdict } from './stripe-person-requirements.mjs';

test('person reference yields the id before the first dot', () => {
  assert.equal(personRef('person_1MqEZ2eZvKYlo2C.verification.document'),
               'person_1MqEZ2eZvKYlo2C');
});

test('ordinary account fields are not person references', () => {
  assert.equal(personRef('business_profile.url'), null);
  assert.equal(personRef(undefined), null);
});

test('past_due outranks currently_due', () => {
  const [state, detail] = verdict({
    requirements: { past_due: ['dob.day'], currently_due: ['dob.day', 'id_number'] },
  });
  assert.equal(state, 'past-due');
  assert.match(detail, /dob\.day/);
});

test('currently_due names the fields', () => {
  const [state, detail] = verdict({ requirements: { currently_due: ['id_number'] } });
  assert.equal(state, 'blocking');
  assert.match(detail, /id_number/);
});

test('pending verification is not something to collect', () => {
  assert.equal(verdict({ verification: { status: 'pending' } })[0], 'verifying');
});

test('missing verification status is not silently clear', () => {
  assert.equal(verdict({})[0], 'unknown');
});

test('account requirements resolve to a deduplicated person list', () => {
  const acct = { requirements: {
    past_due: ['person_1A.verification.document'],
    currently_due: ['person_1A.verification.document', 'person_1B.dob.day',
                    'business_profile.url'],
  } };
  assert.deepEqual(blockedOn(acct), ['person_1A', 'person_1B']);
});

FAQ

What does an entry like person_1Mq.verification.document mean?

It is a path, not a field name. Everything left of the first dot is the id of a Person object on that account, and everything right of it is the field that person owes. Fetch GET /v1/accounts/{id}/persons and you will find the same requirement listed on that person's own requirements hash.

Which people does Stripe create Person objects for?

The account representative, plus the owners, directors and executives that the account's country requires. A sole trader usually has one; a company can have several, and each of them carries an independent requirements hash and verification status.

Why is charges_enabled false when the account object looks complete?

Because capabilities are verified against everything outstanding, including the sub-objects. One director missing a date of birth blocks the account, and nothing on the account object itself will show you which director or which field without resolving the person reference.

What is the difference between verification.status pending and a currently_due field?

Pending means something was submitted and Stripe is reviewing it, so there is nothing for the seller to do. A currently_due field means Stripe is waiting on you. Sending an onboarding link for a pending person opens a form with no fields on it.

Can I do this check without a live secret key?

Yes. A restricted key with read access to Connected accounts covers both the account list and the persons list. That key cannot submit any of the fields it reports, which is the point: the script tells you who owes what and you run the update yourself.

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.