Quickback Docs

API Contract

The contract layer in one place — envelope, Problem Details, cursors, Idempotency-Key, include, and the v1 → v2 version matrix.

Every generated API speaks one public HTTP contract, selected once per project:

quickback/quickback.config.ts
export default defineConfig({
  // Omitted = v1 (the pinned compatibility mode for already-deployed clients).
  contract: { version: "v2" },
  // ...
});

v2 is the flagship contract. Everything new lands on v2; projects pinned to v1 keep today's byte-stable wire and receive no new contract features until they migrate.

Response envelope

Collection reads return a flat envelope; single reads return the resource object itself:

{
  "data": [ { "id": "po_1", "vendorId": "ven_1", "status": "open" } ],
  "pagination": { "count": 1, "page": 1, "pageSize": 50, "hasMore": false,
                  "nextCursor": "…", "prevCursor": null },
  "included": { "vendorId": { "ven_1": { "id": "ven_1", "name": "Acme" } } }
}

included appears only when the request carried ?include= (contract v2 — see query params).

Errors — RFC 9457 Problem Details

Every error response is application/problem+json:

{
  "type": "https://quickback.dev/problems/access-role-required",
  "title": "Access role required",
  "status": 403,
  "detail": "Access denied",
  "instance": "/api/v2/purchase-orders",
  "code": "ACCESS_ROLE_REQUIRED",
  "layer": "access"
}

The internal fields (code, layer, details, hint, request) survive as extension members, so clients that switch on code keep working. The full code catalog ships in openapi.json as the Problem.code enum.

Cursor pagination

List endpoints accept an opaque keyset cursor alongside classic ?limit=/?offset=:

ParamMeaning
?starting_after=<cursor>rows strictly after the boundary (forward)
?ending_before=<cursor>rows strictly before the boundary (backward)
?total=trueopt into a counted total

pagination.nextCursor / prevCursor carry the boundaries. Cursors are opaque — never parse them.

Idempotency-Key

Any POST/PATCH/PUT/DELETE may carry an Idempotency-Key header (a caller-generated UUID, ≤ 255 chars) to make the write safe to retry:

  • First request claims the key (the claim is the concurrency lock) and caches the response.
  • A retry with the same key replays the cached response byte-identically with Idempotency-Replayed: true.
  • A concurrent duplicate gets 409 IDEMPOTENCY_IN_PROGRESS.
  • Reusing a key for a different request (method/path) is a 422 IDEMPOTENCY_KEY_REUSED.
  • Keys expire after 24 hours (Stripe semantics); 5xx and Set-Cookie responses release the lock instead of caching.

Claims are bound to the calling principal. A replay from a different principal — another user, another scope subject, a delegated machine principal, or a legacy row with no principal — is refused with a 422 Problem: the cached response body is never served to a foreign principal, and nothing beyond the 422 itself leaks. The comparison runs before the stale-claim takeover, so a foreign caller can neither read nor overwrite your claim.

Unauthenticated requests carrying the header are rejected (400) — an anonymous caller has no principal to bind the claim to. A PUBLIC standalone action may opt in explicitly:

quickback/features/orders/actions/submitForm.ts
export default defineAction({
  path: "/orders/submit-form",
  method: "POST",
  access: { roles: ["PUBLIC"] },
  idempotency: "dedupe",   // dedupe-only: duplicate ⇒ 409 reference,
                           // the cached body is NEVER served to a second caller
  // ...
});

Contract version matrix

Flipping contract.version moves the entire contract at once — plan the client migration as one move:

Surfacev1 (pinned)v2 (flagship)
API base/api/v1/api/v2
Auth base/auth/v1/auth/v2
Realtime base/broadcast/v1/broadcast/v2
Quickback HMAC-JWT laneon by defaultonly with explicit auth.jwt
Realtime data framesraw (byte-stable)CloudEvents 1.0 envelopes (realtime)
Outbound webhook signingStandard Webhooks + legacy X-Webhook-*Standard Webhooks only
?include= FK embeddingnot availableavailable where allowlisted
Aggregate changesets (application/vnd.quickback.changeset+json on PATCH/POST, owns)not availableavailable where owns is declared
Bare ?fields= unknown namessilently dropped400
fields[<fk>]= unknown namesn/a400
Action input schemasbest-effort static parsecomplete client-side harvest required

Sequencing a migration: ship clients that speak the v2 paths, CloudEvents frames, and session-first auth (or declare auth.jwt); confirm webhook consumers validate the webhook-* headers; then flip the switch. Until then v1 stays byte-identical.

Machine-readable contract

  • GET /openapi.json — OpenAPI 3.1: Problem responses, cursor params, Idempotency-Key on every write op, per-resource include params.
  • GET /asyncapi.json — AsyncAPI 3.0: the realtime channel + named invalidation events + outbound webhook messages. Served under the same auth gating as /openapi.json (openapi.publish).

On this page