Last updated
Errors
Every non-2xx response is a JSON envelope with the same four fields. Branch on code, show message to humans, log requestId, and retry only when retryable is true.
The envelope
429 Too Many Requests
{ "code": "rate_limited", "message": "Too many requests for this workspace. Retry after 12 seconds.", "requestId": "req_01J9X4R7Q2M8V3K6N5P1T0ZWAB", "retryable": true}| Field | Type | Meaning |
|---|---|---|
code | string | Stable, machine-readable identifier in snake_case. Branch on this, never on message. |
message | string | Human-readable explanation in English. Safe to show to an operator; wording may change. |
requestId | string | Unique id of the request. Include it when you contact support; it is the only way we can find the request in our logs. |
retryable | boolean | true when the same request may succeed later without changes (rate limits, transient failures). Everything else needs a different request. |
Status codes still matter
The HTTP status is the coarse class (4xx yours, 5xx ours);
code is the fine reason. A client that only looks at the status loses the distinction between an empty wallet (402) and a missing scope (403).Handling errors
The pattern below is what the RoomScope dashboard itself uses: parse the envelope, fall back to http_error when the body is not JSON, and retry with exponential backoff only when the server marks the error retryable.
class ApiError extends Error { constructor(status, { code, message, requestId, retryable }) { super(message); Object.assign(this, { status, code, requestId, retryable: Boolean(retryable) }); }} async function call(path, init) { const res = await fetch(`https://api.roomscope.io${path}`, init); if (res.ok) return res.status === 204 ? undefined : res.json(); let envelope = {}; try { envelope = await res.json(); } catch {} throw new ApiError(res.status, { code: "http_error", message: `Request failed (${res.status})`, ...envelope });} // Retry only when the server says so.async function withRetry(fn, attempts = 3) { for (let i = 0; ; i++) { try { return await fn(); } catch (e) { if (!(e instanceof ApiError) || !e.retryable || i === attempts - 1) throw e; await new Promise((r) => setTimeout(r, 500 * 2 ** i)); } }}Codes by status
The status column is stable. The code names below describe the current behaviour and are marked Subject to change until the OpenAPI document publishes the final list.
| Status | Code Subject to change | When | Retryable |
|---|---|---|---|
| 400 | validation_error | A parameter is missing or malformed. The message names the field. | no |
| 401 | unauthorized | Missing, malformed, revoked or expired API key. | no |
| 402 | payment_required | The wallet is empty, or the plan does not allow this read. | no |
| 403 | forbidden | The key lacks the scope, or the plan does not include the resource. | no |
| 404 | not_found | No such resource (creator, session, rule, …). | no |
| 409 | conflict | The write collides with the current state (duplicate rule, stale version). | no |
| 429 | rate_limited | Too many requests. Honour Retry-After. | yes |
| 5xx | internal_error | Something failed on our side. Retry with backoff; include the requestId when you report it. | yes |
Rate limits have their own page: Rate limits.
Was this page helpful?