dabadataDocs
Reference / HTTP API

Reference

HTTP API

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). 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 · rows and schema (schema, mutate, db, trace, live) · user tokens · Project API · File storage · Sites · Logs · SQL console · End-user accounts · Console accounts · Workspaces.

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 (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.
  • Calls that touch rows return a traceId: the id of the call's row in the trace.
  • 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.
  • The console 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.
  • 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:

StatusBodyWhen
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.
403{ "error": "origin_not_allowed" }Hosted: a WebSocket from an origin the environment does not allow.
403{ "error": "csrf", "message" }A write to the console's account routes 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.
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 (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 with no steps whose model call failed; message names the cause.
503{ "error": "secrets_unavailable" }The secrets routes on a server started without its encryption key.
503{ "error": "sql_unavailable", "message" }The SQL console 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

StatuserrorWhen
400link_invalidA mailed link's token is unknown, used or expired (console and end-user links).
400sqlPostgres refused a SQL console query; with message, code and maybe position.
400commit_token_invalidA SQL commit token that is unknown, used, expired or not yours.
401invalid_loginWrong email or password (console, end users). 403 when it is the current password of a password change.
403email_unverifiedThe right password, but the address is not confirmed yet. Nothing is mailed.
403user_disabledAn end user the project disabled.
403signup_closedEnd-user sign-up is by invitation only.
403plan_requires_approvalDuring the beta, Pro and Scale are switched on by us.
409over_limitA plan change down while the workspace uses more than the new plan allows; messages says what.
409billing_requiredBilling is on: a paid plan starts with checkout and changes in the billing portal.
409billing_offA billing route (checkout, portal, caps) while billing is off - the beta.
409subscription_exists, subscription_required, no_customerCheckout with a subscription in force; raising caps without one; the portal before the first checkout.
400invalid_signatureA Stripe webhook whose signature does not check out.
502billing_unavailableThe payment provider did not answer; try again in a minute.
409already_member, already_existsInviting a member; adding an end user whose address has an account.
409email_mismatchAccepting an invitation while signed in with another address.
409conflictMembers 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. 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 }
FieldMeaning
nameThe display name (DABADATA_BRAND on a self-hosted server, default dabadata). Show this rather than hard-coding a name.
contractThe date of the API contract the server implements.
realtime"inproc": live changes fan out inside the server process.
hostedtrue 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. 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.

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 (createdBy: "file"), even when nothing changed.

StatusMeaning
400The file is invalid; messages lists every problem. Nothing was applied and the previous definition stays.
401Not 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, plus the hosted issuer rules, 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.

FieldTypeMeaning
definitionobject, requiredThe JSON form of the definition. name is optional; it is filled with the environment's name.
dryRunboolean, optionaltrue: change nothing and answer with the plan.
expectVersioninteger, optionalThe version you edited from (the newest of GET /v1/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 }
StatusMeaning
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.
401Not 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"
}
StatusMeaning
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 its file columns held, unless another table holds them. Declared tables are refused. Audited.

FieldTypeMeaning
tablestring, requiredThe table to drop.
dryRunbooleantrue: answer the row count, drop nothing.
confirmstringThe 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.

FieldTypeMeaning
stepsarray, required1-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>" }.
definitionobjectThe definition (JSON form) applied after the steps and stored as a new version. Default: the current one, applied again without a new version.
dryRunbooleantrue: run it all and roll back; answer what it met.
confirmstring[]Every dropTable, dropColumn and changeType target; required for those unless dryRun.
expectVersionintegerAs for PUT /v1/schema: 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 }
StatusWhen
400A 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.
401Not the secret key or a console session.
403A console role below developer.
409expectVersion 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.

FieldTypeMeaning
versionnumber, requiredThe 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
}
StatusMeaning
400version must be a positive integer, or the old definition no longer validates - for example it names a secret that has since been deleted.
401Not the environment's secret key.
404No 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.

