commerce · mt failure webhooks spike
Spike conclusion · commerce#retire-lawsuit-intact · RENTAL-594

How Modern Treasury reports verification failure — and how you fix one

MT's failure vocabulary is status-shaped, not requirement-shaped. Everything requirement-keyed arrives synchronously at submission time as a 422; the async webhook is a bare status transition carrying the full entity and zero explanation. And there is no resubmit endpoint — the patch is the resubmission.

POC run 2026-07-06 · raw REST against the shared sandbox, create-only · scripts & wire captures in spikes/mt-failure-webhooks/

01

Failure speaks two languages

Where Stripe puts everything in one recomputed requirements object, MT splits it: a rich, itemized synchronous channel at create/patch time, and a mute asynchronous channel afterwards. Whatever detail you want to key requirements off, you get it in the 422 or you don't get it at all.

sync — 422 at create / patch

Complete error_list, every entry {code, message, parameter}. Up to 8 observed on one bare create. parameter is a dot-path for field errors, but collapses to a coarse legal_entity for compliance-profile rules — there the detail lives only in the message text.

codes seen: parameter_invalid · parameter_missing
keyed: addresses.0.line1 · identifications.0.id_type · date_of_birth
coarse: legal_entity (completeness, dedupe)

async — status webhook, later

One of created / updated / activated / suspended / denied. The body is the full legal entity object — no errors array, no reason, no requirement keys. It says that verification failed, never why.

