Envelope & errors

The shared REST envelope: meta fields, success and error shapes, and the full ErrorCode-to-HTTP-status table.

🔗Envelope & errors

Every /api/v1 response the daemon returns is wrapped in one of two envelopes. Success bodies carry their payload under data; error bodies carry an error detail under error. Both always include a meta block with the API version, a correlation ID, and a generation timestamp. This page is the canonical contract for those shapes and for the ErrorCode to HTTP status mapping. Every endpoint in the REST reference returns one of these two envelopes, so the shapes are documented once here and linked from each route.

The whole envelope lives in a single file, api/envelope.rs, and is generated by ApiResponse<T> (success) and ApiError (failure). There is no per-endpoint variation in the wrapper.

🔗Success envelope

A successful response is { data, meta }. The data field holds the endpoint-specific payload; the meta block is identical in shape across every response.

{
  "data": {
    "id": "borealis",
    "name": "Borealis",
    "category": "ambient"
  },
  "meta": {
    "api_version": "1.0",
    "request_id": "req_0190d4c8-7e21-7c3a-9f0b-3a2e8c1d4f56",
    "timestamp": "2026-06-24T18:03:11.482Z"
  }
}

The daemon emits three success statuses, depending on the operation:

StatusBuilderWhen
200 OKApiResponse::okRead succeeded, or a mutation completed inline.
201 CreatedApiResponse::createdA new resource was created (scene, profile, layout).
202 AcceptedApiResponse::acceptedWork was queued and will complete asynchronously.

All three carry the same { data, meta } body. The status line is the only difference, so a client that only inspects the body sees a uniform shape.

🔗Error envelope

A failed response is { error, meta }. The top-level key is error, not data, so the presence of data versus error is the cleanest discriminator a client can branch on.

{
  "error": {
    "code": "validation_error",
    "message": "speed must be between 0.0 and 2.0",
    "details": {
      "field": "speed",
      "min": 0.0,
      "max": 2.0
    }
  },
  "meta": {
    "api_version": "1.0",
    "request_id": "req_0190d4c8-9a13-7b88-a0f2-5e6d7c8b9a01",
    "timestamp": "2026-06-24T18:03:12.118Z"
  }
}

The error object has three fields:

FieldTypeNotes
codestringA machine-readable ErrorCode, serialized snake_case. See the table below.
messagestringA human-readable description. Safe to surface in a UI, not meant for branching logic.
detailsobject or nullOptional structured context: the offending field, conflicting IDs, validation bounds, retry timing. Present only when the daemon has extra context to attach.

Branch on code, render message, and read details when you need the specifics. Never parse message to decide control flow; it is prose and can change.

The discriminator is the top-level key. If the body has a data field you have a success; if it has an error field you have a failure. You do not need to inspect the HTTP status first, though the status will always agree with the code.

🔗The meta block

The meta block is attached to every envelope, success or error, and never varies in shape.

FieldExampleMeaning
api_version"1.0"The contract version of the response body. This is a fixed string and is not the same as the v1 URL segment. The URL path versions the routes; api_version versions the envelope.
request_id"req_0190d4c8-7e21-7c3a-9f0b-3a2e8c1d4f56"A per-request correlation ID, always prefixed req_ followed by a UUID v7. It is not a bare UUID. UUID v7 is time-ordered, so request IDs sort chronologically. Log it and quote it in bug reports to trace a single request through the daemon.
timestamp"2026-06-24T18:03:11.482Z"ISO 8601 UTC with millisecond precision and a trailing Z. This is the response generation time, not the request arrival time.

api_version is the literal string "1.0". It is not "v1", not "1", and not the URL segment. Do not assert equality against the path version. The two evolve independently.

🔗Error codes and HTTP status

Every error maps to exactly one HTTP status. The code string in the body and the HTTP status line always agree, so a client can switch on either. The full set of codes lives in the ErrorCode enum in api/envelope.rs; this is the complete mapping.

codeHTTP statusMeaning
bad_request400 Bad RequestMalformed request: bad JSON, missing required field, unparseable parameter.
unauthorized401 UnauthorizedMissing or invalid credentials. See auth & security.
forbidden403 ForbiddenCredentials are valid but lack the permission for this operation (for example, a read-only key attempting a write).
not_found404 Not FoundThe resource does not exist: unknown effect ID, scene ID, device ID.
conflict409 ConflictA state conflict, including optimistic-concurrency failures where a revision token no longer matches.
payload_too_large413 Payload Too LargeThe request body exceeds the size limit (asset and attachment uploads).
unsupported_media_type415 Unsupported Media TypeThe Content-Type is not accepted for this endpoint.
validation_error422 Unprocessable EntityThe request was well-formed but semantically invalid: a value out of range, an incompatible combination of fields.
rate_limited429 Too Many RequestsThe request was throttled by the rate limiter. See below for retry headers.
internal_error500 Internal Server ErrorAn unexpected daemon-side failure. The message is intentionally generic; the request_id is your key to the daemon logs.

validation_error maps to 422, not 400. A well-formed request that fails a semantic check (a speed outside its bounds, a zone that cannot accept an output) is a 422. Reserve 400 (bad_request) for requests the daemon could not parse at all. Clients that treat every client-side error as 400 will mis-handle validation failures.

🔗Status flow

The same ErrorCode value drives both the body and the response status. There is no path where they disagree.

graph LR
  H[Handler] -->|ApiError::validation| C[ErrorCode::ValidationError]
  C -->|serialize| B["body.error.code = validation_error"]
  C -->|status map| S["HTTP 422"]

🔗Rate-limit responses

A rate_limited error (HTTP 429) carries extra signal beyond the envelope. The daemon attaches a details.retry_after field (seconds until the window resets) and a set of response headers so a client can back off precisely instead of guessing.

{
  "error": {
    "code": "rate_limited",
    "message": "discovery rate limit exceeded. Retry in 12 seconds.",
    "details": {
      "retry_after": 12
    }
  },
  "meta": {
    "api_version": "1.0",
    "request_id": "req_0190d4c8-c0de-7a44-8b91-77aa55ee33cc",
    "timestamp": "2026-06-24T18:03:14.902Z"
  }
}

The accompanying headers:

HeaderMeaning
Retry-AfterSeconds to wait before retrying. Mirrors details.retry_after. Present only when a wait is required.
X-RateLimit-LimitThe request budget for the current window.
X-RateLimit-RemainingRequests left in the current window.
X-RateLimit-ResetUnix epoch seconds at which the window resets.

Prefer Retry-After (or the equal details.retry_after) for backoff timing. Throttling is a property of specific operation classes, so a 429 on one endpoint does not mean every endpoint is throttled.

Rate limiting follows the scale-not-nerf rule: it protects shared hardware paths like device discovery, not ordinary reads. Loopback clients (a local CLI, TUI, or the web UI) generally never see a 429 in normal use.

🔗Handling errors in a client

A robust client follows the same three steps regardless of language.

  1. Check for the error key (or equivalently, a non-2xx status). If present, you have a failure envelope.
  2. Switch on error.code. The string is stable; the HTTP status is its mirror.
  3. Read error.details for structured context, and on a 429 honor Retry-After. Always capture meta.request_id so a failure can be correlated with the daemon logs.
# Inspect the envelope of any endpoint with curl + jq.
curl -s http://localhost:9420/api/v1/effects/does-not-exist \
  | jq '{status_code: .error.code, message: .error.message, request_id: .meta.request_id}'
# => { "status_code": "not_found", "message": "...", "request_id": "req_..." }

For the routes that return these envelopes, see the REST API reference. For the real-time channel and its own framing, see the WebSocket protocol.