FieldTypeMeaning
idstring, requiredYour id for the person. Not empty.
namestring, optionalDisplay name for the trace and the user list.
rolestring, requiredOne 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"
}
FieldMeaning
tokenThe access token, sent as Authorization: Bearer. 64 hex characters.
expiresAtWhen token expires: 1 hour from now.
refreshTokenExchanges for the next pair at POST /v1/users/refresh. 64 hex characters.
refreshExpiresAtWhen refreshToken expires: 30 days from now.
StatusMeaning
400e.g. id must be a non-empty string, role "owner" is not declared (declared: ["admin","editor","viewer"]).
401Not 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.

FieldTypeMeaning
refreshTokenstring, requiredThe 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.

StatusMeaning
400The 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.

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.

FieldTypeMeaning
jwtstring, requiredThe 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 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 and POST /v1/users/revoke signs them out of dabadata (not out of the provider).

StatusMeaning
400The 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.

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.

FieldTypeMeaning
idstring, requiredThe 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.

StatusMeaning
400Missing or empty id.
401Not 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, 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, never a read followed by a write of the computed value (see Safe concurrent writes).

FieldTypeMeaning
tablestringThe table.
op"insert" | "update" | "delete"What to do.
idstringThe row, for update and delete. Not allowed on insert.
valuesobjectInsert: 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.
expectobjectUpdate 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.
ifAbsentobjectInsert: 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 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.

OperatorColumnsResult
{ "$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, datetimethe smaller / larger of value and v; null becomes v
{ "$append": v }json holding an arrayv added at the end (an array v is one element); null starts [v]
{ "$remove": v }json holding an arrayevery element equal to v removed; null stays null
{ "$set": v }anyv 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
}
StatusMeaning
400Validation, e.g. unknown column "colour" in tasks, column "done": expected a boolean, missing required column "title", update needs values (a non-empty object).
401A token that matches nothing.
403Denied by the rule: { "error": "forbidden", "explanation", "traceId" }. With a view token: view tokens are read-only. A strict table for an end user: insert questions: denied, questions is written through actions only (access: views) (update / delete the same), before anything else is checked.
403Hosted: { "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.
404Unknown 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 fieldMeaning
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.
refInsert only: a name ([A-Za-z0-9_.-], 1 to 64) for the row it creates.
id, a column valueMay 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).

StatusMeaning
400The 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"? }.
403An op denied by its rule: { "error": "forbidden", "index", "explanation", "traceId" } - the same explanation /v1/mutate gives. Hosted plan limits as for /v1/mutate.
404An op's table or row does not exist; with index.
409An 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): its steps, in one SERIALIZABLE transaction like /v1/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).

FieldTypeMeaning
paramsobjectThe 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 ] }
StatusMeaning
403The allow rule refused the caller: { "error": "forbidden", "explanation", "traceId" }, e.g. action rsvp: denied, anonymous is not authenticated.
404No such action, or a row a step names is missing (with index).
400 / 409A step failed: the body of /v1/transact (index = the step) plus actionTraceId. A full event answers 409 from its check step. Nothing is written.
429An 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.
502An 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.statusShapeWhen
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.

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.

ParameterDefaultMeaning
limit100At most 1000.
wherenoneURL-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.
orderBycreated_atOne or more columns, comma separated, each optionally prefixed with - for descending: orderBy=starts_at,-created_at.
descsee meaning1 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
}
StatusMeaning
200Also 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).
400where 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).
403A strict table for an end user: read questions: denied, questions is read through views only (access: views).
401A token that matches nothing.
404Unknown 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
}
StatusMeaning
403The 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.
404Unknown 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: 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.

