commerce · stripe requirements spike
Spike conclusion · commerce#sugar-teach-visa · RENTAL-593

How Stripe Connect tracks onboarding requirements — and what we should steal

Adopt the shape, not the engine. Stripe's requirements object is a live, recomputed checklist of keys. The data model transfers to Commerce almost wholesale; the thing that doesn't is who computes it — for us that's a projection of Modern Treasury state, not our own risk engine.

POC run 2026-07-06 · raw REST, 3 scenarios · scripts & snapshots in spikes/stripe-connect-requirements/

01

A requirement is a key, not data

Stripe never sends the values back. requirements.currently_due is just a list of dot-path strings — each one names a gap in the account graph. Any UI can render a checklist from keys alone and resolve each key to a form field. That's how the onboarding banner surfaces gaps without holding the data.

Actual currently_due keys observed at account creation
individual.first_name individual.dob.year individual.address.city individual.ssn_last_4 business_profile.mcc tos_acceptance.date external_account representative.first_name owners.email company.owners_provided
solid = plain field  ·  dashed = an object that doesn't exist yet (a bank account, a person in a role)  ·  gold = an attestation the platform asserts, which cleared the whole owners.* family at once
currently_due — verbatim, bare company account snapshots/company/00-bare-account.json
"currently_due": [
  "business_profile.mcc",
  "business_profile.url",
  "company.address.city",
  "company.address.line1",
  "company.address.postal_code",
  "company.address.state",
  "company.name",
  "company.owners_provided",      // attestation — a boolean the platform asserts
  "company.phone",
  "company.tax_id",
  "external_account",             // object that doesn't exist yet
  "owners.email",                 // role placeholder — no person exists yet
  "owners.first_name",
  "owners.last_name",
  "representative.address.city",
  "representative.address.line1",
  "representative.address.postal_code",
  "representative.address.state",
  "representative.dob.day",
  "representative.dob.month",
  "representative.dob.year",
  "representative.email",
  "representative.first_name",
  "representative.last_name",
  "representative.phone",
  "representative.relationship.title",
  "representative.ssn_last_4",
  "settings.payments.statement_descriptor",
  "tos_acceptance.date",
  "tos_acceptance.ip"
]
02

Urgency is membership, not state

There's no per-field status enum. The same key appears in every bucket it qualifies for, and the buckets nest: everything past due is currently due; everything currently due will eventually be due. Two side channels carry the in-flight and failed work, and one derived flag summarizes the whole object.

eventually_due
everything the requested capabilities will ever need
currently_due
needed now to keep the account moving
past_due
deadline passed — on custom accounts current_deadline is null, so unmet keys land here immediately
pending_verificationkeys transit here while async checks run — including keys you never submitted (sending ssn_last_4 put individual.id_number here)
errors[]structured {requirement, code, reason} — the key ties each failure to its checklist item; reason is user-presentable
disabled_reason → gatesderived summary (requirements.past_due…pending_verificationnull) feeding the hard gates charges_enabled / payouts_enabled
requirements — verbatim, mid-onboarding: one key in all three buckets at once snapshots/individual/03-individual-identity.json
{
  "alternatives": [],
  "current_deadline": null,       // custom accounts: no grace period, unmet ⇒ past_due immediately
  "currently_due": [
    "external_account"            // ┐
  ],                                // │ same key, three buckets —
  "disabled_reason": "requirements.past_due",
  "errors": [],                     // │ urgency is membership, not a status field
  "eventually_due": [
    "external_account"            // │
  ],                                // │
  "past_due": [
    "external_account"            // ┘
  ],
  "pending_verification": [       // async checks in flight — incl. id_number, inferred from ssn_last_4
    "individual.address.city",
    "individual.address.line1",
    "individual.address.postal_code",
    "individual.address.state",
    "individual.id_number"
  ]
}
03

The happy path drains fast

Scenario 1, real numbers: a bare US individual account owes 18 keys at birth. Each API update drains its slice; identity data transits verification; the account was fully enabled seconds after the last field landed.

currently_due count per step — acct_1TqEfEH8Vce5CyP6
18
create
bare account, capabilities requested
15
+ business_profile
mcc, url; statement descriptor inferred
13
+ tos_acceptance
date + ip
1
+ identity
12 keys drain; address + id_number enter pending_verification
0
+ external_account
test bank account; verification still settling
seconds later
charges_enabled · payouts_enabled

Company accounts behave the same, with role-keyed placeholders (representative.*, owners.*) satisfied by creating persons — after which each person's own gaps re-key as person_<id>.*.