compliance_details: null (deprecated)
third_party_verifications: [ ]
errors / reason field:(doesn't exist)
The org's compliance profile enforces completeness at create — one-shot means one-shot
address business_name date_formed EIN · classification ID intended_use expected_activity_volume document: proof_of_address or ein_letter dedupe: us_ein already exists
dashed = an alternatives analog, expressed only as message text  ·  red = MT-enforced dedupe, same envelope, distinguishable only by message
error_list — verbatim, bare business create snapshots/01-sync-errors/01-business-missing-name.json
{
  "errors": {                      // = first entry of error_list, nothing more
    "code": "parameter_invalid",
    "message": "A valid LegalEntityAddress is missing.",
    "parameter": "legal_entity"
  },
  "error_list": [
    { "code": "parameter_invalid", "parameter": "legal_entity", "message": "A valid LegalEntityAddress is missing." },
    { "code": "parameter_invalid", "parameter": "legal_entity", "message": "The LegalEntity requires an ID corresponding to its classification." },
    { "code": "parameter_invalid", "parameter": "legal_entity", "message": "The business LegalEntity is missing a date formed." },
    { "code": "parameter_invalid", "parameter": "legal_entity", "message": "A business LegalEntity must have a business name." },
    { "code": "parameter_invalid", "parameter": "legal_entity", "message": "Missing required document: proof_of_address or ein_letter" },  // alternatives, in prose
    { "code": "parameter_invalid", "parameter": "legal_entity", "message": "Missing identification: business identification" },
    { "code": "parameter_invalid", "parameter": "legal_entity", "message": "The LegalEntity is missing an intended use." },
    { "code": "parameter_invalid", "parameter": "legal_entity", "message": "The LegalEntity is missing an expected activity volume." }
  ]
}
error_list — dot-path keys on a field error snapshots/01-sync-errors/02-bad-country-code.json
{
  "error_list": [
    { "code": "parameter_invalid", "parameter": "addresses.0.address.country", "message": "Country is not a valid record" },
    { "code": "parameter_missing", "parameter": "addresses.0.line1",           "message": "Line1 is required" },
    { "code": "parameter_invalid", "parameter": "addresses.0.country",         "message": "Country is not a valid record" },
    … completeness rules follow, keyed "legal_entity" …
  ]
}
dedupe — same us_ein twice snapshots/06-probes/02-dedupe-same-ein-second.json
{
  "errors": {
    "code": "parameter_invalid",                          // no dedicated code —
    "message": "A legal entity with this us_ein already exists",  // message text is the only signal
    "parameter": "legal_entity"
  }
}
02

The webhook says that, never why

The failed-to-activate hypothesis (“~95% sure failure detail is in the legal_entity webhook”) did not survive contact. The webhook body is the entity itself — 48 fields — and every field that could carry a verification verdict came back empty. In sandbox, Persona never runs, so this stays unconfirmed for production; until MT says otherwise, design for no detail.

suspended webhook body — the three places detail could live, all empty
status = suspended compliance_details = null third_party_verifications = [ ] errors / reasonno such field
third_party_verifications is typed to carry persona/middesk verdicts per category (legal_name, dob, address, government_id_number, adverse_media) with outcome passed|failed — the only plausible production carrier
event.data — suspended webhook, pruned to the relevant fields snapshots/05-transitions-clean/05-events-final.json
{
  "id": "019f389d-…",
  "object": "event",
  "resource": "legal_entity",
  "event_name": "suspended",
  "entity_id": "96fba1f1-2888-4247-aead-c9c6492af89c",
  "data": {                                  // ← the entire legal entity, 48 fields
    "id": "96fba1f1-2888-4247-aead-c9c6492af89c",
    "object": "legal_entity",
    "business_name": "SPIKE RENTAL-594 Transitions LLC",
    "status": "suspended",                  // ← the entire failure signal
    "compliance_details": null,             // deprecated, always null
    "third_party_verification": null,       // deprecated, always null
    "third_party_verifications": [],        // empty — persona doesn't run in sandbox
    "risk_rating": null,
    "addresses": […], "identifications": […], "documents": […],
    … 38 more entity fields, no errors/reason/requirements anywhere …
  }
}
Consequence: our errors[] analog cannot be sourced from MT webhooks. The async signal is exactly one bit of new information — which status the entity landed in.
Consolation: since the envelope is just the entity, our existing handler pattern — ignore the payload, fetch fresh, sync — loses nothing. And the Events API (GET /api/events) is a replayable log of every webhook body, which is how this spike captured them without a public endpoint.
03

Sandbox reality: denied is unreachable

The sandbox auto-activates every complete entity about one second after create — and the simulation endpoint's validator considers it active at +0 ms, while the API still shows pending. A 30-attempt race at 100 ms cadence never won. suspended → denied is also rejected (the error blames active regardless of actual state), so suspended is the only failure state we can manufacture.

One entity's event log — created to suspended and back (96fba1f1)
18:07:47createdstatus pending in the create response…
18:07:48activated…auto-verified one second later, no simulation involved
18:07:52suspendedvia /api/simulations/…/update_status — returns 202, applies async
— 3 minutes pass; denied is attempted and rejected: "Cannot transition status from active to denied" —
18:10:48updateda plain PATCH /api/legal_entities/:id fixing one field…
18:10:48activated…re-verifies and re-activates in the same second (see 04)
the unwinnable race — denied at +0ms, entity still "pending" 03-denied-fast · 30 attempts, 100ms cadence
create → 201  id=583a5ecd…  status="pending"
attempt 0  (+0ms):    422 "Cannot transition status from active to denied"   // active at +0ms!
attempt 1  (+100ms):  422 "Cannot transition status from active to denied"

attempt 29 (+2900ms): 422 "Cannot transition status from active to denied"

and from a settled, GET-confirmed suspended entity:
simulate denied →     422 "Cannot transition status from active to denied"   // message blames active anyway
202 ≠ applied — rapid-fire transitions interleave snapshots/04-transitions · scenario without settling
sent, back to back:   suspended → 202 · denied → 202 · active → 202
events that fired:    updated · suspended · activated      (no denied event, ever)
final GET:            status="suspended"                  // contradicts event order
lesson: the simulation endpoint is async and racy — settle (poll the entity) between transitions
04

The patch is the resubmission

There is no resubmit endpoint, and none is needed. PATCH /api/legal_entities/:id works in every state (200, sync, full entity in the response) — and patching a suspended entity re-triggered verification on the spot: the PATCH response already showed status: active, with updated + activated webhooks landing in the same second. Documents amend the same way, post-create.

fix a field — PATCH legal_entities/:id

entity before: suspended
response: HTTP 200 · status: active
webhooks: updated + activated, same second

Re-verification is implicit in the patch. In sandbox it auto-passes; in production this is where Persona would re-run.

amend a document — POST /api/documents

shape: multipart · documentable_type=legal_entity
while suspended: HTTP 201 ✓
accepts: ein_letter ✓ (standalone endpoint)

Quirk: the create-embedded documents[] array rejects ein_letter ("Invalid document type … for this resource") even though the completeness rule offers it as an alternative. Attach it post-create.

05

What this settles for the requirements model

The Stripe spike said: adopt the keyed-checklist shape, project the truth from MT. This spike pins down exactly what MT contributes to that projection — and it's less than hoped, which simplifies the schema.

What MT gives us

  • Submission-time requirement keys. The 422 error_list dot-paths map straight onto checklist keys — translate the whole list at the provider boundary, not just the first entry.
  • Coarse async facts. Entity activated / suspended / denied — one checklist key's worth of signal (legal_entity.verification), flipped by webhook.
  • Free resubmission semantics. Patch → re-verify. Checklist key goes pending_verification on PATCH, resolves on the next status webhook. No extra provider calls.
  • Dedupe enforcement. Map the already-exists 422 to a distinct domain error at the boundary (message-text match — ugly, but it's the only signal).

What MT doesn't give us

  • Requirement-keyed failure detail. No errors[] analog exists in the async channel. Per-requirement error rows sourced from MT are off the table for v1.
  • ⇒ resolves the open schema question: single optional error_code/error_reason on the requirement row. No child error table.
  • A poll-able checklist. Completeness is enforced at create; there's no requirements object to sync. Our checklist is genuinely ours.
  • Sandbox denial coverage. denied can't be manufactured; test the denied path at the translator level, not end-to-end.
the projection, updated with what MT actually supplies
MT sync 422s error_list dot-paths at submit time — itemized, requirement-keyed
MT status webhooks activated / suspended / denied — one coarse fact, no detail
translate at boundary *.translator.ts: error_list → domain errors · status → LegalEntityStatus · dedupe by message
keyed checklist onboarding_requirement rows · currently_due / pending_verification · single optional error
derived gate onboarding_completed = zero currently_due rows → API access
Commerce-native facts (external account linked, controlling-org edge, API key) join the projection unchanged from the Stripe-spike design. The only revision this spike forces: the errors[] lane is fed by sync submission errors and our own checks, never by webhook payloads.

sources: spikes/mt-failure-webhooks/NOTES.md · wire captures in spikes/mt-failure-webhooks/snapshots/
test entities SPIKE RENTAL-594 … (da3bea2a · d62d40bd · 96fba1f1 · 583a5ecd · 0dba0764) — shared sandbox, create-only
companion: stripe-requirements-spike.html · tracking: commerce#retire-lawsuit-intact · RENTAL-594