ParameterDefaultMeaning
paramsnoneURL-encoded JSON: the view's params, typed as declared. Required ones must be given.
wherenoneURL-encoded JSON, the filter grammar, on the view's filter columns only.
orderBy, descthe view'sAs for /v1/db, on the view's sort columns.
limitthe view'sClamped to the view's max.
asnoneSecret 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" }
}
StatusMeaning
400The 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.
403The view's allow refused the caller: { "error": "forbidden", "explanation", "traceId" }, e.g. view open_questions: denied, role "viewer" not in ["player","host"].
404unknown 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). 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 page. Callers who read through trace.read get each row without the columns their column read rules hide (in values, before and after), and explanations print those columns as row.<column> (hidden); a location with a precision comes rounded, as in rows. The project key and view tokens see every value.

ParameterDefaultMeaning
limit200At most 1000.
beforenoneOnly rows with a smaller id, for paging.
tablenoneOnly this table.
usernoneOnly 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). An end-user role on a strict table 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).

FieldTypeMeaning
user{ id?, role }The caller to imagine. role may be a declared role, anonymous, or service / view to see a bypass.
tablestringA declared table.
opstringread, insert, create, update or delete. insert and create are the same rule.
rowobject or stringFor read, update and delete: the row to judge, or the id of a real row to load.
valuesobjectFor insert: the values, filled like a real insert.
nowstringOptional: 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"] }
}
StatusMeaning
400e.g. user must be { id, role }, op must be one of read, insert, create, update, delete, now must be an ISO datetime.
404Unknown 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.

text
wss://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
StatusMeaning
401A token that matches nothing, or a key and a token of different environments.
400A plain HTTP request, not a WebSocket upgrade.
403Hosted: 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).
410Hosted: the project is being deleted.
429Hosted: 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, 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.

StatusMeaning
404{ "error": "not found" } when demo mode is off, exactly like an unknown route.
400No project definition, a persona role the definition does not declare, a body that is not JSON, or a session that is not a string.
403A 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.
429More than 30 calls a minute, or more than 30 new sessions an hour, from one client IP.

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 (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, which it may use with its project key.

The concepts behind these routes are on Projects and keys.

GET /v1/project

The project with both environments, which environment the key belongs to, the workspace, and the plan's limits (null = unlimited). The full shape is shown under Reading your project.

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.

FieldTypeMeaning
titlestring, optionalThe project's name, 1 to 200 characters. Changes both environments.
corsOriginsstring[], optionalThis environment's allowed origins, each exactly http(s)://host[:port]. At most 100; duplicates are dropped. [] allows every origin.
expect{ corsOrigins: string[] }, optionalThe 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"]}'
StatusMeaning
400e.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.

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

StatusMeaning
400kind 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.

FieldTypeMeaning
prefixstring, requiredThe 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.

StatusMeaning
400prefix must be a key's prefix (its first 12 characters).
404No 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 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 (e.g. ANTHROPIC_KEY). Values are encrypted at rest and never returned.

FieldTypeMeaning
valuestring, required1 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" }
StatusMeaning
400A malformed name and a bad value are reported together, e.g. value must be a string of 1..4096 characters.
503secrets_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 }
StatusMeaning
404No secret of that name (or a malformed name).
503As 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.

bash
curl https://dabadata.com/v1/project/export -H "Authorization: Bearer <secret key>" -o export.ndjson
text
{"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.

ParameterDefaultMeaning
days30How 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", ... }
}
StatusMeaning
400days 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 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. Who: the same credentials as /v1/db (a publishable key with a user token or JWT, a user token, the secret key, a view token for reads, or a 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).

StatusMeaning
400A bad body (every problem listed), a type the bucket does not take, a file above the bucket's maxSize.
403forbidden 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.

StatusMeaning
400Nothing was uploaded yet (PUT and complete again), or the object is not what was declared (it is deleted; start over).
403Not the uploader, or the rule denies it now.
404No 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> (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 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 (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 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 when that list is not empty. Audited as site.created.

StatusMeaning
400A malformed name.
403plan_limit with limit: "sites"; email_unverified on Free without a confirmed email address.
409name_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.

StatusMeaning
400A bad path, a duplicate, a bad size or type (every problem listed).
403plan_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. 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 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).

StatusMeaning
409nothing_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.
429rate_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).

