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:
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=:
| Param | Meaning |
|---|---|
?starting_after=<cursor> | rows strictly after the boundary (forward) |
?ending_before=<cursor> | rows strictly before the boundary (backward) |
?total=true | opt 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-Cookieresponses 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:
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.
| Key | Controls | Default |
|---|---|---|
routes | The 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/v1contract.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 behaviorDeleting 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:
| Surface | What version: "v1" did | What you get now |
|---|---|---|
| Quickback HMAC-JWT lane | on by default | only with explicit auth.jwt |
| Realtime data frames | raw (byte-stable) | CloudEvents 1.0 envelopes (realtime) |
| Outbound webhook signing | Standard Webhooks + legacy X-Webhook-* | Standard Webhooks only |
Bare ?fields= unknown names | silently dropped | 400 |
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:
| Surface | routes: "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-Keyon every write op, per-resourceincludeparams.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).