# API Contract

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:

```ts title="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](#contractversion-is-retired).

## Response envelope

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

```json
{
  "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](/api/query-params).

## Errors — RFC 9457 Problem Details

Every error response is `application/problem+json`:

```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-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:

```ts title="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.

| 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.

{/* doc-compile: skip — config fragment, not a compilable project */}
```typescript
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.

{/* doc-compile: skip — config fragment, not a compilable project */}
```typescript
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:

| 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](/platform/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`](/define/changesets) 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-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`).