StatusMeaning
400A malformed hostname or redirectFrom.
403plan_limit with limit: "customDomains"; on Free with the message custom domains start with Starter.
409domain_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 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.

ParameterMeaning
statusA code (404) or a class (1xx to 5xx).
routeAn exact normalised route, e.g. /v1/db/:table.
userAn 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.

StatusMeaning
400before 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.

FieldTypeMeaning
querystring, requiredOne 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.
confirmstring, write modeThe 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. 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.

StatusMeaning
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.
400validation: an empty or too long query, an unknown mode, a missing or wrong confirm.
403Write mode with a view token (view tokens are read-only) or a console role below developer.
429More 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).

StatusMeaning
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.
403A console role below developer.
429As 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": "[email protected]",
                 "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. 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 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 to keep the password (why). 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":"[email protected]","password":"correct horse battery","redirectUrl":"https://tasks.example.com/"}'
StatusMeaning
400validation: address, password or name; a redirectUrl that is not listed.
403{ "error": "signup_closed" }: sign-up is "invite".
42920 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.

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

StatusMeaning
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" }.
42910 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 }.

StatusMeaning
400Another 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 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 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). 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).
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..."}'
StatusMeaning
400validation: 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.
42930 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: 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.

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

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

The same list as GET /v1/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>"
StatusMeaning
401No 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. Audited (user.updated) with the hosted user as the actor.

StatusMeaning
400Other fields, an undeclared role, a bad name, disabled not a boolean.
403manage_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).
404No 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": "[email protected]", "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 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":"[email protected]","role":"viewer","invite":true}'
StatusMeaning
400Unknown 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. -> { "ok": true, "user" }.

StatusMeaning
404An unknown id.
409not_pending: the user has a password or has signed in (they can use Forgot password). user_disabled: enable them first.
429More 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": "[email protected]", "liveId": "usr_p8d2n5c1v7t4r9qe" }],
  "skipped": [{ "id": "usr_a1b2c3d4e5f6g7h8", "email": "[email protected]", "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).

StatusMeaning
400Not a test environment, a bad body, more than 200 users.
403A secret key, or a console role below developer.
404An 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 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.

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 [email protected] 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: accounts, sessions and sign-in (Accounts and teams). 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.

StatusMeaning
400validation: the address, password and name problems together.
4295 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": "[email protected]", "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.

StatusMeaning
400The 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.
42910 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": "[email protected]", "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.

StatusMeaning
400A password that breaks the rules; currentPassword missing.
403{ "error": "invalid_login" }: currentPassword is wrong.
429Counted 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 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 }.

StatusMeaning
400A 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 is on (workspace.billingMode is stripe), a paid plan starts with checkout and changes in the 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).

StatusMeaning
400An 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.

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

StatusMeaning
400An unknown plan or period (free needs no checkout; enterprise is arranged with us).
409billing_off (the beta), subscription_exists (use the portal to change the plan), or conflict on an enterprise workspace.
502billing_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 object. Keys left out keep their value.

StatusMeaning
400An unknown key, a value that is not a non-negative integer, or one below the allowance (all listed in messages).
409billing_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": "[email protected]", "name": "Someone", "role": "owner",
                 "createdAt": "2026-10-15T09:00:00.000Z" } ],
  "invitations": [ { "id": "inv_q4w8z1abk3x9", "email": "[email protected]", "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.

StatusMeaning
400Not one of the three roles.
403The owner's role, or an admin changing another admin.
404Not 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).

StatusMeaning
400A 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" }.
42920 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 }.

StatusMeaning
404The invitation is not pending (the same answer in every case).
409{ "error": "email_mismatch" }: signed in with another address.
429Without 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. 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 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": "[email protected]",
      "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'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.

StatusMeaning
400A bad title (1 to 80 characters) or template.
403plan_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.

dabadata documentation, written against API contract v2.