Errors
One envelope for every refusal, the identifiers to branch on, and the three places the envelope does not hold.
Every refusal the API makes — from the middleware, from a route handler, or from the schema validator — comes back as JSON with a stable, machine-readable identifier. You branch on that identifier. You never branch on the prose.
The envelope
{
"error": "ticket_not_found",
"message": "ticket not found",
"detail": "ticket not found"
}Prop
Type
Some refusals add fields before those three. They are the machine-readable version of whatever the message says in words:
| Extra field | Appears on | Carries |
|---|---|---|
role, required_role | insufficient_role (403) | The role you have, and the floor you needed. |
field | validation_failed (422) | The single field to mark in a form. |
scope, limit, window_seconds | rate_limited (429) | Which budget you exhausted, and how big it is. |
sqlstate | invalid_data (400) | The Postgres SQLSTATE that rejected the value. |
Match on error, not on message or status
Several distinct identifiers share one status code. 403 alone cannot tell
"your role is too low" from "this key's scopes do not cover the operation" —
and those two send you to different people to fix them. The identifier can.
What each status means here
The three denial codes are used precisely, and the distinction is deliberate:
| Status | Meaning |
|---|---|
400 | The value you sent is malformed, and a handler caught it. |
401 | No resolvable identity, or a credential that failed verification. |
403 | A known member, but below the operation's role floor — or a key whose scopes do not reach it. |
404 | The row does not exist, or it exists in a workspace you are not a member of. |
409 | The request is well-formed but conflicts with current state. |
413 | Body over 4 MiB. |
422 | The request failed schema validation. |
429 | You exhausted a rate budget. Nothing was written. |
5xx | Ours, not yours — except 503, which usually means a dependency is unconfigured or unreachable. |
404 never distinguishes "gone" from "not yours." Existence is not disclosed
across a workspace boundary, because a 403 would confirm the id is real. If an
id you believe in returns 404, check which workspace your credential belongs
to first.
Schema validation is shaped differently
A 422 from the validator is the one case where detail is an array, and
where error is always the generic validation_error rather than something
specific:
curl -sk -X POST https://api.localhost/v1/tickets \
-H "Authorization: Bearer $AURALIS_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"subject": 123, "priority": "nope"}'{
"error": "validation_error",
"message": "subject: Input should be a valid string",
"detail": [
{ "loc": ["body", "subject"], "msg": "Input should be a valid string", "type": "string_type" },
{ "loc": ["body", "priority"], "msg": "Input should be 'low', 'normal', 'high' or 'urgent'", "type": "literal_error" }
]
}message names only the first failure. If you are rendering errors against
a form, read detail, not message.
Unknown fields are rejected, not ignored. Request models are strict, so a
typo'd key is a 422 (extra_forbidden) rather than a silently dropped value.
That is a deliberate trade: it costs you a round trip and saves you a field you
thought you set.
Where the validator catches things a handler would have phrased better
An invalid UUID gives you two different errors depending on where it sits:
# In a query parameter — caught by a handler, gets a specific code
curl -sk "https://api.localhost/v1/tickets?team_id=not-a-uuid"
# 400 {"error":"invalid_uuid","message":"team_id must be a UUID", ...}
# In the path — caught by the parser first, gets the generic code
curl -sk "https://api.localhost/v1/tickets/not-a-uuid"
# 422 {"error":"validation_error","message":"ticket_id: Input should be a valid UUID...", ...}Same for ?overdue=maybe, which is a 422 validation_error, not a filter
error. Handle both shapes if you are validating user-supplied ids.
Three places the envelope does not hold
Stated plainly, because a client that assumes error is always present will
throw on these:
The identifiers you will actually meet
The runtime defines over two hundred identifiers. Most are specific to one surface and are named on that operation's own page. These are the ones a general integration hits.
Credentials and identity
| Identifier | Status | Meaning |
|---|---|---|
missing_tenant_or_agent | 401 | No credential at all. |
api_key_invalid | 401 | Unknown, wrong or malformed key. One code for all three, deliberately. |
api_key_revoked | 401 | The key was revoked. |
api_key_expired | 401 | The key is past its expiry. |
token_malformed | 401 | A bearer credential that is neither a valid JWT nor a sk_zr_ key. |
token_expired | 401 | The access token has expired. Refresh and retry. |
session_expired, session_revoked | 401 | The signed-in session ended. |
user_inactive | 403 | The account exists and is switched off. |
tenant_mismatch | 403 | The workspace header disagrees with the session's workspace. |
A typo'd API key does not say `api_key_invalid`
Only credentials beginning sk_zr_ are treated as keys. Anything else is routed
to the token verifier, so a mistyped key comes back as token_malformed,
which reads like a completely different problem. Check the prefix first.
Permission
| Identifier | Status | Meaning |
|---|---|---|
insufficient_role | 403 | Your workspace role is below the floor. Carries role and required_role. |
api_key_unscoped | 403 | The key has no scopes, so it reaches nothing. |
api_key_out_of_scope | 403 | The key's scopes do not cover this operation. The message names the scope needed. |
api_key_owner_not_member | 403 | The person who owns the key is no longer a member. |
api_key_identity_only | 403 | The operation answers "for a person". No scope makes a key work here. |
These are two independent axes — see Roles and permissions.
Input
| Identifier | Status | Meaning |
|---|---|---|
validation_error | 422 | Schema failure. detail is an array. |
invalid_uuid | 400 | A query parameter that must be a UUID is not one. |
invalid_status, invalid_priority, invalid_source, invalid_sort | 400 | A filter value outside the allowed set. The message names the value. |
no_fields_to_update | 400 | A PATCH body with nothing editable in it. |
body_too_large | 413 | Over 4 MiB. |
body_too_deep | 422 | JSON nested deeper than 64 levels. |
An unknown filter value is an error, not an empty result. That is on purpose:
?status=opne returning zero rows would read as "no tickets", which is a much
more expensive mistake than a 400.
State
| Identifier | Status | Meaning |
|---|---|---|
ticket_not_found | 404 | No such ticket here. |
illegal_transition | 409 | That status move is not legal from the current one. The message lists the legal ones. |
ticket_terminal | 409 | The ticket is solved, closed, spam or merged. Reopen it first. |
merge_self | 400 | You tried to merge a ticket into itself. |
team_name_taken, macro_name_taken, customer_email_taken | 409 | A uniqueness collision. |
illegal_transition is worth catching specifically, because its message
enumerates what is legal:
{
"error": "illegal_transition",
"message": "illegal transition: open → open (legal: ['closed', 'on_hold', 'pending', 'solved', 'spam'])"
}Better still, read legal_next_states off the ticket before you try — every
ticket carries it.
Throttling
rate_limited (429) is the only one. It always means "come back later", never
"rejected" — see Rate limits.
Retrying
| Status | Retry? |
|---|---|
400, 403, 404, 409, 413, 422 | No. Replaying the identical request gives the identical answer. Fix the request. |
401 | Only after refreshing the credential. A retry loop on a bad key will burn a verification budget and lock you out — see Rate limits. |
429 | Yes, after Retry-After seconds. Nothing was written. |
502, 503, 504 | Yes, with backoff. A dependency is unreachable. |
500 | No. Report it, quoting x-request-id. |
Every response carries an x-request-id header. It is the only handle that ties
your call to the runtime's logs, so capture it on failures.