Quickback Docs

API Contract

The contract layer in one place — envelope, Problem Details, cursors, Idempotency-Key, include, and the routes prefix.

Every generated API speaks one public HTTP contract. There is no version to select: the behavior on this page is what every project gets.

The only contract choice is the URL prefix, and it defaults to /api/v1 so existing clients keep the address they already call:

quickback/quickback.config.ts
export default defineConfig({
  contract: { routes: "v2" },   // optional — serve on /api/v2 instead
  // ...
});

contract.version is retired and pinning it is a compile error — see below.

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= — 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
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Accept a public order form submission and open a pending order.",
  path: "/orders/submit-form",
  method: "POST",
  input: z.object({ email: z.string().email(), note: z.string().max(2000).optional() }),
  access: { roles: ["PUBLIC"] },
  idempotency: "dedupe",   // dedupe-only: duplicate ⇒ 409 reference,
                           // the cached body is NEVER served to a second caller
  async execute({ db, input }) {
    // ...
    return { ok: true };
  },
});

One axis: routes

contract has a single key. It selects the URL prefix and nothing else.

KeyControlsDefault
routesThe URL prefix, and nothing else'v1'

A project with no contract block serves /api/v1. Set routes: "v2" to serve /api/v2 instead — the behavior is identical either way.

contract: { routes: "v2" }   // serve on /api/v2 instead of /api/v1

contract.version is retired

There is one standard API shape, so there is nothing to select. Pinning contract.version is a compile error.

Delete that one line and keep routes if you have it. routes is the surviving axis and controls your URLs — removing it would move them.

contract: { version: "v2", routes: "v1" }   // before
contract: { routes: "v1" }                  // after — same URLs, same behavior

Deleting version: "v2" changes nothing: it selected what is now simply the standard shape. Deleting version: "v1" is a real migration — that pin opted out of four behaviors that are now unconditional:

SurfaceWhat version: "v1" didWhat you get now
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
Bare ?fields= unknown namessilently dropped400

Features that a v1 pin refused are now always available: ?include= FK embedding where allowlisted, aggregate changesets on PATCH/POST where owns is declared, fields[<fk>]= projection (unknown names 400), auth.principals, and atomicAuthRoutes: 'emailOtpSignIn'.

Migrating off a v1 pin: ship subscribers that parse CloudEvents frames, move webhook consumers onto the webhook-* headers, and either adopt session-first auth or declare auth.jwt.

Pinning your CLI version does not pin behavior. The compiler is a hosted service, so an older CLI gets the same output.

Base paths

routes moves all three together:

Surfaceroutes: "v1" (default)routes: "v2"
API base/api/v1/api/v2
Auth base/auth/v1/auth/v2
Realtime base/broadcast/v1/broadcast/v2

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