AuralisDocs

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 fieldAppears onCarries
role, required_roleinsufficient_role (403)The role you have, and the floor you needed.
fieldvalidation_failed (422)The single field to mark in a form.
scope, limit, window_secondsrate_limited (429)Which budget you exhausted, and how big it is.
sqlstateinvalid_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:

StatusMeaning
400The value you sent is malformed, and a handler caught it.
401No resolvable identity, or a credential that failed verification.
403A known member, but below the operation's role floor — or a key whose scopes do not reach it.
404The row does not exist, or it exists in a workspace you are not a member of.
409The request is well-formed but conflicts with current state.
413Body over 4 MiB.
422The request failed schema validation.
429You exhausted a rate budget. Nothing was written.
5xxOurs, 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

IdentifierStatusMeaning
missing_tenant_or_agent401No credential at all.
api_key_invalid401Unknown, wrong or malformed key. One code for all three, deliberately.
api_key_revoked401The key was revoked.
api_key_expired401The key is past its expiry.
token_malformed401A bearer credential that is neither a valid JWT nor a sk_zr_ key.
token_expired401The access token has expired. Refresh and retry.
session_expired, session_revoked401The signed-in session ended.
user_inactive403The account exists and is switched off.
tenant_mismatch403The 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

IdentifierStatusMeaning
insufficient_role403Your workspace role is below the floor. Carries role and required_role.
api_key_unscoped403The key has no scopes, so it reaches nothing.
api_key_out_of_scope403The key's scopes do not cover this operation. The message names the scope needed.
api_key_owner_not_member403The person who owns the key is no longer a member.
api_key_identity_only403The operation answers "for a person". No scope makes a key work here.

These are two independent axes — see Roles and permissions.

Input

IdentifierStatusMeaning
validation_error422Schema failure. detail is an array.
invalid_uuid400A query parameter that must be a UUID is not one.
invalid_status, invalid_priority, invalid_source, invalid_sort400A filter value outside the allowed set. The message names the value.
no_fields_to_update400A PATCH body with nothing editable in it.
body_too_large413Over 4 MiB.
body_too_deep422JSON 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

IdentifierStatusMeaning
ticket_not_found404No such ticket here.
illegal_transition409That status move is not legal from the current one. The message lists the legal ones.
ticket_terminal409The ticket is solved, closed, spam or merged. Reopen it first.
merge_self400You tried to merge a ticket into itself.
team_name_taken, macro_name_taken, customer_email_taken409A 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

StatusRetry?
400, 403, 404, 409, 413, 422No. Replaying the identical request gives the identical answer. Fix the request.
401Only after refreshing the credential. A retry loop on a bad key will burn a verification budget and lock you out — see Rate limits.
429Yes, after Retry-After seconds. Nothing was written.
502, 503, 504Yes, with backoff. A dependency is unreachable.
500No. 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.

On this page