Skip to main content

Error codes

Every error shares one envelope: a machine-readable code, a human-readable message in the caller's language, per-field details for validation failures, and a trace identifier.

Branch on the code. The message is display text and may be reworded or translated; the code is a stable identifier.

The envelope

Every error the platform returns has this shape:

{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{ "field": "lei",
"code": "errors.LEI_FORMAT",
"message": "Legal Entity Identifier must be 20 characters",
"ruleType": "regex",
"severity": "error" },
{ "field": "address.state",
"code": "REQUIRED",
"message": "State is required when the country is US",
"ruleType": "cross_field",
"severity": "warning" }
],
"traceId": "550e8400-e29b-41d4-a716-446655440000"
}
}

details is populated for validation failures and is what lets a client put each message under the field that caused it, rather than showing one banner for a form with two problems. Note that field uses the dotted path for a composite sub-field — address.state, not state.

Rule-driven failures additionally carry ruleType and severity, so a client can tell a blocking error from a recorded warning without a lookup table.

Errors that are not per-field carry an empty details and rely on the code:

{
"error": {
"code": "ENTITY_ALREADY_MERGED",
"message": "This entity has been merged into another",
"details": [],
"traceId": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}
}

The traceId is generated per request. Quote it when reporting a problem — it locates the exact request in the logs.

Statuses

StatusMeansDo
400The request is malformed or fails validationFix the request. details names each offending field.
401Not authenticated, or the session was revoked (SESSION_REVOKED)Sign in again. There is no refresh flow.
403Authenticated but not permittedSee below — the cause differs by code
404Not foundThe resource does not exist, is soft-deleted, or belongs to another tenant
409ConflictA uniqueness constraint. Change the conflicting value.
422Well-formed but violates a business ruleRead the message — it states the rule
413Payload too largeReduce the request body
429Too many requestsRetry after the interval given
500Unexpected failureReport it with the trace identifier
503Temporarily unavailableRetry after the interval given

Telling the 403s apart

Three different causes, distinguished by code:

CodeCauseRemedy
PERMISSION_DENIEDMissing a permission code. The response names which.Grant it, or use a role that has it
ABAC_DENIEDA ABAC policy denied this specific recordReview the policy — the permission is held, the record is out of scope
SELF_LOCK_FORBIDDEN, SELF_DELETE_FORBIDDEN, SELF_ROLE_REVOKE_FORBIDDEN, SELF_ROLE_ASSIGN_FORBIDDENA self-action guard — you cannot deactivate, delete, or de-role yourselfAsk another administrator
FORBIDDENAnother operation-specific guardRead the message

The distinction matters because the fixes are unrelated: one is a role change, one is a policy change, and one is usually "ask someone else to do it".

Why 404 and not 403 across tenants

A record in another tenant returns 404, not 403.

This is not obfuscation. Tenant isolation is enforced in the database, so the row is genuinely invisible to the query — the platform did not look, find it, and refuse. It also means identifiers cannot be probed to learn what exists elsewhere.

Validation detail

A 400 carries one entry per offending field, each with the field, a code, and a message. Render them against their fields rather than as a single banner — the caller can then fix everything in one pass.

Business-rule errors

A 422 means the request was well-formed and still not allowed, because a rule of the domain forbids it. These carry specific codes rather than a generic one:

CodeRaised when
ENTITY_ALREADY_MERGEDMerging a record that is already merged
ENTITY_STATUS_ENGINE_MANAGEDSetting merged status directly instead of merging
CONSENT_NOT_WITHDRAWABLEWithdrawing a record whose legal basis is not consent
WITHDRAW_ALREADY_REVOKEDWithdrawing an already-revoked record
UNSUPPORTED_RULE_CONFIGSaving a rule the engine cannot honour
UNSUPPORTED_VALUE_SHAPEWriting a scalar into a composite attribute
INVALID_VALUE_TYPESupplying a usage type outside the bound list
NO_ACTIVE_MATCH_PROFILEMatching an entity type with no active profile

Configuration errors are deliberately raised when you save, not silently at execution time. A rule that never fires is worse than no rule, because it looks like coverage.

Backpressure

CodeStatusMeans
TOO_MANY_ROWS400The submission exceeds the per-request row cap
PAYLOAD_TOO_LARGE413The request body exceeds the size cap
QUEUE_LIMIT_EXCEEDED429The tenant's queue depth is reached
PAYLOAD_QUOTA_EXCEEDED429The tenant's payload quota is reached
RATE_LIMITED429The caller is over its rate limit
QUEUE_SATURATED503The platform is saturated

429 and 503 both carry a retry interval. Honour it — retrying immediately makes the condition worse. See limits for the values.

Trace identifiers

Every error carries one. It locates the request in the logs directly, which is far faster than reconstructing from a description.

Surface it in your own interfaces. An error a user can report by identifier is diagnosable; one described from memory usually is not.

A 500 is always a defect

Every error a caller can plausibly cause should be a 4xx with a meaningful code. A 500 means an unhandled case reached the surface. Report it with the trace identifier rather than working around it — the fix is to map the error.

Next


Last verified against commit e6fa3bb7 (2026-08-03)