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.
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.
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.
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.
{
"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": [
{ "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" …
]
}
{
"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"
}
}
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.
{
"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 …
}
}
errors[] analog cannot be sourced from MT webhooks. The async signal is exactly one bit of new information — which status the entity landed in.GET /api/events) is a replayable log of every webhook body, which is how this spike captured them without a public endpoint.denied is unreachableThe 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.
pending in the create response…/api/simulations/…/update_status — returns 202, applies asyncPATCH /api/legal_entities/:id fixing one field…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
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
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.
Re-verification is implicit in the patch. In sandbox it auto-passes; in production this is where Persona would re-run.
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.
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.
error_list dot-paths map straight onto checklist keys — translate the whole list at the provider boundary, not just the first entry.legal_entity.verification), flipped by webhook.pending_verification on PATCH, resolves on the next status webhook. No extra provider calls.errors[] analog exists in the async channel. Per-requirement error rows sourced from MT are off the table for v1.error_code/error_reason on the requirement row. No child error table.denied can't be manufactured; test the denied path at the translator level, not end-to-end.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