Error reference
/api/v1 in docs/backend-api-contract.md §1.2, across ten status classes, and every one of them arrives in the same body: { error: { code, message, category } }. This page reproduces that table, adds what the caller should actually do, and then shows the category-to-status map §4.4 defines — including where the two documents do not line up.- Public codes
- 21, in §1.2's table
- Body on every refusal
- code + message + category
- Categories
- auth · rate_limit · validation · google · not_found · internal
- The 429 extra
- Retry-After header, in seconds
6.1Branch on the code, decide on the category
docs/backend-api-contract.md §1.2 · §4.4
code is the specific reason and is stable enough to switch on. category groups failures, but retry decisions also need the specific code and the operation: not every Google failure is fixed by waiting.message is for a log line or a support ticket: §4.4 requires it never to contain SQL, stack traces, tokens, keys, cell contents or another user's data, so it is safe to display and useless to parse.
- Wait or repair, according to the code: honour
Retry-Afterforrate_limitedandgoogle_quota_exceeded. Reauth needs the owner's consent; an inaccessible sheet needs its access repaired. A sharedgooglecategory does not make these interchangeable. - Not retryable as sent:
validation,auth,not_found. A retry of the identical request produces the identical refusal. - Do not blindly replay writes after a 500. The contract gives no idempotency-key guarantee. Check whether the write took effect before retrying; report persistent failures.
- The action column below is guidance, not contract. Statuses, code names and categories are §1.2's and §4.4's own.
6.2The public codes
docs/backend-api-contract.md §1.2, every row of the table
The status column is §1.2's. Its grouped 422 row says google/validation without assigning a category to each code. This is source notation, not a valid literal ErrorCategoryfrom §4.4; the per-code assignment remains unspecified in that table.
| Status | error.code | Category | What the caller does |
|---|---|---|---|
| 400 | validation_failed | validation | The request asked for something the engine can check without touching Google: a bad page value, page mixed with offset, an unknown column in a filter or sort, an empty q on search. Fix the query or the body; retrying it unchanged answers the same way. |
| 400 | invalid_range | validation | The /range/… segment failed the A1 regex or fell outside the tab's bounds. Re-derive the notation from the tab you are reading and check the sheet name against the configured tab. |
| 400 | invalid_json | validation | The body did not parse as JSON. Send a parsed object (or array, where the operation allows one) rather than a form-encoded or truncated payload. |
| 401 | missing_key | auth | No Authorization: Bearer header arrived. Add it — and do not fall back to ?key=, which §1.1 explicitly does not support. |
| 401 | invalid_key | auth | The key is not one the engine will honour: revoked, expired, or not the key of that API's user. §1.2 and docs/security.md §2 keep this message identical for all three so the endpoint stays a bad-key check and not an existence oracle. Rotate one at /keys. |
| 403 | key_scope_insufficient | auth | A read key was used on a write verb, or a write key on a read (§1.1 maps read to GET and write to POST/PATCH/PUT/DELETE). Use a key with the scope the verb needs. |
| 403 | key_restricted | auth | The key's allowed_api_ids is set and this API is not in it . Use a key authorised for this API. The API's allowed_operations is a separate control; a disallowed verb is operation_not_allowed, not a key restriction. |
| 403 | api_disabled | auth | The API exists but is switched off, so the engine refuses before reading the tab. Nothing a client can retry — the owner re-enables it in the dashboard. |
| 403 | operation_not_allowed | auth | The operation is not available for that API. §1.3 names the documented case: a single-record read on an API with no primary_key_column, where the message tells you to set a primary key or use list plus filter. |
| 403 | write_requires_primary_key | auth | A write was attempted against an API the engine cannot resolve a row for. §1.3 makes every PATCH, PUT and DELETE a primary-key lookup, so the fix is the same configured primary key. |
| 404 | api_not_found | not_found | The path segment names no API. Check it against api_configurations.id, which is the same value meta.api_id echoes on a success. |
| 404 | record_not_found | not_found | Zero rows matched the primary-key value. §1.3: matching is exact and case-sensitive, so retry only with the value as the sheet stores it, or read it back from a list call. |
| 409 | ambiguous_record | validation | More than one row matched (§1.2 names the case: PK matched >1 row; §1.3 gives 409 for a single read). Nothing was written — §1.5 promises ambiguity always 409s rather than silently touching two rows. Make the key unique, or address the row with list plus filter. |
| 413 | body_too_large | validation | The request body crossed the 1 MB ceiling §1.2 names. Split the work into smaller requests; §1.3 also caps a create array at 500 objects per request. |
| 422 | google_sheet_inaccessible | google/validation | The spreadsheet behind the API could not be read. docs/google-integration.md §3 ties this to spreadsheets.get returning 403/404 at register or refresh time, and docs/database-schema.md records the state as an API whose snapshot is unusable. The owner re-checks the sheet and the connection. |
| 422 | tab_not_found | google/validation | The configured tab no longer exists in the spreadsheet — it was renamed or deleted after registration. Repoint the API at the tab, or rename the tab back. |
| 422 | header_mismatch | google/validation | A write body used keys that are not in the header row; §1.2 says the response lists the offenders, and §1.3 confirms missing keys are allowed while unknown ones are not. Align the object's keys to the header row and resend. |
| 429 | rate_limited | rate_limit | A documented window is full. §1.2 and §1.4 both put the wait in the Retry-After header, in seconds — wait that long and resend; do not retry immediately and do not fan out. See the Rate limits page. |
| 503 | google_reauth_required | The owner's Google grant needs a fresh consent. §1.5 describes the engine's side: one token refresh and retry, and this code when that fails. A client cannot fix it — the owner re-connects, and the request is safe to retry once they have. | |
| 503 | google_quota_exceeded | Google itself throttled the call. §1.5 states the engine passes the Google Retry-After through rather than inventing a number, so honour the header and retry after it. | |
| 500 | internal_error | internal | The engine failed in a way it does not describe to callers — §1.2 notes the message never leaks internals. Keep the status, code and request time for support. For a failed write, check the sheet before retrying: the contract provides no idempotency-key guarantee. |
A refusal, end to end
The single-record read below is the documented way to reach operation_not_allowed: §1.3 requires primary_key_column to be configured for that route, and answers 403 when it is not.
curl 'https://sheet.zaber.tech/api/v1/<api-id>/<record>' \ -H "Authorization: Bearer sz_live_…"
{
"error": {
"code": "operation_not_allowed",
"message": "<the message names the fix: set a primary key, or list and filter>",
"category": "auth"
}
}Only the code, the category and the status are from the contract; the message text is a paraphrase of §1.3's note, so match on the code.
6.3How a category becomes a status
docs/backend-api-contract.md §4.4 · src/lib/action-result.ts (CATEGORY_STATUS, statusFor)
§4.4 documents the map the dashboard's JSON routes use: the wrapper in src/server/http/internal-route.ts reads a failure through statusFor, which is a lookup in CATEGORY_STATUS with 500 as the fallback for a category the map does not hold.
| error.category | Status | What the class means |
|---|---|---|
validation | 400 | Malformed or impossible input; nothing was read or written. |
auth | 401 | The caller itself is not established. §4.4's carve-out below is the only 403. |
not_found | 404 | The named resource does not exist for this caller. |
rate_limit | 429 | A documented window is full; the wait is in Retry-After. |
google | 503 | Upstream Google problem — retryable, never a caller bug. |
internal | 500 | Anything else, with no detail in the body by design. |
The two documented exceptions
forbidden→ 403. §4.4 calls this its one carve-out fromauth = 401: a valid cookie with an insufficient role must not be reported as a broken cookie.method_not_allowed→ 405 withAllow. Its category isvalidation, which the map alone answers 400, and §4.4 states 405 for it in the code table. So the exceptions listed by §4.4 are two, even though the status-mapping row callsforbiddenthe only carve-out.
6.4The dashboard's own code set
docs/backend-api-contract.md §4.4, internal code table
/api/internal/** is the signed-in dashboard's surface: session cookie, guards re-checked per route, success serialised as { data: … } with no meta . Its codes share the same category vocabulary as the public API, and §4.4's reuse rule says an internal failure uses an existing code when one fits and a new code only when none does. This table is here for completeness and because §4.4 is where the status map lives — an /api/v1 integration will not receive these. Six code names do appear in both tables (validation_failed, invalid_key, rate_limited, google_sheet_inaccessible, google_reauth_required, internal_error); the Status column below is §4.4's map applied to each category, with the two exceptions §4.4 states by name.
| Status | error.code | Category | What the caller does |
|---|---|---|---|
| 400 | validation_failed | validation | Input rejected by zod; fields carries the per-field messages. |
| 401 | unauthenticated | auth | No sz_session cookie, one that fails signature or expiry verification, or one whose user row is gone or disabled. Sign in again. |
| 403 | forbidden | auth | A valid session whose role is not enough (§4.4: answered 403, never 401, because 401 would claim a working cookie is broken). Ask an owner for the role. |
| 405 | method_not_allowed | validation | A verb the route does not export; §4.4 states it is answered 405 with Allow. Use a listed verb. |
| 401 | invalid_invitation | auth | Unknown, expired, revoked or exhausted code — byte-identical in all four cases so the route is not an oracle. Ask for a fresh invitation. |
| 401 | already_registered | auth | Redemption collided with the unique Google subject or the partial unique on email. Sign in with the account that already holds it. |
| 401 | pending_expired | auth | A missing, tampered or past-exp sz_pending cookie. §4.4: the fix is a round trip to /auth/google, not a retry of the code; the error carries nothing. |
| 429 | rate_limited | rate_limit | The invitation bucket is full — 5 per 15 minutes per pending cookie and per IP . fields.retry_after carries the seconds. |
| 401 | invalid_key | auth | On the internal surface this is a playground key id that is absent or not owned by the session user — one shape for both, no existence oracle. |
| 404 | not_found | not_found | A mutation id naming no row the session user owns; identical for “does not exist” and “not yours”. |
| 503 | google_reauth_required | The action needs a connection whose status is reauth_required . Re-consent. | |
| 503 | google_not_connected | There is no google_connections row at all — the fix is /auth/google, not re-consent, which is exactly why §4.4 keeps it distinct from google_reauth_required. | |
| 503 | google_sheet_inaccessible | The same code name, two documented statuses: §4.4 files it under google, which the internal map answers 503, while §1.2 lists it at 422 on the public surface. Read the status off the response you actually got. | |
| 503 | google_unavailable | A Google call failed for a non-auth reason (network, 5xx, quota). §4.4 marks it retryable. | |
| 500 | playground_unreachable | internal | §4.4 is blunt: the engine produced no answer for a playground call, and fabricating a status: 500 is forbidden — this code is the only acceptable outcome when the handler throws before responding. |
| 500 | internal_error | internal | The fixed sentence from internalError(): “The request could not be completed.” No detail field exists by design; the cause stays in the server log. |
Codes that never cross over
§4.4 closes the list by naming the five codes that belong to the public engine and never appear on /api/internal/**: api_not_found, invalid_range, operation_not_allowed, key_restricted, key_scope_insufficient. If you are handling one of those, you are on /api/v1.
6.5Two refusals that carry something extra
docs/backend-api-contract.md §1.2 · §1.4 · §1.5 · §4.4
rate_limited. On/api/v1§1.2 and §1.4 both put the wait in theRetry-Afterheader, in seconds. Internally, §4.4 has the same number asfields.retry_afterso a route and a server action read one value from one place. A/api/v1caller reads the header.google_quota_exceeded. §1.5 says the engine passes Google's ownRetry-Afterthrough rather than inventing a number — so the header you get is Google's, not ours.
6.6What a 500 tells you, and what it refuses to tell you
docs/backend-api-contract.md §1.2 · §4.4 · src/lib/action-result.ts internalError()
§1.2's 500 row says the message never leaks internals, and §4.4 explains the mechanism: a handler that throws is answered 500 internal_error by the wrapper after the message is logged server-side, the cause is never serialised into the body, and internalError() has no detail field by design. The one documented message is the fixed sentence “The request could not be completed.”
The useful half of that is what §1.5 adds: every request is logged, including 401s and 404s, and logs contain no keys, headers or bodies. So a 500 is not a dead end — it is a row with a timestamp and a duration on it, which is what meta.duration_ms matches, and enough for an owner to find in the dashboard's log view.
6.7Not documented, so not promised
docs/backend-api-contract.md §1.2 · §3 · docs/integration-status.md
- There is no documented error-code catalogue separate from §1.2's table, so this page lists exactly those twenty-one and no more. A code you see that is not here is not part of the contract.
- There is no documented error body for a 2xx, and none for a redirect: §1.2 defines the error envelope as all non-2xx.
fieldsis documented in §4.4 for the internal envelope. The public §1.2 error shape shows three members; this page does not add a fourth to/api/v1.- Per docs/integration-status.md, the public engine's behaviour is recorded on the Measured-versus-asserted board as asserted, not measured. These codes are the contract's, reproduced faithfully; the board says which of them have been seen on a wire.