Quickback Docs

Errors

HTTP error responses and status codes returned by the generated API, organized by security layer.

The generated API returns structured error responses with consistent fields across all security layers.

Status Codes

CodeMeaningWhen
200OKSuccessful GET, PATCH, DELETE
201CreatedSuccessful POST
207Multi-StatusBatch operation with mixed results
400Bad RequestGuard violation, validation error, invalid input, malformed record id (INVALID_ID)
401UnauthorizedMissing or expired authentication
403ForbiddenAccess denied (wrong role, condition failed) or firewall blocked it
404Not FoundFirewall blocked the record on a resource that set firewallErrorMode: 'hide' (or the route doesn't exist)
405Method Not AllowedThe path is a real route, but not for this verb — the response carries an Allow header listing the methods it does accept
409ConflictUnique constraint hit, a duplicate Idempotency-Key still in flight, a row token that belongs to a different record (IDEMPOTENCY_ROW_CONFLICT), or a transition that lost a race (ACCESS_TRANSITION_LOST, see Transitions)
412Precondition FailedIf-Match revision mismatch — changesets and plain single-record PATCH/PUT/DELETE
422UnprocessableIdempotency-Key reused with a different request or principal, or a stored encrypted value will not decrypt (ENCRYPTION_UNREADABLE), or a write carried a platform-managed JSON key (JSON_KEY_RESTRICTED)
428Precondition RequiredIf-Match sent where it can't anchor: no revision column, a batch endpoint (single-resource precondition), or the POST changeset variant
429Too Many RequestsRate limit exceeded
500Internal ErrorUncaught throw — generic body, full error server-side. Also DB_WRITE_OUTCOME_UNKNOWN: a write passed the configured queryTimeoutMs and may still have committed
503Service UnavailableRetryable. AUTH_REVOCATION_UNAVAILABLE, or DB_TIMEOUT — a read passed the configured D1 deadline

Error Response Format — RFC 9457 Problem Details

Every top-level error response is an RFC 9457 Problem Details document, served as application/problem+json (see API Contract). Quickback's contextual fields (code, layer, details, hint, request) survive as 9457 extension members, so clients that switch on code keep working. Every structured error also carries a request block with the HTTP method, URL and request ID — and, when the deploy sets EXPOSE_ERROR_STACK=1, a redacted, truncated copy of the request body — enough to triage or hand the response to an LLM agent without grepping logs:

{
  "type": "https://quickback.dev/problems/access-role-required",
  "title": "Access role required",
  "status": 403,
  "detail": "Insufficient permissions",
  "instance": "/api/v1/candidates/cnd_01H.../advance",
  "layer": "access",
  "code": "ACCESS_ROLE_REQUIRED",
  "details": {
    "required": ["hiring-manager"],
    "current": ["interviewer"]
  },
  "hint": "Contact an administrator to grant necessary permissions",
  "request": {
    "method": "POST",
    "url": "/api/v1/candidates/cnd_01H.../advance",
    "body": "{\"stage\":\"offer\"}",
    "requestId": "0c4f2c9e-..."
  }
}
FieldTypeDescription
typestringProblem type URI — https://quickback.dev/problems/<code-in-kebab-case>
titlestringHuman-stable summary derived from the code
statusnumberThe HTTP status, repeated in the body
detailstringHuman-readable error message (optional)
errorstringExtension — the same text as detail, kept for clients written against the pre-0.70.4 action-error body { error, code }. On by default; set contract.errors.legacyErrorField: false in quickback.config.ts once every client reads detail
instancestringRequest path (optional)
layerstringExtension — security layer that rejected the request
codestringExtension — machine-readable error code (the full enum ships in openapi.json as Problem.code)
detailsobjectExtension — layer-specific context (optional)
pointerstringExtension — first key of details.fields for guard and validation failures, e.g. /stage (optional)
hintstringExtension — actionable guidance for resolution (optional)
requestobjectExtension — method, URL and request ID, auto-attached by the runtime so an error correlates with the server log. The request body is included only when EXPOSE_ERROR_STACK=1 is set on the deploy (dev/staging): capped at 4 KB, with well-known sensitive keys (password, token, secret, apiKey, otp, code, authorization, …) and every credential field the compiler introduces (sessionToken, eventSessionToken, scopeJwt, scopeToken, wsTicket, inviteToken, jwt, signature, …) replaced with "[REDACTED]". Off by default because error bodies land in client logs, error UIs and support screenshots, and redaction cannot know every sensitive field a write carries

The per-layer examples below are abbreviated to detail plus each layer's extension members — every real response also carries type, title, status, and (where derivable) instance.

Errors by Security Layer

Authentication (401)

Missing or invalid authentication tokens.

{
  "detail": "Authentication required",
  "layer": "authentication",
  "code": "AUTH_MISSING",
  "hint": "Include Authorization header with Bearer token"
}

Error codes:

CodeDescription
AUTH_MISSINGNo Authorization header provided
AUTH_INVALID_TOKENToken is malformed or invalid — including a Quickback-minted JWT that no longer verifies (rotated key, tampered, revoked). Presented-and-bad is always 401, never treated as no credential. An API key whose owner is banned answers with details.reason: "owner_banned"; one whose stored scopes are malformed, "malformed_scopes"
AUTH_EXPIREDToken has expired
AUTH_RATE_LIMITEDToo many auth attempts, or an API key over its per-key limit (429)
AUTH_REVOCATION_UNAVAILABLE503, retryable. Only with revocationCheck: 'kv': the bearer's signature verified, but its revocation state could not be read (KV binding missing or unreachable), so it was not trusted. Retry, or authenticate with a session cookie

Firewall (403)

Records outside the user's firewall scope return 403 Forbidden by default — firewallErrorMode defaults to 'reveal':

{
  "detail": "Record not found or not accessible",
  "layer": "firewall",
  "code": "FIREWALL_NOT_FOUND",
  "hint": "Check the record ID and your organization membership"
}

Firewall filtering is transparent — the query is scoped by WHERE organizationId = ? so inaccessible records simply don't appear in results.

Error codes:

CodeDescription
FIREWALL_NOT_FOUNDRecord not found behind firewall (wrong org, soft-deleted, or doesn't exist)
FIREWALL_ORG_ISOLATIONRecord belongs to a different organization
FIREWALL_USER_ISOLATIONRecord belongs to another user
FIREWALL_SOFT_DELETEDRecord has been soft deleted
FIREWALL_WRITE_DENIEDA create would land a row outside the caller's firewall (see Firewall on write); also a Postgres RLS refusal (42501)

An action that throws new ActionError(message, code, status) answers with that status as Problem Details too: detail is the message, code and layer: "action" are extension members, and details / hint ride along when set. Generated clients read the message from detail like every other error.

Inside an action, any statement through the scoped db on a table whose firewall needs a claim the caller does not carry (no activeOrgId for an org-scoped table, say — a PUBLIC or AUTHENTICATED action, or a scope principal on a table with no arm for its kind) fails with 403 FIREWALL_CLAIM_MISSING, layer: "action", and details: { table, claims } (the claim sets the firewall accepts) — the statement never runs, so a read cannot silently return [] and a row is never written into whatever tenant the body named. An INSERT whose conjunctive claim is missing reports details: { table, column, claim }.

An INSERT through the scoped db whose row would land outside the table's firewall (a project the caller has no relationship to) fails with 403 FIREWALL_WRITE_DENIED, layer: "firewall" — the same Problem the CRUD create paths return, and the one a Postgres RLS refusal (42501) inside an action maps to. A .prepare()d insert into such a table is refused with 400 FIREWALL_WRITE_CHECK_UNSUPPORTED.

For security-hardened deployments, set firewallErrorMode: 'hide' at the top level of the resource definition (not inside firewall: — it was lifted off when firewall became a predicate array) to return opaque 404 Not Found responses with NOT_FOUND instead. This prevents attackers from distinguishing between "record exists but you can't access it" and "record doesn't exist".

quickback/features/candidates/candidates.ts
import { feature, q } from "@quickback/compiler";

export default feature("candidates", {
  columns: {
    id:             q.id(),
    name:           q.text({ maxLength: 200 }).required(),
    organizationId: q.text({ maxLength: 64 }).required(),
    ...q.audit(),
    ...q.softDelete(),
  },
  firewallErrorMode: 'hide',   // top level — default: 'reveal' (403)
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
});

Access (403)

Access violations return 403 Forbidden when the user's role doesn't match the required roles for the operation.

{
  "detail": "Insufficient permissions",
  "layer": "access",
  "code": "ACCESS_ROLE_REQUIRED",
  "details": {
    "required": ["hiring-manager"],
    "current": ["interviewer"]
  },
  "hint": "Contact an administrator to grant necessary permissions"
}

Error codes:

CodeDescription
ACCESS_ROLE_REQUIREDUser doesn't have the required role
ACCESS_CONDITION_FAILEDRecord-level access condition not met
ACCESS_OWNERSHIP_REQUIREDUser must own the record
ACCESS_NO_ORGNo active organization set
ACCESS_KEY_SCOPE_REQUIREDA scoped API key does not cover this route and method. details.required lists the scope that would admit it (empty outside the API base, which scoped keys never reach); details.current is the key's scopes
ENTITLEMENT_REQUIREDAn action's requires: JSON key did not hold for the record. details: { column, key }. Checked before execute
ACCESS_NO_ARMThe action declares access.arms and no arm's when matches the validated input

Guards (400)

Guard violations return 400 Bad Request when the request body contains fields that aren't allowed.

{
  "detail": "Field cannot be set during creation",
  "layer": "guards",
  "code": "GUARD_FIELD_NOT_CREATEABLE",
  "details": {
    "fields": { "stage": "cannot be set on create" }
  },
  "pointer": "/stage",
  "hint": "These fields are set automatically or must be omitted"
}

GUARD_FIELD_PROTECTED also carries details.actions — the actions that may change the field.

Guards judge the raw body before body validation, so a guarded field always answers its GUARD_* code — never VALIDATION_ERROR. A key that names no column at all is not a guard matter: it is VALIDATION_ERROR with "Unrecognized field" (see Validation). In a batch, a guard failure is reported per record (207, or BATCH_FAIL_FAST_STOPPED under failFast) rather than failing the whole request.

Error codes:

CodeDescription
GUARD_FIELD_NOT_CREATEABLEField not in createable list
GUARD_FIELD_NOT_UPDATABLEField not in updatable list
GUARD_FIELD_PROTECTEDField is action-only (protected)
GUARD_FIELD_IMMUTABLEField cannot be modified after creation
GUARD_SYSTEM_MANAGEDSystem field (createdAt, modifiedAt, etc.)

Platform-managed JSON keys (422)

A body that sets a platform key of a q.json(schema, { keys }) column answers 422 JSON_KEY_RESTRICTED (layer: "guards"). details.fields names each key as <column>.<key>. The response is the same on POST, PATCH, PUT, the batch routes (the whole request is refused), and changeset ops. An action whose write changes a platform key without a grant gets the same code and status, with details: { table, column, keys }.

{
  "status": 422,
  "code": "JSON_KEY_RESTRICTED",
  "layer": "guards",
  "details": { "fields": { "settings.beta": "is platform-managed — not writable through settings on this route" } }
}

Malformed record id (400)

Record routes (GET/PATCH/PUT/DELETE /:id, the changeset PATCH, and record actions at /:id/<action>) check :id against the table's id format and answer 400 before any database work when it can't be an id — a UUID route given job_123, a serial route given abc:

{
  "type": "https://quickback.dev/problems/invalid-id",
  "title": "Invalid id",
  "status": 400,
  "detail": "Malformed \"id\" path parameter",
  "layer": "validation",
  "code": "INVALID_ID",
  "details": { "param": "id", "expected": "a UUID" },
  "hint": "\"id\" must be a UUID. No record was looked up."
}
CodeDescription
INVALID_ID:id isn't shaped like the table's ids — see Malformed ids for the per-strategy shapes, what is never checked, and the validateIdParams: false opt-out

It is its own code, not a VALIDATION_* one: those describe the request body, and carry a JSON pointer into it. A bad path segment has no pointer, and "that's not an id" (400) versus "no such row" (403/404) is the distinction a client wants to branch on.

Validation (400)

A body that fails the column or action-input schema returns 400 with layer: "validation": VALIDATION_ERROR for CRUD bodies, VALIDATION_INVALID_INPUT for action input and query parameters (which also carries the raw Zod details.issues), VALIDATION_FAILED inside a changeset. details.fields maps each offending field to its message; an unknown key is reported under its own name:

{
  "detail": "Invalid input",
  "layer": "validation",
  "code": "VALIDATION_INVALID_INPUT",
  "details": {
    "issues": [ … ],
    "fields": { "name": "Required", "status": "Unrecognized field" }
  },
  "pointer": "/name"
}

Field errors — one shape

details.fields has one shape on every layer that names fields — guards, body and action-input validation, FK_NOT_FOUND: an object mapping the field name (a dotted path for nested input) to a human-readable message. A form can render each message under its input without knowing which layer rejected the request; the generated client exposes it as QuickbackError.fields. pointer names the first entry.

Masking

Masking doesn't produce errors — it silently transforms field values in the response.

Database

Surfaced when a SQL write fails post-validation — most often a NOT NULL or UNIQUE constraint hit at the storage layer (e.g. an FK target was deleted concurrently, or a unique column collided with another tenant's row). All four codes share the same layer: "database" so consumers can switch on the layer alone and treat the rest as one bucket.

{
  "detail": "Duplicate value",
  "layer": "database",
  "code": "DB_UNIQUE_VIOLATION",
  "details": {
    "column": "organizationId",
    "columns": ["organizationId", "email"],
    "fields": { "organizationId": "Already taken", "email": "Already taken" }
  },
  "hint": "A record with this \"organizationId\" + \"email\" already exists"
}

A unique violation names every column of the constraint (a composite unique lists all of them) as API field names, and marks each in details.fields so a form can show the conflict inline. details.column is the first of them.

Error codes:

CodeStatusDescription
DB_NOT_NULL_VIOLATION400Required column was null at write time
DB_UNIQUE_VIOLATION409Unique constraint hit
DB_RELATIONSHIP_MISMATCH400A referenced row does not match the required parent column
DB_INSERT_FAILED500INSERT failed for an unrecognized reason
DB_UPDATE_FAILED500UPDATE failed for an unrecognized reason
DB_DELETE_FAILED500DELETE (or soft-delete UPDATE) failed for an unrecognized reason
DB_TIMEOUT503Retryable. A read passed queryTimeoutMs (D1 only). The read had no side effects; retry. details: { binding }
DB_WRITE_OUTCOME_UNKNOWN500A write passed queryTimeoutMs. D1 does not cancel the statement, so it may still commit after the response. Re-read the record before retrying — a blind retry can double-apply. details: { binding, outcome: 'unknown' }
ENCRYPTION_UNREADABLE422A stored .encrypted() value will not decrypt
TRIGGER_REJECTED422 / 403A before-trigger refused the write; nothing was persisted. layer: "trigger", details.code for a prefixed sql: rejection

The details.column field is only present when the driver's error message identified the offending column (SQLite/D1 do; some PG variants don't).

The same classification applies to every write path — single-record create, update, PUT and delete, the batch endpoints (including DELETE /batch, which reports these DB_* codes per id, or as the failFast response), bulk actions, and changesets.

DB_INSERT_FAILED, DB_UPDATE_FAILED and DB_DELETE_FAILED are the fallback for a failure that matches no known constraint. The raw driver message (D1 or Postgres text, which can name tables, columns, and SQL fragments) is always logged with console.error, and the production body carries no details:

{
  "detail": "Update failed",
  "layer": "database",
  "code": "DB_UPDATE_FAILED",
  "hint": "Database rejected the update. Check server logs for the underlying error."
}

The raw message appears as details.reason only when the worker runs with EXPOSE_ERROR_STACK set.

ENCRYPTION_UNREADABLE always carries details: { table, column, id } — the row whose stored ciphertext is malformed, was not written under the caller's organization key, was copied from another column or table, or names a key version the organization does not have. Rewriting the field repairs the row.

Batch Errors

Batch operations can return:

  • 200/201 — All records succeeded (201 for create/upsert, 200 for update/delete)
  • 207 — Partial success (some records failed)
  • 400 — failFast: true was set and at least one record failed

Partial Success (207)

A 207 body is a success envelope (plain application/json, not application/problem+json) — the per-record error objects embedded in it keep the internal flat shape (error message field, layer, code, details, hint):

{
  "success": [{ "id": "app_1", "candidateId": "cand_101", "stage": "applied" }],
  "errors": [
    {
      "index": 1,
      "record": { "candidateId": "cand_102", "jobId": "job_201", "stage": "interview" },
      "error": {
        "error": "Field cannot be set during creation",
        "layer": "guards",
        "code": "GUARD_FIELD_NOT_CREATEABLE",
        "details": { "fields": { "stage": "cannot be set on create" } },
        "hint": "These fields are set automatically or must be omitted"
      }
    }
  ],
  "meta": { "total": 2, "succeeded": 1, "failed": 1, "failFast": false, "transactional": true }
}

meta always carries exactly total, succeeded, failed, failFast, and transactional — there is no atomic key. meta.transactional tells you whether you got real rollback or only early-stop semantics; see Batch Operations.

Fail-Fast Failure (400)

{
  "detail": "Batch stopped at first error (fail-fast mode)",
  "layer": "validation",
  "code": "BATCH_FAIL_FAST_STOPPED",
  "details": {
    "failedAt": 2,
    "applied": true,
    "reason": "Not found",
    "errorDetails": { "id": "app_3" }
  },
  "hint": "Records before index 2 were applied and are NOT rolled back. Inspect application state before retrying — re-sending the same batch may double-apply records that already succeeded."
}

details.applied tells a client what to do next, and the hint follows it:

  • true — records before failedAt were committed and stay committed: a PATCH /batch, PUT /batch or bulk action on a provider without transactions (D1, Neon over HTTP). Reconcile before resending.
  • false — nothing in the batch was written, so resend it once record failedAt is fixed. That covers every POST /batch and DELETE /batch (they validate every record before one atomic write), and every fail-fast batch on a transactional provider (rolled back — see Transactional Semantics).

Batch error codes:

CodeDescription
BATCH_SIZE_EXCEEDEDToo many records in a single request
BATCH_FAIL_FAST_STOPPEDfailFast: true batch stopped at the first error
BATCH_MISSING_IDSBatch update/delete missing required IDs

Action Failures (500)

When an action's execute() body throws something that isn't a known transition / state / FK / constraint failure (e.g. a TypeError, a fetch timeout, a third-party API error), the route returns ACTION_EXECUTION_FAILED. The production body is generic: it carries only the error class name in details.name (so callers can branch on e.g. TypeError vs a fetch timeout) — never the underlying message, cause, or stack frames. The raw error is always logged server-side.

{
  "detail": "Action execution failed",
  "layer": "action",
  "code": "ACTION_EXECUTION_FAILED",
  "details": {
    "name": "TypeError"
  },
  "hint": "The action handler threw an unhandled error. Check server logs for the underlying cause.",
  "request": {
    "method": "POST",
    "url": "/api/v1/notebooks/nb_123/updateContent",
    "body": "{\"content\":\"...\"}",
    "requestId": "0c4f2c9e-..."
  }
}

Internal Errors (500)

Uncaught throws that escape every route handler land in the global onError and return INTERNAL_ERROR. Same contract: the production body is generic, and the full error (message, cause chain, stack) is logged via console.error keyed by the requestId in the body — so the response → log correlation is one grep.

{
  "detail": "Internal error",
  "layer": "database",
  "code": "INTERNAL_ERROR",
  "details": { "operation": "request handling" },
  "hint": "Operation \"request handling\" failed unexpectedly. Check server logs for details.",
  "request": {
    "method": "GET",
    "url": "/api/v1/jobs",
    "requestId": "0c4f2c9e-..."
  }
}

Exposing error details in dev / staging

By default nothing derived from the thrown error reaches the response body — messages, cause chains, and stack frames can carry SQL, file paths, or secrets, so they stay server-side. To get verbose bodies while developing or staging, set EXPOSE_ERROR_STACK=1 on the worker (wrangler.toml [vars], or wrangler secret put):

[vars]
EXPOSE_ERROR_STACK = "1"

When set, the global 500 and ACTION_EXECUTION_FAILED bodies forward the underlying error message plus details.cause (cause-chain message), details.frames (top three stack frames with absolute paths sanitized to basename + line number), and details.stack (the full unsanitized stack trace). The same switch adds details.reason (the raw error message) to the database fallbacks (DB_INSERT_FAILED, DB_UPDATE_FAILED, DB_DELETE_FAILED), ACCESS_AUDIT_LOG_FAILED, the INTERNAL_ERROR bodies of the auth handler and the operator /admin/* routes, and uses the raw message as the TRIGGER_AFTER_FAILED error text. Without it, none of those bodies carry anything derived from the thrown error. 1 and true turn it on; any other value leaves it off. Leave it unset in production.

On this page