the drain, step by step — condensed; full payloads in the snapshot files snapshots/individual/*.json
[
  { "after": "00-bare-account",       "currently_due": 18, "pending_verification": 0, "disabled_reason": "requirements.past_due" },
  { "after": "01-business-profile",   "currently_due": 15, "pending_verification": 1, "disabled_reason": "requirements.past_due" },
  { "after": "02-tos-acceptance",     "currently_due": 13, "pending_verification": 0, "disabled_reason": "requirements.past_due" },
  { "after": "03-individual-identity", "currently_due": 1,  "pending_verification": 5, "disabled_reason": "requirements.past_due" },
  { "after": "04-external-account",   "currently_due": 0,  "pending_verification": 5, "disabled_reason": "requirements.pending_verification" }
]

// seconds later — GET /v1/accounts/acct_1TqEfEH8Vce5CyP6, verbatim:
{
  "charges_enabled": true,
  "payouts_enabled": true,
  "requirements": {
    "alternatives": [],
    "current_deadline": null,
    "currently_due": [],
    "disabled_reason": null,
    "errors": [],
    "eventually_due": [],
    "past_due": [],
    "pending_verification": []
  }
}
04

The checklist grows after the fact

The finding the card was really after. Scenario 3: an account with every field satisfied fails identity verification (test id_number 111111111) — and Stripe pushes a requirement that was never in the original list into currently_due, with errors explaining why. The checklist is a live projection of verification state, not a static form.

acct_1TqEiFHmO7s2JARf — before and after verification failed

Complete account, checks running

currently_due   []
pending        individual.id_number
errors         []
disabled      requirements.pending_verification

Verification fails

currently_due   individual.id_number,
               individual.verification.document
errors         2 entries
disabled      requirements.past_due
requirements — verbatim, after the failure settled GET /v1/accounts/acct_1TqEiFHmO7s2JARf
{
  "alternatives": [],
  "current_deadline": null,
  "currently_due": [
    "individual.id_number",
    "individual.verification.document"   // ← never requested; added by the failed check
  ],
  "disabled_reason": "requirements.past_due",
  "errors": [
    {
      "code": "verification_failed_tax_id_match",
      "detailed_code": "verification_failed_tax_id_match",
      "reason": "Taxpayer Identification Number (TIN) on the document doesn't match the one
                 provided by this account. Provide a matching document or update the Taxpayer
                 Identification Number of this account. Please refer to this support article on
                 verifying tax ID numbers: https://support.stripe.com/questions/using-irs-
                 documentation-as-reference-when-entering-business-name-and-tax-id-number-tin-
                 for-us-based-businesses",
      "requirement": "individual.id_number"
    },
    {
      "code": "verification_failed_keyed_identity",
      "detailed_code": "verification_failed_keyed_identity",
      "reason": "The person's keyed-in identity information could not be verified. Correct
                 any errors or upload a document that matches the identity fields (e.g., name
                 and date of birth) entered.",
      "requirement": "individual.verification.document"
    }
  ],
  "eventually_due": [
    "individual.id_number",
    "individual.verification.document"
  ],
  "past_due": [
    "individual.verification.document"   // membership, not state: in all three buckets at once
  ],
  "pending_verification": []
}

The error format is the context worth copying: each entry is {requirement, code, detailed_code, reason} — a machine-stable code for branching, a user-presentable reason (with remediation and a support link inline), and the requirement key tying it back to the checklist item it blocks. Nothing else; no PII.

Also observed: adding a second person appended person_<id>.phone to eventually_due only (threshold-dependent), and the requirement set is a function of which capabilities you request. Change notification in production is the account.updated webhook.

05

What we take, what we don't

Context that changes everything: we one-shot onboarding data to Modern Treasury, and MT runs the actual KYC/KYB. We hold neither all the data nor a risk engine — so we copy the projection, not the computation.

Transfers well

  • Keyed checklist as the core model — dot-path keys in our own vocabulary (legal_entity.tax_id, external_account), zero PII in the requirements object.
  • Superset buckets + derived gate — membership beats a per-field status enum; a disabled_reason-style summary feeds our real gate: API access after onboarding.
  • Requirements naming objects-to-be — exactly what "add an external account before disbursement" needs.
  • Errors keyed by requirement — provider reasons translated at the boundary, adapter/translator style.
  • Attestation-type requirements — the owners_provided pattern, if we do beneficial-owner KYB.

Doesn't transfer

  • Recomputing from data we hold — MT verifies; our list is a projection of MT state plus Commerce-native checks. "Grows after the fact" arrives as MT webhooks we translate into new keys.
  • Deadlines & escalation — Stripe uses current_deadline to progressively restrict live accounts; nothing to enforce in v1.
  • Per-capability requirement sets — ours is effectively one capability: onboarded → API access.
  • person_<id> keys — only if we ever model persons instead of passing them through to MT.
The shape we'd build
MT webhooks / stateonboarding + compliance transitions, failure reasons
Commerce-native checksexternal account linked · controlling-org edge set · API key issued
translate at boundaryprovider vocabulary → our requirement keys & error codes (adapter/translator)
keyed checklistcurrently_due / pending_verification / errors[] — recomputed on every provider event
derived gateonboarded? → API access, per the org-structure rules
Same payload shape a client renders as Stripe's requirements banner: list the keys, resolve each to a form field or action, show errors[] reasons inline. The truth just comes from MT instead of us.

scripts & per-step snapshots: spikes/stripe-connect-requirements/ (NOTES.md is the long-form writeup)
linear: RENTAL-593 + attached doc · headway: commerce#sugar-teach-visa · notebook: notebook@unit-ordinary-grape
feeds: Requirements tracking model (Stripe-style) — commerce#sentence-agree-math