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 |
401 | Unauthorized | Missing or expired authentication |
403 | Forbidden | Access denied (wrong role, condition failed) or firewall blocked it |
404 | Not Found | Record doesn't exist (or firewall blocked with errorMode: 'hide') |
409 | Conflict | Unique constraint hit, or a duplicate Idempotency-Key still in flight |
412 | Precondition Failed | Changeset If-Match revision mismatch |
422 | Unprocessable | Idempotency-Key reused with a different request or principal |
428 | Precondition Required | If-Match sent where no revision exists (root without updatedAt, or the POST changeset variant) |
429 | Too Many Requests | Rate limit exceeded |
500 | Internal Error | Uncaught throw — generic body, full error server-side |
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, request ID, and 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) |
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 offending field for validation failures, e.g. /stage (optional) |
hint | string | Extension — actionable guidance for resolution (optional) |
request | object | Extension — method, URL, redacted body, and request ID. Auto-attached by the runtime so the error body is self-contained — bodies are capped at 4 KB and well-known sensitive keys (password, token, secret, apiKey, otp, code, authorization, …) are replaced with "[REDACTED]" |
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 |
AUTH_EXPIRED | Token has expired |
AUTH_RATE_LIMITED | Too many auth attempts |
AUTH_SESSION_REQUIRED | The endpoint requires session (cookie) authentication — returned by POST /api/v1/token when called with a JWT, API key, or OAuth bearer, so a stolen or revoked token can't mint its own replacement |
Firewall (403)
Records outside the user's firewall scope return 403 Forbidden by default with a structured error:
{
"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 |
For security-hardened deployments, set errorMode: 'hide' in your firewall config to return opaque 404 Not Found responses instead. This prevents attackers from distinguishing between "record exists but you can't access it" and "record doesn't exist".
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 |
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"]
},
"pointer": "/stage",
"hint": "These fields are set automatically or must be omitted"
}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.) |
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": "email" },
"hint": "A record with this \"email\" already exists"
}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_INSERT_FAILED | 500 | INSERT failed for an unrecognized reason |
DB_UPDATE_FAILED | 500 | UPDATE failed for an unrecognized reason |
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).
Batch Errors
Batch operations can return:
- 201 — All records succeeded
- 207 — Partial success (some records failed)
- 400 — Atomic mode and at least one record failed (all rolled back)
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"] },
"hint": "These fields are set automatically or must be omitted"
}
}
],
"meta": { "total": 2, "succeeded": 1, "failed": 1, "atomic": false }
}Atomic Failure (400)
{
"detail": "Batch operation failed in atomic mode",
"layer": "validation",
"code": "BATCH_ATOMIC_FAILED",
"details": {
"failedAt": 2,
"reason": { "error": "Not found", "code": "NOT_FOUND" }
},
"hint": "Transaction rolled back. Fix the error and retry the entire batch."
}Batch error codes:
| Code | Description |
|---|---|
BATCH_SIZE_EXCEEDED | Too many records in a single request |
BATCH_ATOMIC_FAILED | Atomic batch failed, all changes rolled back |
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). Leave it unset in production.