# HTTP API

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

The whole dabadata API is a few dozen JSON endpoints under `/v1` and one WebSocket. The examples use the hosted service at `https://dabadata.com`; on a server of your own use its address, such as `http://localhost:4600` ([Self-hosting](https://dabadata.com/docs/self-hosting/index.md)). Replace `<project key>` (on a hosted project, the environment's secret key), `<secret key>` and `<user token>` with your own.

**On this page:** [conventions and errors](https://dabadata.com/docs/api/index.md#conventions) - rows and schema ([schema](https://dabadata.com/docs/api/index.md#schema), [mutate](https://dabadata.com/docs/api/index.md#mutate), [db](https://dabadata.com/docs/api/index.md#db-list), [trace](https://dabadata.com/docs/api/index.md#trace), [live](https://dabadata.com/docs/api/index.md#live)) - [user tokens](https://dabadata.com/docs/api/index.md#users-token) - [Project API](https://dabadata.com/docs/api/index.md#project) - [File storage](https://dabadata.com/docs/api/index.md#storage) - [Sites](https://dabadata.com/docs/api/index.md#sites) - [Logs](https://dabadata.com/docs/api/index.md#logs) - [SQL console](https://dabadata.com/docs/api/index.md#sql) - [End-user accounts](https://dabadata.com/docs/api/index.md#end-users) - [Console accounts](https://dabadata.com/docs/api/index.md#console-session) - [Workspaces](https://dabadata.com/docs/api/index.md#workspaces).

> **Machine-readable:** Every route on this page is also described as OpenAPI 3.1 at [`GET /v1/openapi.json`](https://dabadata.com/v1/openapi.json) (public, served by every dabadata server): the credentials, request and response shapes and error bodies, for code generators and AI agents. Agents start at [Build with an AI agent](https://dabadata.com/docs/agents/index.md).

## Conventions

- Request and response bodies are JSON. Send `Content-Type: application/json` with a body.
- Credentials go in `Authorization: Bearer <token>`. User and view tokens - and the JWT of an [identity provider the project trusts](https://dabadata.com/docs/identity/index.md#providers) (Google, Auth0, Clerk, your own) - may also be passed as `?token=`; the project key only as a Bearer header. Who may call what is summed up in [Identity and tokens](https://dabadata.com/docs/identity/index.md#what-each-may-do).
- Calls that touch rows return a `traceId`: the id of the call's row in the [trace](https://dabadata.com/docs/trace/index.md).
- **Project key** below means the credential with the `service` identity: on a self-hosted server the value of `DABADATA_PROJECT_KEY`, on a hosted project the secret key of the environment (`sk_live_...` or `sk_test_...`). Both go in `Authorization: Bearer`.
- A hosted project's **publishable key** (`pk_live_...` or `pk_test_...`) goes in the `X-Api-Key` header or `?key=`. It names the environment and grants nothing by itself; anonymous calls and provider JWTs need it, tokens dabadata minted find their environment without it. See [How a call finds its project](https://dabadata.com/docs/projects/index.md#resolution).
- The [console](https://dabadata.com/docs/console/index.md) calls the project routes too, with a session cookie, the header `X-Requested-With` and `X-Project: prj_...` naming the environment. On a hosted project, routes that take the secret key take a console session as well, within the limits of the account's role (viewers: reads only). See [Console accounts](https://dabadata.com/docs/api/index.md#console-session).
- Routes that exist for one kind of project only say so under **Where**. "Default project" is a self-hosted server's own project, the one set up by `DABADATA_PROJECT_KEY`; "hosted" is a project with a `prj_` id and keys.

### Errors

Every error is a JSON object with an `error` code:

| Status | Body | When |
| --- | --- | --- |
| `400` | `{ "error": "validation", "messages": string[] }` | The request is malformed or a value does not fit. Row calls add `traceId`. |
| `401` | `{ "error": "unauthorized" }` | Missing or wrong credentials for this endpoint, or a token that matches nothing. |
| `403` | `{ "error": "forbidden", "explanation", "traceId" }` | A rule denied the call. `explanation` says why. |
| `403` | `{ "error": "plan_limit", "limit", "message" }` | Hosted: over a plan limit, `limit` being `connections`, `storage`, `egress`, `projects`, `members`, `workspaces`, `files`, `maxFileBytes`, `sites`, `customDomains`, `siteBytes` or `siteFiles`. See [Plans and limits](https://dabadata.com/docs/limits/index.md#at-the-limit). |
| `403` | `{ "error": "origin_not_allowed" }` | Hosted: a WebSocket from an origin the environment does not [allow](https://dabadata.com/docs/projects/index.md#cors). |
| `403` | `{ "error": "csrf", "message" }` | A write to the [console's account routes](https://dabadata.com/docs/api/index.md#console-session) without `X-Requested-With`. |
| `404` | `{ "error": "not found", "message"? }` | Unknown route, table or row. |
| `405` | `{ "error": "method not allowed" }` | Known path, wrong method. |
| `409` | `{ "error": "code", "message" }` | A conflict with the current state; see [the codes](https://dabadata.com/docs/api/index.md#error-codes). |
| `410` | `{ "error": "project_deleted" }` | Hosted: the project is being deleted. Only a valid credential gets this; a wrong one is still a 401. |
| `415` | `{ "error": "unsupported_media_type", "message" }` | A body that is not `application/json` on the console's account routes. |
| `429` | `{ "error": "rate_limited", "retryAfter" }` | Hosted (and the demo's token route), sign-in, sign-up, invitations, SQL and the model calls of [actions](https://dabadata.com/docs/api/index.md#actions) (which may also answer `ai_busy`): too many calls. Wait `retryAfter` seconds, also sent as the `Retry-After` header. |
| `500` | `{ "error": "internal error" }` | A bug; the details are in the server log. |
| `502` | `{ "error": "ai_failed", "message", "ai", "traceId" }` | An [action](https://dabadata.com/docs/api/index.md#actions) with no steps whose [model call](https://dabadata.com/docs/ai/index.md) failed; `message` names the cause. |
| `503` | `{ "error": "secrets_unavailable" }` | The [secrets routes](https://dabadata.com/docs/api/index.md#project-secrets-put) on a server started without its encryption key. |
| `503` | `{ "error": "sql_unavailable", "message" }` | The [SQL console](https://dabadata.com/docs/api/index.md#sql-run) cannot reach the database as its roles. |

```json
{ "error": "validation", "messages": ["missing required column \"title\""], "traceId": 57 }
```

### Codes of the account, sign-in and SQL routes

| Status | `error` | When |
| --- | --- | --- |
| `400` | `link_invalid` | A mailed link's token is unknown, used or expired (console and end-user links). |
| `400` | `sql` | Postgres refused a [SQL console](https://dabadata.com/docs/api/index.md#sql-run) query; with `message`, `code` and maybe `position`. |
| `400` | `commit_token_invalid` | A SQL commit token that is unknown, used, expired or not yours. |
| `401` | `invalid_login` | Wrong email or password ([console](https://dabadata.com/docs/api/index.md#auth-login), [end users](https://dabadata.com/docs/api/index.md#eu-signin)). 403 when it is the current password of a password change. |
| `403` | `email_unverified` | The right password, but the address is not confirmed yet. Nothing is mailed. |
| `403` | `user_disabled` | An end user the project disabled. |
| `403` | `signup_closed` | End-user sign-up is by invitation only. |
| `403` | `plan_requires_approval` | During the beta, Pro and Scale are switched on by us. |
| `409` | `over_limit` | A plan change down while the workspace uses more than the new plan allows; `messages` says what. |
| `409` | `billing_required` | Billing is on: a paid plan starts with checkout and changes in the billing portal. |
| `409` | `billing_off` | A billing route (checkout, portal, caps) while billing is off - the beta. |
| `409` | `subscription_exists`, `subscription_required`, `no_customer` | Checkout with a subscription in force; raising caps without one; the portal before the first checkout. |
| `400` | `invalid_signature` | A [Stripe webhook](https://dabadata.com/docs/api/index.md#billing-webhook) whose signature does not check out. |
| `502` | `billing_unavailable` | The payment provider did not answer; try again in a minute. |
| `409` | `already_member`, `already_exists` | Inviting a member; adding an end user whose address has an account. |
| `409` | `email_mismatch` | Accepting an invitation while signed in with another address. |
| `409` | `conflict` | Members changed meanwhile (a transfer and a role change at once); reload and try again. |

### CORS

Every `/v1` response carries `Access-Control-Allow-Headers: Authorization, Content-Type, X-Api-Key` and `Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS`. Any `OPTIONS` preflight is answered with 204 and `Access-Control-Max-Age: 600`.

`Access-Control-Allow-Origin` is `*`: a page on any origin can call the API with a user token, which is safe precisely because the token, not the origin, decides what is allowed. A hosted environment may narrow this to a list of [allowed origins](https://dabadata.com/docs/projects/index.md#cors). Then a request from a listed origin gets that origin back (with `Vary: Origin`), a request from any other origin gets no `Access-Control-Allow-Origin` (the browser blocks the read), a WebSocket from it is refused with 403 `origin_not_allowed`, and requests without an `Origin` header (servers, curl) are never blocked. Answers that belong to no project - health, meta, a 401 before the project is known, unknown routes - always carry `*`.

## GET /v1/health

**Who** Anyone, no token needed.

Liveness check. It does not touch the database.

```bash
curl https://dabadata.com/v1/health
```

```json
{ "ok": true }
```

## GET /v1/meta

**Who** Anyone, no token needed.

What this server is: its display name, the version of the API contract it speaks, how its sockets fan out, and whether it hosts projects.

```bash
curl https://dabadata.com/v1/meta
```

```json
{ "name": "dabadata", "contract": "2026-10-15", "realtime": "inproc", "hosted": false }
```

| Field | Meaning |
| --- | --- |
| `name` | The display name (`DABADATA_BRAND` on a self-hosted server, default `dabadata`). Show this rather than hard-coding a name. |
| `contract` | The date of the API contract the server implements. |
| `realtime` | `"inproc"`: live changes fan out inside the server process. |
| `hosted` | `true` when the server hosts projects (its admin API is on). |

## GET /v1/schema

**Who** Project key, view token or user token. Anonymous: 401.

The definition in force: roles, every table with its columns and rules, and any drift between the definition and the database.

```bash
curl https://dabadata.com/v1/schema -H "Authorization: Bearer <user token>"
```

```json
{
  "project": "default",
  "roles": ["admin", "editor", "viewer"],
  "tables": {
    "tasks": {
      "columns": { "title": { "type": "text" }, "done": { "type": "bool", "default": false } },
      "rules": { "read": "authenticated", "delete": { "role": "admin" } }
    }
  },
  "auth": null,
  "drift": []
}
```

`access` is the project's default table access (`"open"` when not set) and `views` the declared [views](https://dabadata.com/docs/views/index.md). The secret key, view tokens and console sessions get every table and view as declared; end users get the open tables only, and each view in the shape their role gets (`{ allow, params, columns, computed: [names], filter, sort, orderBy?, desc?, limit }`, no `rows`).

`auth` is the definition's `auth` block as declared (`users`, `issuers`), or null. Issuers name their secrets (`secret`, `secretEnv`); a secret's value is never in it.

`project` is the project's name: `default` here, and on a hosted project the environment's name, `prj_example12345` for live and `prj_example12345_test` for test.

## POST /v1/schema/reload

**Who** Project key only.

**Where** Default project only. A hosted project has no file and answers 400 `this project has no file; use PUT /v1/schema`.

Imports `projects/<name>.ts` again, validates it and applies it: creates the project schema if missing, then missing tables and columns. Never drops anything or changes a type. No request body. See [What a reload does](https://dabadata.com/docs/tables/index.md#reload).

```bash
curl -X POST http://localhost:4600/v1/schema/reload -H "Authorization: Bearer <project key>"
```

```json
{
  "ok": true,
  "applied": ["create table comments"],
  "drift": ["column tasks.priority exists in db but not in definition"],
  "version": 4
}
```

Every reload is stored as a new [version](https://dabadata.com/docs/api/index.md#schema-versions) (`createdBy: "file"`), even when nothing changed.

| Status | Meaning |
| --- | --- |
| `400` | The file is invalid; `messages` lists every problem. Nothing was applied and the previous definition stays. |
| `401` | Not the project key. |

## PUT /v1/schema

**Who** Project key only (the environment's secret key).

**Where** Hosted projects. The default project answers 400 `the default project is defined by projects/default.ts - edit it and POST /v1/schema/reload`.

Validates a definition in its JSON form and applies it - or, with `dryRun`, only says what applying it would do. It is validated exactly like a [project file](https://dabadata.com/docs/tables/index.md#project-file), plus the [hosted issuer rules](https://dabadata.com/docs/identity/index.md#hosted), and applied the way a reload applies a file: missing tables and columns are created, nothing is dropped or retyped. See [Definitions on a hosted project](https://dabadata.com/docs/tables/index.md#hosted).

| Field | Type | Meaning |
| --- | --- | --- |
| `definition` | object, required | The [JSON form](https://dabadata.com/docs/tables/index.md#hosted) of the definition. `name` is optional; it is filled with the environment's name. |
| `dryRun` | boolean, optional | `true`: change nothing and answer with the plan. |
| `expectVersion` | integer, optional | The version you edited from (the newest of [`GET /v1/schema/versions`](https://dabadata.com/docs/api/index.md#schema-versions) when you read it; `0` = none yet). If a newer version was applied meanwhile, the answer is `409` and nothing changes. Send it whenever you read the definition, change part of it (for example `auth.users.redirectUrls`) and write it back, so a change made by someone else is not overwritten. A dry run checks it too. |

```bash
curl -X PUT https://dabadata.com/v1/schema \
  -H "Authorization: Bearer <secret key>" \
  -H "Content-Type: application/json" \
  -d @- <<'EOF'
{ "dryRun": true,
  "definition": {
    "roles": ["admin", "editor", "viewer"],
    "tables": {
      "tasks": {
        "columns": { "title": { "type": "text" }, "done": { "type": "bool", "default": false },
                     "owner": { "type": "userId" } },
        "rules": { "read": "authenticated", "create": { "role": ["admin", "editor"] },
                   "update": { "any": [{ "role": "admin" }, { "owner": "owner" }] }, "delete": { "role": "admin" } }
      }
    }
  }
}
EOF
```

A dry run answers with the plan, in the same words `applied` will use, and the drift the database would show afterwards:

```json
{ "ok": true, "plan": ["create table tasks"], "drift": [] }
```

Without `dryRun` the definition is applied and stored as the next version. It is in force at once: the next call, open subscriptions and live fan-out use it.

```json
{ "ok": true, "applied": ["create table tasks"], "drift": [], "version": 2 }
```

| Status | Meaning |
| --- | --- |
| `400` | `{ "error": "validation", "messages" }` with every problem, e.g. `tasks: no read rule (rules.read must be explicit, even "nobody")`, `tasks.rules.delete: unknown role "owner"`, `definition is required (the JSON form)`, `dryRun must be true or false`. Nothing is applied and the definition in force stays. |
| `409` | `{ "error": "conflict", "message": "the definition changed: the newest version is 5, not 4", "version": 5 }`: `expectVersion` is not the newest version. Read version 5, make your change on it, and apply again. |
| `401` | Not the environment's secret key. |

## GET /v1/schema/versions

**Who** Project key only.

**Where** Both. The default project lists the versions its file produced.

Every definition the environment has had, newest first. A version is recorded by every apply: `PUT /v1/schema` and rollback (`createdBy: "secret key"`), project creation (`"admin"`, version 1), and on the default project every reload and every start-up that found a changed file (`"file"`). A dry run records nothing.

```bash
curl https://dabadata.com/v1/schema/versions -H "Authorization: Bearer <secret key>"
```

```json
{
  "versions": [
    { "version": 2, "createdAt": "2026-10-15T09:40:00.000Z", "createdBy": "secret key", "tables": 2 },
    { "version": 1, "createdAt": "2026-10-15T09:00:00.000Z", "createdBy": "admin", "tables": 1 }
  ]
}
```

`tables` is the number of declared tables in that version.

## GET /v1/schema/versions/:n

**Who** Project key only.

**Where** Both.

One version with its whole definition, as validated and stored (with `name` filled in). Postgres does not keep key order in JSON, so tables come back in storage order, not in the order they were declared.

```json
{
  "version": 1,
  "definition": { "name": "prj_example12345", "roles": ["admin", "editor", "viewer"], "tables": { "tasks": { ... } } },
  "createdAt": "2026-10-15T09:00:00.000Z",
  "createdBy": "admin"
}
```

| Status | Meaning |
| --- | --- |
| `404` | `:n` is not a positive whole number, or there is no such version. |

## POST /v1/schema/drop-table

**Who** The environment's secret key, or the console (developer and up).

Drops a table that is in the database but no longer in the definition (drift, such as the `tasks` table of the first project's template), with its rows - and the [files](https://dabadata.com/docs/storage/index.md#file-columns) its file columns held, unless another table holds them. Declared tables are refused. Audited.

| Field | Type | Meaning |
| --- | --- | --- |
| `table` | string, required | The table to drop. |
| `dryRun` | boolean | `true`: answer the row count, drop nothing. |
| `confirm` | string | The table name again; required unless `dryRun`. |

```json
{ "ok": true, "table": "tasks", "rows": 3, "drift": [] }
```

## POST /v1/schema/migrate

**Who** The environment's secret key, or the console (developer and up).

**Where** Hosted projects. The default project answers 400.

Drops, renames and type changes - what an apply never does - then the definition applied, in one transaction with a 5 s lock timeout. A guide with examples is in [Tables](https://dabadata.com/docs/tables/index.md#migrate).

| Field | Type | Meaning |
| --- | --- | --- |
| `steps` | array, required | 1-50 steps, run in order: `{ dropTable: "t" }`, `{ dropColumn: "t.c" }`, `{ renameTable: "t", to: "u" }`, `{ renameColumn: "t.c", to: "d" }`, `{ changeType: "t.c", to: "<column type>" }`. |
| `definition` | object | The definition (JSON form) applied after the steps and stored as a new version. Default: the current one, applied again without a new version. |
| `dryRun` | boolean | `true`: run it all and roll back; answer what it met. |
| `confirm` | string[] | Every `dropTable`, `dropColumn` and `changeType` target; required for those unless `dryRun`. |
| `expectVersion` | integer | As for [`PUT /v1/schema`](https://dabadata.com/docs/api/index.md#schema-put): 409 when the definition changed meanwhile. |

A dry run answers `{ ok, dryRun, plan, blocking, messages, drift }`: the step labels with counts followed by the apply's steps, the rows that do not convert (`{ step, rows, sample: [{ id, value }] }`, five at most), what would refuse it (empty when it would succeed), and the drift afterwards. A migrate answers:

```json
{ "ok": true, "applied": ["rename table tasks to todos"], "drift": [], "version": 4 }
```

| Status | When |
| --- | --- |
| `400` | A malformed step or body; `confirm` missing or different; a step refused (`step 1 (drop table tasks): tasks is declared in the definition - send the definition without it`), a target that does not exist, rows that block a type change (with `blocking`), an invalid definition, or a database error naming the step. Nothing changed. |
| `401` | Not the secret key or a console session. |
| `403` | A console role below developer. |
| `409` | `expectVersion` is not the newest version. |

## POST /v1/schema/rollback

**Who** Project key only (the environment's secret key).

**Where** Hosted projects. The default project answers 400, as for `PUT`: roll its file back instead.

Applies an earlier version's definition again, as a new version. Like every apply it only adds: tables and columns the old version does not have stay in the database, with their data, and show up as drift.

| Field | Type | Meaning |
| --- | --- | --- |
| `version` | number, required | The version to go back to, a positive whole number. |

```bash
curl -X POST https://dabadata.com/v1/schema/rollback \
  -H "Authorization: Bearer <secret key>" \
  -H "Content-Type: application/json" \
  -d '{"version":1}'
```

```json
{
  "ok": true,
  "applied": [],
  "drift": ["table comments exists in db but not in definition"],
  "version": 3
}
```

| Status | Meaning |
| --- | --- |
| `400` | `version must be a positive integer`, or the old definition no longer validates - for example it names a [secret](https://dabadata.com/docs/api/index.md#project-secrets-put) that has since been deleted. |
| `401` | Not the environment's secret key. |
| `404` | No such version. |

## POST /v1/users/token

**Who** Project key only - call it from your server.

Mints a token pair for one user: an access token that lives 1 hour and a refresh token that lives 30 days. See [Refreshing](https://dabadata.com/docs/identity/index.md#refresh).

| Field | Type | Meaning |
| --- | --- | --- |
| `id` | string, required | Your id for the person. Not empty. |
| `name` | string, optional | Display name for the trace and the user list. |
| `role` | string, required | One of the project's declared roles. |

```bash
curl -X POST https://dabadata.com/v1/users/token \
  -H "Authorization: Bearer <project key>" \
  -H "Content-Type: application/json" \
  -d '{"id":"bob","name":"Bob","role":"editor"}'
```

```json
{
  "token": "<user token>",
  "expiresAt": "2026-10-15T10:30:00.000Z",
  "refreshToken": "<refresh token>",
  "refreshExpiresAt": "2026-11-14T09:30:00.000Z"
}
```

| Field | Meaning |
| --- | --- |
| `token` | The access token, sent as `Authorization: Bearer`. 64 hex characters. |
| `expiresAt` | When `token` expires: 1 hour from now. |
| `refreshToken` | Exchanges for the next pair at [`POST /v1/users/refresh`](https://dabadata.com/docs/api/index.md#users-refresh). 64 hex characters. |
| `refreshExpiresAt` | When `refreshToken` expires: 30 days from now. |

| Status | Meaning |
| --- | --- |
| `400` | e.g. `id must be a non-empty string`, `role "owner" is not declared (declared: ["admin","editor","viewer"])`. |
| `401` | Not the project key. |

## POST /v1/users/refresh

**Who** Anyone holding a refresh token. No Authorization header: the refresh token is the credential, and a stray header is ignored.

Exchanges a refresh token for a new pair for the same user (`{ id, name?, role }` as stored when the pair was first minted). The JavaScript client calls it by itself when it has a refresh token.

| Field | Type | Meaning |
| --- | --- | --- |
| `refreshToken` | string, required | The refresh token from the last pair. |

```bash
curl -X POST https://dabadata.com/v1/users/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"<refresh token>"}'
```

```json
{
  "token": "<new user token>",
  "expiresAt": "2026-10-15T11:30:00.000Z",
  "refreshToken": "<new refresh token>",
  "refreshExpiresAt": "2026-11-14T10:30:00.000Z"
}
```

**Rotation.** The refresh token you send is deleted and a new one is issued, valid for 30 days from now. Keep the new one; the old one will never work again. Access tokens the user already holds keep working until their own expiry. Use is single-shot even under a race: two calls with one refresh token get one new pair and one 401.

| Status | Meaning |
| --- | --- |
| `400` | The body is not JSON, or has no string `refreshToken`. |
| `401` | `{ "error": "unauthorized" }`: the refresh token is unknown, expired (it is deleted on sight), already used, revoked, or from another project - or a publishable key sent with it belongs to another environment. |

> **Note:** On a hosted project this route is rate-limited: 30 refreshes and exchanges a minute per client IP, and 60 token calls a minute per environment ([Token routes](https://dabadata.com/docs/limits/index.md#token-routes)); over them it answers 429. The default project has no limit here, so put one in front of it where you host it (production runs it behind nginx, which caps it).

## POST /v1/users/exchange

**Who** Anyone holding a JWT from an issuer in the project's `auth` block. No Authorization header: the JWT is the credential, and a stray header is ignored.

Trades a provider JWT for a dabadata pair for the same identity, so the app can use dabadata's refresh model instead of resending the provider's token. The JWT is verified exactly as when it is sent as a Bearer token (issuer, signature, `exp`, `nbf`, audience); see [Your own identity provider](https://dabadata.com/docs/identity/index.md#providers).

| Field | Type | Meaning |
| --- | --- | --- |
| `jwt` | string, required | The provider's token, for example a Google ID token or an Auth0 access token. |

```bash
curl -X POST https://dabadata.com/v1/users/exchange \
  -H "Content-Type: application/json" \
  -d '{"jwt":"<provider JWT>"}'
```

```json
{
  "token": "<user token>",
  "expiresAt": "2026-10-15T10:30:00.000Z",
  "refreshToken": "<refresh token>",
  "refreshExpiresAt": "2026-11-14T09:30:00.000Z"
}
```

The pair is the same as [`POST /v1/users/token`](https://dabadata.com/docs/api/index.md#users-token) mints, for `{ id, name?, role, issuer }` as read from the JWT, and it follows dabadata's lifetimes (1 hour, 30 days sliding), not the JWT's own `exp`. Exchanged users appear in [`GET /v1/users`](https://dabadata.com/docs/api/index.md#users) and [`POST /v1/users/revoke`](https://dabadata.com/docs/api/index.md#users-revoke) signs them out of dabadata (not out of the provider).

| Status | Meaning |
| --- | --- |
| `400` | The body is not JSON, or has no string `jwt`. |
| `401` | `{ "error": "unauthorized" }`: the JWT does not verify - unknown issuer, bad signature, expired, not valid yet, wrong audience, no id claim - or the project declares no issuers. The reason is not sent back. |

> **Note:** On a hosted project, send the environment's publishable key with the call (`X-Api-Key` or `?key=`): the JWT is checked against that environment's issuers, and without a key the call goes to the server's default project. Hosted exchanges share refresh's rate limits; the default project has none here, so put one in front of it where you host it.

## POST /v1/users/revoke

**Who** Project key only.

Signs a user out everywhere: deletes every access token and every refresh token of that user id in this project. Sockets that are already open keep going until they close.

| Field | Type | Meaning |
| --- | --- | --- |
| `id` | string, required | The user id to revoke. Not empty. |

```bash
curl -X POST https://dabadata.com/v1/users/revoke \
  -H "Authorization: Bearer <project key>" \
  -H "Content-Type: application/json" \
  -d '{"id":"bob"}'
```

```json
{ "ok": true, "revoked": { "tokens": 2, "refreshTokens": 1 } }
```

An id with nothing to revoke answers 200 with zero counts.

| Status | Meaning |
| --- | --- |
| `400` | Missing or empty `id`. |
| `401` | Not the project key. |

## GET /v1/users

**Who** Project key or view token.

Everyone who holds tokens, one entry per user id, newest first. It reads access tokens and refresh tokens together: name and role come from the newest token of either kind, and `lastSeen` is when that newest one was created (minted or refreshed). A user whose access tokens have all expired but who still holds a refresh token is listed; a revoked user is not.

Only token-minted users are listed (`/v1/users/token`, `/v1/users/exchange`, demo tokens). Someone who calls with a provider JWT directly leaves nothing behind to list; they show up in the [trace](https://dabadata.com/docs/api/index.md#trace), with the issuer that vouched for them.

```json
{
  "users": [
    { "id": "bob", "name": "Bob", "role": "editor", "lastSeen": "2026-10-15T09:30:00.000Z" }
  ]
}
```

## POST /v1/mutate

**Who** User token or anonymous, under the rules; the project key, as a bypass. View tokens: 403.

Inserts, updates or deletes one row. The rule is checked on the values for an insert and on the existing row for an update or delete. For writes that many clients make at once, use operators, `expect` or [/v1/transact](https://dabadata.com/docs/api/index.md#transact), never a read followed by a write of the computed value (see [Safe concurrent writes](https://dabadata.com/docs/concurrency/index.md)).

| Field | Type | Meaning |
| --- | --- | --- |
| `table` | string | The table. |
| `op` | `"insert"` \| `"update"` \| `"delete"` | What to do. |
| `id` | string | The row, for update and delete. Not allowed on insert. |
| `values` | object | Insert: the new row's columns (plain values; `{ "$set": v }` is unwrapped). Update: the columns to change (at least one), each a value or an operator (below). Unknown columns and values of the wrong type are a 400. |
| `expect` | object | Update and delete: what must hold on the row at write time, checked under the row lock after the rule. `{ "stock": 3 }` (equality, `null` matches an empty column) or a comparison `{ "stock": { "$gte": 1 } }` (`$eq $ne` on any column; `$gt $gte $lt $lte` on int, float, date, datetime). Does not hold -> 409, nothing changes. |
| `ifAbsent` | object | Insert: an equality map; when a row with these values exists -> 409 with that row, nothing is inserted. Inserts with the same `ifAbsent` values are serialized, so two never both insert. |

On insert, defaults are filled in, every `userId` column you leave out gets the caller's id, and every required column must then be present. Delete answers with the row as it was. The answer's `row` is the full row only when the caller's read rule lets them read it (after the write; for a delete, before it), else `{ "id" }`, and it leaves out columns the caller's [column read rules](https://dabadata.com/docs/rules/index.md#column-read-rules) hide; the secret key always gets the full row.

**Operators.** An update value may be an operator object, computed by Postgres from the stored value inside the UPDATE, so concurrent operators never overwrite each other. The trace stores the operators as sent and the resulting row as `after`.

| Operator | Columns | Result |
| --- | --- | --- |
| `{ "$inc": n }` / `{ "$dec": n }` | int (n an integer), float (n finite) | value + n / value - n; null counts as 0 |
| `{ "$min": v }` / `{ "$max": v }` | int, float, date, datetime | the smaller / larger of value and v; null becomes v |
| `{ "$append": v }` | json holding an array | v added at the end (an array v is one element); null starts `[v]` |
| `{ "$remove": v }` | json holding an array | every element equal to v removed; null stays null |
| `{ "$set": v }` | any | v as a literal (a json value that would look like an operator) |

An object with exactly one key starting with `$` is read as an operator; anything else is a 400 naming the column and the reason (`column "title": $inc applies to int and float columns (title is text)`, `column "tags": $append needs an array (the row holds an object)`).

```bash
curl -X POST https://dabadata.com/v1/mutate \
  -H "Authorization: Bearer <user token>" \
  -H "Content-Type: application/json" \
  -d '{"table":"tasks","op":"update","id":"5f1c2a9e-8b1d-4c2e-9a77-3d0e6b2f4a10","values":{"done":true},"expect":{"done":false}}'
```

```json
{
  "ok": true,
  "row": {
    "id": "5f1c2a9e-8b1d-4c2e-9a77-3d0e6b2f4a10",
    "title": "Order chairs", "done": true, "owner": "bob", "due": null, "notes": null,
    "created_at": "2026-10-15T13:40:02.118Z", "updated_at": "2026-10-15T14:05:41.530Z", "created_by": "bob"
  },
  "traceId": 44
}
```

| Status | Meaning |
| --- | --- |
| `400` | Validation, e.g. `unknown column "colour" in tasks`, `column "done": expected a boolean`, `missing required column "title"`, `update needs values (a non-empty object)`. |
| `401` | A token that matches nothing. |
| `403` | Denied by the rule: `{ "error": "forbidden", "explanation", "traceId" }`. With a view token: `view tokens are read-only`. A [strict table](https://dabadata.com/docs/rules/index.md#strict-tables) for an end user: `insert questions: denied, questions is written through actions only (access: views)` (`update` / `delete` the same), before anything else is checked. |
| `403` | Hosted: `{ "error": "plan_limit", "limit": "storage" \| "egress", "message" }` when the workspace has been over its allowance for 7 days. Reads keep working; see [Storage and egress](https://dabadata.com/docs/limits/index.md#soft-cap). |
| `404` | Unknown table, or no row with that id. |
| `409` | `{ "error": "conflict", "message", "traceId", ... }`, nothing written. `expect` did not hold, or `ifAbsent` found a row: `row` = the current (existing) row, left out when the caller's read rule does not let them see it. A unique column refused it: `column`; a composite unique: `columns`; a table check: `check` (its name). The trace row has `result: "error"` and an `error` starting `conflict`. |

## POST /v1/transact

**Who** As `/v1/mutate`: user token or anonymous, under the rules; the secret key (project key), as a bypass; the console, developer and up. View tokens: 403.

Runs 1 to 50 ops in one Postgres transaction at isolation SERIALIZABLE: all are applied or none is. A serialization failure or deadlock runs the whole transaction again, up to 5 attempts with jittered backoff. Live subscribers get the changes after the commit only.

| Op field | Meaning |
| --- | --- |
| `op` | `"insert"` \| `"update"` \| `"delete"`, as in `/v1/mutate` (with `values`, operators, `expect`, `ifAbsent`), or `"check"`: `{ op, table, id, expect }` only asserts. A check locks the row, is decided by the table's read rule and fails with 409 when `expect` does not hold. |
| `ref` | Insert only: a name (`[A-Za-z0-9_.-]`, 1 to 64) for the row it creates. |
| `id`, a column value | May be `{ "$ref": "<name>" }`: the id of the row an earlier insert of the same transaction created. |

```bash
curl -X POST https://dabadata.com/v1/transact \
  -H "Authorization: Bearer <user token>" \
  -H "Content-Type: application/json" \
  -d '{"ops":[
    {"op":"check","table":"products","id":"p1","expect":{"stock":{"$gte":1}}},
    {"op":"update","table":"products","id":"p1","values":{"stock":{"$dec":1}}},
    {"op":"insert","table":"orders","values":{"product":"p1"},"ref":"o"},
    {"op":"update","table":"carts","id":"c1","values":{"items":{"$inc":1},"last_order":{"$ref":"o"}}}
  ]}'
```

```json
{ "ok": true,
  "results": [ { "row": { "id": "p1", "stock": 4, ... } }, { "row": { "id": "p1", "stock": 3, ... } },
               { "row": { "id": "9b0e...", "product": "p1", ... } }, { "row": { "id": "c1", "items": 2, ... } } ],
  "traceIds": [ 812, 813, 814, 815 ] }
```

Every op writes one trace row (op `check` for checks); the rows of one transaction share `values._tx`. When an op fails, every op is still traced: the failing one with its result, the others with `not applied: the transaction rolled back (op i failed)`.

| Status | Meaning |
| --- | --- |
| `400` | The body (`ops` not a list of 1 to 50 objects, an unknown op key) or an op (as `/v1/mutate`; a `$ref` to no earlier insert): `{ "error": "validation", "messages", "index"?, "traceId"? }`. |
| `403` | An op denied by its rule: `{ "error": "forbidden", "index", "explanation", "traceId" }` - the same explanation `/v1/mutate` gives. Hosted plan limits as for `/v1/mutate`. |
| `404` | An op's table or row does not exist; with `index`. |
| `409` | An op's conflict, with `index` and the fields of a `/v1/mutate` 409; or `{ "error": "conflict", "retryable": true, "message", "traceIds" }` when Postgres refused to serialize it 5 times - send it again. |

## POST /v1/actions/:name

**Who** As `/v1/mutate`, then the action's own `allow` rule decides. View tokens: 403.

Runs an action the definition declares ([Rules: actions](https://dabadata.com/docs/rules/index.md#actions-protecting-counters-capacity-and-stock)): its steps, in one SERIALIZABLE transaction like [/v1/transact](https://dabadata.com/docs/api/index.md#transact), with the server's rights. Table and column rules are not checked for the steps, so this is how counters, capacity and stock change while clients may not write them directly. The caller stays the identity in the trace and in filled `userId` columns. An action with an `ai` block then calls a model ([AI calls in actions](https://dabadata.com/docs/ai/index.md)).

| Field | Type | Meaning |
| --- | --- | --- |
| `params` | object | The action's params, of the types it declares. Unknown or missing params: 400 with every message. |

```bash
curl -X POST https://dabadata.com/v1/actions/rsvp \
  -H "X-Api-Key: <publishable key>" \
  -H "Authorization: Bearer <user token>" \
  -H "Content-Type: application/json" \
  -d '{"params":{"event":"e1"}}'
```

```json
{ "ok": true,
  "results": [ { "row": { "id": "e1", "taken": 7, ... } }, { "row": { "id": "e1", "taken": 8, ... } },
               { "row": { "id": "5c1d...", "event": "e1", "user": "usr_..." } } ],
  "traceId": 921, "traceIds": [ 918, 919, 920 ] }
```

| Status | Meaning |
| --- | --- |
| `403` | The `allow` rule refused the caller: `{ "error": "forbidden", "explanation", "traceId" }`, e.g. `action rsvp: denied, anonymous is not authenticated`. |
| `404` | No such action, or a row a step names is missing (with `index`). |
| `400` / `409` | A step failed: the body of `/v1/transact` (`index` = the step) plus `actionTraceId`. A full event answers 409 from its `check` step. Nothing is written. |
| `429` | An action with `ai`, over its model call limits: `{ "error": "rate_limited", "message", "retryAfter", "traceId" }` (`perUser` / `total`), or `{ "error": "ai_busy", "message", "retryAfter": 1, "traceId" }` (10 model calls already running in this environment). With a `Retry-After` header. Nothing runs. |
| `502` | An action with `ai` and no steps whose model call failed: `{ "error": "ai_failed", "message", "ai": { "status": "failed", "error", "traceId" }, "traceId" }`. |

**With `ai`.** After the steps commit, an action with an `ai` block calls Claude, OpenAI or Gemini with the key in a project secret, and the 200 answer adds `ai`:

| `ai.status` | Shape | When |
| --- | --- | --- |
| `done` | `{ "status", "text", "stopReason", "usage": { "inputTokens", "outputTokens" }, "traceId" }` | Waited for (no `saveTo`), and the model answered. `stopReason`: `"end"`, `"max_tokens"` (cut) or the provider's own value. |
| `failed` | `{ "status", "error", "traceId" }` | Waited for, and the call failed after the steps committed (still HTTP 200). `error` names the cause and never holds the key. |
| `queued` | `{ "status", "id" }` | With `saveTo`: the call runs in the background and writes the answer into the row. |

```json
{ "ok": true, "results": [], "traceId": 931, "traceIds": [],
  "ai": { "status": "done", "text": "Bonjour", "stopReason": "end",
          "usage": { "inputTokens": 24, "outputTokens": 3 }, "traceId": 932 } }
```

The `ai` block, its limits, the error texts and the security rules are on [AI calls in actions](https://dabadata.com/docs/ai/index.md).

## GET /v1/db/:table

**Who** User token or anonymous, under the read rule; project key and view token, as a bypass.

Lists rows, filtered by the read rule. Rows the caller may not read are simply not there.

| Parameter | Default | Meaning |
| --- | --- | --- |
| `limit` | `100` | At most 1000. |
| `where` | none | URL-encoded JSON. An equality map on columns (`{"done":false,"owner":"bob"}`, `null` matches empty columns), or operators per column: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$like`, `$ilike`, `$null`, `$contains` (json: an array holding the value, or an object holding the sub-object), and `$and` / `$or` lists. Live subscriptions take the same `where`. |
| `orderBy` | `created_at` | One or more columns, comma separated, each optionally prefixed with `-` for descending: `orderBy=starts_at,-created_at`. |
| `desc` | see meaning | `1` or `true` for descending. Without `orderBy` the order is newest first; with `orderBy` it is ascending unless `desc=1`. |

```bash
curl -G https://dabadata.com/v1/db/tasks \
  -H "Authorization: Bearer <user token>" \
  --data-urlencode 'where={"done":false}' \
  --data-urlencode 'orderBy=due'
```

```json
{
  "rows": [
    { "id": "5f1c...", "title": "Order chairs", "done": false, "owner": "bob", "due": "2026-10-20" }
  ],
  "traceId": 45
}
```

| Status | Meaning |
| --- | --- |
| `200` | Also when the read rule leaves nothing for this caller: `rows` is empty and the trace row says `denied`. With `since=`, a caller whose read depends on time gets `{ "reset": true, "cursor", "traceId" }` instead of a delta ([catching up](https://dabadata.com/docs/protocol/index.md#catch-up)). |
| `400` | `where is not valid JSON`, `where: unknown column "x" in tasks`, `orderBy: unknown column "x" in tasks`, `where: column "answer" is not readable by this caller` (a column whose read rule is not statically true for the caller). |
| `403` | A [strict table](https://dabadata.com/docs/rules/index.md#strict-tables) for an end user: `read questions: denied, questions is read through views only (access: views)`. |
| `401` | A token that matches nothing. |
| `404` | Unknown table. |

## GET /v1/db/:table/:id

**Who** As for the list.

One row by id. Traced like a list read.

```json
{
  "row": { "id": "5f1c...", "title": "Order chairs", "done": false, "owner": "bob" },
  "traceId": 46
}
```

| Status | Meaning |
| --- | --- |
| `403` | The row exists but the read rule hides it from this caller; with `explanation`. On a strict table, the strict text for every id, existing or not. |
| `404` | Unknown table or no such row. |

## GET /v1/views/:name

**Who** As for `/v1/db`: user token or anonymous, then the view's `allow` rule decides; project key, view token and console as a bypass of `allow`.

Reads a declared [view](https://dabadata.com/docs/views/index.md): the rows its `rows` rule lets the caller read, in their role's shape, computed fields included. The base table's read rule and `access` are not evaluated; its column read rules are.

| Parameter | Default | Meaning |
| --- | --- | --- |
| `params` | none | URL-encoded JSON: the view's params, typed as declared. Required ones must be given. |
| `where` | none | URL-encoded JSON, the [filter grammar](https://dabadata.com/docs/api/index.md#db-list), on the view's `filter` columns only. |
| `orderBy`, `desc` | the view's | As for `/v1/db`, on the view's `sort` columns. |
| `limit` | the view's | Clamped to the view's `max`. |
| `as` | none | Secret key and console only: URL-encoded `{ "id", "role" }`, to read the view as that user. |

```bash
curl -G https://dabadata.com/v1/views/open_questions \
  -H "Authorization: Bearer <user token>" \
  --data-urlencode 'params={"round":"r1"}'
```

```json
{
  "rows": [
    { "id": "q1", "text": "Capital of Peru?", "options": ["Lima", "Cusco"], "reveal_at": "2026-10-16T20:00:00.000Z", "revealed": false, "_seq": 41 }
  ],
  "traceId": 512,
  "params": { "round": "r1" }
}
```

| Status | Meaning |
| --- | --- |
| `400` | The checks run in the order 404, 400 `as`, 403 `allow`, 400 params, 400 filter and sort. `as is for the secret key and the console` (and the other `as` messages); params, every problem in `messages` (`params.round is required (the id of a rounds row)`); `where: column "text" is not a filter of view open_questions (filters: reveal_at)`, `orderBy: column "text" is not sortable in view open_questions (sort: reveal_at)`, the column read rules' refusals, `limit must be a positive integer`. |
| `403` | The view's `allow` refused the caller: `{ "error": "forbidden", "explanation", "traceId" }`, e.g. `view open_questions: denied, role "viewer" not in ["player","host"]`. |
| `404` | `unknown view "x"`. Checked first; a query string that is not JSON is a 400 after it. |

## POST /v1/views/:name

**Who** As `GET /v1/views/:name`. It is a read: view tokens and console viewers may use it.

The same read with a JSON body: `{ params?, where?, orderBy?, desc?, limit?, as? }`. Same answer and errors.

## GET /v1/views/:name/:id

**Who** As `GET /v1/views/:name`.

One row of the view, shaped: `{ "row", "traceId" }`. Takes `params` and `as` as query parameters. A row that is not in the view for this caller, or does not exist: 404 `row "<id>" not found in view open_questions`.

## GET /v1/trace

**Who** Project key or view token; and any caller - a user token, or the publishable key alone as anonymous - who passes the definition's `trace.read` rule ([Let your app's admins read it](https://dabadata.com/docs/trace/index.md#read-rule)). Without a `trace` block anyone else gets 401; a caller the rule refuses gets 403 `forbidden` with the `explanation`.

Trace rows, newest first. The fields are described on the [Trace](https://dabadata.com/docs/trace/index.md#what-is-recorded) page. Callers who read through `trace.read` get each row without the columns their [column read rules](https://dabadata.com/docs/rules/index.md#column-read-rules) hide (in `values`, `before` and `after`), and explanations print those columns as `row.<column> (hidden)`; a [location](https://dabadata.com/docs/tables/index.md#locations) with a `precision` comes rounded, as in rows. The project key and view tokens see every value.

| Parameter | Default | Meaning |
| --- | --- | --- |
| `limit` | `200` | At most 1000. |
| `before` | none | Only rows with a smaller id, for paging. |
| `table` | none | Only this table. |
| `user` | none | Only this user id. |

```bash
curl "https://dabadata.com/v1/trace?limit=20&user=carol" -H "Authorization: Bearer <project key>"
```

```json
{
  "trace": [
    {
      "id": 39,
      "op": "delete",
      "table": "tasks",
      "decision": "denied",
      "explanation": "delete tasks: denied, role \"viewer\" not in [\"admin\"]",
      "fanout": 0
    }
  ]
}
```

The example row is shortened; real rows carry every field. `400` when `before` is not a positive id.

## POST /v1/simulate

**Who** Project key or view token.

Evaluates a rule for a made-up caller without changing anything. Not recorded in the trace. A `read` of a given `row` on a table with column read rules adds `columns`: `{ "<column>": { "decision", "explanation" } }` for each such column ([example](https://dabadata.com/docs/rules/index.md#simulator)). An end-user role on a [strict table](https://dabadata.com/docs/rules/index.md#strict-tables) is always denied with the strict text. It takes an id and a role only: to see what a provider user may do, simulate the role their token maps to (the issuer plays no part in rules).

| Field | Type | Meaning |
| --- | --- | --- |
| `user` | `{ id?, role }` | The caller to imagine. `role` may be a declared role, `anonymous`, or `service` / `view` to see a bypass. |
| `table` | string | A declared table. |
| `op` | string | `read`, `insert`, `create`, `update` or `delete`. `insert` and `create` are the same rule. |
| `row` | object or string | For read, update and delete: the row to judge, or the id of a real row to load. |
| `values` | object | For insert: the values, filled like a real insert. |
| `now` | string | Optional: an ISO datetime with a zone (`Z` or an offset). The rules are evaluated at that instant (`$now`, `$today`) and the answer carries `now`, the instant used. Without it, the server's clock. |

```bash
curl -X POST https://dabadata.com/v1/simulate \
  -H "Authorization: Bearer <project key>" \
  -H "Content-Type: application/json" \
  -d '{"user":{"id":"carol","role":"viewer"},"table":"tasks","op":"insert","values":{"title":"Plan the party"}}'
```

```json
{
  "decision": "denied",
  "explanation": "create tasks: denied, role \"viewer\" not in [\"admin\",\"editor\"]",
  "rule": { "role": ["admin", "editor"] }
}
```

| Status | Meaning |
| --- | --- |
| `400` | e.g. `user must be { id, role }`, `op must be one of read, insert, create, update, delete`, `now must be an ISO datetime`. |
| `404` | Unknown table, or `row` names an id that does not exist. |

## POST /v1/view-token

**Who** Project key only.

Mints a view token for the viewer, valid for 12 hours. Open the viewer at `/?token=<view token>`. No request body.

```bash
curl -X POST https://dabadata.com/v1/view-token -H "Authorization: Bearer <project key>"
```

```json
{ "token": "<view token>", "expiresAt": "2026-10-15T21:30:00.000Z" }
```

## WS /v1/live

**Who** Anyone; the token (or none) decides what each subscription sees.

Upgrades to a WebSocket. Pass the token as `?token=` and, on a hosted project, the publishable key as `?key=` (needed for an anonymous socket or a provider JWT; a token dabadata minted finds its environment by itself). The first message is `{ "type": "hello", "project", "projectId", "env" }`. The protocol - `subscribe`, `rows`, `change`, `unsubscribe`, `error` - is described on [Live subscriptions](https://dabadata.com/docs/live/index.md).

```
wss://https://dabadata.com/v1/live?key=pk_live_EXAMPLEexample0000000000&token=<user token>
ws://localhost:4600/v1/live?token=<user token>   # a self-hosted server's default project: no key
```

| Status | Meaning |
| --- | --- |
| `401` | A token that matches nothing, or a key and a token of different environments. |
| `400` | A plain HTTP request, not a WebSocket upgrade. |
| `403` | Hosted: `origin_not_allowed` (the page's origin is not in the environment's list), or `plan_limit` with `limit: "connections"` (the workspace has all its connections open). |
| `410` | Hosted: the project is being deleted. |
| `429` | Hosted: the key's request rate is used up; an upgrade counts as a call. |

## POST /v1/demo/tokens

**Who** Anyone, no auth - but only in demo mode (`DABADATA_DEMO=1`).

**Where** A demo room of the caller's own, started from the default project. A server without a default project answers 404 even in demo mode.

Hands out tokens for the public [demo](https://dabadata.com/demo/), so it runs without anyone holding the project key. Each call mints a pair for each of three fixed personas - alice (admin), bob (editor) and carol (viewer), exactly like `POST /v1/users/token` - plus one 12-hour view token, like `POST /v1/view-token`.

Every visitor gets their own copy. A call with no body starts a **demo room**: the visitor's own part of one shared demo database with the default project's tables and rules, filled with the rows of `projects/<name>.seed.json`. The answer adds `session.key`. Send it back as `{ "session": "<key>" }` to mint another set in the same room - a second pane, a persona switch, a new tab. What happens in one room, no other room sees: not its rows, not its trace; another room's row is simply not found. A room with no call and no open socket for `idleMinutes` (120) is deleted with everything in it. At most 300 exist at once; starting one past that deletes the least recently used one that nobody has open. A room takes 240 calls a minute, bodies up to 16 KB and 200 rows per table. Crawlers and bots start no rooms.

```bash
curl -X POST http://localhost:4600/v1/demo/tokens
curl -X POST http://localhost:4600/v1/demo/tokens -d '{ "session": "demo_..." }'
```

```json
{
  "users": {
    "alice": {
      "token": "<user token>",
      "expiresAt": "2026-10-15T10:30:00.000Z",
      "refreshToken": "<refresh token>",
      "refreshExpiresAt": "2026-11-14T09:30:00.000Z"
    },
    "bob": { "token": "...", "expiresAt": "...", "refreshToken": "...", "refreshExpiresAt": "..." },
    "carol": { "token": "...", "expiresAt": "...", "refreshToken": "...", "refreshExpiresAt": "..." }
  },
  "view": { "token": "<view token>", "expiresAt": "2026-10-15T21:30:00.000Z" },
  "session": { "key": "demo_...", "idleMinutes": 120 }
}
```

`session` is in the answer only when the call started the room. Its `key` names the room; keep it private like a password. The personas' tokens and the view token reach only the demo's routes (rows, writes, the socket, the room's trace and users); anything else is 403.

| Status | Meaning |
| --- | --- |
| `404` | `{ "error": "not found" }` when demo mode is off, exactly like an unknown route. |
| `400` | No project definition, a persona role the definition does not declare, a body that is not JSON, or a `session` that is not a string. |
| `403` | A crawler, bot or headless browser (by its User-Agent): the demo is for people. |
| `410` | `{ "error": "demo_session_gone" }`: the key names no demo room (it was deleted, or is something else). Start a new one. |
| `503` | `{ "error": "demo_full" }`: all 300 rooms are in use right now. Try again after `Retry-After` seconds. |
| `429` | More than 30 calls a minute, or more than 30 new sessions an hour, from one client IP. |

> **Security:** Keep demo mode off on anything but a throwaway demo project. Anyone who can reach the server can call this route and start sessions. Within a session, the view token reads every table (rules bypassed) and the whole trace, values included, and the admin persona can change or delete any row the rules let an admin touch. Rooms cannot see each other, and none of them touches the default project.

## Project API

A hosted project is managed with the secret key of one of its environments, under `/v1/project`. For every route in this section:

- **Who**: the environment's secret key, or a [console session](https://dabadata.com/docs/api/index.md#console-session) (viewers may read, except secret names and the export; developers and up may do the rest). Any other credential - a publishable key, a user or view token - is a 401.
- **Where**: hosted projects only. The default project answers 404 `{ "error": "not found", "message": "the default project has no project API (it is configured by the server's .env)" }`, except for the [export](https://dabadata.com/docs/api/index.md#project-export), which it may use with its project key.

The concepts behind these routes are on [Projects and keys](https://dabadata.com/docs/projects/index.md).

## GET /v1/project

The project with both environments, which environment the key belongs to, the workspace, and the plan's [limits](https://dabadata.com/docs/limits/index.md) (`null` = unlimited). The full shape is shown under [Reading your project](https://dabadata.com/docs/projects/index.md#project-info).

```bash
curl https://dabadata.com/v1/project -H "Authorization: Bearer <secret key>"
```

```json
{
  "project": {
    "id": "prj_example12345", "title": "Shop", "workspaceId": "ws_example12345", "region": "il-central-1",
    "status": "active", "createdAt": "2026-10-15T09:00:00.000Z",
    "envs": { "live": { "env": "live", "status": "active", "corsOrigins": [], "publishableKey": "pk_live_...", "keys": [ ... ] },
              "test": { "env": "test", "status": "active", "corsOrigins": [], "publishableKey": "pk_test_...", "keys": [ ... ] } }
  },
  "env": "live",
  "workspace": { "id": "ws_example12345", "name": "Example shop", "plan": "free" },
  "limits": { "plan": "free", "connections": 50, "dbBytes": 524288000, ... }
}
```

`keys` lists the keys that work now, each `{ kind, prefix, createdAt, expiresAt }`, oldest first; `prefix` is the first 12 characters. `status` is `active`, `paused` or `deleting`.

## PATCH /v1/project

Renames the project or sets this environment's allowed origins. Answers with the same shape as `GET`.

| Field | Type | Meaning |
| --- | --- | --- |
| `title` | string, optional | The project's name, 1 to 200 characters. Changes both environments. |
| `corsOrigins` | string[], optional | This environment's [allowed origins](https://dabadata.com/docs/projects/index.md#cors), each exactly `http(s)://host[:port]`. At most 100; duplicates are dropped. `[]` allows every origin. |
| `expect` | `{ corsOrigins: string[] }`, optional | The list you last read. The change is made only if the stored list is still exactly that (same order), else `409`. Goes with `corsOrigins`. Use it when you read, edit and write the list back: a site deploy adds its own address to it. |

```bash
curl -X PATCH https://dabadata.com/v1/project \
  -H "Authorization: Bearer <secret key>" \
  -H "Content-Type: application/json" \
  -d '{"corsOrigins":["https://shop.example.com"]}'
```

| Status | Meaning |
| --- | --- |
| `400` | e.g. `title must be a non-empty string`, `corsOrigins[0] "https://shop.example.com/" must be exactly scheme://host[:port] with no path (did you mean "https://shop.example.com"?)`. |
| `409` | `{ "error": "conflict", "message": "corsOrigins changed since you read them", "corsOrigins": [...] }`: the list no longer matches `expect`; it holds the stored list. Nothing changed, not the title either. |

## POST /v1/project/keys/rotate

Issues a new key of one kind for this environment. The previous key keeps working for 24 hours; a key that was already rotated keeps its own expiry. See [Rotating keys](https://dabadata.com/docs/projects/index.md#rotation).

| Field | Type | Meaning |
| --- | --- | --- |
| `kind` | `"secret"` \| `"publishable"` | Which key to replace. |

```bash
curl -X POST https://dabadata.com/v1/project/keys/rotate \
  -H "Authorization: Bearer <secret key>" \
  -H "Content-Type: application/json" \
  -d '{"kind":"publishable"}'
```

```json
{
  "key": "pk_live_EXAMPLEnewkey00000000000",
  "previous": { "prefix": "pk_live_EXAM", "expiresAt": "2026-10-16T09:30:00.000Z" }
}
```

`key` is the new key in full; a new secret key is shown only here. `previous` is `null` when there was no current key of that kind.

| Status | Meaning |
| --- | --- |
| `400` | `kind must be "secret" or "publishable"`. |

## POST /v1/project/keys/revoke

Stops one key of this environment at once, whether it is the current key or one still in its rotation grace. See [Revoking a key at once](https://dabadata.com/docs/projects/index.md#revoke).

| Field | Type | Meaning |
| --- | --- | --- |
| `prefix` | string, required | The key's first 12 characters, as `keys[].prefix` lists them. |

```bash
curl -X POST https://dabadata.com/v1/project/keys/revoke \
  -H "Authorization: Bearer <secret key>" \
  -H "Content-Type: application/json" \
  -d '{"prefix":"pk_live_EXAM"}'
```

```json
{ "ok": true, "key": "pk_live_EXAMPLEnewkey00000000000" }
```

Revoking the current key of a kind first makes its successor and answers it as `key` (a new secret key is shown only here), so an environment always has one current key of each kind. Revoking a key in its grace makes nothing: `{ "ok": true }`. Audited as `key.revoked`. Console: developers and up.

| Status | Meaning |
| --- | --- |
| `400` | `prefix must be a key's prefix (its first 12 characters)`. |
| `404` | No valid key with that prefix in this environment: already revoked or expired, or another environment's. |

## PUT /v1/project/secrets/:name

Stores a [project secret](https://dabadata.com/docs/projects/index.md#secrets) of this environment, or replaces its value: the HS256 key of an identity provider, the client secret of a sign-in provider, or the API key of an AI provider an [action calls](https://dabadata.com/docs/ai/index.md) (e.g. `ANTHROPIC_KEY`). Values are encrypted at rest and never returned.

| Field | Type | Meaning |
| --- | --- | --- |
| `value` | string, required | 1 to 4,096 characters. |

`:name` matches `^[A-Z][A-Z0-9_]{0,63}$`.

```bash
curl -X PUT https://dabadata.com/v1/project/secrets/LEGACY_JWT_SECRET \
  -H "Authorization: Bearer <secret key>" \
  -H "Content-Type: application/json" \
  -d '{"value":"<at least 32 random characters>"}'
```

```json
{ "ok": true, "name": "LEGACY_JWT_SECRET", "updatedAt": "2026-10-15T09:45:00.000Z" }
```

| Status | Meaning |
| --- | --- |
| `400` | A malformed name and a bad value are reported together, e.g. `value must be a string of 1..4096 characters`. |
| `503` | `secrets_unavailable`: the server was started without its encryption key. |

## GET /v1/project/secrets

The names of this environment's secrets, without their values.

```json
{
  "secrets": [
    { "name": "LEGACY_JWT_SECRET", "createdAt": "2026-10-15T09:45:00.000Z", "updatedAt": "2026-10-15T09:45:00.000Z" }
  ]
}
```

There is no route that reads one secret: `GET /v1/project/secrets/:name` answers 405. `503` as for `PUT`.

## DELETE /v1/project/secrets/:name

Deletes a secret. An identity provider that uses it refuses every token from then on, and an action's model call that uses it fails with `secret X is not set`.

```bash
curl -X DELETE https://dabadata.com/v1/project/secrets/LEGACY_JWT_SECRET -H "Authorization: Bearer <secret key>"
```

```json
{ "ok": true }
```

| Status | Meaning |
| --- | --- |
| `404` | No secret of that name (or a malformed name). |
| `503` | As for `PUT`. |

## GET /v1/project/export

**Where** Hosted projects with the environment's secret key, and the default project with its project key.

Every row of every declared table of this environment as `application/x-ndjson`, one JSON object per line, streamed with no size cap. See [Exporting your data](https://dabadata.com/docs/projects/index.md#export).

```bash
curl https://dabadata.com/v1/project/export -H "Authorization: Bearer <secret key>" -o export.ndjson
```

```
{"type":"project","project":{"id":"prj_example12345","title":"Shop",...},"env":"live","definition":{...}}
{"type":"row","table":"tasks","row":{"id":"5f1c...","title":"Order chairs","done":false,...}}
{"type":"end","rows":1}
```

On the default project `project` is `{ "id": null, "name": "default", "title": null }`. A stream that fails midway stops without the `end` line.

## GET /v1/project/usage

This environment's daily usage and the workspace's calendar month so far. The counters are explained under [Usage metering](https://dabadata.com/docs/limits/index.md#usage).

| Parameter | Default | Meaning |
| --- | --- | --- |
| `days` | `30` | How many days ending today (UTC), 1 to 400. |

```bash
curl "https://dabadata.com/v1/project/usage?days=7" -H "Authorization: Bearer <secret key>"
```

```json
{
  "days": [ { "day": "2026-10-09", "requests": 0, "egressBytes": 0, "peakConnections": 0, "dbBytes": 0 }, ... ],
  "month": { "requests": 241877, "egressBytes": 690123456, "peakConnections": 31, "dbBytes": 9437184 },
  "limits": { "plan": "free", ... }
}
```

| Status | Meaning |
| --- | --- |
| `400` | `days must be an integer from 1 to 400`. |

## GET /v1/project/overview

**Who** The environment's secret key (or the default project's key), a view token, or a console session of any role.

Today at a glance (UTC), as the console's Overview shows it.

```json
{
  "connections": 3,
  "requestsToday": 1840,
  "deniedToday": 12,
  "errorsToday": 2,
  "lastErrors": [ { "at": "2026-10-15T09:12:44.901Z", "route": "/v1/mutate", "status": 400,
                    "error": "validation: missing required column \"title\"" } ],
  "firstLiveRowAt": "2026-10-15T09:05:10.000Z",
  "usage": { "month": { "requests": 241877, "egressBytes": 690123456, "peakConnections": 31, "dbBytes": 9437184 } },
  "limits": { "plan": "free", ... }
}
```

`connections` counts the environment's open sockets, the console's not included. `deniedToday` counts trace rows the rules denied; `errorsToday` and `lastErrors` (the five newest) count errors as [`/v1/logs/errors`](https://dabadata.com/docs/api/index.md#logs-errors) does. `firstLiveRowAt` is when the first row was inserted, updated or deleted by a real caller - not the console, not the secret key - or null: the "your app is live" moment. `usage.month` is the workspace's month; `limits` is null on the default project.

## File storage

Files in the environment's [storage buckets](https://dabadata.com/docs/storage/index.md). **Who**: the same credentials as [`/v1/db`](https://dabadata.com/docs/api/index.md#db-list) (a publishable key with a user token or JWT, a user token, the secret key, a view token for reads, or a [console session](https://dabadata.com/docs/api/index.md#console-session): viewers read, developers and up upload and delete). The bucket's rules decide; the secret key and the console bypass them. Every route in this section answers 404 while file storage is off on the server, and 404 `unknown bucket` for a bucket the definition does not declare.

## POST /v1/storage/:bucket/uploads

Starts an upload. Body `{ name, size, type }`: the file's name (no `/`, `\` or control characters, at most 255 characters), its size in bytes and its MIME type (no parameters). Checked in order: the body, the bucket's `write` rule (403, traced), its types and `maxSize` (400), the plan's largest file and file storage allowance (403 `plan_limit`).

```json
{
  "file": { "id": "6f1c...", "bucket": "avatars", "name": "me.png", "size": 48213, "type": "image/png",
            "owner": "u1", "status": "pending", "createdAt": "2026-10-15T09:41:07.312Z" },
  "upload": { "url": "https://<account>.r2.cloudflarestorage.com/...&X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost&...",
              "method": "PUT", "headers": { "Content-Type": "image/png" }, "expiresAt": "2026-10-15T09:51:07.312Z" }
}
```

Then `PUT` exactly `size` bytes to `upload.url` with `upload.headers` and no other credential; the size and type are signed into the URL (10 minutes).

| Status | Meaning |
| --- | --- |
| `400` | A bad body (every problem listed), a type the bucket does not take, a file above the bucket's `maxSize`. |
| `403` | `forbidden` with the rule's `explanation`; `plan_limit` with `limit: "maxFileBytes"` (the file is larger than the plan allows) or `limit: "files"` (the workspace's file storage would go over its allowance; pending uploads count). |

## POST /v1/storage/:bucket/uploads/:id/complete

Finishes an upload: the server checks the stored object's size and type against the declared ones and the `write` rule again, then the file is a row of `files_<bucket>`: `{ "ok": true, "file": { id, name, size, type, owner, created_at, updated_at, created_by, _seq }, "traceId" }`. Live subscribers get the insert. Only the uploader (or the secret key, or the console) may complete; completing a finished upload again answers the row with `traceId: null`. Uploads not completed within an hour are removed by the nightly job.

| Status | Meaning |
| --- | --- |
| `400` | Nothing was uploaded yet (PUT and complete again), or the object is not what was declared (it is deleted; start over). |
| `403` | Not the uploader, or the rule denies it now. |
| `404` | No such upload. |

## GET /v1/storage/:bucket/:id

The bucket's `read` rule on the file, then a 302 to a signed download URL valid 5 minutes (`Cache-Control: private, no-store`). With `?json=1`: `{ "url", "expiresAt", "publicUrl"?, "traceId" }`, where `publicUrl` is there for buckets whose read rule is `"anyone"`. Traced as `op: "download"`. 403 with the explanation, 404 for no such file.

Files of `"anyone"` buckets also answer at `GET /files/:env/:bucket/:id/:name` (no credential; `:env` is `prj_...`, `prj_..._test` or the default project's name, `:name` the file's name): a 302 to a signed URL valid an hour, cacheable for 30 minutes, with `Access-Control-Allow-Origin: *`. Anything else there is a 404.

## DELETE /v1/storage/:bucket/:id

The bucket's `delete` rule on the file; the object is deleted, then the row: `{ "ok": true, "file": { ...the deleted row }, "traceId" }`. Live subscribers get the delete. 403 with the explanation, 404 for no such file, 502 `storage_unavailable` when object storage did not answer (nothing changed).

## GET /v1/storage/:bucket

The bucket's files the caller may read: exactly [`GET /v1/db/files_<bucket>`](https://dabadata.com/docs/api/index.md#db-list) (`where`, `orderBy`, `desc`, `limit`) -> `{ "rows", "traceId", "cursor" }`. `files_<bucket>` is read-only: `/v1/mutate` and `/v1/transact` on it answer 400 `files_<bucket> is managed by storage`.

## GET /v1/project/storage

For the console's Files page. **Who**: the secret key or a console session (any role); anything else 401. The plan's file allowance, the bytes it counts (every environment of the workspace, pending uploads included), and per bucket its rules as declared (`null` where the bucket leaves one out) with its ready files, their bytes and the pending uploads. A definition without `storage` answers `buckets: []`. `?pending=1` adds `pendingUploads` (the newest 100) to each bucket.

```json
{
  "configured": true,
  "plan": { "fileBytes": 2147483648, "maxFileBytes": 52428800 },
  "usedBytes": 1048576,
  "buckets": [
    { "name": "avatars", "read": "anyone", "write": { "owner": "owner" }, "delete": { "owner": "owner" },
      "maxSize": "5MB", "types": ["image/png", "image/jpeg"], "files": 12, "bytes": 1040000, "pending": 1 }
  ]
}
```

## Sites

Static [sites](https://dabadata.com/docs/sites/index.md) for the environment's frontend, served at `https://<name>.dabadata.site` and at connected domains. **Who**: the environment's secret key, or a [console session](https://dabadata.com/docs/api/index.md#console-session) (viewers read, developers and up write); anything else 401. Every route in this section answers 404 `{ "error": "not found", "message": "sites are off on this server" }` while sites are off on the server (before any credential is checked), and 404 for an unknown site, deploy or domain, or one of another environment.

```json
{
  "site": {
    "id": "site_8k2m4n6p1q3r", "name": "my-shop", "url": "https://my-shop.dabadata.site",
    "status": "active", "spa": true, "liveDeploy": "dep_3f2a9c1e7b5d",
    "domains": [
      { "id": "dom_5t7v9x1z3b5d", "hostname": "www.example.com", "redirectFrom": "example.com",
        "status": "pending", "error": null,
        "dns": [ { "type": "CNAME", "name": "www.example.com", "value": "sites.dabadata.site" } ],
        "createdAt": "2026-10-16T09:20:00.000Z", "activatedAt": null }
    ],
    "health": {
      "deploy": "dep_3f2a9c1e7b5d", "status": "reachable",
      "startedAt": "2026-10-16T10:00:00.000Z", "checkedAt": "2026-10-16T10:00:03.000Z",
      "reachableAt": "2026-10-16T10:00:03.000Z", "propagatedBy": "2026-10-16T10:02:00.000Z",
      "hosts": [
        { "hostname": "my-shop.dabadata.site", "ok": true,
          "dns": { "ok": true, "detail": "104.16.1.1" }, "tls": { "ok": true, "detail": null },
          "route": { "ok": true, "detail": null }, "deploy": { "ok": true, "detail": null } }
      ]
    },
    "createdAt": "2026-10-16T09:12:00.000Z"
  }
}
```

A site's `status` is `active`, `paused` (its Free environment is idle), `disabled` (beyond the plan's allowance), `suspended` (taken down for abuse) or `deleting`. A domain's is `pending`, `active`, `failed` or `disabled`; `dns` lists the records its owner adds.

`health` is the [readiness check](https://dabadata.com/docs/sites/index.md#readiness) of the live deploy, or `null` before the first one: `status` is `pending` (checking, every 5 seconds for at most 2 minutes), `reachable` (every public hostname serves the live deploy), `unverified` (every hostname answers, but which deploy could not be told for some) or `unreachable`. Each of `hosts` - the site's own hostname and every active domain - has the steps `dns`, `tls`, `route`, `deploy` and, for a domain, `certificate`, each `{ ok, detail }` with `ok: null` for a step not reached. The check runs from the server; `propagatedBy` is when every edge location has the deploy.

## GET /v1/project/sites

The environment's sites, each with its domains: `{ "sites": [ Site ] }`.

## GET /v1/project/sites/available?name=

Whether a name can be taken: `{ "available": true }`, or `{ "available": false, "reason" }` for a name that is malformed, reserved or in use.

## POST /v1/project/sites

Body `{ name, spa? }` -> `{ "site" }`. A name is 3 to 40 characters of `a-z`, `0-9` and `-`, not starting or ending with `-`, without `--`. The site's origin is added to the environment's [allowed origins](https://dabadata.com/docs/projects/index.md#cors) when that list is not empty. Audited as `site.created`.

| Status | Meaning |
| --- | --- |
| `400` | A malformed name. |
| `403` | `plan_limit` with `limit: "sites"`; `email_unverified` on Free without a confirmed email address. |
| `409` | `name_taken`: in use, reserved, or deleted less than 30 days ago. |

## PATCH /v1/project/sites/:id

Body `{ spa? }` -> `{ "site" }`. The name cannot change; delete the site and create another.

## DELETE /v1/project/sites/:id

Stops serving the site and its domains at once and removes its origins from the allowed origins; the status becomes `deleting` and the nightly job deletes its files. Audited as `site.deleted`.

## POST /v1/project/sites/:id/move

Body `{ to: "live" | "test" }`, the project's other environment -> `{ "site" }`. The site moves there with its name, deploys, files, domains and readiness; nothing is uploaded and its address keeps serving the same deploy. Its origins (its own and each active domain's) leave the old environment's allowed origins and join the new one's, when that list is not empty. Its status follows the new environment (`paused` there, `paused` here; `disabled` stays). A console session of a developer or up only: a secret key answers 403 `forbidden`. `to` its own environment is 400; a suspended site 403 `site_suspended`. Audited as `site.moved` with `from` and `to`.

## POST /v1/project/sites/:id/deploys

Starts a deploy. A deploy is the whole site; nothing is merged with the one before. Body `{ files: [ { path, size, type } ] }`: each path relative (`index.html`, `assets/app.3f2a.js`; no leading `/`, no `.` or `..` segment, no `\`, at most 512 bytes, no duplicates), its size in bytes and its MIME type.

```json
{
  "deploy": { "id": "dep_3f2a9c1e7b5d", "status": "uploading", "files": 2, "bytes": 51234 },
  "uploads": [
    { "path": "index.html", "url": "https://<account>.r2.cloudflarestorage.com/...", "method": "PUT",
      "headers": { "Content-Type": "text/html" } },
    { "path": "assets/app.3f2a.js", "url": "https://<account>.r2.cloudflarestorage.com/...", "method": "PUT",
      "headers": { "Content-Type": "text/javascript" } }
  ],
  "expiresAt": "2026-10-16T10:12:00.000Z"
}
```

Then `PUT` each file to its URL with its `headers` and no other credential; the exact size and type are signed in, and the URLs are valid an hour.

| Status | Meaning |
| --- | --- |
| `400` | A bad path, a duplicate, a bad size or type (every problem listed). |
| `403` | `plan_limit` with `limit: "siteBytes"` (the deploy is larger than the plan allows), `"siteFiles"` (too many files) or `"maxFileBytes"` (one file is larger than the plan's largest file). |

## POST /v1/project/sites/:id/deploys/:dep/publish?wait=

Checks that every file of the deploy was uploaded with its declared size and type, then makes it live: the deploy's status becomes `live` and the site's `liveDeploy` its id. The previous live deploy stays kept for [rollback](https://dabadata.com/docs/api/index.md#sites-rollback). Answers `{ "deploy", "site" }`; the site's `health` starts as `pending` and the edge serves the deploy within about 30 seconds. `?wait=` (whole seconds, 0 to 60; default 0) waits until the readiness check leaves `pending` or the time is up, then answers with the `health` as it is; another value is 400. A file missing or different answers 409 `{ "error": "upload_incomplete", "missing": [ path ], "files": [ { path, size, stored } ] }` (`size` as declared, `stored` as arrived or `null`); upload those and publish again. Audited as `site.deployed`.

## GET /v1/project/sites/:id/deploys

`{ "deploys": [ { id, status, files, bytes, createdAt, createdBy, publishedAt } ] }`, newest first. `status` is `uploading`, `ready` (uploaded, not live), `live`, `failed` or `expired`. The newest 10 deploys and the live one are kept; the nightly job expires older ones, and uploads not published within 24 hours.

## GET /v1/project/sites/:id/deploys/:dep

`{ "deploy", "files": [ { path, size, type } ] }`: the deploy (as in the list) and its files as they were announced, sorted by path. An `expired` deploy has no files left (`files: []`). Only the live deploy is served, at `<url>/<path>`.

## POST /v1/project/sites/:id/rollback?wait=

Body `{ deploy }`, the id of a `ready` deploy: it becomes live as with publish, with no upload, and `?wait=` works the same. Audited as `site.rolled_back`.

## POST /v1/project/sites/:id/health

Runs one [readiness check](https://dabadata.com/docs/sites/index.md#readiness) now and answers `{ "site" }` with its `health`: `reachable` when every public hostname serves the live deploy, else `unverified` or `unreachable` (or still `pending` while a check started by a publish is running).

| Status | Meaning |
| --- | --- |
| `409` | `nothing_deployed`: the site has no live deploy; `site_not_active` with `status`: the site is paused, disabled or suspended, so nothing is served to check. |
| `429` | `rate_limited` with `retryAfter` (seconds): one check per site every 10 seconds. |

## POST /v1/project/sites/:id/domains

Connects a domain. Body `{ hostname, redirectFrom? }`: `hostname` in lower case, a valid DNS name with a dot, not under `dabadata.site` or the service's own domain; `redirectFrom`, if given, is the same hostname with or without `www.`, and answers a 301 to `hostname`. The pair counts as one domain. Answers `{ "domain" }` with `status: "pending"` and the `dns` records to add: a `CNAME` to `sites.dabadata.site` for each hostname (a bare domain needs CNAME flattening, `ALIAS` or `ANAME` at its DNS host).

| Status | Meaning |
| --- | --- |
| `400` | A malformed hostname or `redirectFrom`. |
| `403` | `plan_limit` with `limit: "customDomains"`; on Free with the message `custom domains start with Starter`. |
| `409` | `domain_taken`: the hostname is connected to a site already. |

## POST /v1/project/sites/:id/domains/:dom/check

Checks a domain's DNS and certificate now -> `{ "domain" }`. The server also checks pending domains every 5 minutes. A domain becomes `active` once its certificate is issued (its origin is then added to the allowed origins; audited as `site.domain_active`), and `failed`, with `error` saying why, when it is still pending after 7 days. A failed domain can be checked again.

## DELETE /v1/project/sites/:id/domains/:dom

Stops serving the domain and its redirect at once and removes it. Audited as `site.domain_removed`.

## Logs

Every call placed in an environment is written to its **API log**, and every socket event to its **realtime log**. Rows are written about once a second and kept for the plan's API log retention (the default project: `DABADATA_LOG_DAYS`, default 1 day). For every route in this section:

- **Who**: the environment's secret key (on the default project, its project key), a view token, or a [console session](https://dabadata.com/docs/api/index.md#console-session) of any role. Anything else is a 401.
- `limit` is 1 to 1000 (default 200; a value out of range means the default); `before` is an entry id, for paging back. Entries come newest first. The routes write any rows still queued first, so you see your own recent calls.
- **Live tail**: a socket of the view, service or console identity also receives `{ "type": "api_log", "entry" }` and `{ "type": "rt_log", "entry" }` as rows are written. End users' sockets never do.

## GET /v1/logs/api

The API log.

| Parameter | Meaning |
| --- | --- |
| `status` | A code (`404`) or a class (`1xx` to `5xx`). |
| `route` | An exact normalised route, e.g. `/v1/db/:table`. |
| `user` | An end user's id, or a console account's id. |

```bash
curl "https://dabadata.com/v1/logs/api?status=4xx&limit=20" -H "Authorization: Bearer <secret key>"
```

```json
{
  "entries": [
    { "id": 9120, "at": "2026-10-15T09:41:07.312Z", "method": "POST", "route": "/v1/mutate", "path": "/v1/mutate",
      "status": 403, "durationMs": 4, "credential": "token", "userId": "carol", "origin": "https://tasks.example.com",
      "ip": "203.0.113.7", "error": "forbidden", "bytes": 151 }
  ]
}
```

`route` is normalised (`/v1/db/:table`, `/v1/db/:table/:id`, `/v1/schema/versions/:n`, `/v1/project/secrets/:name`, `/v1/project/users/:id`, `/v1/sql/saved/:name`, `/v1/storage/:bucket/...`, `/files/:env/:bucket/:id/:name`; every other route as it is); `path` never has the query string, which may carry tokens. `credential` is `secret`, `publishable`, `token`, `view`, `console` or `none`. `error` is the answer's `error` code, with the first validation message after it. A socket upgrade is a row with status 101.

| Status | Meaning |
| --- | --- |
| `400` | `before` is not a positive id, or `status` is not a code or a class. |

## GET /v1/logs/realtime

The realtime log. Filters: `event` (`connect`, `disconnect`, `subscribe`, `unsubscribe`, `refused`, `fanout`), `table`, `user` (the socket user's id).

```json
{
  "entries": [
    { "id": 311, "at": "2026-10-15T09:41:08.020Z", "event": "fanout", "socket": null, "user": null,
      "table": "tasks", "detail": "insert", "count": 2 },
    { "id": 310, "at": "2026-10-15T09:40:55.501Z", "event": "subscribe", "socket": "a41f09c2b7e3",
      "user": { "id": "alice", "role": "editor", "name": "Alice" }, "table": "tasks", "detail": "s1", "count": 12 }
  ]
}
```

Per event: `connect` has the credential kind in `detail`; `disconnect` the seconds open in `count` and `code <n> <reason>` in `detail`; `subscribe` the rows sent and the subscription id; `refused` the rule's explanation or the limit that refused it; `fanout` one row per change on a table someone subscribes to, with the operation and how many messages went out (0 when the rules filtered everyone out). `400` for an unknown `event` or a bad `before`.

## GET /v1/logs/errors

Errors grouped by route and status, most frequent first: answers with status 500 and up, and 400s whose `error` is a validation error. `days` is 1 to 400 (default 7), else 400.

```json
{
  "routes": [
    { "route": "/v1/mutate", "status": 400, "count": 31, "lastAt": "2026-10-15T09:12:44.901Z",
      "lastError": "validation: missing required column \"title\"" }
  ]
}
```

## SQL console

Plain SQL on one environment's schema, isolated by Postgres privileges rather than by parsing: every environment has a read-only and a read-write database role that can see only its own schema and can never create, alter or drop anything. Each call opens a fresh connection as that role, runs, and closes it. A server whose database user cannot create roles turns this off with `DABADATA_SQL_ROLES=0`; then every route in this section answers 404.

## POST /v1/sql

**Who** The environment's secret key (the default project's project key), a view token (read mode only), or a console session: read mode for any role, write mode for developers and up. End users and the publishable key alone: 401.

| Field | Type | Meaning |
| --- | --- | --- |
| `query` | string, required | One or more statements, at most 100,000 characters. |
| `mode` | `"read"` \| `"write"` | Default `read`: runs as the read-only role inside a read-only transaction that is rolled back. `write`: runs as the read-write role and is **rolled back** - a preview. |
| `confirm` | string, write mode | The project id (`prj_...`, for either environment) or the default project's name. A guard against writing to the wrong project. |

```bash
curl -X POST https://dabadata.com/v1/sql \
  -H "Authorization: Bearer <secret key>" \
  -H "Content-Type: application/json" \
  -d '{"query":"select title, done from tasks order by created_at desc limit 2"}'
```

```json
{
  "results": [
    { "columns": [ { "name": "title", "type": "text" }, { "name": "done", "type": "boolean" } ],
      "rows": [ ["Order chairs", false], ["Book the hall", true] ],
      "rowCount": 2, "truncated": false, "command": "SELECT" }
  ],
  "durationMs": 7
}
```

One result per statement. At most 1,000 rows per statement and 10 MB per call come back (`truncated: true` beyond that); `rowCount` is the real count. Numbers, booleans and JSON come as JSON; `bigint`, `numeric`, timestamps and the rest as strings. A write preview adds `"preview": true` and a `commitToken` for [`POST /v1/sql/commit`](https://dabadata.com/docs/api/index.md#sql-commit). Every call is in the trace (`op: "sql"`, decision `bypass`). Statements are cancelled after 10 seconds. Transaction control (`BEGIN`, `COMMIT`, `ROLLBACK` ...) is refused before anything runs; savepoints are fine.

| Status | Meaning |
| --- | --- |
| `400` | `{ "error": "sql", "message", "position"?, "code" }`: Postgres refused the query (`position` = the character it points at); `canceled after 10 s` (code `57014`); transaction control (code `25000`). Results of statements before the failing one are not returned. |
| `400` | `validation`: an empty or too long `query`, an unknown `mode`, a missing or wrong `confirm`. |
| `403` | Write mode with a view token (`view tokens are read-only`) or a console role below developer. |
| `429` | More than 30 runs (reads, previews and commits together) a minute per caller and environment. |
| `503` | `{ "error": "sql_unavailable", "message" }`: the roles could not be prepared or the database cannot be reached. |

## POST /v1/sql/commit

**Who** The same caller that made the preview: the same secret key, or the same console account (developer and up).

Runs the previewed query again as the read-write role and commits. Body `{ "commitToken": "..." }`; the token works once, for 5 minutes. Answers the same shape with `"preview": false`. The query runs again, so if the data changed in between its results can differ from the preview's. Writes made here bypass the rules and are **not** sent to live subscribers; clients see them after their next reconnect. A commit by the console is in the workspace's audit log (`sql.write`).

| Status | Meaning |
| --- | --- |
| `400` | `{ "error": "commit_token_invalid", "message" }`: unknown, used, expired, another environment's or another caller's token (a wrong caller does not use it up). Or `sql`, as above. |
| `403` | A console role below developer. |
| `429` | As above. |

## GET /v1/sql/saved

**Who** Secret key, view token, or a console session of any role.

The environment's saved queries, by name.

```json
{ "queries": [ { "name": "open tasks", "query": "select * from tasks where not done", "createdBy": "someone@example.com",
                 "createdAt": "2026-10-15T09:00:00.000Z", "updatedAt": "2026-10-15T09:30:00.000Z" } ] }
```

## PUT /v1/sql/saved/:name

**Who** Secret key, or a console session of a developer and up. View tokens: 403.

Saves a query under `:name` (URL-encoded; 1 to 100 characters, no control characters, no spaces around it), or replaces it, keeping who created it and when. Body `{ "query": "..." }` -> `{ "saved": { name, query, createdBy, createdAt, updatedAt } }`. `400` for a bad name or query. Saving is not rate-limited.

## DELETE /v1/sql/saved/:name

**Who** As for `PUT`.

Deletes a saved query -> `{ "ok": true }`; `404` when there is none of that name.

## End-user accounts

The routes behind [Sign-in for your app's users](https://dabadata.com/docs/end-user-auth/index.md). For every `/v1/auth/users/*` route:

- **Who**: anyone with the environment's **publishable key** (`X-Api-Key` or `?key=`). No key places the call in the server's default project. `Authorization` is ignored, except on `me`, `signout`, the invite routes and the `admin/` routes, which need a live token of a hosted user of this environment (not a key, not a token from `/v1/users/token`, not a provider JWT).
- **Where**: environments whose definition has `auth.users.enabled: true`; anywhere else every route answers 404 `not found`.
- Problems with the email, password, name and `redirectUrl` are reported together as a 400, before any rate limit. Passwords: 10 to 200 characters, not one character repeated, not the email.
- `user` is `{ id, email, name, role, emailVerified, hasPassword, createdAt }`; `tokens` is the pair of [`POST /v1/users/token`](https://dabadata.com/docs/api/index.md#users-token) for `{ id, name, role, issuer: "hosted" }`.
- These routes are not covered by the console's CSRF check: they use no cookie for authentication.

## POST /v1/auth/users/signup

Body `{ email, password, name?, redirectUrl? }`. With `verifyEmail` on, a confirmation link (24 hours) is mailed and the answer is `{ "ok": true, "verify": true, "signupSecret": "..." }`, with the same secret set as a cookie for the hosted pages; send it back with [`verify`](https://dabadata.com/docs/api/index.md#eu-verify) to keep the password ([why](https://dabadata.com/docs/end-user-auth/index.md#pre-registration)). With `verifyEmail` off a new user is signed in at once: `{ "ok": true, "verify": false, "tokens", "user" }`. An address that already has an account gets a mail with a sign-in link and the same `verify: true` answer; nothing changes.

```bash
curl -X POST https://dabadata.com/v1/auth/users/signup \
  -H "X-Api-Key: pk_test_EXAMPLEexample0000000000" \
  -H "Content-Type: application/json" \
  -d '{"email":"someone@example.com","password":"correct horse battery","redirectUrl":"https://tasks.example.com/"}'
```

| Status | Meaning |
| --- | --- |
| `400` | `validation`: address, password or name; a `redirectUrl` that is not listed. |
| `403` | `{ "error": "signup_closed" }`: sign-up is `"invite"`. |
| `429` | 20 sign-ups per IP address per hour; 5 sign-ups and sign-in links per email address per hour. |

## POST /v1/auth/users/verify

Uses a confirmation link. Body `{ token, signupSecret? }` -> `{ tokens, user, redirectUrl }`; the address is now confirmed. The password and name from sign-up stay only when the sign-up's secret comes back (in the body, or as the hosted pages' cookie). `redirectUrl` is the one the link was requested with, if it is still listed; else null.

| Status | Meaning |
| --- | --- |
| `400` | `{ "error": "link_invalid" }`: unknown, used or expired. |
| `403` | `{ "error": "user_disabled" }`; the link stays usable. |

## POST /v1/auth/users/signin

Body `{ email, password }` -> `{ tokens, user }`.

| Status | Meaning |
| --- | --- |
| `401` | `{ "error": "invalid_login" }`: wrong email or password, an unknown address, or an account without a password. |
| `403` | `{ "error": "email_unverified" }` (the right password, the address not confirmed, `verifyEmail` on; nothing is mailed) or `{ "error": "user_disabled" }`. |
| `429` | 10 attempts per email address and 30 per IP address per 15 minutes, good or bad. |

## POST /v1/auth/users/magic

Mails a sign-in link that works once, for 15 minutes. Body `{ email, redirectUrl? }` -> always `{ "ok": true }`. Existing users get the link (disabled ones get nothing); an unknown address gets one only with `signup: "open"`, and using it creates the user. `400` for a malformed address or a `redirectUrl` that is not listed; `429` at 20 per IP address per hour, or the 5 per email address shared with sign-up.

## POST /v1/auth/users/magic/verify

Uses a sign-in link. Body `{ token }` -> `{ tokens, user, redirectUrl }`; the address counts as confirmed. `400 link_invalid` (also when sign-up was switched to `invite` since an unknown address asked); `403 user_disabled`.

## POST /v1/auth/users/forgot

Mails a reset link that works for an hour, to existing users that are not disabled. Body `{ email, redirectUrl? }` -> always `{ "ok": true }`. `400` as for `magic`; `429` at 5 per email address per hour.

## POST /v1/auth/users/reset

Uses a reset link or an invitation link. Body `{ token, password }` -> `{ tokens, user, redirectUrl }`. Sets the password, confirms the address, and revokes every other token of the user. The token is checked first (`400 link_invalid`), then the password (`400 validation`, and the link stays usable).

## GET /v1/auth/users/me

**Who** Publishable key and the user's token.

-> `{ user }`. `401` for anything but a live token of a hosted user of this environment, including a user who was deleted or disabled since.

## PATCH /v1/auth/users/me

**Who** Publishable key and the user's token.

Body `{ name?, password?, currentPassword? }`: `name` a string up to 200 characters or null. Changing an existing password needs `currentPassword`, and revokes every token of the user: the answer is `{ user, tokens }` with a fresh pair. Otherwise `{ user }`.

| Status | Meaning |
| --- | --- |
| `400` | Another field, a bad name or password, or `currentPassword` missing. |
| `403` | `{ "error": "invalid_login" }`: `currentPassword` is wrong (403, so a client does not read it as "signed out"). Counted with sign-in attempts. |

## POST /v1/auth/users/signout

**Who** Publishable key and the user's token.

Revokes the access token presented and the `refreshToken` in the body (when it is this user's), or with `{ "everywhere": true }` every token of the user -> `{ "ok": true, "revoked": { "tokens": 1, "refreshTokens": 1 } }`.

## GET /v1/auth/users/providers

The [sign-in providers](https://dabadata.com/docs/sign-in-providers/index.md) whose browser flow is on, in the order `google`, `apple`, `microsoft`, `github`, `gitlab`, `discord`, `linkedin`, `slack`, and the hosted pages' address. A provider is on when `auth.users.providers` has it with its client secret (Apple needs none; Google without a secret is for [`idtoken`](https://dabadata.com/docs/api/index.md#eu-idtoken) only and is not listed).

```bash
curl https://dabadata.com/v1/auth/users/providers -H "X-Api-Key: pk_test_EXAMPLEexample0000000000"
```

```json
{ "providers": ["google", "github"], "page": "https://dabadata.com/auth/prj_example12345_test/" }
```

## POST /v1/auth/users/idtoken

Signs in with an ID token the app got from Google or Apple itself: Sign in with Apple on iOS, Google Sign-In, the Google Identity Services button ([Native apps and your own buttons](https://dabadata.com/docs/sign-in-providers/index.md#native)). Body `{ provider, idToken, nonce?, name? }` -> `{ tokens, user, created }`; `created` is true when the sign-in made a new user.

- `provider`: `"google"` or `"apple"`. `idToken`: up to 8 KB. `nonce`: up to 200 characters. `name`: up to 200 characters or null, used only when a user is created.
- The token is checked in full: its signature against the provider's published keys, `iss`, `exp`, and `aud`, which must be the provider's `clientId` or one of its `audiences`. A token with a `nonce` claim needs the body's `nonce`, equal to the claim or with the claim as its SHA-256 (hex), as Sign in with Apple on iOS does.
- Then the provider account is linked to a user ([Accounts and linking](https://dabadata.com/docs/sign-in-providers/index.md#linking)).

```bash
curl -X POST https://dabadata.com/v1/auth/users/idtoken \
  -H "X-Api-Key: pk_test_EXAMPLEexample0000000000" \
  -H "Content-Type: application/json" \
  -d '{"provider":"google","idToken":"eyJhbGciOiJSUzI1NiIs..."}'
```

| Status | Meaning |
| --- | --- |
| `400` | `validation`: unknown fields, a bad `provider`, `idToken`, `nonce` or `name`. `{ "error": "provider_not_enabled", "message" }`: that provider is not in `auth.users.providers`. |
| `401` | `{ "error": "invalid_token" }`: the signature, issuer, audience, expiry or nonce did not check out (the reason is logged, never answered). |
| `403` | `{ "error": "email_missing" }`, `email_unverified` (the provider does not vouch for the email), `signup_closed` or `user_disabled`. |
| `409` | `{ "error": "on_signup_failed" }`: the `onSignUp` row could not be inserted; no user was made. |
| `429` | 30 per IP address per 15 minutes. |

## GET /auth/:env/:provider

**Who** A browser; no key. `:env` is `prj_...`, `prj_..._test` or a self-hosted server's project name; `:provider` one of the eight ids.

Starts a [sign-in with a provider](https://dabadata.com/docs/sign-in-providers/index.md#how-it-works): `302` to the provider's authorize page, with a 10-minute, single-use sign-in state tied to this browser by the HttpOnly cookie `sso_<provider>`. `?redirect=` is where the person goes back to, signed in; it must be one of `redirectUrls` (a `mode=app` on it is dropped: the tokens go in the fragment either way). Plain-text answers otherwise: `404` when hosted accounts or the provider's browser flow are off, `400` for a `redirect` that is not listed, `405` for another method, `429` (with `Retry-After`) over 20 starts per IP address per 15 minutes.

```
https://dabadata.com/auth/prj_example12345_test/github?redirect=https%3A%2F%2Ftasks.example.com%2F
```

## GET /auth/:env/:provider/callback

**Who** The provider, through the browser. Apple posts instead: `POST /auth/:env/apple/callback` with the form fields `state`, `id_token`, `user` and `error`.

The callback URL you register at the provider. It uses up the sign-in state (which must be this environment's and provider's, unexpired, and come with the browser's cookie), signs the person in and links the account ([Accounts and linking](https://dabadata.com/docs/sign-in-providers/index.md#linking)). Every answer is a `303` and clears the cookie:

- Success: to the start's `redirect` (if it is still listed) with `#token=...&refreshToken=...&expiresAt=...&refreshExpiresAt=...`, for `db.auth.completeLink()`; without one, to `/auth/:env/?signedin=1`.
- Failure: to `/auth/:env/?error=<code>` (plus `&redirect=` when the start had one), with `provider_cancelled`, `provider_failed`, `email_missing`, `email_unverified`, `signup_closed`, `user_disabled` or `on_signup_failed` ([what each means](https://dabadata.com/docs/sign-in-providers/index.md#errors)).

These routes answer with `Cache-Control: no-store` and `Referrer-Policy: no-referrer`.

## GET /v1/auth/users/admin/users

**Who** Publishable key and the token of a hosted user who passes the definition's `auth.users.manage` rule (with their current role). For this and the next two routes ([App admins](https://dabadata.com/docs/end-user-auth/index.md#app-admins)).

The same list as [`GET /v1/project/users`](https://dabadata.com/docs/api/index.md#project-users): `query`, `limit`, `after` -> `{ users, next }`, emails included.

```bash
curl "https://dabadata.com/v1/auth/users/admin/users?limit=20" \
  -H "X-Api-Key: pk_live_EXAMPLEexample0000000000" -H "Authorization: Bearer <the admin's token>"
```

| Status | Meaning |
| --- | --- |
| `401` | No live token of a hosted user of this environment (a key, a `/v1/users/token` token, a disabled or deleted user). |
| `403` | `{ "error": "manage_not_allowed", "message" }`: no `auth.users.manage`, or its rule refuses the caller. |

## PATCH /v1/auth/users/admin/users/:id

Body `{ role?, name?, disabled? }` -> `{ user }`, with the effects of [the management route](https://dabadata.com/docs/api/index.md#project-users-patch). Audited (`user.updated`) with the hosted user as the actor.

| Status | Meaning |
| --- | --- |
| `400` | Other fields, an undeclared role, a bad name, `disabled` not a boolean. |
| `403` | `manage_not_allowed`; `self_not_allowed` (the caller's own role, or `disabled: true` on themselves); `role_not_allowed` (the user's role, or the new one, outside `auth.users.manageRoles`). |
| `404` | No such user. |

## DELETE /v1/auth/users/admin/users/:id

Deletes the user, their links and tokens -> `{ "ok": true, "revoked" }`. `403 self_not_allowed` for the caller themselves, `403 role_not_allowed` for a user whose role is outside `auth.users.manageRoles`, `404` for an unknown id. Audited (`user.deleted`).

## GET /v1/project/users

**Who** The environment's secret key (or the default project's key), or a console session (viewers read; developers and up change). For this and the next three routes; `404` when users are not enabled.

The environment's hosted users, newest first. `query` matches part of the email or name (any case); `limit` is 1 to 1000 (default 100); `after` is the last id of the previous page.

```json
{
  "users": [
    { "id": "usr_k3x9m2p7q4w8z1ab", "email": "someone@example.com", "name": "Someone", "role": "editor",
      "emailVerified": true, "hasPassword": true, "createdAt": "2026-10-15T09:00:00.000Z",
      "disabled": false, "lastSignInAt": "2026-10-15T09:41:00.000Z",
      "providers": ["google"] }
  ],
  "next": null
}
```

`providers` lists the [sign-in providers](https://dabadata.com/docs/sign-in-providers/index.md) linked to the user, in the order of the provider table; the `user` of the other management routes carries it too. `next` is the id to pass as `after` for the next page, or null. `400` for a bad `limit` or an unknown `after`.

## POST /v1/project/users

Adds a user. Body `{ email, name?, role?, password?, invite? }`: `role` defaults to `defaultRole` and must be declared; with `invite: true` a 7-day link to choose a password is mailed. Users added here count as confirmed. -> `{ user }`.

```bash
curl -X POST https://dabadata.com/v1/project/users \
  -H "Authorization: Bearer <secret key>" \
  -H "Content-Type: application/json" \
  -d '{"email":"someone@example.com","role":"viewer","invite":true}'
```

| Status | Meaning |
| --- | --- |
| `400` | Unknown fields, a bad address or password, an undeclared role. |
| `409` | `{ "error": "already_exists", "message" }`: the address has an account. `{ "error": "on_signup_failed", "message" }`: the `auth.users.onSignUp` row could not be inserted, so the user was not created (also on sign-up, a sign-in link that creates the user, and an invite). |

## PATCH /v1/project/users/:id

Body `{ role?, name?, disabled? }` -> `{ user }`. Disabling revokes every token of the user and drops their unused links; enabling again restores nothing (they sign in again). A new role or name reaches the user's next call and refresh. `400` for other fields, `404` for an unknown id.

## DELETE /v1/project/users/:id

Deletes the user, their links and tokens -> `{ "ok": true, "revoked": { "tokens", "refreshTokens" } }`. Rows they own stay. `404` for an unknown id. Changes made from the console are audited (`user.created`, `user.updated`, `user.deleted`).

## POST /v1/project/users/:id/invite

Mails a fresh 7-day invitation to a user who never got in (no password, never signed in), for when the first mail was lost or its link expired. It is the same account, so whatever your app linked to it stays; the user's unused invite links stop working. Body `{ redirectUrl? }`, checked as on `POST /v1/project/users` (`?mode=app` too); without it the link keeps the last invitation's redirect URL, and it stays in the inviting user's [list of pending invites](https://dabadata.com/docs/end-user-auth/index.md#invites). -> `{ "ok": true, "user" }`.

| Status | Meaning |
| --- | --- |
| `404` | An unknown id. |
| `409` | `not_pending`: the user has a password or has signed in (they can use Forgot password). `user_disabled`: enable them first. |
| `429` | More than 3 invitations to the address in a day, counted with invites from the app. |

Audited as `user.invite_resent`. In the console, such a user shows as `invited` with a **Resend invite** button.

## POST /v1/project/users/to-live

Copies users of a **test** environment to the project's live environment, for when an app built on test goes live. Body `{ "ids": ["usr_..."] }` for chosen users, or `{ "all": true }` for everyone (at most 200 per call). **Who**: a console session of a developer or above; a secret key answers `403 forbidden`, because a key reaches only its own environment.

Each user gets a live account with the same email, name and role, the address counted as confirmed, and **no password**: live mails them an invitation (a 7-day link) to choose one. Passwords and linked sign-in providers are never copied, the test account stays as it is, and rows they own in test stay there.

```json
{
  "copied": [{ "id": "usr_k3x9m2p7q4w8z1ab", "email": "someone@example.com", "liveId": "usr_p8d2n5c1v7t4r9qe" }],
  "skipped": [{ "id": "usr_a1b2c3d4e5f6g7h8", "email": "someone.else@example.com", "reason": "exists" }]
}
```

`reason` is `disabled` (disabled in test), `exists` (live already has the address) or `role_missing` (the live definition does not declare the user's role: apply the definition to live first).

| Status | Meaning |
| --- | --- |
| `400` | Not a test environment, a bad body, more than 200 users. |
| `403` | A secret key, or a console role below developer. |
| `404` | An id that is not a user of this environment. |
| `409` | `{ "error": "live_users_off" }`: the live definition does not enable `auth.users`. |

Audited as `users.copied_to_live` with the count and the addresses.

## GET /v1/project/test-mail?to=&limit=

The [test inbox](https://dabadata.com/docs/end-user-auth/index.md#test-inbox) of a **test** environment: every end-user mail it sent (or, with `auth.users.testMail: "inbox"`, would have sent) in the last 7 days, the newest 200. **Who**: the environment's secret key, or a console session of a developer or above (reading too: the messages hold sign-in links). A live environment answers 404 `{ "error": "not found", "message": "the test inbox belongs to test environments" }`.

`to` filters by address (any case); `limit` is 1 to 200 (default 50). -> `{ "messages": [ { id, to, subject, text, link, purpose, linkState, createdAt } ], "sending" }`, newest first. `purpose` is `verify`, `magic`, `reset`, `invite` or null; `linkState` is `{ state: "unused" | "used" | "expired", expiresAt, usedAt }`, or null when the message has no single-use link or the link is gone. `sending` says whether the environment's mail also goes out.

## POST /v1/project/test-mail/:id/link

Body `{ "state": "expired" | "used" }` -> `{ "message" }`: the message's link now answers as an expired or used one does (`400 link_invalid`). `404` unknown message, `409 no_link` a message without a link, `409 link_done` a link already used or gone, `400` another body.

## DELETE /v1/project/test-mail

Empties the test inbox -> `{ "ok": true, "deleted" }`. Add `?to=someone@example.com` to delete that address's messages only, so a test run can clean up after itself. Links already mailed keep working until they expire.

## DELETE /v1/project/test-mail/<id>

Deletes one message -> `{ "ok": true, "deleted": 1 }`; an unknown id is a 404.

## Console accounts

The routes of the [console](https://dabadata.com/docs/console/index.md): accounts, sessions and sign-in ([Accounts and teams](https://dabadata.com/docs/accounts/index.md)). They are for the console's own pages, on the same origin; the browser sends the session cookie.

- **Session**: the cookie `sid`, set by a sign-in (`HttpOnly`, `SameSite=Lax`, `Secure` on https). It lasts 30 days from its last use and at most 90 days from the sign-in; an answer that moves the end sends the cookie again.
- **`X-Requested-With`**: every call that uses the session must carry this header (any value, the console sends `console`); without it the cookie is ignored. Every write (any method but `GET`, `HEAD`, `OPTIONS`) under `/v1/auth/*`, `/v1/account*`, `/v1/invitations/*` and `/v1/workspaces*` without it is refused before anything happens: 403 `{ "error": "csrf", "message" }`. The header is not in the CORS allow-list, so another site's page cannot send it.
- **JSON only**: on those routes a request with a body or a `Content-Type` must be `application/json`, else 415 `{ "error": "unsupported_media_type", "message" }`.
- **Project routes**: every project route (`/v1/project*`, `/v1/schema*`, `/v1/db/*`, `/v1/mutate`, `/v1/trace`, `/v1/simulate`, `/v1/users*`, `/v1/logs/*`, `/v1/sql*`, the socket) also accepts a session with the header `X-Project: prj_...` (or `prj_..._test`; the socket takes `?project=`). The caller is the `console` identity: rules bypassed (traced as `bypass`, source `console`), limited by the workspace role - viewers may make every `GET` except secret names and the export, plus `/v1/simulate` and read-only SQL; developers and up everything else. Any other credential sent with `X-Project` is a 401; an unknown environment, or one whose workspace you are not in, is a 404; a role too low is a 403 `forbidden` with an `explanation`.
- **Account** JSON: `{ id, email, name, avatarUrl, createdAt, emailVerified, hasPassword }`.

```bash
curl https://dabadata.com/v1/project \
  -H "Cookie: sid=<session>" -H "X-Requested-With: console" -H "X-Project: prj_example12345_test"
```

## GET /v1/auth/providers

**Who** Anyone.

How one may sign in to this server's console.

```json
{ "email": true, "password": true, "google": false, "github": false }
```

`google` is true when the server has `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`, `github` when it has `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`.

## POST /v1/auth/register

**Who** Anyone (with `X-Requested-With`).

Body `{ email, password, name? }` -> always `{ "ok": true }` and a `reg` cookie, whether or not the address has an account. A new address gets an unconfirmed account and a confirmation link (24 hours, `/console/auth/verify?token=`); the password and name stay only if that link is used in the browser holding the `reg` cookie. A known address gets a mail saying someone tried to register with it, with a sign-in link; nothing changes.

| Status | Meaning |
| --- | --- |
| `400` | `validation`: the address, password and name problems together. |
| `429` | 5 per email address and 20 per IP address per hour, shared with `/v1/auth/email` and invitations accepted without a session. |

## POST /v1/auth/email

**Who** Anyone (with `X-Requested-With`).

Mails a sign-in link that works once, for 15 minutes: `/console/auth/verify?token=...&next=...`. Body `{ email, next? }`; `next` is kept only when it is a path under `/console/`. Always `{ "ok": true }`: the same work happens for every address. `400` when `email` is not an address; `429` as for register.

## POST /v1/auth/verify

**Who** Anyone holding a link's token (with `X-Requested-With`).

Uses a sign-in or confirmation link and starts a session (the `sid` cookie). Body `{ token }`.

```json
{ "account": { "id": "acc_k3x9m2p7q4w8", "email": "someone@example.com", "name": null, "avatarUrl": null,
               "createdAt": "2026-10-15T09:00:00.000Z", "emailVerified": true, "hasPassword": true },
  "created": true, "next": "/console/", "joined": null }
```

`created` is true for a new account or its first confirmed sign-in (when its workspace and first project are set up). `joined` is `{ id, name }` of the workspace an invitation in the link joined, or null. Opening the link's URL only shows the console page that makes this call, so mail scanners do not use links up. `400 { "error": "link_invalid" }` for an unknown, used or expired token.

## POST /v1/auth/login

**Who** Anyone (with `X-Requested-With`).

Body `{ email, password }` -> `{ account }` and the session cookie.

| Status | Meaning |
| --- | --- |
| `400` | The body is not `{ email, password }` with two strings. |
| `401` | `{ "error": "invalid_login" }`: wrong email or password; the same answer, in about the same time, for an unknown address. |
| `403` | `{ "error": "email_unverified" }`: the right password, but the address is not confirmed. Nothing is mailed. |
| `429` | 10 attempts per email address and 30 per IP address per 15 minutes, good or bad. |

## POST /v1/auth/password/forgot

**Who** Anyone (with `X-Requested-With`).

Body `{ email }` -> always `{ "ok": true }`; an existing account gets a reset link that works for an hour (`/console/auth/reset?token=`). `400` for a malformed address; `429` at 5 per email address and 20 per IP address per hour (counters of its own).

## POST /v1/auth/password/reset

**Who** Anyone holding a reset link's token (with `X-Requested-With`).

Body `{ token, password }` -> `{ account }` and a new session. Sets the password, confirms the address, signs out every other session and cancels every unused link of the account. `400 link_invalid` for a bad token; `400 validation` for a password that breaks the rules (the link stays usable).

## GET /v1/auth/google/start

**Who** Anyone. **Where** Servers with Google configured; else 404.

Redirects (302) to Google; `?next=` is where to land afterwards. Google sends the browser back to `GET /v1/auth/google/callback`, which starts a session and redirects to `next` (default `/console/`), or to `/console/auth/signin?error=google` when anything fails.

## GET /v1/auth/github/start

**Who** Anyone. **Where** Servers with GitHub configured; else 404.

Redirects (302) to GitHub; `?next=` is where to land afterwards. GitHub sends the browser back to `GET /v1/auth/github/callback`, which uses the account's primary, verified GitHub address (a `@users.noreply.github.com` address is refused), starts a session and redirects to `next` (default `/console/`), or to `/console/auth/signin?error=github` when anything fails. The GitHub access token is used only during the callback and never stored.

## POST /v1/auth/logout

**Who** Session.

Ends the session, clears the cookie and closes the session's console sockets -> `{ "ok": true }`. No body. Without `X-Requested-With`: 403 `csrf` and nothing changes.

## GET /v1/account

**Who** Session. Without one: 401.

```json
{
  "account": { "id": "acc_k3x9m2p7q4w8", "email": "someone@example.com", "name": "Someone", "avatarUrl": null,
               "createdAt": "2026-10-15T09:00:00.000Z", "emailVerified": true, "hasPassword": true },
  "workspaces": [ { "id": "ws_example12345", "name": "someone", "slug": "someone", "plan": "free", "role": "owner" } ],
  "sessions": [ { "id": "3f9a01c2", "createdAt": "2026-10-15T09:00:00.000Z", "lastSeenAt": "2026-10-15T09:41:00.000Z",
                  "userAgent": "Mozilla/5.0 ...", "ip": "203.0.113.7", "current": true } ]
}
```

## PATCH /v1/account

**Who** Session.

Body `{ name }`: 1 to 80 characters without control characters, or null (or empty) to clear it -> `{ account }`. `400` otherwise.

## POST /v1/account/password

**Who** Session.

Sets a password, or changes it. Body `{ password, currentPassword? }`; `currentPassword` is required when the account has one. -> `{ account }`. Signs out every other session and cancels every unused link of the account.

| Status | Meaning |
| --- | --- |
| `400` | A password that breaks the rules; `currentPassword` missing. |
| `403` | `{ "error": "invalid_login" }`: `currentPassword` is wrong. |
| `429` | Counted with sign-in attempts (10 per address per 15 minutes). |

## DELETE /v1/account/sessions/:id

**Who** Session.

Signs out one session of your account, the current one included; `:id` is the 8-character id from `GET /v1/account`. -> `{ "ok": true }`; `404` when there is none.

## POST /v1/account/sessions/revoke-others

**Who** Session.

Signs out every session but this one -> `{ "ok": true, "revoked": 2 }`. Their console sockets close at once.

## Workspaces

Workspaces, their members, invitations, audit log and projects. Every route needs a [session](https://dabadata.com/docs/api/index.md#console-session) and, except where noted, membership of the workspace with at least the role in brackets. A workspace you are not a member of (or that is deleted) is a 404, never a 403: a workspace id reveals nothing. A role too low is 403 `{ "error": "forbidden", "explanation": "your role in this workspace (developer) may not invite members - that needs admin or higher" }`. Workspace JSON is `{ id, name, slug, plan, billingMode, createdAt }`.

## POST /v1/workspaces

**Who** Session.

Creates a workspace on the Free plan with you as its owner. Body `{ name }` (1 to 80 characters) -> `{ workspace }`.

| Status | Meaning |
| --- | --- |
| `400` | A missing or bad name. |
| `403` | `{ "error": "plan_limit", "limit": "workspaces", "message": "an account may own at most 3 workspaces - delete one or ask us for more" }`. |

## GET /v1/workspaces/:id

**Who** [viewer]

```json
{
  "workspace": { "id": "ws_example12345", "name": "Example shop", "slug": "example-shop", "plan": "starter",
                 "billingMode": "off", "createdAt": "2026-10-15T09:00:00.000Z" },
  "role": "admin",
  "limits": { "plan": "starter", "projects": 5, "members": 2, "connections": 200, ... },
  "usage": { "connections": 3, "projects": 2, "members": 2,
             "month": { "requests": 241877, "egressBytes": 690123456, "dbBytes": 9437184, "peakConnections": 31 } }
}
```

## PATCH /v1/workspaces/:id

**Who** [admin]

Renames it. Body `{ name }` -> `{ workspace }`; `400` for a bad name.

## DELETE /v1/workspaces/:id

**Who** [owner]

-> `{ "ok": true }`. Every project of it goes to `deleting` at once (sockets closed, calls answered 410) and every route of the workspace is 404 from then on. The projects are removed after 7 days, and the workspace with the last of them. Not restorable.

## POST /v1/workspaces/:id/plan

**Who** [owner]

Changes the plan, at once and free of charge while billing is off (the beta). Body `{ plan }`: `free`, `starter`, `pro` or `scale` -> `{ workspace, limits }`. The same plan again changes nothing.

When [billing](https://dabadata.com/docs/billing/index.md) is on (`workspace.billingMode` is `stripe`), a paid plan starts with [checkout](https://dabadata.com/docs/api/index.md#billing-checkout) and changes in the [billing portal](https://dabadata.com/docs/api/index.md#billing-portal); this route then only moves to `free`. Without a subscription that is at once; with one, the subscription is cancelled at the end of the paid period and the answer carries `cancelAt` (the plan stays until then).

| Status | Meaning |
| --- | --- |
| `400` | An unknown plan, or `enterprise` (arranged with us). |
| `403` | `{ "error": "plan_requires_approval", "message": "Pro and Scale are enabled by us during the beta - write to us" }`: a move up past Starter (the server's `DABADATA_BETA_MAX_PLAN`). Staying on, or moving down from, a plan we set is fine. |
| `409` | `{ "error": "over_limit", "messages" }`: moving down while the workspace has more projects, or more members and pending invitations, than the new plan allows. |
| `409` | `{ "error": "billing_required", "message" }`: billing is on and the plan is a paid one - use checkout or the billing portal. |
| `502` | `{ "error": "billing_unavailable", "message" }`: billing is on and the payment provider did not answer the cancellation. |

## GET /v1/workspaces/:id/billing

**Who** [admin]

The workspace's billing, in the beta too (then `mode` is `off` and `state` `none`). See [Billing](https://dabadata.com/docs/billing/index.md).

```json
{
  "mode": "stripe",
  "plan": "starter",
  "period": "monthly",
  "state": "active",
  "pastDueSince": null,
  "caps":      { "connections": 300, "egressBytes": 26843545600, "dbBytes": 5368709120, "fileBytes": 21474836480 },
  "allowance": { "connections": 200, "egressBytes": 26843545600, "dbBytes": 5368709120, "fileBytes": 21474836480 },
  "cancelAt": null,
  "softCapSince": null,
  "limits": { "plan": "starter", "connections": 300, ... }
}
```

- `mode`: `off` (the beta) or `stripe`. `period`: `monthly`, `annual` or `null`. `state`: `none`, `active`, `past_due` or `canceled`.
- `allowance` is what the plan includes; `caps` is what is in force: the allowance, or the owner's raised cap above it while a subscription is in force. Bytes are binary (1 GB = 2^30).
- `cancelAt`: when a subscription set to cancel ends (the plan goes to Free then). `softCapSince`: when Free limits were applied for an unpaid bill (7 days after `pastDueSince`). `limits` are the limits in force.

## POST /v1/workspaces/:id/billing/checkout

**Who** [owner]

Starts a subscription. Body `{ plan, period }`: `plan` is `starter`, `pro` or `scale`, `period` `monthly` (default) or `annual` (10 months for 12) -> `{ "url": "https://checkout.stripe.com/..." }`. Send the person to `url`; Stripe brings them back to the console's **Usage & plan** page with `?billing=success` or `?billing=canceled`. The plan does not change here: it changes when Stripe confirms the subscription (a few seconds later).

| Status | Meaning |
| --- | --- |
| `400` | An unknown plan or period (`free` needs no checkout; `enterprise` is arranged with us). |
| `409` | `billing_off` (the beta), `subscription_exists` (use the portal to change the plan), or `conflict` on an enterprise workspace. |
| `502` | `billing_unavailable`: the payment provider did not answer. |

## POST /v1/workspaces/:id/billing/portal

**Who** [owner]

No body -> `{ "url": "https://billing.stripe.com/..." }` of the customer portal: cards, invoices, plan changes and cancellation. It returns to **Usage & plan**. `409` `billing_off`, or `no_customer` before the first checkout; `502` `billing_unavailable`.

## PATCH /v1/workspaces/:id/caps

**Who** [owner]

Raises the caps above the plan's allowance, so usage past it is billed instead of refused. Body: any of `{ connections, egressBytes, dbBytes, fileBytes }`, each an integer at least the allowance, or `null` (back to the allowance) -> the [billing](https://dabadata.com/docs/api/index.md#billing-get) object. Keys left out keep their value.

| Status | Meaning |
| --- | --- |
| `400` | An unknown key, a value that is not a non-negative integer, or one below the allowance (all listed in `messages`). |
| `409` | `billing_off` (the beta), or `subscription_required`: caps are raised only with an active subscription. |

## POST /v1/billing/webhook

**Who** Stripe (signed with `Stripe-Signature`); 404 while billing is off.

The only thing that changes a paid plan. The signature is HMAC-SHA256 with the endpoint's secret over `<t>.<raw body>`; any `v1` signature may match and `t` must be within 5 minutes, else `400` `invalid_signature`. Each event id is handled once -> `{ "received": true, "result" }` (`result` is `duplicate` for a repeat). Handled: `checkout.session.completed`, `customer.subscription.created|updated|deleted`, `invoice.paid`, `invoice.payment_failed`; other types are acknowledged and ignored.

## GET /v1/workspaces/:id/members

**Who** [viewer]

```json
{
  "members": [ { "accountId": "acc_k3x9m2p7q4w8", "email": "someone@example.com", "name": "Someone", "role": "owner",
                 "createdAt": "2026-10-15T09:00:00.000Z" } ],
  "invitations": [ { "id": "inv_q4w8z1abk3x9", "email": "someone.else@example.com", "role": "developer",
                     "invitedBy": "Someone", "expiresAt": "2026-10-22T09:00:00.000Z" } ]
}
```

## PATCH /v1/workspaces/:id/members/:accountId

**Who** [admin]; another admin's role only the owner.

Body `{ role }`: `viewer`, `developer` or `admin` -> `{ member }`. Ownership moves only by [transfer](https://dabadata.com/docs/api/index.md#transfer).

| Status | Meaning |
| --- | --- |
| `400` | Not one of the three roles. |
| `403` | The owner's role, or an admin changing another admin. |
| `404` | Not a member. |
| `409` | `{ "error": "conflict", "message" }`: the member's role changed meanwhile (a transfer); reload and try again. |

## DELETE /v1/workspaces/:id/members/:accountId

**Who** [admin] to remove someone else (another admin only the owner); any member to leave (their own id).

-> `{ "ok": true }`. The member loses access with their next request; their console sockets on the workspace's projects close at once (code `4003`). The owner can neither leave nor be removed (403). `409 conflict` as above.

## POST /v1/workspaces/:id/transfer

**Who** [owner]

Makes another member the owner; you become an admin. Body `{ accountId }` -> `{ "ok": true, "role": "admin" }`. `400` for yourself or no id; `404` when that account is not a member; `403` when you are no longer the owner; `409 conflict` when the members changed meanwhile (nothing changes).

## POST /v1/workspaces/:id/invitations

**Who** [admin]

Invites someone by mail; the link (`/console/invite?token=`) works for 7 days. Body `{ email, role }`, role `viewer`, `developer` or `admin` -> `{ invitation: { id, email, role, invitedBy, expiresAt } }`. Inviting an address that has a pending invitation replaces it (the old link stops working).

| Status | Meaning |
| --- | --- |
| `400` | A bad address or role. |
| `403` | `{ "error": "plan_limit", "limit": "members", "message" }`: members plus pending invitations already fill the plan's seats. |
| `409` | `{ "error": "already_member", "message" }`. |
| `429` | 20 invitations per workspace per hour, 50 per inviting account per 24 hours, 3 per invited address per 24 hours. |

## DELETE /v1/workspaces/:id/invitations/:invId

**Who** [admin]

Withdraws a pending invitation; its link stops working -> `{ "ok": true }`. `404` when it is not pending.

## GET /v1/invitations/:token

**Who** Anyone holding the link.

What an invitation is for -> `{ "workspace": { "name" }, "role", "email", "invitedBy", "expiresAt" }`; `invitedBy` is the inviter's name or email. `404` for an unknown, withdrawn, accepted or expired invitation, or a deleted workspace.

## POST /v1/invitations/accept

**Who** Anyone holding the link, with or without a session (with `X-Requested-With`).

Body `{ token }`. With a session of the invited address: joins -> `{ "workspace": { "id", "name", "slug", "plan", "role" } }` (a member already keeps their role). Without a session: mails a 15-minute sign-in link to the invited address that joins when it is used -> `{ "ok": true, "sent": true }`.

| Status | Meaning |
| --- | --- |
| `404` | The invitation is not pending (the same answer in every case). |
| `409` | `{ "error": "email_mismatch" }`: signed in with another address. |
| `429` | Without a session: the sign-in link counters of the invited address. |

## GET /v1/account/invitations

**Who** A session (any account).

The pending invitations addressed to your account's email, oldest first -> `{ "invitations": [ { "id", "workspace": { "name" }, "role", "invitedBy", "expiresAt" } ] }`. Someone who signed in on their own, not through the mailed link, joins from here. Every session proves its email (a mailed link, a verified Google or GitHub email, or a password on a verified account), so this is the mailed link's equal.

## POST /v1/account/invitations/:invId/accept | decline

**Who** A session of the invited address (with `X-Requested-With`).

`accept` joins -> `{ "workspace": { "id", "name", "slug", "plan", "role" } }`, as [POST /v1/invitations/accept](https://dabadata.com/docs/api/index.md#invitation-accept). `decline` withdraws it and frees its seat -> `{ "ok": true }` (audit `invitation.declined`). `404` unless the invitation is pending and addressed to your email.

To send an invitation again (a new 7-day link; the old one stops working), an admin repeats [POST /v1/workspaces/:id/invitations](https://dabadata.com/docs/api/index.md#invitations-post) with the same email and role. It replaces itself, so it goes through even when every seat is taken.

## GET /v1/workspaces/:id/audit

**Who** [admin]

The audit log, newest first. `limit` 1 to 1000 (default 100) and `before` an entry id, else 400.

```json
{
  "entries": [
    { "id": 77, "at": "2026-10-15T09:30:00.000Z", "actorId": "acc_k3x9m2p7q4w8", "actorEmail": "someone@example.com",
      "action": "key.revoked", "target": "prj_example12345",
      "details": { "env": "live", "kind": "secret", "prefix": "sk_live_EXAM", "replacedBy": "sk_live_NEWK" } }
  ]
}
```

Actions: `workspace.created|renamed|deleted`, `member.invited|joined|role_changed|removed|left`, `ownership.transferred`, `invitation.revoked|declined`, `plan.changed`, `project.created|deleted|restored`, `key.rotated|revoked`, `secret.set|deleted`, `definition.applied`, `cors.changed`, `sql.write`, `user.created|updated|deleted`, `caps.changed`, `billing.changed`, `billing.cancel_scheduled`. Calls made with a secret key have `actorId: null` and `actorEmail: "secret key"`; the operator's, `"admin"`; changes from Stripe and the nightly billing step, `"stripe"`. Secret values are never recorded.

## GET /v1/workspaces/:id/projects

**Who** [viewer]

-> `{ "projects": [ ... ] }`, each in the shape of [`GET /v1/project`](https://dabadata.com/docs/api/index.md#project-get)'s `project`.

## POST /v1/workspaces/:id/projects

**Who** [developer]

Creates a project with its two environments. Body `{ title, template? }`: `template` is `blank` (default; roles `admin` and `member`, no tables) or `tasks` (the demo's task list). -> `{ project, keys }`, where `keys` is `{ live: { publishable, secret }, test: { publishable, secret } }`. **This is the only time the secret keys are shown.**

| Status | Meaning |
| --- | --- |
| `400` | A bad title (1 to 80 characters) or template. |
| `403` | `plan_limit`, `limit: "projects"`. |

## DELETE /v1/workspaces/:id/projects/:prj

**Who** [owner]

Deletes a project: both environments answer 410 at once, and the data is removed after 7 days -> `{ project }`. Doing it twice changes nothing. `404` for a project of another workspace.

## POST /v1/workspaces/:id/projects/:prj/restore

**Who** [admin]

Brings a deleted project back within its 7 days -> `{ project }`. `403 plan_limit` when the plan's project allowance is full; `404` as above.
