Errors
Real error response format of the Axuz API, HTTP status map, the resource 404 versus the route 404, tracing with X-Trace-Id and when it is safe to retry a call.
Every error response comes with an HTTP status and, almost always, a small JSON body. Decide what to do from the status; use the body text to show to the user or to log, not as a contract.
The format
The canonical format is an object with an error key:
{ "error": "task not found" }
This holds for the main service and for the workspace, calendar, forms, files and billing services. Some errors add fields of their own next to error:
| Situation | Body |
|---|---|
Validation 422 (UnprocessableEntity helper) | {"error": "name_required"}, optionally with "message" carrying the detail |
Personal token 429 | {"error": "rate_limited", "limit": 60, "count": 61, "retry_after": 12} |
Mutation 429 on team routes | {"error": "rate_limited", "retry_after": 12} |
IP-block 429 on the authentication service | {"error": "ip_blocked", "retry_after_seconds": 300} |
503 when the limiter is down (fail-closed mode) | {"error": "rate_limiter_unavailable"} |
409 for a custom field that is in use | {"error": "field_has_values"} |
The OAuth endpoints (/oauth/token, /oauth/revoke, /oauth/introspect) follow the OAuth 2 error format (RFC 6749), with error as a short code such as rate_limited; see Authentication.
The error text is not a stable code
The value of error is one of two things: a slug (board_not_found, name_required, field_has_values) or a short English phrase (task not found, missing team membership, invalid body). Slugs tend to be stable; phrases can be reworded without notice. So:
- branch on the HTTP status (and at most on slugs the reference names);
- never do
error.includes("not found")to decide control flow; - show the user a message of your own, and keep the original
errorin your log.
In some older handlers of the main service a database failure may still arrive as a 500 with the raw driver text in error. Do not depend on it, do not show it to users and do not parse it: the workspace service already returns only internal error and records the detail on the server.
Status map
| Status | Meaning | Real error examples | What to do |
|---|---|---|---|
400 | Malformed request: invalid JSON, invalid id, invalid parameter | invalid body, name required, invalid teamId, invalid before, at least one scope required | Fix the request. Do not resend it unchanged. |
401 | No valid credential: missing, expired or revoked token, or ended session | unauthorized | Renew or recreate the credential. Do not resend unchanged. |
403 | Authenticated, but not allowed | missing team membership, insufficient team role: requires admin, scope required: me:read, api key not accepted on this route | A role, scope or membership is missing. Retrying will not help. |
404 | The resource (or route) does not exist, or is not visible to you | task not found, team not found, board_not_found, no draft | See the next section. |
409 | Conflicts with the current state | slug already taken, an export is already queued or running for this scope, field_has_values | Resolve the conflict; re-reading the resource is usually the first step. |
413 | Body too large | body too large | Shrink the body (or send files through the upload flow). |
422 | Valid JSON, but a business rule is violated | name_required, invalid_range | Fix the data. Do not resend unchanged. |
429 | Request limit reached | rate_limited | Wait for Retry-After. See Rate limits. |
500 | Internal failure | internal error | May be retried with backoff, only if the operation is safe (below). |
503 | Dependency unavailable or limiter in fail-closed mode | rate_limiter_unavailable, an idempotency-guard-unavailable message | Retry with backoff; honour Retry-After when present. |
Two details that often cause confusion. First, the calendar service sometimes writes its message in Portuguese (cursor inválido), because the human-readable text varies per service. Second, a denied authorization is not always a 403: to avoid revealing that resources of other teams exist, several routes answer 404 when the resource exists but belongs to another tenant.
The singular-resource 404 and the route 404
A 404 has two meanings that the client must tell apart.
Contractual 404. Some routes that return one resource per scope answer 404 with a slug when the resource simply has not been created yet. This is by design, not a failure. Two real cases:
- GET
/teams/{teamId}/timelines/{timelineId}/boardanswers404with{"error": "board_not_found"}when the channel has no board yet. The right flow is to offer “create board” and call thePOSTon the same path, which is idempotent: it returns201on creation and200with the existing board if there already was one. - GET
/teams/{teamId}/timelines/{timelineId}/draftanswers404with{"error": "no draft"}when there is no saved draft for that channel (andparent_post_id). Treat it as “empty”, not as an error.
Route 404. A path the API does not know (a typo, the wrong version, the wrong service) also yields 404. The main and workspace services have no custom 404 handler: the response comes from the router, normally as plain text (404 page not found), with no JSON.
In practice, the client should handle it like this:
const res = await fetch(url, { headers });
if (res.status === 404) {
const type = res.headers.get('content-type') ?? '';
if (!type.includes('application/json')) {
throw new Error(`no such route: ${url}`); // your bug, do not retry
}
const { error } = await res.json();
if (error === 'board_not_found') return null; // singular resource not created yet
throw new Error(`resource not found: ${error}`);
}
Tracing: X-Trace-Id
The main service returns the X-Trace-Id header on every response. If you send your own X-Trace-Id (up to 128 characters, with no quotes, backslashes or line breaks) it is preserved; otherwise the server generates a UUID. The value shows up in server logs and is what support uses to find the exact line of a failure. Store it with any 5xx you log or report.
curl -sS -i https://api.axuz.net/me/summary \
-H "Authorization: Bearer $AXUZ_TOKEN" \
-H "X-Trace-Id: my-job-2026-10-22-0001" | grep -i '^x-trace-id'
The workspace service correlates internal errors by the router’s request identifier, but it is not documented that it returns it in a response header; do not rely on that.
Retrying safely
General rule: retry only when the failure is transient and the operation is idempotent.
- Always safe to retry:
GET,HEAD,PUTandDELETE(the end result is the same; a secondDELETEmay return404because the resource is already gone, and that counts as success), on statuses429,500,502,503,504and on network failure, with exponential backoff and jitter. - Never retry blindly: a
POSTthat creates something. If the connection drops after you sent it, you cannot know whether the server processed it. On routes that accept theIdempotency-Keyheader, send a unique key per operation; a retry returns the same response withIdempotent-Replay: true. The same key with a different body gives422, and the same key still in flight gives409. Routes without that support must be reconciled by reading state (for example, listing before recreating). - Never retry:
400,401,403,404,409,413and422. The same request will produce the same response.
import random
import time
RETRY_STATUS = {429, 500, 502, 503, 504}
def backoff(attempt: int, retry_after: float | None) -> float:
"""Wait: honour Retry-After; otherwise exponential with full jitter."""
if retry_after is not None:
return retry_after + random.uniform(0, 0.5)
return random.uniform(0, min(30, 0.5 * 2 ** attempt))
The complete example, reading Retry-After, is in Rate limits.