Skip to content

Diagnostic Stripe

requirements.errors explains the rejected document

A seller has uploaded the same photo of their passport four times over two weeks. Your onboarding UI has said verification pending every time, because that is the only string it knows. Stripe has been returning the specific reason since the first attempt: the scan is greyscale. Nobody has ever read the field it is in, so the seller keeps sending the file that cannot pass.

Read-only key Python and Node.js Tests included
Man sitting on chair wearing gray crew-neck long-sleeved shirt using apple magic keyboard
Photo by Tim van der Kuip on Unsplash
The short answer

Read requirements.errors on the account. It is an array of {code, reason, requirement} objects saying exactly why a submitted piece of information was rejected — verification_document_failed_greyscale, verification_document_not_readable, verification_document_expired, verification_document_missing_back, verification_failed_keyed_identity, invalid_street_address, invalid_tax_id_format, and the invalid_url_website_* family.

Look in all four places it appears, because the account-level array is not the whole story: future_requirements.errors on the account, requirements.errors on each object under GET /v1/accounts/{id}/persons, and again on each entry from GET /v1/accounts/{id}/capabilities.

Then map each code to an instruction a human can act on, and make sure the re-submission is a different file. Re-uploading the identical document fails automatically, which is what turns this into a loop rather than a delay.

The problem in plain words

The account is not disabled and no requirement has gone past due, so every check you have says this account is progressing normally. It is progressing nowhere. The field is in currently_due, the seller keeps submitting it, and Stripe keeps rejecting it for a reason that is sitting in the API response your onboarding page already fetched and discarded.

What makes it a loop rather than a delay is the duplicate rule: submitting the same file again fails on its own, without a fresh review. So the seller's most natural response — try again — is guaranteed to fail, and each cycle costs a day or two and some more goodwill. Support sees "verification is slow" tickets and has nothing to tell them, because support is reading the same generic status the seller is.

Documentuploadedrequest returns200Stripe rejectsitcode inrequirements.errorsUI says pendingthe array is neverreadSellerresubmitsthe identical fileDuplicate autofailsloop starts againthe same file cannot pass
The same file resubmitted fails automatically, so the natural response is guaranteed to fail again.

Why it happens

The error is on the object, not in an exception. Nothing throws. Your integration submitted the document successfully, got a 200, and the rejection arrives later as an entry in an array on the account. Code written around request failures never looks at it.

"Pending" is the default thing to render. A field in currently_due with no message is naturally displayed as awaiting review, and that display is correct right up until it is catastrophically wrong. There is no signal to change it unless requirements.errors is read.

The codes have specific, different remedies. verification_document_failed_greyscale needs a colour scan of the same passport. verification_failed_keyed_identity needs the typed name or date of birth corrected, not a new file at all. invalid_url_website_incomplete needs a change to the website, then the URL flipped to another value and back to force re-verification. Showing one generic "please try again" for the set guarantees most people do the wrong thing.

Errors hide one level down. On a company account, KYC lives on Person objects and the account-level array can be empty while a director's is not. Reading only the account is how a team concludes there is no error to show while the seller is staring at a rejection.

The fix, as a flow

The script reads requirements.errors from every place it hides, then maps each code to one instruction, and reports an unrecognised code rather than swallowing it.

requirements.errorsaccount, person and capabilityempty arraycleardocument codea different capture, in colourkeyed identitycorrect the typed fieldsinvalid_url_website_*fix the site, flip the urlcode not in the tableunmapped, show the reason
A new photo fixes a greyscale scan and never fixes a keyed identity mismatch. An unknown code has to surface, not vanish.

How to fix it

Read requirements.errors, in all four places

Account requirements.errors, account future_requirements.errors, every Person's requirements.errors, and every capability's. An empty array at the top means nothing about the ones underneath.

Map every code to one specific instruction

Not a category, an instruction. "The scan was in black and white, we need it in colour" is actionable; "document verification failed" produces another identical upload. Keep the mapping in code so an unrecognised code is visible rather than swallowed.

Surface the reason string as well as your own copy

reason is human readable and written to be shown. Your mapped instruction tells the seller what to do; the reason tells them what happened, and having both cuts the support conversation to nothing.

Force a genuinely different submission

