# Wire protocol

Source: https://dabadata.com/docs/protocol/ (HTML) - https://dabadata.com/docs/protocol/index.md (this page as markdown)

Everything a client library needs to speak to dabadata, written so a Dart, Swift or Kotlin SDK can be built without reading the JavaScript client: the HTTP basics, credentials and how a call is placed, token refresh with exact timings, every data route, the `/v1/live` socket, the Centrifugo transport with recovery, the end-user sign-in routes, the error bodies, and the list of rules a conforming SDK follows. The [conformance suite](https://dabadata.com/docs/protocol/index.md#conformance) checks an SDK against all of it.

## HTTP basics

- **Base URL.** `https://dabadata.com` for hosted projects, or the URL of a self-hosted server. Every API route starts with `/v1/`.
- **Bodies.** Requests with a body send `Content-Type: application/json` (UTF-8). Every answer is JSON (`application/json; charset=utf-8`) except the export, which is NDJSON.
- **Discovery.** `GET /v1/meta` (no credential) answers `{ "name", "contract": "2026-10-15", "realtime": "inproc" | "centrifugo", "realtimeUrl"?, "hosted" }`. Ask it once, before the first socket: `realtime` names the live transport ([inproc](https://dabadata.com/docs/protocol/index.md#live) or [centrifugo](https://dabadata.com/docs/protocol/index.md#centrifugo)). A network failure means "ask again next time"; any HTTP answer is final.
- **Times** are ISO 8601 strings in UTC (`2026-10-15T09:30:00.000Z`), dates `YYYY-MM-DD`.
- **CORS.** Browsers may call from any origin unless the environment lists its origins; servers and mobile apps send no `Origin` and are never blocked by it.
- **Health.** `GET /v1/health` answers `{ "ok": true }`.

Every route, with its full request and answer, is on the [HTTP API](https://dabadata.com/docs/api/index.md) page; this page covers what an SDK uses and the rules around it.

## Credentials and placement

A project has two environments, `live` and `test`, each with its own data, keys and tokens. Every call is **placed** in exactly one environment by the credentials it carries, never by anything in the URL or the body.

| Credential | Looks like | Sent as | Held by |
| --- | --- | --- | --- |
| Publishable key | `pk_live_` / `pk_test_` + 24 characters | header `X-Api-Key`; `?key=` on sockets | the app (it is public) |
| Access token | 64 hex characters, lives 1 hour | `Authorization: Bearer`; `?token=` on sockets | the signed-in end user |
| Refresh token | 64 hex characters, lives 30 days (sliding) | only in the body of `POST /v1/users/refresh` | the end user's device |
| Provider JWT | three base64url parts (`xxx.yyy.zzz`) | like an access token | apps that bring their own identity provider |
| Secret key | `sk_live_` / `sk_test_` + 40 characters | `Authorization: Bearer` | servers only: it bypasses every rule |

The server places a call in this order:

1. `Authorization: Bearer sk_...`: that key's environment, as the `service` identity (rules bypassed).
2. On a self-hosted server, a Bearer equal to the server's project key: the default project, as `service`.
3. A publishable key (`X-Api-Key` or `?key=`): that key's environment. A token sent with it (Bearer or `?token=`) must belong to the same environment; no token means the `anonymous` caller.
4. No key: an access token places the call in the environment it was minted for. A provider JWT, or no credential at all, goes to the server's default project (self-hosted); a server without one answers 401.

A credential of one environment sent with the key of another is a 401. So is an unknown, expired or revoked key, anything in `X-Api-Key` that is not a valid publishable key, and a publishable key sent as Bearer. A project being deleted answers 410 `project_deleted` (after the credentials were checked); a paused one wakes up on the call.

> **Security:** An SDK that runs on a device takes the publishable key and the user's tokens, never a secret key. Minting tokens for your own users (`POST /v1/users/token`) happens on your server.

## Errors

Every error answer is a JSON object with a string `error`. The other fields depend on the error:

| Status | `error` | Also | When |
| --- | --- | --- | --- |
| 400 | `validation` | `messages: string[]` (every problem at once); `index` in a transaction | a bad body, value, filter or definition |
| 401 | `unauthorized` | - | no credential where one is needed, or a bad, expired or revoked one |
| 403 | `forbidden` | `explanation` (the rule's decision, as text), `traceId`; `index` in a transaction | a rule denied the call |
| 403 | `plan_limit` | `limit`, `message` | over the plan (connections, storage, egress) |
| 404 | `not found` | `message`, `traceId` (data routes) | an unknown route, table or row |
| 405 | `method not allowed` | - | a known route, another method |
| 409 | `conflict` | `message`, `traceId`, and one of `row` (expect / ifAbsent), `column`, `columns`, `check`, `constraint`, `ref` (a ref column's `onDelete`); `index` and `retryable` in a transaction | a write that would break an `expect`, `ifAbsent`, unique column or check |
| 410 | `project_deleted` | - | the project is being deleted |
| 429 | `rate_limited` | `retryAfter` (seconds), and the `Retry-After` header | too many calls |
| 500 | `internal error` | - | a server fault |
| 503 | `realtime_unavailable`, ... | - | a backing service is down |

An SDK error object carries the HTTP status (0 for a network failure), the `error` code, the `explanation`, the `traceId` and the whole body. Its message is the explanation, else `message`, else the validation messages joined with `"; "`, else the code. Show developers the explanation: it names the rule path that decided, e.g. `update tasks: denied, any[0] role "viewer" not in ["admin"]; any[1] row.owner "bob" != user "carol"`.

## Tokens and refresh

End users get a pair: an **access token** valid 1 hour and a **refresh token** valid 30 days. Pairs come from `POST /v1/users/token` (your server, with the secret key: `{ id, name?, role }`), `POST /v1/users/exchange` (`{ jwt }`: a provider token traded for a pair), the hosted sign-in routes ([below](https://dabadata.com/docs/protocol/index.md#end-users)), and refreshes. Every pair has the same shape:

```json
{ "token": "9f2c...", "expiresAt": "2026-10-15T10:30:00.000Z",
  "refreshToken": "41ab...", "refreshExpiresAt": "2026-11-14T09:30:00.000Z" }
```

`POST /v1/users/refresh` with `{ "refreshToken": "..." }` (no Authorization header; send the publishable key, which must be of the token's environment) answers a new pair. The used refresh token is deleted and the new one is valid 30 days from now, so a user who comes back within 30 days stays signed in. A refresh token works once, even under a race: two calls with one token get one pair and one 401. Access tokens already handed out keep working until their own expiry. An unknown, expired, used or revoked refresh token is 401; a body without one is 400. `POST /v1/users/revoke` (secret key, `{ id }`) deletes every token and refresh token of a user: their next call is 401 and so is their next refresh.

The rules an SDK follows, with these exact timings:

1. **Ahead of time.** Schedule a refresh 60 seconds before `expiresAt`, never sooner than 5 seconds from when it is scheduled.
2. **On 401.** When any HTTP call answers 401 and the SDK holds a refresh token, refresh once and retry that call once. If the refresh fails, the caller gets the original 401. If another call already refreshed while this one was in flight, just retry.
3. **One at a time.** Concurrent refreshes share one request (a second refresh would present a refresh token the first one already used).
4. **Dead token.** A refresh answered 401 means the refresh token is gone for good: drop it; the user is signed out.
5. **Persist every pair.** Hand each new pair to the app (or the SDK's own storage); the previous refresh token no longer works.
6. **Sockets.** A socket's identity is fixed when it opens; it stays open after its access token expires. Every new connection, a reconnect included, needs a valid token: when a socket closes before it opened while the access token is expired (or of unknown expiry), refresh once per streak of failed connects, then reconnect. An open socket is kept across a refresh; the next reconnect uses the new token.

Hosted projects limit the token routes: 60 calls a minute per environment for `/v1/users/token`, `/exchange` and `/refresh` together, and 30 a minute per IP address for refresh and exchange.

## Rows and types

A row is a JSON object with the table's declared columns plus `id` (text, a UUID), `created_at` and `updated_at` (datetime), `created_by` (the id of the user who inserted it, `service` for the secret key) and `_seq` (a number that grows with every write in the environment). Clients never set these; sending one is a 400.

| Column type | JSON |
| --- | --- |
| `text`, `userId`, `ref` | string |
| `int` | number (a safe integer) |
| `float` | number |
| `bool` | true / false |
| `date` | `"YYYY-MM-DD"` |
| `datetime` | ISO 8601 string |
| `json` | any JSON value |
| `location` | `{ "lat": 52.52, "lng": 13.405 }` |

An empty optional column is `null`. A column the caller may not read (a [column read rule](https://dabadata.com/docs/rules/index.md#column-read-rules)) is absent from the row - the key is missing, not null - on every route and socket; an SDK must not invent it. The definition of the environment (tables, columns, rules, views) is `GET /v1/schema` for any signed-in user; end users see the open tables and their own shape of each view.

## Reading

`GET /v1/db/:table` answers `{ "rows", "traceId", "cursor" }`: the rows the caller's read rule lets them see. Parameters:

- `where`: a JSON object (URL-encoded). Keys are columns (declared ones, plus `id`, `created_by`, `created_at`, `updated_at`). A value is equality (`null` = is null) or an object of operators: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$like`, `$ilike`, `$null`, and on json columns `$contains` (Postgres containment: an array holding a value, an object holding a sub-object); `$and` and `$or` take lists of filters. An unknown column or a value of the wrong type is a 400. A [location](https://dabadata.com/docs/tables/index.md#locations) column is filtered with `"$near": { "column", "lat", "lng", "km" }` (top level); every row then carries `_distance` in km.
- `orderBy`: a column (default `created_at`, newest first), or up to 5 separated by commas, each with an optional `-` for descending (`orderBy=due,-created_at`); a single column is ascending unless `desc=1`. With a `$near`, `orderBy=_distance` sorts nearest first.
- `limit`: 1 to 1,000, default 100.
- `since`: a `_seq` cursor; see [catching up](https://dabadata.com/docs/protocol/index.md#catch-up).

A list is not a 403 for a table the caller's read rule shuts out: it answers `"rows": []`. The exception is a [strict table](https://dabadata.com/docs/rules/index.md#strict-tables) (`access: "views"`): every `/v1/db` route answers end users 403 `read <table>: denied, <table> is read through views only (access: views)`. `GET /v1/db/:table/:id` answers `{ "row", "traceId" }`, 404 when there is no such row, 403 when it exists but the read rule hides it. An unknown table is 404. A `where` or `orderBy` on a column the caller may not read is a 400 (`where: column "answer" is not readable by this caller`).

**Views.** `GET /v1/views/:name?params=&where=&orderBy=&desc=&limit=` (or `POST` with the same fields as a body) answers `{ "rows", "traceId", "params" }`; `GET /v1/views/:name/:id?params=` answers `{ "row", "traceId" }`. Rows carry the view's columns, its computed fields and `_seq`. Errors: 404 unknown view, 403 `forbidden` from its `allow`, 400 params (every message in `messages`), 400 a `where` / `orderBy` outside the view's `filter` / `sort`. See [Views](https://dabadata.com/docs/views/index.md); SDKs offer it as `view(name, options)` and `viewRow(name, id, options)`.

## Writing

`POST /v1/mutate` with `{ "table", "op": "insert" | "update" | "delete", "id"?, "values"?, "expect"?, "ifAbsent"? }` answers `{ "ok": true, "row", "traceId" }`; a delete answers the deleted row. `row` is the full row only when the caller's read rule lets them read it (after the write; for a delete, before it) - otherwise it is `{ "id" }` - and leaves out columns the caller may not read. The same holds for every `results[i].row` of `/v1/transact`.

- **insert** takes `values`: unknown columns, missing required columns and wrong types are a 400 listing every problem. Columns with a default get it; every missing `userId` column is filled with the caller's id. Insert takes plain values only (`{ "$set": v }` is unwrapped; any other operator is a 400).
- **update** takes `id` and `values`; a value may be an atomic operator computed by the server: `{ "$inc": n }`, `{ "$dec": n }` (int, float; null counts as 0), `{ "$min": v }`, `{ "$max": v }` (int, float, date, datetime), `{ "$append": v }`, `{ "$remove": v }` (json arrays), `{ "$set": v }` (a literal that would otherwise look like an operator). A value is an operator when it is an object with exactly one key starting with `$`.
- **delete** takes `id`.
- **expect** (update, delete): column values that must hold on the row at write time, as literals (equality) or comparisons `{ "$eq" | "$ne" | "$gt" | "$gte" | "$lt" | "$lte": v }`. When they do not hold, nothing changes and the answer is 409 `conflict` with the current `row` (left out when the caller may not read it).
- **ifAbsent** (insert): values that no existing row may have; one that does is a 409 with that row.

The order on update and delete is: the row is locked, then 404, then the rule (403), then `expect` (409), then the write. Insert: the rule on the values, then `ifAbsent`, then the insert. A value that breaks a `unique` column is 409 with `column`; one that breaks a check, 409 with `check` (its name). A column may carry its own update rule and `ops` (the operators it takes); an update must pass the rule of every column it writes, and the 403 names the column. A delete applies the `onDelete` of `ref` columns pointing at the row in the same transaction: `restrict` (409 with `ref`), `cascade`, `setNull`.

```http
POST /v1/mutate
X-Api-Key: pk_test_...
Authorization: Bearer 9f2c...
Content-Type: application/json

{ "table": "products", "op": "update", "id": "6c1d...",
  "values": { "stock": { "$dec": 1 } }, "expect": { "stock": { "$gte": 1 } } }
```

## Transactions

`POST /v1/transact` with `{ "ops": [ ... ] }` (1 to 50 ops) runs every op in one serializable Postgres transaction: all or nothing. Each op is `{ "table", "op": "insert" | "update" | "delete" | "check", "id"?, "values"?, "expect"?, "ifAbsent"?, "ref"? }`, checked against its table's rules exactly like a single write. A `check` op only asserts `expect` on a row. An insert may name itself with `"ref": "x"`; later ops refer to its row with `{ "$ref": "x" }` as `id` or as a whole column value.

The answer is `{ "ok": true, "results": [ { "row" }, ... ], "traceIds": [ ... ] }`, one entry per op. A failing op fails the whole transaction with its own status and body plus `index` (its position); nothing is written. When Postgres keeps reporting a serialization conflict after 5 attempts the answer is 409 `{ "error": "conflict", "retryable": true }`: call again. Live subscribers see the changes only after the commit.

## Actions

`POST /v1/actions/<name>` with `{ "params": { ... } }` runs an action of the definition: its steps (transaction ops with placeholders) in one transaction with the server's rights, when the caller passes the action's `allow` rule (else 403 with the explanation). The answer is `{ "ok": true, "results": [ { "row" }, ... ], "traceId", "traceIds" }`, a row being `null` when the caller's read rule hides it. Bad params are a 400 listing every problem; a failing step answers like a failing transaction op, with `index` = the step. SDKs offer it as `action(name, params)`. An action with an [`ai` block](https://dabadata.com/docs/ai/index.md) adds `ai` to the answer (`{ status: "done", text, stopReason, usage, traceId }`, `{ status: "failed", error, traceId }` or `{ status: "queued", id }`), may answer 429 `rate_limited` / `ai_busy` with `Retry-After`, and 502 `ai_failed` when it has no steps and the model call failed.

## The /v1/live socket

When `GET /v1/meta` says `"realtime": "inproc"` (self-hosted servers), live data comes over one WebSocket per client:

```
wss://<host>/v1/live?key=pk_test_...&token=<access token>
```

Leave out `token` for an anonymous socket. A bad or expired token fails the upgrade with 401 (a browser sees the socket close before it opens); an origin outside the environment's list fails with 403 `origin_not_allowed`; a workspace at its connection allowance with 403 `plan_limit`. Messages are JSON text frames:

| Direction | Message | Meaning |
| --- | --- | --- |
| server | `{ "type": "hello", "project", "projectId", "env" }` | first message after the upgrade |
| client | `{ "type": "subscribe", "id": "s1", "table": "tasks", "where"?: {...} }` | `id` is the client's, unique on the socket |
| client | `{ "type": "subscribe", "id": "s2", "view": "open_questions", "params"?: {...}, "where"?: {...} }` | a [view](https://dabadata.com/docs/views/index.md); exactly one of `table` / `view` |
| server | `{ "type": "rows", "id": "s1", "rows": [...], "reason"? }` | the full result (read rule and `where` applied, at most 1,000 rows); sent again at any time as a reset, with `reason` `"definition"`, `"time"`, `"overflow"`, `"relation"` or `"join"` |
| server | `{ "type": "change", "id": "s1", "op": "insert" \| "update" \| "delete", "row" }` | one per change the subscriber may see; `row` of a delete is `{ "id" }` |
| server | `{ "type": "error", "id": "s1", "error": "forbidden", "explanation" }` | the subscription was refused (or `error` is a message, e.g. an unknown table) |
| client | `{ "type": "unsubscribe", "id": "s1" }` | no more messages for `s1` |
| client / server | `{ "type": "ping" }` / `{ "type": "pong" }` | keep-alive |

Changes made while the first rows load are held and sent right after `rows`, so there is no gap. For each change the server decides per subscriber, with the subscriber's read rule and `where`: an insert whose row passes is an `insert`; an update whose new row passes is an `update` (also when the subscriber did not have the row before, e.g. it was just handed to them: treat an update of an unknown row as an insert); an update whose old row passed but whose new row does not is a `delete`; a delete whose row passed is a `delete`. A subscription to a table the caller may not read at all is refused with the rule's explanation. Other message types (`trace`, `event`, `api_log`, ...) go to operator sockets: ignore them.

Changes are shaped per subscriber: columns they may not read are left out, an update that changes nothing they can see is not sent, and an `update` carries the whole visible row (replace the row, do not merge). Rows may enter or leave by time with no write (a rule on `$now`): they arrive as `update` and `delete` within about a second. A `rows` message for an open subscription replaces what the SDK holds for it, exactly as after a reconnect.

The server may close the socket with code 4001 (project paused or deleted). After any close the SDK reconnects with backoff (1, 2, 4, 8, then every 10 seconds; reset after a successful open), sends every subscription again, and treats the new `rows` as a reset of that subscription's data. Send a ping every 25 seconds so proxies keep the connection.

## The realtime tier (Centrifugo)

Hosted servers answer `"realtime": "centrifugo"` and a `realtimeUrl`. Live data then comes from [Centrifugo v6](https://centrifugal.dev/docs/transports/client_protocol) over one WebSocket, in its JSON protocol; no Centrifugo SDK is needed. The server decides which channels a connection hears; the client never subscribes to channels itself.

### Connection token

`GET /v1/rt/token` with the same credentials as any HTTP call (publishable key plus the user's token, or the key alone for anonymous) answers `{ "token", "url", "expiresAt", "channels": [...] }`. The token is a JWT valid 15 minutes; `channels` is its claim. A 401 refreshes the user's token once, like any call. A workspace at its connection allowance gets 403 `plan_limit`.

| Channel | Carries |
| --- | --- |
| `env:<env>` | nothing (presence only: connection counts) |
| `tbl:<env>.<table>` | every change of a table the user may read entirely, every column, now and later |
| `tbl:<env>.<table>~<role>` | the changes that role sees, shaped once per role, when the read depends on the role, row values or time but not on who the user is (anonymous callers always) |
| `tbl:<env>.<view>~<role>` | a view's changes for that role (publications carry `view`) |
| `usr:<env>#<sub>` | changes this user may see when the read depends on who they are (`owner`, `$user.id`, a demo room), shaped for them, and `resync` |
| `live:<env>.<table>` | ephemeral messages between clients; never data |

`<env>` is `prj_x`, `prj_x_test`, or `default` on a self-hosted default project. A table with no `live:` channel in the claim is one the caller may not read (a strict table included): report it to that subscription as a 403. The token answer also carries `views: { "<name>": { "channel" } | { "error" } }`: the lane of each view whose `allow` passes, or why the view has none - a view whose params do not bind by equality (`<column> = $params.<name>`) is served over HTTP only, and its live subscription reports that `error`.

### Connect, pushes, pings

Open `url`, then send the connect command. Centrifugo may put several JSON objects in one frame, one per line: split on newlines.

```json
{ "id": 1, "connect": { "token": "eyJ...", "name": "my-sdk",
    "subs": { "tbl:prj_x_test.tasks": { "recover": true, "offset": 42, "epoch": "Kx9q" } } } }

{ "id": 1, "connect": { "client": "...", "ping": 25, "pong": true, "expires": true, "ttl": 900,
    "subs": { "tbl:prj_x_test.tasks": { "recoverable": true, "epoch": "Kx9q", "offset": 45,
              "recovered": true, "publications": [ { "data": {...}, "offset": 43 }, ... ] } } } }
```

`subs` on connect lists every data channel (`tbl:`, `usr:`) the SDK holds a position (`offset`, `epoch`) in; leave it out on the first connect. Remember each data channel's `epoch` and `offset` from the reply and from every publication. Publications arrive as pushes:

```json
{ "push": { "channel": "tbl:prj_x_test.tasks",
            "pub": { "offset": 46, "data": { "type": "change", "table": "tasks", "op": "update",
                                               "row": { "id": "6c1d...", "title": "Ship it", "_seq": 1890 }, "seq": 1890 } } } }
```

The data lanes carry `{ "type": "change", "table", "op", "row", "seq" }` (a delete's `row` is `{ "id" }` and its `seq` the tombstone's; a view lane's carries `view` instead of `table`, plus `bind`: the row's values of the columns its params bind) and `{ "type": "resync", "table": "tasks" | null }` (or `"view"`): drop that table's or view's rows (or all) and load again. A change with `"reshape": true` is the same row at a later instant (a time boundary passed, on `~<role>` and `usr:` lanes): apply it when its `seq` is at least the row's known `_seq`. An empty object `{}` from the server is a ping: answer `{}` when the connect reply said `"pong": true`. When nothing at all arrived for the ping interval plus 10 seconds, close and reconnect.

Renew the token 60 seconds before the connection's `ttl` ends: `GET /v1/rt/token?refresh=1`, then send `{ "id": n, "refresh": { "token": "..." } }`. If the renewal is refused (the user signed out) Centrifugo closes the connection when the token expires. The server disconnects with code 4100 (`resync`: the definition or the user's role changed) and 4403 (`revoked`); after 4100 reconnect and reload every subscription.

### Applying changes

1. **Load over HTTP.** A subscription to `table` with `where` loads `GET /v1/db/<table>?limit=1000&where=...` after the connection is up and keeps its `cursor`. Pushes for that table that arrive during the load are applied after it.
2. **At most once per row.** Track, per row id, the highest `seq` applied or deleted. A change whose `seq` is not above it is a duplicate (recovery and catch-up may repeat changes): drop it.
3. **Filter.** Apply changes of the subscription's table whose row matches its `where` (for a view: whose `bind` values equal the subscription's params - a null on either side never matches); an update whose row no longer matches is a delete; an update of a row not held is an insert. An update replaces the row; it is never merged.
4. **Never apply `live:`.** Anything on a `live:` channel is an ephemeral message from another client (typing, cursors): hand it to the app, never to the data.

### Recovery and catching up

On reconnect, send each data channel's position with `recover: true`. For a channel whose reply says `recovered: true`, the missed publications come in `publications` (and are applied like pushes). For a channel that did not recover (history is kept 10 minutes, 200 publications), or that is new in the claim, every subscription of the tables it carries catches up over HTTP:

```http
GET /v1/db/tasks?since=1840&where=...&limit=1000

{ "rows": [ ...changed rows the caller may read, in _seq order... ],
  "deleted": [ { "id": "6c1d...", "_seq": 1871 } ], "cursor": 1890, "more": false, "traceId": 77 }
```

Apply `rows` as inserts (unknown id) or updates (known id) and `deleted` as deletes of rows held, keep the new `cursor`, and ask again while `more` is true. A caller whose read of the table depends on time gets `{ "reset": true, "cursor", "traceId" }` with no rows instead (time moves rows without a new `_seq`): load the subscription again. Views answer no `?since=`: a view lane that lost history reloads the view over HTTP. A cursor older than 6 days reloads the whole subscription instead (deletions are remembered 7 days). While a catch-up or reload runs the connection status is `resyncing`.

Presence: `GET /v1/rt/presence?table=` answers who is connected to a table's live lane, `{ "presence": [ { "id", "name"?, "role" } ] }`. Publishing an ephemeral message: `{ "id": n, "publish": { "channel": "live:<env>.<table>", "data": {...} } }` (signed-in users only).

## End-user sign-in routes

When a project hosts its users' accounts (`auth.users.enabled`), these routes take the publishable key and nothing else (404 when the project does not host accounts). `tokens` is a [pair](https://dabadata.com/docs/protocol/index.md#tokens); `user` is `{ id, email, name, role, emailVerified, hasPassword, createdAt }`.

| Route | Body | Answer |
| --- | --- | --- |
| `POST /v1/auth/users/signup` | `{ email, password, name?, redirectUrl? }` | `{ ok, verify: true, signupSecret }` (a link was mailed), or `{ ok, verify: false, tokens, user }`; 403 `signup_closed` |
| `POST /v1/auth/users/verify` | `{ token, signupSecret? }` | `{ tokens, user, redirectUrl }` |
| `POST /v1/auth/users/signin` | `{ email, password }` | `{ tokens, user }`; 401 `invalid_login`, 403 `email_unverified` / `user_disabled` |
| `POST /v1/auth/users/magic` | `{ email, redirectUrl? }` | `{ ok: true }` (a sign-in link is mailed) |
| `POST /v1/auth/users/magic/verify` | `{ token }` | `{ tokens, user, redirectUrl }` |
| `POST /v1/auth/users/forgot` | `{ email, redirectUrl? }` | `{ ok: true }` |
| `POST /v1/auth/users/reset` | `{ token, password }` | `{ tokens, user, redirectUrl }` |
| `GET /v1/auth/users/me` | (the user's token) | `{ user }` |
| `PATCH /v1/auth/users/me` | `{ name?, password?, currentPassword? }` | `{ user }`, plus a new `tokens` after a password change |
| `POST /v1/auth/users/signout` | `{ refreshToken? }` or `{ everywhere: true }` | `{ ok, revoked: { tokens, refreshTokens } }` |

Links in mails go to the project's hosted pages, which send the person back to `redirectUrl` with the pair in the URL fragment (`#token=...&refreshToken=...&expiresAt=...&refreshExpiresAt=...`). An app that handles links itself asks for `redirectUrl?mode=app` and gets `#token=<link token>&type=verify|magic|reset|invite`; it posts the token to the matching route (reset and invite with a new password). Keep the `signupSecret` of a sign-up for 24 hours and send it with that sign-up's verify link: only then do the password and name given at sign-up survive. Passwords are 10 to 200 characters. Details on [Sign-in for your app's users](https://dabadata.com/docs/end-user-auth/index.md).

## What a conforming SDK does

**Must**

1. Send the publishable key as `X-Api-Key` on every HTTP call and as `?key=` on sockets (hosted projects); never take or embed a secret key in a device app.
2. Send the user's access token as `Authorization: Bearer` (HTTP) and `?token=` (sockets) when signed in, and nothing when anonymous.
3. Turn every non-2xx answer into an error with the status, `error`, `explanation`, `traceId` and the body; a network failure is status 0.
4. Follow the six [refresh rules](https://dabadata.com/docs/protocol/index.md#tokens): 60 s ahead (never sooner than 5 s), one refresh and one retry on 401, one refresh at a time, drop a refresh token answered 401, hand every new pair to the app, refresh once per failed socket connect streak when the token is expired.
5. Pick the live transport from `GET /v1/meta` and speak both: [`/v1/live`](https://dabadata.com/docs/protocol/index.md#live) and [Centrifugo](https://dabadata.com/docs/protocol/index.md#centrifugo).
6. On `/v1/live`: send every subscription again after a reconnect and treat every `rows` as a reset, whenever it arrives; apply `change` messages (an update of an unknown row is an insert and replaces a known one; a delete carries only `id`); ignore unknown message types.
7. On Centrifugo: recover with offsets and epochs; apply each row change at most once by `seq` (a `reshape` change at an equal `seq` too); catch up with `?since=` when history is lost, and load again on `reset: true`; reload on `resync` (also when it arrives while that subscription loads: load once more after it) and on close code 4100; renew the connection token 60 s before it expires; answer pings; never apply `live:` messages as data.
8. Reconnect after any close the app did not ask for, with backoff 1, 2, 4, 8, then 10 seconds.
9. Send values as the JSON types above, and operator objects as given; a literal value that looks like an operator goes in `{ "$set": v }` (values) or `{ "$eq": v }` (`expect`).

**Should**

1. Offer helpers for the operators (`inc`, `dec`, `min`, `max`, `append`, `remove`), `transact`, and a read-modify-write helper that writes with `expect` on the values it read and retries 409s that carry no `column`, `columns`, `check` or `constraint` (with a random wait up to 200 ms x 2^n).
2. Report a connection status: `connecting`, `open`, `reconnecting`, `resyncing`, `closed`.
3. Wait `Retry-After` seconds after a 429 before trying again.
4. Store the hosted sign-in session (the pair) in the platform's secure storage and restore it while the refresh token is valid.
5. Pass the conformance suite.

## The conformance suite

`conformance/` in the repository holds a fixed project definition (`project.json`: tables whose rules cover `anyone`, `authenticated`, roles, `owner`, `nobody`, a unique column and a check), more than 50 cases as JSON, and a runner that replays them against any server through any SDK:

```bash
bun conformance/run.ts --base http://localhost:4600 --admin-key "$DABADATA_ADMIN_KEY" --adapter all
```

The runner creates a throwaway project with the server's admin API, mints tokens for the personas (alice is an admin, bob an editor, carol a viewer, plus anonymous and the secret key), empties the tables before every case, runs it, prints a report and deletes the project. Each case is a list of steps: who acts (`as`), a raw HTTP `call` or an SDK `op` (`insert`, `update`, `delete`, `query`, `transact`, `subscribe`, `unsubscribe`, `refresh`, `setToken`), and what must come back: `status`, a subset of the body (`bodyMatch`) and the exact live messages each subscription receives (`liveMessages`).

An SDK joins by implementing the adapter interface in `conformance/lib/types.ts`: open a session per case, run an op as a persona and answer `{ status, body }` in the canonical shape, and hand over the live messages a subscription received. `adapters/raw.ts` is the protocol written out by hand in about 200 lines, a good template for a port; `adapters/js.ts` drives the JavaScript client. Both pass every case.
