Sheet Zaber · Reference

/api/v1 documentation

The contract in docs/backend-api-contract.md §1, rendered from the same snippet builders the playground uses. Private platform, public documentation — no signup on these pages.

Docs · 06 Error reference

Error reference

Twenty-one codes are documented for /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-After for rate_limited and google_quota_exceeded. Reauth needs the owner's consent; an inaccessible sheet needs its access repaired. A shared google category 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.

Statuserror.codeCategoryWhat the caller does
400validation_failedvalidationThe 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.
400invalid_rangevalidationThe /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.
400invalid_jsonvalidationThe 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.
401missing_keyauthNo Authorization: Bearer header arrived. Add it — and do not fall back to ?key=, which §1.1 explicitly does not support.
401invalid_keyauthThe 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.
403key_scope_insufficientauthA 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.
403key_restrictedauthThe 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.
403api_disabledauthThe 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.
403operation_not_allowedauthThe 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.
403write_requires_primary_keyauthA 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.
404api_not_foundnot_foundThe path segment names no API. Check it against api_configurations.id, which is the same value meta.api_id echoes on a success.
404record_not_foundnot_foundZero 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.
409ambiguous_recordvalidationMore 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.
413body_too_largevalidationThe 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.
422google_sheet_inaccessiblegoogle/validationThe 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.
422tab_not_foundgoogle/validationThe 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.
422header_mismatchgoogle/validationA 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.
429rate_limitedrate_limitA 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.
503google_reauth_requiredgoogleThe 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.
503google_quota_exceededgoogleGoogle 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.
500internal_errorinternalThe 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
curl 'https://sheet.zaber.tech/api/v1/<api-id>/<record>' \
  -H "Authorization: Bearer sz_live_…"
403 · §1.3 Single record
{
  "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.categoryStatusWhat the class means
validation400Malformed or impossible input; nothing was read or written.
auth401The caller itself is not established. §4.4's carve-out below is the only 403.
not_found404The named resource does not exist for this caller.
rate_limit429A documented window is full; the wait is in Retry-After.
google503Upstream Google problem — retryable, never a caller bug.
internal500Anything else, with no detail in the body by design.

The two documented exceptions

  • forbidden → 403. §4.4 calls this its one carve-out from auth = 401: a valid cookie with an insufficient role must not be reported as a broken cookie.
  • method_not_allowed → 405 with Allow. Its category is validation, 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 calls forbidden the 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.

Statuserror.codeCategoryWhat the caller does
400validation_failedvalidationInput rejected by zod; fields carries the per-field messages.
401unauthenticatedauthNo sz_session cookie, one that fails signature or expiry verification, or one whose user row is gone or disabled. Sign in again.
403forbiddenauthA 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.
405method_not_allowedvalidationA verb the route does not export; §4.4 states it is answered 405 with Allow. Use a listed verb.
401invalid_invitationauthUnknown, expired, revoked or exhausted code — byte-identical in all four cases so the route is not an oracle. Ask for a fresh invitation.
401already_registeredauthRedemption collided with the unique Google subject or the partial unique on email. Sign in with the account that already holds it.
401pending_expiredauthA 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.
429rate_limitedrate_limitThe invitation bucket is full — 5 per 15 minutes per pending cookie and per IP . fields.retry_after carries the seconds.
401invalid_keyauthOn 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.
404not_foundnot_foundA mutation id naming no row the session user owns; identical for “does not exist” and “not yours”.
503google_reauth_requiredgoogleThe action needs a connection whose status is reauth_required . Re-consent.
503google_not_connectedgoogleThere 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.
503google_sheet_inaccessiblegoogleThe 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.
503google_unavailablegoogleA Google call failed for a non-auth reason (network, 5xx, quota). §4.4 marks it retryable.
500playground_unreachableinternal§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.
500internal_errorinternalThe 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 the Retry-After header, in seconds. Internally, §4.4 has the same number as fields.retry_after so a route and a server action read one value from one place. A /api/v1 caller reads the header.
  • google_quota_exceeded. §1.5 says the engine passes Google's own Retry-After through 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.
  • fields is 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.