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
| Code | Meaning | When |
|---|---|---|
200 | OK | Successful GET, PATCH, DELETE |
201 | Created | Successful POST |
207 | Multi-Status | Batch operation with mixed results |
400 | Bad Request | Guard violation, validation error, invalid input, malformed record id (INVALID_ID) |
401 | Unauthorized | Missing or expired authentication |
403 | Forbidden | Access denied (wrong role, condition failed) or firewall blocked it |
404 | Not Found | Firewall blocked the record on a resource that set firewallErrorMode: 'hide' (or the route doesn't exist) |
405 | Method Not Allowed | The path is a real route, but not for this verb — the response carries an Allow header listing the methods it does accept |
409 | Conflict | Unique 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) |
412 | Precondition Failed | If-Match revision mismatch — changesets and plain single-record PATCH/PUT/DELETE |
422 | Unprocessable | Idempotency-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) |
428 | Precondition Required | If-Match sent where it can't anchor: no revision column, a batch endpoint (single-resource precondition), or the POST changeset variant |
429 | Too Many Requests | Rate limit exceeded |
500 | Internal Error | Uncaught throw — generic body, full error server-side. Also DB_WRITE_OUTCOME_UNKNOWN: a write passed the configured queryTimeoutMs and may still have committed |
503 | Service Unavailable | Retryable. 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-..."
}
}| Field | Type | Description |
|---|---|---|
type | string | Problem type URI — https://quickback.dev/problems/<code-in-kebab-case> |
title | string | Human-stable summary derived from the code |
status | number | The HTTP status, repeated in the body |
detail | string | Human-readable error message (optional) |
error | string | Extension — 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 |
instance | string | Request path (optional) |
layer | string | Extension — security layer that rejected the request |
code | string | Extension — machine-readable error code (the full enum ships in openapi.json as Problem.code) |
details | object | Extension — layer-specific context (optional) |
pointer | string | Extension — first key of details.fields for guard and validation failures, e.g. /stage (optional) |
hint | string | Extension — actionable guidance for resolution (optional) |
request | object | Extension — 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:
| Code | Description |
|---|---|
AUTH_MISSING | No Authorization header provided |
AUTH_INVALID_TOKEN | Token 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_EXPIRED | Token has expired |
AUTH_RATE_LIMITED | Too many auth attempts, or an API key over its per-key limit (429) |
AUTH_REVOCATION_UNAVAILABLE | 503, 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:
| Code | Description |
|---|---|
FIREWALL_NOT_FOUND | Record not found behind firewall (wrong org, soft-deleted, or doesn't exist) |
FIREWALL_ORG_ISOLATION | Record belongs to a different organization |
FIREWALL_USER_ISOLATION | Record belongs to another user |
FIREWALL_SOFT_DELETED | Record has been soft deleted |
FIREWALL_WRITE_DENIED | A 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".
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:
| Code | Description |
|---|---|
ACCESS_ROLE_REQUIRED | User doesn't have the required role |
ACCESS_CONDITION_FAILED | Record-level access condition not met |
ACCESS_OWNERSHIP_REQUIRED | User must own the record |
ACCESS_NO_ORG | No active organization set |
ACCESS_KEY_SCOPE_REQUIRED | A 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_REQUIRED | An action's requires: JSON key did not hold for the record. details: { column, key }. Checked before execute |
ACCESS_NO_ARM | The 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:
| Code | Description |
|---|---|
GUARD_FIELD_NOT_CREATEABLE | Field not in createable list |
GUARD_FIELD_NOT_UPDATABLE | Field not in updatable list |
GUARD_FIELD_PROTECTED | Field is action-only (protected) |
GUARD_FIELD_IMMUTABLE | Field cannot be modified after creation |
GUARD_SYSTEM_MANAGED | System 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."
}| Code | Description |
|---|---|
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:
| Code | Status | Description |
|---|---|---|
DB_NOT_NULL_VIOLATION | 400 | Required column was null at write time |
DB_UNIQUE_VIOLATION | 409 | Unique constraint hit |
DB_RELATIONSHIP_MISMATCH | 400 | A referenced row does not match the required parent column |
DB_INSERT_FAILED | 500 | INSERT failed for an unrecognized reason |
DB_UPDATE_FAILED | 500 | UPDATE failed for an unrecognized reason |
DB_DELETE_FAILED | 500 | DELETE (or soft-delete UPDATE) failed for an unrecognized reason |
DB_TIMEOUT | 503 | Retryable. A read passed queryTimeoutMs (D1 only). The read had no side effects; retry. details: { binding } |
DB_WRITE_OUTCOME_UNKNOWN | 500 | A 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_UNREADABLE | 422 | A stored .encrypted() value will not decrypt |
TRIGGER_REJECTED | 422 / 403 | A 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 (
201for create/upsert,200for update/delete) - 207 — Partial success (some records failed)
- 400 —
failFast: truewas 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 beforefailedAtwere committed and stay committed: aPATCH /batch,PUT /batchor 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 recordfailedAtis fixed. That covers everyPOST /batchandDELETE /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:
| Code | Description |
|---|---|
BATCH_SIZE_EXCEEDED | Too many records in a single request |
BATCH_FAIL_FAST_STOPPED | failFast: true batch stopped at the first error |
BATCH_MISSING_IDS | Batch 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.