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
| Status | Means | Do |
|---|---|---|
| 400 | The request is malformed or fails validation | Fix the request. details names each offending field. |
| 401 | Not authenticated, or the session was revoked (SESSION_REVOKED) | Sign in again. There is no refresh flow. |
| 403 | Authenticated but not permitted | See below — the cause differs by code |
| 404 | Not found | The resource does not exist, is soft-deleted, or belongs to another tenant |
| 409 | Conflict | A uniqueness constraint. Change the conflicting value. |
| 422 | Well-formed but violates a business rule | Read the message — it states the rule |
| 413 | Payload too large | Reduce the request body |
| 429 | Too many requests | Retry after the interval given |
| 500 | Unexpected failure | Report it with the trace identifier |
| 503 | Temporarily unavailable | Retry after the interval given |
Telling the 403s apart
Three different causes, distinguished by code:
| Code | Cause | Remedy |
|---|---|---|
PERMISSION_DENIED | Missing a permission code. The response names which. | Grant it, or use a role that has it |
ABAC_DENIED | A ABAC policy denied this specific record | Review 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_FORBIDDEN | A self-action guard — you cannot deactivate, delete, or de-role yourself | Ask another administrator |
FORBIDDEN | Another operation-specific guard | Read 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:
| Code | Raised when |
|---|---|
ENTITY_ALREADY_MERGED | Merging a record that is already merged |
ENTITY_STATUS_ENGINE_MANAGED | Setting merged status directly instead of merging |
CONSENT_NOT_WITHDRAWABLE | Withdrawing a record whose legal basis is not consent |
WITHDRAW_ALREADY_REVOKED | Withdrawing an already-revoked record |
UNSUPPORTED_RULE_CONFIG | Saving a rule the engine cannot honour |
UNSUPPORTED_VALUE_SHAPE | Writing a scalar into a composite attribute |
INVALID_VALUE_TYPE | Supplying a usage type outside the bound list |
NO_ACTIVE_MATCH_PROFILE | Matching 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
| Code | Status | Means |
|---|---|---|
TOO_MANY_ROWS | 400 | The submission exceeds the per-request row cap |
PAYLOAD_TOO_LARGE | 413 | The request body exceeds the size cap |
QUEUE_LIMIT_EXCEEDED | 429 | The tenant's queue depth is reached |
PAYLOAD_QUOTA_EXCEEDED | 429 | The tenant's payload quota is reached |
RATE_LIMITED | 429 | The caller is over its rate limit |
QUEUE_SATURATED | 503 | The 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.
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)