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:
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=:
| 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:
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:
| Surface | v1 (pinned) | v2 (flagship) |
|---|---|---|
| API base | /api/v1 | /api/v2 |
| Auth base | /auth/v1 | /auth/v2 |
| Realtime base | /broadcast/v1 | /broadcast/v2 |
| 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 |
?include= FK embedding | not available | available where allowlisted |
Aggregate changesets (application/vnd.quickback.changeset+json on PATCH/POST, owns) | not available | available where owns is declared |
Bare ?fields= unknown names | silently dropped | 400 |
fields[<fk>]= unknown names | n/a | 400 |
| Action input schemas | best-effort static parse | complete 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-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).