The same file re-uploaded fails automatically. If the code is a document one, the UI has to make it clear that a new capture is required: colour, in focus, uncropped, under the size limits, and not a PDF where an image is required.

Handle keyed identity and website errors as field edits

These two are the common ones that no document will ever fix. Keyed identity means the typed fields disagree with the document; the website family means business_profile[url] is unreachable, incomplete, or missing the business details Stripe looks for.

How to check it worked

Re-run the script. Every account should report clear, and any code the script cannot map should show up as unmapped rather than disappearing.

python3 stripe_verification_errors.py
# 412 account(s): 0 with errors, 0 unmapped codes

The full code

One paginated GET, plus two small GETs per account when --persons is set — a restricted key with read access to Connected accounts is enough. The mapping from code to instruction is a pure function over the errors array, which is the part worth testing: an unrecognised code has to come out as unmapped rather than being quietly treated as clear, because Stripe adds codes and this table will go stale.

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_verification_errors.py
"""Report unread requirements.errors on connected accounts, with the fix for each.

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_verification_errors")

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

# A new file is required. The same one re-uploaded fails automatically, so the
# instruction has to say what is different about the next capture.
DOCUMENT_CODES = {
    "verification_document_failed_greyscale":
        "the upload was greyscale: a colour scan or photo of the same document",
    "verification_document_not_readable":
        "the image could not be read: re-capture it in focus and uncropped",
    "verification_document_expired":
        "the document is out of date: a current one, not a better scan",
    "verification_document_missing_back":
        "only one side was submitted: the back of the same document",
    "verification_document_failed_other":
        "rejected without a specific cause: a different capture, colour, under "
        "the size limits, and an image rather than a PDF for identity documents",
}

# A new file will never fix these. The typed fields are what disagree.
IDENTITY_CODES = {
    "verification_failed_keyed_identity":
        "the typed name or date of birth does not match the document: correct "
        "the fields, not the file",
}

# Ordinary field edits on the account or person.
FIELD_CODES = {
    "information_missing":
        "a required field was left out: read the requirement and supply it",
    "verification_missing_owners":
        "beneficial owners are missing: add the Person objects for them",
    "invalid_street_address":
        "the address could not be validated: check it against the postal service "
        "format for the country",
    "invalid_tax_id_format":
        "the tax id is not in the format for that country",
}

# The whole invalid_url_website_* family. Matched by prefix because it is long
# and Stripe keeps adding to it.
WEBSITE_PREFIX = "invalid_url_website"

GROUPS = (("document", DOCUMENT_CODES),
          ("identity", IDENTITY_CODES),
          ("field", FIELD_CODES))


def classify(errors):
    """Turn a requirements.errors array into one state and one instruction.

    Pure, so the code table can be tested without a network. Groups are checked
    in order of how blocking they are: a rejected document stops verification
    dead, a keyed identity mismatch is a field edit, a website error is often the
    last thing left. An unrecognised code returns `unmapped` rather than `clear`,
    because Stripe adds codes and a table that silently swallows new ones is
    worse than no table.

    Returns (state, detail).
    """
    items = [e for e in (errors or []) if isinstance(e, dict) and e.get("code")]
    if not items:
        return ("clear", "requirements.errors is empty")

    for state, table in GROUPS:
        for e in items:
            if e["code"] in table:
                return (state, "%s on %s: %s"
                        % (e["code"], e.get("requirement") or "an unnamed requirement",
                           table[e["code"]]))

    for e in items:
        if str(e["code"]).startswith(WEBSITE_PREFIX):
            return ("website",
                    "%s on %s: fix the site itself, then set business_profile[url] "
                    "to another value and back to force re-verification"
                    % (e["code"], e.get("requirement") or "business_profile.url"))

    e = items[0]
    return ("unmapped", "%s on %s: %s"
            % (e["code"], e.get("requirement") or "an unnamed requirement",
               e.get("reason") or "no reason string returned"))


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 error_sources(session, account, deep):
    """Yield (where, errors) for every place a verification error can hide."""
    reqs = account.get("requirements") or {}
    future = account.get("future_requirements") or {}
    yield ("account", reqs.get("errors") or [])
    yield ("future", future.get("errors") or [])
    if not deep:
        return
    acct_id = account.get("id", "")
    persons = get(session, "/accounts/%s/persons" % acct_id, limit=100)
    for p in persons.get("data") or []:
        preqs = p.get("requirements") or {}
        yield ("person %s" % p.get("id", "person_?"), preqs.get("errors") or [])
    caps = get(session, "/accounts/%s/capabilities" % acct_id)
    for c in caps.get("data") or []:
        creqs = c.get("requirements") or {}
        yield ("capability %s" % c.get("id", "?"), creqs.get("errors") or [])


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-accounts", type=int, default=5000,
                    help="stop paginating after this many accounts")
    ap.add_argument("--persons", action="store_true",
                    help="also read persons and capabilities, two extra GETs each")
    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})

    with_errors = 0
    unmapped = 0
    scanned = 0
    for acct in accounts(s, args.max_accounts):
        scanned += 1
        hits = 0
        for where, errors in error_sources(s, acct, args.persons):
            state, detail = classify(errors)
            if state == "clear":
                continue
            hits += 1
            if state == "unmapped":
                unmapped += 1
            log.warning("%s  %-9s %-12s %s",
                        acct.get("id", "acct_?"), state, where, detail)
        if hits:
            with_errors += 1

    log.info("%d account(s): %d with errors, %d unmapped code(s)",
             scanned, with_errors, unmapped)

    if with_errors:
        log.warning("  repair: show the mapped instruction and the reason string "
                    "in your onboarding UI, then require a genuinely different "
                    "submission. The same file re-uploaded fails on its own.")
        log.warning("  documents: upload to https://files.stripe.com/v1/files with "
                    "purpose=identity_document, then attach the file id to the "
                    "person's verification[document][front]")
        log.warning("  fields: POST %s/accounts/{id} with the corrected values", API)
    if unmapped:
        log.warning("  add the unmapped code(s) above to the table in this script. "
                    "Stripe adds codes; a stale table shows a seller nothing.")
    if not args.persons:
        log.info("  re-run with --persons: on a company account the account-level "
                 "array is often empty while a director's is not")
    return 1 if with_errors else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-verification-errors.mjs
/**
 * Report unread requirements.errors on connected accounts, with the fix for each.
 *
 * 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';

// A new file is required. The same one re-uploaded fails automatically.
const DOCUMENT_CODES = {
  verification_document_failed_greyscale:
    'the upload was greyscale: a colour scan or photo of the same document',
  verification_document_not_readable:
    'the image could not be read: re-capture it in focus and uncropped',
  verification_document_expired:
    'the document is out of date: a current one, not a better scan',
  verification_document_missing_back:
    'only one side was submitted: the back of the same document',
  verification_document_failed_other:
    'rejected without a specific cause: a different capture, colour, under the ' +
    'size limits, and an image rather than a PDF for identity documents',
};

// A new file will never fix this. The typed fields are what disagree.
const IDENTITY_CODES = {
  verification_failed_keyed_identity:
    'the typed name or date of birth does not match the document: correct the ' +
    'fields, not the file',
};

// Ordinary field edits on the account or person.
const FIELD_CODES = {
  information_missing:
    'a required field was left out: read the requirement and supply it',
  verification_missing_owners:
    'beneficial owners are missing: add the Person objects for them',
  invalid_street_address:
    'the address could not be validated: check it against the postal service ' +
    'format for the country',
  invalid_tax_id_format:
    'the tax id is not in the format for that country',
};

// The whole invalid_url_website_* family, matched by prefix.
const WEBSITE_PREFIX = 'invalid_url_website';

const GROUPS = [
  ['document', DOCUMENT_CODES],
  ['identity', IDENTITY_CODES],
  ['field', FIELD_CODES],
];

/**
 * Turn a requirements.errors array into one state and one instruction. Pure, so
 * the code table can be tested without a network. An unrecognised code returns
 * `unmapped` rather than `clear`: Stripe adds codes, and a table that silently
 * swallows new ones is worse than no table.
 * Returns [state, detail].
 */
export function classify(errors) {
  const items = (errors ?? []).filter((e) => e && typeof e === 'object' && e.code);
  if (!items.length) return ['clear', 'requirements.errors is empty'];

  for (const [state, table] of GROUPS) {
    for (const e of items) {
      if (e.code in table) {
        return [state,
          `${e.code} on ${e.requirement || 'an unnamed requirement'}: ${table[e.code]}`];
      }
    }
  }

  for (const e of items) {
    if (String(e.code).startsWith(WEBSITE_PREFIX)) {
      return ['website',
        `${e.code} on ${e.requirement || 'business_profile.url'}: fix the site ` +
        'itself, then set business_profile[url] to another value and back to ' +
        'force re-verification'];
    }
  }

  const e = items[0];
  return ['unmapped',
    `${e.code} on ${e.requirement || 'an unnamed requirement'}: ` +
    `${e.reason || 'no reason string returned'}`];
}

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 errorSources(key, account, deep) {
  const out = [
    ['account', account.requirements?.errors ?? []],
    ['future', account.future_requirements?.errors ?? []],
  ];
  if (!deep) return out;
  const persons = await get(key, `/accounts/${account.id}/persons`, { limit: 100 });
  for (const p of persons.data ?? []) {
    out.push([`person ${p.id ?? 'person_?'}`, p.requirements?.errors ?? []]);
  }
  const caps = await get(key, `/accounts/${account.id}/capabilities`);
  for (const c of caps.data ?? []) {
    out.push([`capability ${c.id ?? '?'}`, c.requirements?.errors ?? []]);
  }
  return out;
}

async function main() {
  const key = process.env.STRIPE_API_KEY;
  if (!key) {
    console.error('set STRIPE_API_KEY (use a restricted, read-only key)');
    process.exitCode = 2;
    return;
  }
  const deep = process.argv.includes('--persons');

  let withErrors = 0;
  let unmapped = 0;
  let scanned = 0;
  for await (const acct of accounts(key)) {
    scanned += 1;
    let hits = 0;
    for (const [where, errors] of await errorSources(key, acct, deep)) {
      const [state, detail] = classify(errors);
      if (state === 'clear') continue;
      hits += 1;
      if (state === 'unmapped') unmapped += 1;
      console.warn(`${acct.id ?? 'acct_?'}  ${state.padEnd(9)} ${where.padEnd(12)} ${detail}`);
    }
    if (hits) withErrors += 1;
  }

  console.log(`${scanned} account(s): ${withErrors} with errors, ` +
              `${unmapped} unmapped code(s)`);

  if (withErrors) {
    console.warn('  repair: show the mapped instruction and the reason string in ' +
                 'your onboarding UI, then require a genuinely different ' +
                 'submission. The same file re-uploaded fails on its own.');
    console.warn('  documents: upload to https://files.stripe.com/v1/files with ' +
                 "purpose=identity_document, then attach the file id to the " +
                 "person's verification[document][front]");
    console.warn(`  fields: POST ${API}/accounts/{id} with the corrected values`);
    process.exitCode = 1;
  }
  if (unmapped) {
    console.warn('  add the unmapped code(s) above to the table in this script. ' +
                 'Stripe adds codes; a stale table shows a seller nothing.');
  }
}

// Only run when invoked directly, so importing this module in the test file does
// not run main(), fail on the missing key and fail the suite.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The table is the product here, so the tests check that each family produces the instruction that actually resolves it — and, more importantly, that a code the table has never seen comes out as unmapped. Stripe adds error codes; a lookup that returns a cheerful clear for anything it does not recognise would put this note's exact bug back into the script written to find it.

test_stripe_verification_errors.py
from stripe_verification_errors import classify


def test_no_errors_is_clear():
    assert classify([])[0] == "clear"
    assert classify(None)[0] == "clear"


def test_greyscale_asks_for_colour_not_for_patience():
    state, detail = classify([{
        "code": "verification_document_failed_greyscale",
        "reason": "The document could not be verified because it is greyscale.",
        "requirement": "individual.verification.document",
    }])
    assert state == "document"
    assert "colour" in detail


def test_keyed_identity_is_a_field_edit_not_a_new_file():
    state, detail = classify([{"code": "verification_failed_keyed_identity",
                               "requirement": "individual.first_name"}])
    assert state == "identity"
    assert "not the file" in detail


def test_a_field_code_names_its_requirement():
    state, detail = classify([{"code": "invalid_tax_id_format",
                               "requirement": "company.tax_id"}])
    assert state == "field"
    assert "company.tax_id" in detail


def test_the_website_family_is_matched_by_prefix():
    # Stripe keeps adding to invalid_url_website_*, so this must not be a list.
    state, detail = classify([{"code": "invalid_url_website_incomplete_cancellation_policy",
                               "requirement": "business_profile.url"}])
    assert state == "website"
    assert "force re-verification" in detail


def test_an_unknown_code_is_unmapped_and_keeps_its_reason():
    state, detail = classify([{"code": "verification_something_brand_new",
                               "reason": "A reason only Stripe knows yet.",
                               "requirement": "individual.id_number"}])
    assert state == "unmapped"
    assert "A reason only Stripe knows yet." in detail


def test_a_blocking_document_error_wins_over_a_website_one():
    state, _ = classify([
        {"code": "invalid_url_website_other", "requirement": "business_profile.url"},
        {"code": "verification_document_expired",
         "requirement": "individual.verification.document"},
    ])
    assert state == "document"
stripe-verification-errors.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-verification-errors.mjs';

test('no errors is clear', () => {
  assert.equal(classify([])[0], 'clear');
  assert.equal(classify(null)[0], 'clear');
});

test('greyscale asks for colour, not for patience', () => {
  const [state, detail] = classify([{
    code: 'verification_document_failed_greyscale',
    reason: 'The document could not be verified because it is greyscale.',
    requirement: 'individual.verification.document',
  }]);
  assert.equal(state, 'document');
  assert.match(detail, /colour/);
});

test('keyed identity is a field edit, not a new file', () => {
  const [state, detail] = classify([{
    code: 'verification_failed_keyed_identity',
    requirement: 'individual.first_name',
  }]);
  assert.equal(state, 'identity');
  assert.match(detail, /not the file/);
});

test('a field code names its requirement', () => {
  const [state, detail] = classify([{
    code: 'invalid_tax_id_format', requirement: 'company.tax_id',
  }]);
  assert.equal(state, 'field');
  assert.match(detail, /company\.tax_id/);
});

test('the website family is matched by prefix', () => {
  const [state, detail] = classify([{
    code: 'invalid_url_website_incomplete_cancellation_policy',
    requirement: 'business_profile.url',
  }]);
  assert.equal(state, 'website');
  assert.match(detail, /force re-verification/);
});

test('an unknown code is unmapped and keeps its reason', () => {
  const [state, detail] = classify([{
    code: 'verification_something_brand_new',
    reason: 'A reason only Stripe knows yet.',
    requirement: 'individual.id_number',
  }]);
  assert.equal(state, 'unmapped');
  assert.match(detail, /A reason only Stripe knows yet\./);
});

test('a blocking document error wins over a website one', () => {
  const [state] = classify([
    { code: 'invalid_url_website_other', requirement: 'business_profile.url' },
    { code: 'verification_document_expired', requirement: 'individual.verification.document' },
  ]);
  assert.equal(state, 'document');
});

FAQ

Where does requirements.errors appear?

In four places, and they do not agree with each other. On the account's requirements hash, on its future_requirements hash, on every Person object under GET /v1/accounts/{id}/persons, and on every entry from GET /v1/accounts/{id}/capabilities. On a company account the account-level array is frequently empty while a director's is not.

Why does re-uploading the same document keep failing?

Because a duplicate submission fails on its own. Stripe will not re-review a file it has already rejected, so the seller's instinctive response guarantees another failure. The instruction has to make clear what must be different about the next capture: colour, in focus, current, both sides where required.

What is verification_failed_keyed_identity?

The name or date of birth typed into the form does not match the document that was uploaded. No new photo fixes it. Correct the fields on the account or the person, which is why mapping the code to an instruction matters more here than anywhere else in the table.

How do I fix an invalid_url_website_* error?

Fix the website first: reachable, describing the business, and carrying whatever the specific code names, such as a returns or cancellation policy. Then set business_profile[url] to a different value and back, because otherwise nothing has changed on the object and Stripe has no reason to look again.

Should I show the reason string to the seller?

Show both. The reason string is human readable and written to be displayed, and your mapped instruction says what to do about it. Together they end the ticket; either one alone tends to produce another identical upload.

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.