Documentation
API — ZigBase
The ZigBase HTTP REST and realtime WebSocket reference — collections, records, query grammar, access rules, auth, OAuth2, files, and realtime.
ZigBase is a single-binary backend. This document is the user-facing reference for its HTTP REST API and its realtime WebSocket interface. It describes only what the server actually implements.
New to ZigBase? Start with the tutorial (build an app end to end), then reach for the field-type catalog and the task recipes. This page is the endpoint reference.
Conventions
Base path: all REST endpoints live under
/api.Encoding: requests and responses are JSON (
Content-Type: application/json), except file uploads (multipart/form-data, see Files) and file downloads.List envelope: every list endpoint returns
{"items":[…]}, never a bare array. Endpoints that paginate additionally use the records cursor vocabulary (?cursor=/?limit=request params;nextCursor/hasNextresponse keys — see Cursor (keyset) pagination).Error envelope: every error response is a JSON object of the shape:
{ "status": 404, "code": "not_found", "message": "Not found.", "data": {} }statusis the HTTP status.codeis a frozen machine string — it never changes meaning once shipped, and it is what your client should branch on.messageis human text and is not contract: it may be reworded in any release, so never match on it. Runzigbase explain-codeto list every code, orzigbase explain-code <CODE>for the long form. Key order is stable and part of the contract.For validation failures (
400,code: "validation_failed"),datamaps each offending field to its own frozen{ "code": …, "message": … }:{ "status": 400, "code": "validation_failed", "message": "Failed to validate the request.", "data": { "name": { "code": "validation_invalid_name", "message": "Invalid." } } }This is the only error shape for the JSON API. Typed (
rpc.*) routes, auth-method endpoints, and custom routes all emit it; the older bare{"message": …}and{"error": …}bodies are gone.Two deliberate non-JSON exceptions exist, both outside the JSON API surface, and a client that blindly parses every non-2xx body must tolerate them: a 416 Range Not Satisfiable from a file download carries an empty body with a
Content-Rangeheader (the range headers are the response), and a static-file 404 returnstext/plainbecause static assets are browser-facing, not API responses.
Authentication transport
A request is authenticated by either of:
- Bearer token —
Authorization: Bearer <jwt>. - Cookie — the httpOnly
zb_authcookie. When authenticating via the cookie, unsafe methods (POST, PUT, PATCH, DELETE) additionally require a double-submit CSRF check: send the value of the readablezb_csrfcookie back in theX-CSRF-Tokenheader. The server compares it against the CSRF claim embedded in the token; a missing or mismatched header fails authentication on unsafe methods.
The zb_auth cookie is httpOnly, SameSite=Strict; zb_csrf is readable (not httpOnly), SameSite=Strict. Both are set by the auth endpoints (see Auth).
CSRF on unsafe methods (cookie sessions)
This applies only to the cookie transport. Bearer-token requests carry no ambient cookie, so they are not subject to the CSRF check.
- Safe methods (
GET,HEAD,OPTIONS) are exempt. Reads work with just the cookie — no header needed. - Unsafe methods (
POST,PUT,PATCH,DELETE) require the header. The request must carryX-CSRF-Tokenequal to the currentzb_csrfcookie value, or the request is treated as unauthenticated. The resulting status then follows the access rules: aPOST(create) denial returns403, while aPATCH/DELETEdenial on a protected record returns404(to hide the record’s existence).
If you authenticate and read fine but writes return 403 (or 404 on updates/deletes), this is almost always the missing piece: the client never echoed the zb_csrf cookie into the X-CSRF-Token header.
The zb_csrf cookie is deliberately not httpOnly so that a browser SPA / fetch client can read it and replay it as a header on writes — that is what makes the double-submit check work. Read the cookie and set the header on every unsafe request:
// read the readable zb_csrf cookie, send it back as X-CSRF-Token on writes
const csrf = document.cookie
.split("; ")
.find((c) => c.startsWith("zb_csrf="))
?.slice("zb_csrf=".length);
await fetch("/api/collections/posts/records", {
method: "POST",
credentials: "include", // send zb_auth + zb_csrf cookies
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify({ title: "Hello" }),
});
Collections
Collection creation and updates reject malformed access rules or unresolved field/relation paths with 400 before changing the schema. Validation uses the proposed collection together with retained live collections, so removing a field referenced by another collection’s rule is rejected too. Request macros remain dynamic; validation does not prove access decisions. Use schema apply for coordinated changes across multiple collections. The gate validates all retained non-system collections, not just dependencies of the mutation: a pre-existing invalid rule elsewhere also blocks POST/PATCH. The 400 message labels that collection Retained (different) and names the rule to repair.
Collection management endpoints are superuser-only.
| Method | Path | Description |
|---|---|---|
| GET | /api/collections | List all collections: {"items":[…]} (changed: was a bare array). |
| POST | /api/collections | Create a collection. |
| GET | /api/collections/:idOrName | Get one collection by id or name. |
| PATCH | /api/collections/:idOrName | Update a collection. |
| DELETE | /api/collections/:idOrName | Delete a collection. |
Input vs. output shape
On input (POST/PATCH), the field list is supplied under the key fields. On output, the serialized collection exposes the field list under the key schema:
// request body (create)
{
"name": "posts",
"type": "base",
"fields": [
{ "name": "title", "type": "text", "options": {} }
]
}
// response
{
"id": "...",
"name": "posts",
"type": "base",
"system": false,
"schema": [ { "name": "title", "type": "text", "options": {} } ],
"indexes": []
}
Fields
A field has a name, a type (e.g. text, relation, file, …), and a type-specific options object. Common options include required, unique, and encrypted (text/editor/json only — see fields.md → Encryption at rest); relation fields reference another collection. Auth collections ("type":"auth") have system fields such as email injected automatically. The complete field-type catalog is in fields.md.
Collection options
The collection body carries an options object for collection-level settings. Beyond the auth block on auth collections (see Auth), one notable option is row expiry:
| Option | Type | Meaning |
|---|---|---|
ttl_field | string | null | Name of a date/autodate field on this collection used as the row’s expiry timestamp. Rows whose value is non-null and at/before “now” are reaped by an internal GC and hidden from every read (list, get, relation expand). Default null = no expiry. |
// create a TTL collection — "expires_at" rows auto-expire
{
"name": "sessions",
"type": "base",
"fields": [
{ "name": "token", "type": "text" },
{ "name": "expires_at", "type": "date" }
],
"options": { "ttl_field": "expires_at" }
}
Expiry is eventually consistent: the GC sweeps at startup and every ~5 minutes, but expired rows are excluded from reads immediately by a read-time predicate. See framework.md → Row expiry (TTL) for the full semantics.
Records
Record endpoints operate on a collection by name (:col).
| Method | Path | Description |
|---|---|---|
| GET | /api/collections/:col/records | List records (paginated). |
| GET | /api/collections/:col/records/:id | Get one record. |
| GET | /api/collections/:col/records/:id/abilities | What the current principal may do with this record — see abilities.md. |
| POST | /api/collections/:col/records | Create a record. |
| PATCH | /api/collections/:col/records/:id | Update a record. |
| DELETE | /api/collections/:col/records/:id | Delete a record. |
Access to each operation is governed by the collection’s access rules.
A create (POST) or update (PATCH) that violates a database integrity constraint — most commonly a duplicate value on a unique field, such as signing up with an already-registered email — returns 409 Conflict ("A record with these values already exists."), not 500, so a client or SDK can tell a routine user conflict apart from a genuine server fault.
The abilities endpoint returns a JSON object of booleans introspecting what the current principal may do with the record — e.g. {"view": true, "update": false, "delete": false}. It requires view access (404 otherwise, so it never leaks a record’s existence), which is why "view" is always true on a 200. See abilities.md for the full model.
Update: changing a password on an auth collection
A PATCH on an auth collection’s record that includes a password field is a self-service password change, gated on top of the normal update rule:
- Non-superusers must also send a verifying
oldPassword. A wrongoldPassword, a missingoldPassword, an unknown record, or a target record with no password set (passwordless — e.g. OAuth2-only) all return the same login-identical400 {"message":"Invalid credentials."}— the failure modes are indistinguishable by design, so the endpoint can’t be used to probe which accounts have a password. Passwordless accounts cannot bootstrap a password viaPATCH; use the password-reset email flow or a superuser update instead. - Superusers are exempt from the
oldPasswordcheck (and from its rate limit — see Rate limiting). - On a successful self-change (the caller is the record being updated), the response sets fresh
zb_auth/zb_csrfcookies — the JSON body itself stays the plain updated record. - Every other outstanding session for the record is invalidated (the same guarantee as
confirm-password-reset).
This rides the record beforeUpdate/afterUpdate hooks plus the .auth beforePasswordChange/afterPasswordChange lifecycle hooks — see framework.md §6.
List: query parameters
The list endpoint supports two pagination styles: offset (page/perPage) and cursor (keyset). Both can be enabled/disabled at compile time (see Pagination configuration); by default both are on.
| Param | Default | Meaning |
|---|---|---|
page | 1 | Offset page number (1-based). |
perPage | 30 | Items per page (clamped to 500). |
cursor | — | Opaque keyset cursor. Its presence (even empty cursor=) selects cursor mode; an empty value is the first page. |
limit | — | Cursor-mode page size (alias of perPage, clamped to 500); its presence also selects cursor mode. |
skipTotal | true in cursor mode | Skip the COUNT(*) total. Set skipTotal=false to include totalItems/totalPages. |
filter | — | Filter expression (see Filter grammar). |
sort | — | Sort spec (see sort). In cursor mode this defines the keyset order; id is auto-appended as a tiebreaker. |
search | — | Full-text search terms (alias q). Matches searchable fields; results are ranked by relevance (see Search). |
vector | — | Nearest-neighbor search (opt-in -Dvector build only); ` |
expand | — | Relation expansion (see expand). Works in both modes. |
fields | — | Response projection: comma-separated dot-paths select which keys are returned (see fields). Works in both modes; also on the single-record view endpoint. |
The offset list response envelope:
{
"page": 1,
"perPage": 30,
"totalItems": 42,
"totalPages": 2,
"items": [ { "id": "...", "title": "..." } ]
}
Cursor (keyset) pagination
Offset pagination walks page/perPage but has two structural problems: deep offsets (OFFSET 100000) scan every skipped row, and an insert/delete on an earlier page shifts every later page (duplicating or skipping rows during infinite scroll). Cursor pagination fixes both: each page returns an opaque nextCursor/prevCursor that encodes the boundary row’s sort-key values, and the next request resumes strictly after that boundary — value-based, so it’s drift-resistant and cheap regardless of depth.
Send cursor= (empty) or a limit to start a cursor walk, then forward nextCursor:
GET /api/collections/posts/records?sort=-created&limit=20&cursor=
GET /api/collections/posts/records?sort=-created&limit=20&cursor=<nextCursor>
The cursor list response envelope:
{
"page": 0,
"perPage": 20,
"nextCursor": "eyJ2Ijox...",
"prevCursor": null,
"hasNext": true,
"hasPrev": false,
"items": [ { "id": "...", "title": "..." } ]
}
totalItems/totalPages are present only when skipTotal=false. page is 0 (a sentinel: “cursor mode”; page is not meaningful for keyset). Walk backward with prevCursor.
Rules and filters always apply — a cursor only narrows the window; the collection’s list rule and your filter are still AND-ed into the same query, so a cursor can never reveal a row a rule would hide. Cursor values are bound parameters (never interpolated into SQL).
Stale cursors fail loudly. A cursor is bound to the sort and filter it was minted under. Reusing it with a different sort returns 400 (“Cursor does not match the requested sort.”); a different filter returns 400 (“Cursor does not match the requested filter.”); a malformed/oversized token returns 400 (“Invalid cursor.”).
Pagination configuration
Both modes and the cursor token format are selected at compile time on App(.{ ... }):
zigbase.App(.{
.pagination = .{
.offset = true, // enable page/perPage (default true)
.cursor = true, // enable cursor (default true)
.cursor_token = .stateless, // .stateless | .signed | .stateful (default .stateless)
},
});
.offset = falserejectspage/perPagewith a 400; only cursor paging is allowed..cursor = falserejectscursorwith a 400; only offset paging is allowed.- Setting both to false is a compile error (a list endpoint must have a pagination mode).
The three cursor token formats trade off statelessness vs. tamper-evidence:
cursor_token | What the token is | Tamper-evident? | State | Notes |
|---|---|---|---|---|
.stateless (default) | base64url JSON payload, validated structurally + against the request’s sort/filter | No (rules + parameterization provide the security) | None | CDN-friendly; byte-compatible with the SDK’s client-synthesized cursors. |
.signed | the stateless payload + an HMAC-SHA256 tag keyed by the server’s JWT secret | Yes — a tampered/hand-crafted token returns 400 (“Invalid cursor signature.”) | None | No extra config; reuses the existing token secret. Not synthesizable by a client without the secret. |
.stateful | a random opaque id; the keyset payload is stored server-side in _cursorStates with a TTL | N/A (server holds the state) | A row per minted cursor, GC’d on expiry | Smallest token; unknown/expired id returns 410 Gone. Not compatible with client-synthesized cursors. |
For most apps the default .stateless is the right choice — security comes from access rules gating every row and from parameterized binding, not from signing the cursor. Choose .signed when you want tamper-evidence with no extra storage, or .stateful when you want the server to fully control cursor validity/expiry (e.g. revocable cursors) and can accept a small write per page.
SDK forward-compatibility
The cursor response shape (items + nextCursor/prevCursor/hasNext/hasPrev + optional totalItems) is exactly what the TypeScript SDK’s CursorPage reads, so the SDK can forward a native cursor instead of synthesizing the keyset filter itself, with no SDK type changes. Treat the token as fully opaque and round-trip whatever the server returned.
Filter grammar
The filter parameter (and rule expressions, and realtime subscription filters) share one grammar.
Comparison operators:
| Operator | Meaning |
|---|---|
= | equal |
!= | not equal |
> | greater than |
>= | greater than or equal |
< | less than |
<= | less than or equal |
~ | LIKE (contains / pattern match) |
!~ | NOT LIKE |
in | set membership — field in (a, b, c) or field in <list-macro> |
The in operator tests a field against a list: either a parenthesized, comma-separated literal list (status in ("draft", "published")) or a list-valued macro (see below). It compiles to a parameter-bound IN (?, ?, …). An empty list (field in (), or an empty membership set) matches nothing (fail-closed). in is a reserved word — a field literally named in cannot appear bare on the left of a comparison.
Boolean combination: && (and), || (or), with parentheses ( ) for grouping.
Operands: field paths (identifiers, may contain . for relation traversal, e.g. author.name), single- or double-quoted strings, numbers, booleans (true/false), and null.
String escapes: inside a quoted string a backslash starts an escape: \\ → \, \' → ', \" → ", plus \n \t \r. This lets a value contain the same quote character used to delimit it (e.g. name = 'O\'Brien') or even both quote characters at once (name = 'both \' and \"'). A backslash followed by any other character is rejected. The bound parameter receives the unescaped value.
Request macros resolve against the current request:
| Macro | Value |
|---|---|
@request.auth.<field> | a field of the authenticated record (e.g. @request.auth.id) |
@request.data.<field> | a field of the incoming request body |
@request.method | the HTTP method (e.g. "GET") |
@request.account.id | the active account scope’s id ("" when none); multi-tenancy foundation |
@request.account.role | the principal’s role in the active account ("" when none) |
@request.account.ids | list macro — every account the principal belongs to; use with in |
The
@request.account.*macros are the foundation for multi-tenancy and row-level/relationship authorization. Until the tenancy resolver ships they resolve to""/ the empty list, so a rule using them is fail-closed and existing rules are unaffected. A typical use is scoping a collection to the caller’s accounts:account in @request.account.ids.
Examples:
status = "published"
title ~ "zig" && views >= 100
@request.auth.id = owner
author.role = "admin" || @request.method = "GET"
status in ("draft", "published")
account in @request.account.ids
Encrypted fields are not filterable or sortable. A field marked
encrypted(see fields.md → Encryption at rest) is stored as per-row-nonce ciphertext, so referencing it infilterorsortreturns400("Cannot filter or sort by an encrypted field."). The same applies to an access rule comparing an encrypted field — it can only ever match against ciphertext.Hidden fields are not filterable or sortable from client input either. A client-supplied
?filter=or?sort=that references a hidden field (passwordHash,tokenKey,token_epoch, or any field markedhidden) is rejected closed with400("Cannot filter or sort by a hidden field."), matching exactly the set of columns the API never serializes — so it can never become a boolean oracle over a non-serialized secret. A trusted, operator-authored access rule may still gate on a hidden field: a rule is a server-sideWHEREclause whose truth is never returned to the client, so it is no oracle.
sort
Comma-separated field list. Prefix a field with - for descending; ascending is the default:
sort=-created,title
expand
Comma-separated relation paths; nest deeper relations with .:
expand=author,comments.user
Expansion runs on both the list endpoint and the single-record view endpoint.
fields
fields= narrows which keys of each returned record are serialized. It is a comma-separated list of dot-paths and runs on both the list endpoint and the single-record view endpoint.
fields=id,title,expand.author.name
- Segments split on
.:expand.author.namekeeps top-levelexpand, thenauthorinside it, then onlynameinside that. Descent recurses arbitrarily deep (nestedexpandblocks); when a descended value is a multi-relation array, the sub-projection is applied to each element. *as a segment means “all keys at this level” — a lonefields=*returns the full record.- Strict: when
fieldsis present, ONLY the listed paths appear.idis not auto-included (list it, or cover it with*). Theexpandblock appears only if anexpand…path is listed. - Exclusion: a path beginning with
-removes that path, applied after the includes, e.g.*,-secret(all top-level exceptsecret) orexpand.author.*,-expand.author.email. Excluding a non-present path is a no-op.fields=-secretalone means “everything butsecret”. - Unknown/non-present include paths are silently ignored (never an error).
Projection is a pure output filter applied after expand, view-rule authorization, and any sensitive-field stripping — it can only remove keys, never read a new column, re-query, or add data. It therefore cannot widen access: a field the record wouldn’t otherwise return can never appear via fields=. On the list endpoint it projects each record; the envelope (page/perPage/items/ cursor fields) is never projected.
Search
ZigBase has first-class search on the list endpoint. It is not a separate, unscoped query: the search predicate is AND-ed into the same composed WHERE as your filter, the list rule, abilities and tenant scope — so search can never widen visibility. A search of a tenant-owned or ability-guarded collection returns only the rows the caller may already view.
Full-text (FTS5) — default build. Mark one or more text/editor fields .searchable in the schema:
.posts = .{ .fields = .{
.title = .{ .type = .text, .searchable = true },
.body = .{ .type = .editor, .searchable = true },
} },
At startup ZigBase provisions an FTS5 external-content index per searchable collection ("<col>_fts", content='<col>') plus INSERT/UPDATE/DELETE triggers that keep it in lock-step with the base table — no doubled storage, no migration. Query it with search (or its alias q):
GET /api/collections/posts/records?search=zig%20database
GET /api/collections/posts/records?q=alpha%20OR%20beta&filter=published=true
Results are ranked by relevance (bm25) in offset mode. The terms support the basic FTS5 operators (AND, OR, NOT, and a trailing * for prefix search); the whole term is passed as a bound parameter (never interpolated) and lowered to a guaranteed-valid query, so a malformed input is harmless — it can never become a SQL error or injection. search AND-s with your filter (the result is the intersection — a row must match both). A search whose terms reduce to nothing (e.g. operator-only, ?search=AND) matches no rows rather than returning the whole collection. A search on a collection with no searchable field returns 400. The _fts collection-name suffix is reserved (it backs the per-collection shadow tables).
SQLite FTS5 is compiled in by default — opt out with -Dfts5=false. A lean custom build that never declares a .searchable field can drop FTS5 (~250-400 KB smaller); a ?search= then answers a clean 400, and the server refuses to start over a .searchable SQLite schema. Postgres full-text search (below) is unaffected by the flag. See docs/search.md.
Full-text on Postgres. On a Postgres backend the SAME .searchable schema flag and ?search= API are backed by PostgreSQL’s native full-text search instead of FTS5: each searchable collection gets a STORED tsvector generated column (to_tsvector('simple', …) over the searchable columns) plus a GIN index, queried with @@ plainto_tsquery('simple', $n) and ranked by ts_rank(…) DESC. The query surface, the bound-parameter safety, and — critically — the same composed-WHERE scoping (filter + list rule + abilities + tenant) are identical to the SQLite path, so a tenant-/ability-scoped search returns only the rows the caller may view on either backend. The exact relevance ORDER can differ between the two ranking functions (FTS5 bm25 length-normalizes; ts_rank does not), but the matched set is equivalent. plainto_tsquery parses the plain term (it does not honor the AND/OR/NOT/* operators).
Vector / nearest-neighbor — opt-in -Dvector build. Vector search is not compiled into the default binary. The single -Dvector=true flag enables KNN on both backends — on SQLite it vendors and links sqlite-vec (registered on every connection); on Postgres it emits the pgvector lowering. It enables KNN ordering over a field that stores a JSON embedding array:
GET /api/collections/docs/records?vector=embedding:cosine:[0.12,0.04,...]
GET /api/collections/docs/records?vector=embedding:l2:[0.12,0.04,...]&filter=lang="en"
The form is <field>[:cosine|:l2]:<json-embedding> (cosine is the default metric); rows are ordered nearest-first. The embedding is validated (a non-empty JSON array of finite numbers) and bound; a malformed or dimension-mismatched embedding returns a clean 400. In the default build a vector query returns 400 ("Vector search is not enabled in this build."), and the binary is byte-for-byte unaffected. Vector search runs in offset mode (cursor paging is rejected with 400).
The stored embedding field must hold a numeric JSON array of a consistent dimension across the collection’s rows — that is the value the distance operator compares against. There is currently no write-time validation of stored embeddings (planned as future work), so a row whose embedding is malformed (valid JSON but not a numeric array) or of a differing dimension makes that scoped collection’s ?vector= query fail closed — a clean 400 (no data leak; the connection recovers), symmetric on both backends — until the offending row is corrected.
Vector on Postgres (pgvector) — opt-in -Dvector build. On a Postgres backend the SAME ?vector= API is backed by pgvector: the embedding column and the bound query embedding are cast to the vector type at query time and ordered by the native KNN operators <=> (cosine distance) / <-> (L2). The embedding stays in an ordinary JSON field (no schema change) — a brute-force scan, exactly symmetric with sqlite-vec’s scalar distance (no ANN index either side). A -Dvector build runs CREATE EXTENSION IF NOT EXISTS vector at startup, so the target PostgreSQL must have pgvector available (e.g. the pgvector/pgvector:pgNN image, or apt install postgresql-NN-pgvector); if the connecting role lacks privilege to create the extension, install it once as a superuser (CREATE EXTENSION vector;) — startup then logs a warning and continues rather than aborting. As with full-text search, the KNN composes with the same composed-WHERE scoping (filter + list rule + abilities + tenant), so a tenant-/ability-scoped vector search returns only the rows the caller may view — identically on both backends.
Access rules
Each collection defines five rules: list, view, create, update, delete. A rule is one of:
| Rule value | Meaning |
|---|---|
null | Locked — only a superuser may perform the operation; everyone else is denied. |
"" (empty string) | Locked — same as null (safe-by-default). An empty rule is not public. |
"@public" | Public — anyone may perform the operation. This explicit sentinel is the only way to open a collection. |
| a filter expression | The operation is allowed only when the expression matches (using the filter grammar, including @request.* macros). |
Superusers bypass all rules.
Safe-by-default (changed): a blank rule (
nullor"") is locked to superusers. To open an operation to the public you must set the rule to exactly"@public". On startup, ZigBase logs a prominent warning for every@publicrule (collection 'X' is PUBLIC for <op>) so a wide-open collection is never silent.
Denial status codes:
- view / update / delete on a record that does not exist or does not satisfy the rule return 404 — this hides record existence.
- create denial returns 403.
- A locked (
nullor"") list/view rule denies non-superusers (list returns 403; view returns 404).
Auth
Auth endpoints target an auth-type collection (:col).
| Method | Path | Description |
|---|---|---|
| POST | /api/collections/:col/auth-with-password | Log in with identity + password. |
| POST | /api/collections/:col/auth-refresh | Issue a fresh token for the current session. |
| POST | /api/collections/:col/auth-logout | Clear the auth cookies. |
| POST | /api/collections/:col/request-verification | Request an email-verification token. 204 (no body). |
| POST | /api/collections/:col/confirm-verification | Confirm verification with a token. 204 (no body) on success. |
| POST | /api/collections/:col/request-password-reset | Request a password-reset token. 204 (no body). |
| POST | /api/collections/:col/confirm-password-reset | Confirm a reset with a token. 204 (no body) on success. |
| GET | /api/collections/:col/auth/sessions | List the caller’s active sessions. .auth.session.store = .table only — 404 in .epoch mode. |
| DELETE | /api/collections/:col/auth/sessions/:sid | “Log out THIS device”. 204 (no body). .auth.session.store = .table only — 404 in .epoch mode. |
| DELETE | /api/collections/:col/auth/sessions | “Log out everywhere” — works in both session-store modes. 204 (no body). |
auth-with-password
// request
{ "identity": "user@example.com", "password": "secret" }
// response (200) — also sets zb_auth (httpOnly) and zb_csrf cookies
{ "token": "<jwt>", "record": { "id": "...", "email": "..." } }
identity is matched against the collection’s configured identity fields. auth-refresh returns the same { token, record } shape and re-sets the cookies. auth-logout clears zb_auth and zb_csrf.
When you authenticate via these cookies, writes must echo the zb_csrf cookie in the X-CSRF-Token header — see CSRF on unsafe methods.
Embeddable hooks. When ZigBase is used as a Zig library,
auth-refreshandauth-logoutrun through the.authlifecycle hook group (before_refresh/after_refresh,before_logout/after_logout); abeforehook that fails closed aborts the request before any session change.beforeAuthSuccessalso fires onauth-with-password(tag.password) andauth-refresh(tag.refresh, in the same transaction asbeforeRefresh, lifecycle phase first) — see framework.md → Auth lifecycle. Embedders also get thectx.auth()session verbs (refresh/rotate/revokeAllSessions, and per-devicelistActiveSessions/revoke) and the optional table-backed session store. See framework.md §6.
Session management
GET/DELETE /api/collections/:col/auth/sessions[/:sid] (added in the auth endpoints table above) are the REST surface over the table-mode ctx.auth() session verbs (the TypeScript SDK’s listSessions/revokeSession/revokeAllSessions ride these):
// GET /api/collections/:col/auth/sessions — 200
{
"items": [
{ "id": "...", "created": "...", "last_seen": "...", "user_agent": "...", "ip": "...", "is_current": true }
]
}
itemsis newest-first.DELETE …/auth/sessions/:sid(“log out this device”) returns204with an empty body on success. A:sidyou don’t own and an absent:sidare an identical404(non-owner probing can’t be distinguished from a stale/unknown id).- Both per-device routes return
404when the collection is running in.epochmode (the feature simply isn’t enabled — same non-oracle policy as a disabled auth-method slug). DELETE …/auth/sessions(“log out everywhere”) works in both modes: it bumps the token epoch (killing every outstanding token, including ones minted before.tablewas enabled) and, in table mode, also wipes the principal’s session rows. It returns204and clears the caller’s own auth cookies — the current session dies too, by design.:colmust match the caller’s authenticated collection, else401(parity withauth-refresh).
Registration / signup
There is no dedicated register endpoint. Signing up a user is a normal record create on the auth collection:
POST /api/collections/users/records
{ "email": "user@example.com", "password": "a-good-password" }
On this create the server hashes the password (argon2id), strips the plaintext, mints a tokenKey, and forces verified to false (a client-supplied verified is ignored, as is a client-supplied externalAuths — see OAuth2); passwordHash/tokenKey are hidden in the response. The auth collection needs a public create rule ("@public") for open signup, and the password must be at least minPasswordLength (default 8) — otherwise the create is a 400. After signup, obtain a token via auth-with-password above. Full walkthrough: recipes.md → User registration.
doctor --production enumerates that non-system auth createRule as a warning, not an error: open signup is a supported boundary that still requires explicit review. Other public writes and public creation on system auth collections remain production errors.
Verification & password reset — email delivery
The request-verification and request-password-reset endpoints mint a token and deliver it via the configured mailer, then return 204 (they never reveal whether the email exists). The matching confirm-* endpoint takes that token in its body and also returns 204 (no body) on success.
The token email is delivered on a background queue, so the endpoint returns 204 with identical status and timing whether or not the email matches a record — a mailer outage or a slow SMTP round-trip is never observable, so neither latency nor a send failure can be used to enumerate which addresses are registered.
- With SMTP configured (
ZIGBASE_SMTP_HOST+ friends — see the README config table), the token is emailed over the configured transport (none/starttls/implicit/auto). - With a local MTA (
ZIGBASE_SENDMAIL_COMMAND, e.g.sendmail -t -i/msmtp -t), the message is piped to that command instead — the app holds no SMTP credentials. This takes precedence overZIGBASE_SMTP_HOST. - Without either (the default), the token is logged to the server instead — a dev/CI convenience. To complete a flow locally, read the token from the log and POST it to the matching
confirm-*endpoint.
Verification and password-reset tokens are strictly single-use: each token carries a random jti that is recorded on first redemption, so a second confirm-* with the same token is rejected with 400 (independent of the token’s TTL). The reset path validates the new password before consuming the token, so a too-short password does not burn it.
Configure SMTP or a local MTA command for production; see KNOWN_LIMITATIONS.md → Auth & email.
Auth method endpoints (pluggable auth)
For every auth collection that enables a method (built-in or custom), two endpoints are auto-mounted:
| Method | Path | Description |
|---|---|---|
| POST | /api/collections/:col/auth/:method/initiate | Phase 1: challenge/email/options. Returns 200 with method-specific JSON body, or 204 for enumeration-safe methods (e.g. magic-link). |
| POST | /api/collections/:col/auth/:method/complete | Phase 2: proof → session. Returns 200 with { token } and sets zb_auth/zb_csrf cookies on success. |
:method is the method slug (magic_link, otp, password, webauthn, oauth2, or a custom plugin’s slug). Returns 404 when the collection doesn’t exist, isn’t an auth collection, or the method isn’t enabled.
The generated TypeScript client exposes these as
zb.auth.<col>.<method>.{initiate,complete}. Built-ins are typed; custom methods can declare comptime I/O types to get precise interfaces too — see typescript-sdk.md → Typed auth methods.
require_verified: if the auth collection is configured withrequire_verified: true,completereturns 403 when the matched record’sverifiedfield isfalse. This applies to all methods — including WebAuthn and OAuth2 accounts from providers that did not confirm the email address (createdverified=false).
WebAuthn passkey registration (authed — requires a valid session):
| Method | Path | Description |
|---|---|---|
| POST | /api/collections/:col/auth/webauthn/register/begin | Returns WebAuthn creation options (challenge, rpId, rpName). |
| POST | /api/collections/:col/auth/webauthn/register/finish | Stores the new passkey bound to the authenticated user. 204 (no body) on success. |
magic_link initiate:
// request
{ "identity": "user@example.com" }
// response: 204 (enumeration-safe — always 204 whether email exists or not)
magic_link complete:
// request
{ "token": "<magic-link-token>" }
// response (200) — sets zb_auth and zb_csrf cookies
{ "token": "<jwt>" }
magic_link consume + redirect (the classic email-link UX):
Renamed from
magic_linkto dash-case in 0.10:GET .../auth/magic-link/consume(wasauth/magic_link/consume). Hard cutover — no redirect shim — so links emailed by a pre-upgrade server 404 after the upgrade; tokens are short-lived, so this is a narrow window. The method slug (magic_link, used byinitiate/completeabove and inonAuth) is unchanged — only this bespoke consume path uses the dash.
| Method | Path | Description |
|---|---|---|
| GET | /api/collections/:col/auth/magic-link/consume?token=...&redirect=/app | Verify + consume the token, set zb_auth/zb_csrf cookies, and 302 to the redirect target. For browser email links: the user clicks a plain GET URL and lands logged-in. Fires onAuth(.magic_link) and honors require_verified (403) exactly like complete. |
The token is single-use (replay returns 400 Link already used.); a missing token returns 400, and the route 404s unless magic_link is enabled on the collection.
The redirect target is validated server-side so each app does not re-implement an open-redirect guard. Only same-origin relative paths are ever honored — anything with a scheme/host, a protocol-relative //host, a backslash, a ./.. path-traversal segment, an encoded %2e/%2f/%5c, or a control/CRLF byte is rejected. A per-method allow-list narrows it further:
// collection options.auth.methods.magic_link
{
"ttl_s": 900,
"redirect_default": "/club/welcome", // used when ?redirect= is absent or rejected
"redirect_allow": ["/club/", "/dashboard"] // entry ending in "/" is a prefix; else exact path
}
- Empty
redirect_allow⇒ any same-origin relative path is accepted (the scheme/host guard still applies). - A non-empty
redirect_allowrestricts to matching paths; a non-matching (or unsafe)?redirect=falls back toredirect_default. redirect_defaultitself must be a safe relative path; an off-origin value degrades to/.
otp initiate:
// request
{ "identity": "user@example.com" }
// response: 204
otp complete:
// request
{ "identity": "user@example.com", "code": "123456" }
// response (200)
{ "token": "<jwt>" }
webauthn initiate:
// request (identity optional for discoverable credentials)
{ "identity": "user@example.com" }
// response (200)
{ "challenge": "<base64url>", "rpId": "app.example.com", "ceremonyId": "<opaque>" }
webauthn complete:
// request
{
"ceremonyId": "<opaque>",
"credentialId": "<base64url>",
"authenticatorData": "<base64url>",
"clientDataJSON": "<base64url>",
"signature": "<base64url>"
}
// response (200)
{ "token": "<jwt>" }
webauthn register/begin (authed):
// request: empty body or {}
// response (200)
{ "challenge": "<base64url>", "rpId": "app.example.com", "rpName": "My App", "ceremonyId": "<opaque>" }
webauthn register/finish (authed):
// request
{
"ceremonyId": "<opaque>",
"id": "<base64url>",
"rawId": "<base64url>",
"response": {
"clientDataJSON": "<base64url>",
"attestationObject": "<base64url>"
}
}
// response (200): {}
The onAuth hook — fires on every login
Every successful login — password, OAuth2, magic-link, OTP, WebAuthn, and custom flows built with ev.issueSession / zigbase.auth.issueSession — fires the onAuth handler registered in your App(.{ .onAuth = ... }). This is the single chokepoint for cross-cutting session logic (audit logging, account-state checks, etc.). There is no path through ZigBase’s session-issuance machinery that bypasses it.
AuthEvent.method is an enum: .password, .oauth2, .magic_link, .otp, .webauthn, .custom for custom plugins, or .refresh for auth-refresh (previously mislabeled .password).
See framework.md §6 for the zigbase.auth helper surface and the seam guarantee.
Rate limiting
The sensitive auth endpoints — auth-with-password (login), request-verification, request-password-reset, password change (PATCH …/records/:id with a password, scope pwchange), and all auth/:method/initiate / auth/:method/complete endpoints — are rate limited. Over the limit, the endpoint returns 429 Too Many Requests ({ "message": "Too many requests. Try again later." }). Password change shares the same global ZIGBASE_RATE_LIMIT_MAX/ZIGBASE_RATE_LIMIT_WINDOW budget as login/verify/reset (it isn’t a .auth.methods entry, so it has no per-method override); superusers bypass it.
Per-method rate-limit behavior is configured in .auth.methods via the rate_limit field (.default | .off | .{ .custom = .{ .max, .window_s } }). See framework.md §6.
- Config:
ZIGBASE_RATE_LIMIT_MAXattempts (default10) perZIGBASE_RATE_LIMIT_WINDOWseconds (default60), per client key, per endpoint. SettingZIGBASE_RATE_LIMIT_MAX=0disables only the global env-configured limiter; any per-method.auth.methods.<m>.rate_limit = .{ .custom = … }still applies (a custom limit overrides the global one for that method). To turn a specific method’s limiting off, set itsrate_limit = .off. - Keying:
X-Forwarded-For/X-Real-IPare ignored by default — they are attacker-controlled on direct exposure. With--trust-proxy(ZIGBASE_TRUST_PROXY=true), set only behind a trusted reverse proxy that rewrites them, the key is the IP fromX-Forwarded-For(first hop) orX-Real-IP. Otherwise the limiter keys on the submitted identity/email, which is not header-spoofable. This makes direct exposure safe by default. See Known limitations.
Two-factor authentication
The standalone binary includes TOTP and WebAuthn second factors. Embedded apps select the subsystem and factors at compile time; see framework configuration. Set collection options.auth.two_factor to disabled (default), optional (required after voluntary enrollment), or required (all users must enroll). An application policy hook can add requirements for users, roles, or groups.
For the built-in admin collection, a current superuser can PATCH /api/collections/_superusers with { "name": "_superusers", "type": "auth", "options": { "auth": { "two_factor": "required" } } }. This updates auth options only; omit fields, indexes, and access rules. Include any other auth options you want to preserve. The admin login supports TOTP enrollment, WebAuthn ceremonies, and recovery. Keep an administrative recovery procedure before enforcing a requirement on every superuser.
A successful primary login can return HTTP 200 with {status: "factor_required" | "enrollment_required", pendingToken, expiresIn, factors, recoveryCodes} instead of {token, record?}. Pending responses set no session cookies. Keep the pending capability in memory, not an auth store or URL. Password, OAuth, magic-link, OTP, and custom method completion share this gate. Magic-link GET consumption returns the pending JSON instead of redirecting when another factor is required. Existing login clients must handle that response.
All ceremony endpoints are POSTs below /api/collections/:col/auth/two-factor/. Their JSON bodies contain pendingToken unless noted. The server derives the principal from its attempt; never send a user ID as authentication authority.
| Action | Additional request fields | Result |
|---|---|---|
enroll | No pending token; authenticated session required | Restricted first-enrollment attempt for an unenrolled user |
enroll-begin | factor: "totp" | ceremonyId, Base32 secret, SHA1, six digits, 30-second period |
enroll-complete | factor: "totp", ceremonyId, code | Activates enrollment after verifying a code |
initiate / enroll-begin | factor: "webauthn" | WebAuthn request/creation options plus ceremonyId |
complete | factor: "totp", code | Completes an enrolled user’s login |
complete | factor: "webauthn", ceremonyId, credentialId, clientDataJSON, authenticatorData, signature | Verifies a WebAuthn assertion |
enroll-complete | factor: "webauthn", ceremonyId, clientDataJSON, attestationObject | Activates a WebAuthn second factor |
complete | factor: "recovery", code | Consumes one recovery code |
replace-recovery | Use a fresh managementToken as pendingToken | Replaces recovery codes and requires reauthentication |
remove | Management capability, factor, optional credentialId (WebAuthn) | 204; refuses to remove the last required factor |
Successful enrollment/completion returns {status: "authenticated", token, record, managementToken, recoveryCodes?} and ordinary session cookies. Save the ten recovery codes shown after initial enrollment; plaintext codes are returned once. A management capability can authorize new enrollment or TOTP replacement through the enrollment endpoints. It lasts five minutes and is single-use. Factor changes invalidate existing sessions and pending attempts. Recovering an account does not disable its factors; use the returned management capability to explicitly replace the lost factor.
WebAuthn byte strings use unpadded Base64url. Decode challenge, user ID, and credential IDs to buffers before calling navigator.credentials.create/get; encode response buffers before posting them. RP/origin configuration uses options.auth.methods.webauthn. Second-factor credentials and ceremony purposes are separate from primary passkeys. After a WebAuthn primary login, choose a different second factor. Local browser testing requires localhost, not an IP address as RP ID; deployed origins must use HTTPS.
Attempts expire after five minutes and permit five verification tries. A durable ten-attempt/five-minute account limit persists across new primary logins and applies even when the global rate limiter is disabled. Invalid proofs return 401; exhausted account limits return 429. Restart an enrollment ceremony after a failed enrollment proof. TOTP accepts one adjacent time step each way and rejects reuse of an accepted step across login attempts.
Current policy is checked again for authenticated requests and refresh. Enabling a requirement invalidates access by primary-only sessions. Custom code calling zigbase.auth.issueSession receives SecondFactorRequired instead of bypassing policy; use zigbase.auth.beginAuthentication after primary verification and return its pending response when present.
TypeScript clients throw TwoFactorRequiredError from primary login and expose typed enrollment/completion methods on client.collection(name). Generated TypeScript auth collections also expose that service as zb.db.users.auth. Generated raw method-completion results are a session/pending union. Python, Dart, and Kotlin expose pending exceptions and second_factor/secondFactor for ceremony actions. Pending capabilities never enter the ordinary auth store.
OAuth2
ZigBase uses client-driven PKCE: the client generates and holds the PKCE state and code verifier, runs the authorization redirect itself, then submits the authorization code to the server.
OAuth2 is the fifth built-in AuthMethod (slug oauth2) and is exposed exclusively through the standard auth-method contract endpoints:
| Method | Path | Description |
|---|---|---|
| GET | /api/collections/:col/auth/oauth2/providers | List enabled providers (name, authURL, clientId, scopes) as {"items":[…]} (changed: was {"providers":[…]}). Secrets are never returned. Gated on .auth.oauth2.enabled. |
| POST | /api/collections/:col/auth/oauth2/initiate | Return provider metadata so the client can drive the authorization redirect. |
| POST | /api/collections/:col/auth/oauth2/complete | Exchange the authorization code for a session. |
| DELETE | /api/collections/:col/records/:id/external-auths/:provider | Unlink a provider from a record. |
There is deliberately no HTTP counterpart that creates a link. A (provider, providerId) link is written only by a successful complete — a first sign-in that creates the record, or an authenticated caller linking an additional provider to their own record. externalAuths is server-managed (auth.isServerManagedField): it is stripped from every record create/update payload, because whoever holds a (provider, providerId) pair signs in as that record. The one other writer is the operator-only offline import seam, zigbase import --external-auths, for carrying existing social-login accounts over from another backend — see migration-tools.md §4b.
GET .../auth/oauth2/providers — no request body. Response:
{
"items": [
{ "name": "google", "authURL": "https://accounts.google.com/o/oauth2/v2/auth?...", "clientId": "my-client-id.apps.googleusercontent.com", "scopes": ["openid", "email", "profile"] }
]
}
oauth2 initiate — body { "provider": "<name>" }:
// response (200)
{
"authURL": "https://accounts.google.com/o/oauth2/v2/auth?...",
"clientId": "my-client-id.apps.googleusercontent.com",
"scopes": ["openid", "email", "profile"],
"state": "<server-issued-state>"
}
state is always present by default (ZIGBASE_OAUTH_STATE_SERVER defaults to true); omitted only when the server-side state is explicitly disabled.
oauth2 complete — body:
{
"provider": "google",
"code": "<authorization-code>",
"codeVerifier": "<pkce-verifier>",
"redirectUrl": "https://app.example.com/callback",
"state": "<server-issued-state>"
}
state is required by default; omitted only when ZIGBASE_OAUTH_STATE_SERVER=false. redirectUrl must be in the provider’s configured allowlist.
// response (200) — sets zb_auth and zb_csrf cookies
{ "token": "<jwt>" }
On success, onAuth(.oauth2) fires through the shared session seam. Security enforced on all paths: single-use TTL’d CSRF state consumed before the code exchange, PKCE required, redirect allow-list, and https-only provider URLs.
CSRF on the OAuth flow: state
The OAuth state parameter prevents login-CSRF. ZigBase supports two modes:
- Server-side (default).
ZIGBASE_OAUTH_STATE_SERVERdefaults totrue(TTL viaZIGBASE_OAUTH_STATE_TTL, default 600s). Theinitiateendpoint issues astatevalue that the client must round-trip through the provider and back tocomplete. The backend verifies the state exists, matches the (collection, provider), is unexpired, and is single-use (deleted on first use). A missing, mismatched, expired, or replayedstateis rejected with400before the provider is contacted.- The client calls
POST .../auth/oauth2/initiatewith{ "provider": "<name>" }and receives{ ..., "state": "<value>" }. - The client embeds that
statein the provider authorization URL. - On callback, the client adds
"state": "<value>"to thecompletebody.
- The client calls
- Client-driven (opt-out). Set
ZIGBASE_OAUTH_STATE_SERVER=falseto restore the previous behavior: the SPA generates and verifiesstateitself; the backend does not see or check it.
PKCE (codeVerifier) is required in both modes — server-side state adds CSRF protection, it does not replace PKCE.
Settings (key/value store)
ZigBase ships a built-in key→value store (backed by an internal _kv system table) and a superuser-only HTTP surface over it. It is the same store that the embeddable ctx.kv() API and the admin UI’s “Settings / Feature Flags” screen use, and where the declared feature-flag/experiment overrides live (flag:<name>, exp:<name>:weights). Every endpoint requires a valid superuser token (401/403 otherwise); values are server-managed and never public by default.
| Method | Path | Description |
|---|---|---|
| GET | /api/settings | List every setting: {"items":[{ key, value, created, updated }, …]} (changed: was a bare array). |
| GET | /api/settings/:key | Fetch one: { key, value }; 404 if absent. |
| PUT | /api/settings/:key | Upsert. Body { "value": "..." }; returns { key, value }. A malformed body is 400. |
| DELETE | /api/settings/:key | Remove; 204, or 404 if absent. |
// PUT /api/settings/welcome_banner (Authorization: Bearer <superuser-jwt>)
{ "value": "Closed for maintenance" }
Values are stored as opaque strings. Declared feature flags (0.8.0) store their override under the flag:<name> key as "true" / "false"; resolution uses the declared default when no override is set. To publish a value to non-superusers, write your own custom route that reads it via ctx.kv(), or ctx.flagByName() / ctx.flags().resolveAll() for declared flags — see framework.md → Feature flags + experiments.
Email — verified senders & bounce webhook (#154)
Tenant-scoped verified sender identities and a bounce/complaint ingestion webhook. These are additive and off by default — see framework.md → Email subsystem. Present only when .mail is configured — without it these routes are absent from your binary (not merely 404 at runtime). The sender routes require an authenticated principal and resolve the active account (via the X-Account-Id header or signed zb_account cookie); a member may only manage its own account’s senders (fail closed, 403). Superusers may target any account.
| Method | Path | Description |
|---|---|---|
| POST | /api/senders | Request verification of a From address. Body { "email": "from@acct.com" }; emails a single-use token. Returns { id, email, status } (201 pending, 200 if already verified). Re-sends are rate-limited per (account,email) — a repeat within ~60s is 429. |
| POST | /api/senders/:id/verify | Confirm a pending identity. Body { "token": "..." }; { "verified": true } on success, 404 on a wrong/absent token (no oracle). |
| GET | /api/senders | List the active account’s identities: { "items": [{ id, email, status, verified_at }, …] } (changed in 0.10.0 — was a bare array). |
| POST | /api/mail/webhooks/:provider | Inbound bounce/complaint ingestion (provider = ses | postmark). Verifies a shared-secret HMAC-SHA256 signature over "<X-Webhook-Timestamp>.<provider>.<X-Account-Id>.<body>" (constant-time) and a ±5m timestamp-freshness window; 401 on a stale timestamp or bad signature, 404 when no webhook_secret is configured. Upserts a suppression per hard bounce / complaint; returns { "suppressed": n }. A genuine provider webhook (no signature/X-Account-Id) is GLOBAL-only — per-account scoping requires a signing relay. |
| GET | /api/mail/config | Superuser-only, read-only mail policy state for the admin UI: { "require_verified_sender": bool, "check_suppression": bool, "webhook_configured": bool, "unsubscribe_configured": bool }. Booleans only — never exposes the webhook secret or the unsubscribe URL value. 401 unauthenticated, 403 non-superuser. |
When .mail.require_verified_sender = true, an account-scoped send whose From is not a verified identity is rejected. When .mail.check_suppression = true, a send to a suppressed recipient is blocked.
The embedded admin UI exposes senders/suppressions/batches and this policy state as the Email screen (/_/#/email) — see framework.md → Admin UI.
Features (declared registry)
A separate superuser-only read endpoint exposes the comptime-declared flag + experiment registry together with each entry’s active _kv override:
| Method | Path | Description |
|---|---|---|
| GET | /api/features | Return declared flags + experiments and their current overrides. |
// GET /api/features (Authorization: Bearer <superuser-jwt>)
{
"flags": [
{ "name": "dark_mode", "default": false, "description": "",
"override": "true" }
],
"experiments": [
{ "name": "onboarding_flow",
"variants": ["control", "streamlined"], "weights": [70, 30],
"sticky": false, "description": "Onboarding flow A/B test",
"weight_override": "[90,10]" }
]
}
override / weight_override are null when no _kv row is present (the declared default is used). To change an override, use the existing PUT /api/settings/flag:<name> / PUT /api/settings/exp:<name>:weights verbs.
The embedded admin UI exposes this as the Feature Flags & Experiments screen (/_/#/features) — see framework.md → Admin UI.
Feature state (public)
The read-only public projection of resolved feature flags + experiments. Unlike the superuser /api/settings surface above, this endpoint is unauthenticated and exposes only resolved values — never the raw _kv keys, timestamps, declared defaults, or any admin verb.
| Method | Path | Description |
|---|---|---|
| GET | /api/state?subject=<id> | Resolve every declared flag + experiment for subject. |
subject is the caller-supplied bucketing key (a user id, session id, or any stable string) used for deterministic experiment assignment; omit it (or pass empty) to get the stable “anonymous” assignment. The response is exactly:
// GET /api/state?subject=user-42 (no Authorization header)
{
"flags": { "checkout_enabled": true, "new_dashboard": false },
"experiments": { "checkout_layout": "compact" }
}
flags maps each declared flag name to its resolved boolean (override else declared default); experiments maps each declared experiment name to its resolved variant. Apps that declare no flags/experiments get { "flags": {}, "experiments": {} }.
A .sticky experiment returns its persisted assignment here (the same value App.experiment resolves), so the public projection survives later weight changes. The lookup is reader-first — a repeat call for a known subject is served from a pooled reader, and only a subject’s first-ever resolve briefly takes the writer to persist it — so this unauthenticated, caller-supplied-subject endpoint never storms the writer lock.
Mount + disable. The route auto-mounts at /api/state. Configure it with the .features knob: .features = .{ .public_route = "/state" } remaps it, and .features = .{ .public_route = .disabled } turns it off (then it 404s). The typed TypeScript SDK exposes this as zb.flags.resolveAll(subject) — see TypeScript SDK → Typed feature state.
To discover where this route is currently mounted (or that it’s disabled) without guessing, read endpoints.state on GET /api/meta — it carries the remapped path, or null when disabled.
Analytics
Read the built-in product-analytics data: the raw event feed and the declarative rollups (see framework → Product analytics). Events are emitted server-side with ctx.track(name, payload); the rollups aggregate them on a schedule into per-rollup summary tables. Present only when .analytics is configured — without it these routes are absent from your binary (not merely 404 at runtime).
| Method | Path | Description |
|---|---|---|
| GET | /api/analytics/events?name=&actor=&since=&limit=&cursor= | The raw activity feed (newest first). |
| GET | /api/analytics/rollups/:name?from=&to= | A rollup’s summary rows. |
Both endpoints are authenticated and fail closed. A superuser sees all data; a member sees only their active account’s data (resolved from a verified _memberships row — send X-Account-Id or activate the zb_account cookie). With tenancy disabled, the feed is scoped to the caller’s own events and a (global) rollup is superuser-only (403). A member can never read another account’s events or rollups; an anonymous request gets 401 (the rollups handler authenticates before the rollup-name lookup, so 401-vs-404 never leaks which rollup names exist).
Visibility within an account is account-level, not role-level: any active member of an account — whatever their role — reads the whole account’s event feed (including other members’ events and payloads) and all of its rollup buckets. The trust boundary is the tenant, not the role.
Events. Filters: name (exact event name), actor (exact principal id), since (an ISO-8601 lower bound on occurred_at), limit (default 50, max 200). The actor / account / occurred_at fields are stamped server-side at capture time and cannot be forged by a client.
Events paginate with the house cursor vocabulary: pass the previous page’s nextCursor back as ?cursor= to fetch the next page. nextCursor/hasNext are always present, even on the last page (nextCursor: null, hasNext: false). A malformed cursor is 400 "Invalid cursor.".
// GET /api/analytics/events?name=user.signup&limit=2 (Authorization + X-Account-Id)
{
"items": [
{ "id": "…", "created": "2026-06-29T12:00:05Z", "name": "user.signup",
"payload": { "plan": "pro" }, "actor_collection": "users", "actor": "u_123",
"account": "acc_abc", "occurred_at": "2026-06-29T12:00:05Z" }
],
"nextCursor": "2026-06-29T12:00:05Z|e_122",
"hasNext": true
}
Rollups. :name must be a declared rollup (else 404, no table-name oracle). Filters: from / to bound the bucket value. Each row is { bucket, account, actor, value, computed_at }; columns absent from the rollup’s group_by are the empty string. The summary table is created on the first scheduled run — until then the endpoint returns { "items": [] }.
// GET /api/analytics/rollups/signups_daily (Authorization + X-Account-Id)
{
"items": [
{ "bucket": "2026-06-29", "account": "acc_abc", "actor": "", "value": 5,
"computed_at": "2026-06-29T13:00:00Z" }
]
}
Files
Opt-in ImageMagick thumbnails add GET/HEAD /api/files/:col/:rec/:name/thumbnail/:profile for named compile-time profiles on built-in local storage. Original file authorization and hooks apply; derivatives do not support ranges and never redirect to S3. See the linked contract for PNG/JPEG/WebP formats, conditional caching, deployment requirements, resource limits and errors.
File-type fields hold uploaded files.
- Upload: files are submitted via
multipart/form-dataon record create (POST .../records) or update (PATCH .../records/:id), alongside the other field values. - Concurrent uploads: bytes transfer before the database writer is acquired. An upload POST returns
409if the collection schema changes; upload PATCH returns409if the record or collection schema changes during transfer; reload and retry. Failed requests best-effort clean up uploaded bytes. Upload PATCH requires update permission on the existing row before transfer and checks the rule again on the updated row before commit. - Serve:
GET /api/files/:col/:rec/:name. - Admin config:
GET /api/files/config— superuser-only, read-only storage backend info for the admin UI:{ "backend": "local" | "s3", ... }(S3 addsbucket/region/endpoint/key_prefix; local addsdir). Non-secret only — never exposes the S3 access key id / secret.401unauthenticated,403non-superuser.
Access
File access reuses the collection’s view rule:
- Files in a public collection (
@publicview rule) serve directly (cacheable). - Files in a protected collection require an authenticated identity. Supply it via a bearer token, the auth cookie, or a short-lived file token:
POST /api/files/tokenreturns{ "token": "<jwt>" }(the caller must already be authenticated). Pass that token to the serve endpoint as thetokenquery parameter:GET /api/files/:col/:rec/:name?token=....
Content handling
Only known-safe types are rendered inline (images such as png/jpg/gif/webp/avif/ bmp/ico, plus pdf). Everything else is served as a download: Content-Disposition: attachment with X-Content-Type-Options: nosniff (this neutralizes HTML/SVG/JS XSS). Appending ?download forces a download for any type.
Range and conditional requests
File downloads support HTTP range and conditional requests (0.10.0):
Accept-Ranges: byteson every 200/206. A singleRange: bytes=a-b,bytes=a-, orbytes=-nanswers206 Partial ContentwithContent-Range; a syntactically multi-range request is served as a full200(RFC-permitted). An unsatisfiable range answers416withContent-Range: bytes */<size>.- Every response carries a strong
ETagderived from the stored file’s identity (stored names are content-immutable — an update mints a new name), soIf-None-Matchrevalidation answers304.If-Rangerequires an exact strong match, otherwise the range is ignored. HEADmirrorsGET(status, headers,Content-Length) with no body.?downloadand?token=compose withRangeunchanged.- File tokens vs. seeking:
ZIGBASE_FILE_TOKEN_TTLdefaults to 120 s; a video player seeking via?token=URLs gets 404s once the token expires mid-playback. Use cookie/bearer auth for long media, or re-mint tokens per seek.
Resumable uploads (opt-in)
Build with -Dresumable-uploads=true to register these endpoints (otherwise 404). Every operation requires fresh bearer authentication; cookies alone do not authorize, and session IDs are bound to their original auth collection and principal. Transfers target one file field on an existing record.
| Method | Path | Request / success |
|---|---|---|
| POST | /api/collections/{col}/records/{record}/uploads | JSON {field, filename, length, mimetype?}; 201 status object. |
| GET | /api/uploads/{id} | 200 status object: {id, offset, length, expiresAt, state, durability}. |
| PATCH | /api/uploads/{id} | Raw bytes and unsigned decimal Upload-Offset; 204. |
| POST | /api/uploads/{id}/commit | Commit fully received bytes; 204. Completed retries acknowledge the old commit without repeating hooks or writes. |
| DELETE | /api/uploads/{id} | Abort a receiving session; 204. Committing, completed, and failed sessions return 409; mutations are never undone. |
Begin checks update authorization before field shape and record lookup, and checks declared length against current file maxSize before reserving memory (413 payload_too_large). Commit rechecks current schema, rules, and file constraints through the normal record-update pipeline. Offset/state conflicts return 409; invalid metadata/chunks return 400; exhausted quotas return 429 too_many_requests. Missing/expired/aborted sessions or a different principal return 404; invalid authentication returns 401.
The default is bounded, fully buffered, process-local network resume; restart loses RAM sessions. Additional -Ddurable-resumable-uploads=true and .files.resumable.durable = true enable SQLite/local single-owner process-restart persistence, returning durability: "sqlite-restart". It remains fully buffered, not cross-instance or power-loss durability. A completion-receipt write failure returns 503 internal; after successful rollback and failure cleanup, only that session is terminal and the store remains usable. Uncertain store persistence failures stop upload operations with 503 internal until restart. A durable rollback failure additionally terminates the process rather than leave an unusable shared SQLite writer behind a healthy liveness response. Inspect and recover the database before restarting. Durable payload budgets also reserve whole-row headroom within SQLite’s length limit (see the protocol guide). Completed/failed tombstones release payloads immediately but retain slots until their fixed TTL expires (lazy reclamation on API calls). Abort cannot erase these acknowledgements. See the full protocol and capacity tradeoffs for retry uncertainty, configuration, and examples.
Static files
When static serving is configured (see framework.md for the comptime modes and the --serve-static <dir> flag), GET and HEAD requests that match none of the admin UI (/_/), the built-in API, or the app’s custom routes are served from the static root. The /api namespace (the bare /api path and everything under /api/) is never served statically — an unmatched API path keeps the JSON 404 envelope, while a static miss returns a plain-text 404 (text/plain) — unless an SPA fallback applies.
Static files are served without authentication — collection access rules do not apply to the static root, so never place secrets there. For access-controlled file delivery, use file storage instead.
/and directory paths resolve to that directory’sindex.html.- Range requests: a single
Range: bytes=a-b,bytes=a-(open-ended, e.g. video seeking), orbytes=-n(suffix) answers206 Partial ContentwithContent-Range; a range past the end of the file answers416 Range Not SatisfiablewithContent-Range: bytes */<size>. A syntactically malformed or multi-rangeRangeheader is ignored (plain200). This applies to both dir mode (the request is normalized into the canonical closed form so facil.io’s own transport assembles the206) and embedded mode (a single-range206sliced out of the compiled-in asset bytes). - Caching: every response — embedded or dir — now emits a
Cache-Controlheader. The value defaults tomax-age=3600and is tunable process-wide via the--static-cache-control <value>flag, theZIGBASE_STATIC_CACHE_CONTROLenv var, or the comptimeApp(.{ .static_cache_control = "…" })key (flag > env > comptime; unset keeps the stock default). See framework.md → Static files for the full precedence and scope notes. In embedded mode each asset also has a precomputed CRC32 contentETag; a request with a matchingIf-None-Matchgets304 Not Modifiedfrom zigbase itself. In dir mode,ETag/Last-Modified/If-None-Match/If-Rangehandling is delegated to facil.io’ssendFile, which uses its own exact-matchETagsemantics (an unquoted base64 size^mtime tag) rather than RFC 7232 list/weak comparison. A ranged request with a matchingIf-Rangeresumes (206); zigbase neutralizes an inverted branch in the vendored facil.io that otherwise deleted theRangeon a match and forced a full200(RFC 9110 §13.1.5). A stale/mismatchedIf-Rangein dir mode still returns206rather than the RFC-mandated200— facil.io’s dir-modeETagis process-local and cannot be recomputed to distinguish the two cases. Owned serving (record-file downloads and embedded static) is fully RFC-correct: a mismatchedIf-Rangethere ignores theRangeand returns200. .gzsidecar negotiation (dir mode): if the request sendsAccept-Encodingcontaininggzipand a<file>.gzsibling exists next to the matched<file>, the sidecar’s bytes are served instead withContent-Encoding: gzip(facil.io’s existing behavior, unchanged) and — new —Vary: Accept-Encoding, so a shared cache in front of dir-mode static serving doesn’t conflate the plain and gzip-encoded responses for the same URL.- Every response includes
X-Content-Type-Options: nosniff; content types are derived from the file extension (html, css, js, mjs, json, map, svg, png, jpg/jpeg, gif, webp, avif, ico, woff/woff2, ttf, wasm, txt, xml, pdf, mp4, webm; unknown →application/octet-stream). - Paths containing
.., backslashes, or NUL bytes are rejected (404). There are no directory listings.
SPA fallback
A directory containing a file named .spa is an SPA root — GET/HEAD misses at or below it serve that directory’s index.html with 200 (real files always win; the .spa file itself is never served). The fallback shell response is always Cache-Control: no-cache with a revalidation ETag (a redeploy can’t strand a deep link on a stale cached shell), regardless of the Cache-Control knob above; a direct hit on that same index.html file (not via the fallback) keeps the knob’s normal value. In embedded mode the marker set is derived once at startup (the manifest can’t change at runtime); in dir mode it’s resolved live against the filesystem on every miss, so adding/removing a marker needs no restart (startup only fails fast if a .spa has no index.html). Custom builds can also declare comptime static_routes rewrites, consulted before the marker. See framework.md → Static files for details.
Realtime (WebSocket + SSE)
Durable record invalidation replay (SQLite and PostgreSQL)
Build with -Ddurable-realtime=true (and -Dpostgres=true for PostgreSQL). This also enables GET /api/realtime/backfill with the checkpoint protocol and current authorization described below, replacing the process-local store with a transactional database journal. Checkpoints survive application restarts and work on other instances connected to the same database and PostgreSQL schema. Cursor positions are encrypted and authenticated with a persistent journal secret and fresh random nonces; they bind collection identity and rename generation without exposing other collections’ aggregate write positions.
Built-in REST record create, update and delete operations append invalidations inside the record transaction, including resumable record updates. A journal failure rolls back the mutation; an oversized delete snapshot also rejects the mutation. Response serialization, after-hooks and live transport run after commit, so failure there does not remove the committed invalidation. Create/update entries retain only record IDs. Deletes retain private at-rest authorization snapshots; replay never returns these snapshots or historical field values.
A single metadata row serializes journal positions through commit on both backends. PostgreSQL sequence allocation cannot run ahead of an earlier uncommitted writer. This introduces a shared lock across journaled mutations: capacity should be measured for the application, especially transactions that also perform slow work. Replay reads a consistent database snapshot, releases that reader, then rechecks current authorization independently for each item.
By default the journal has a global ceiling of 4096 entries and 4 MiB of encoded frames, with 64 KiB per frame and 24-hour retention. Build-time controls are -Dreplay-max-entries (1..65536), -Dreplay-max-bytes (1..1073741824), -Dreplay-max-frame-bytes (1..1048576, no greater than total bytes), and -Dreplay-retention-seconds (1..31536000). Successful replay responses report these effective budgets as retention: {maxEntries,maxBytes,maxFrameBytes,seconds}. All writer replicas must use the same budgets. Expiry is stamped when captured; changing retention does not recompute existing event expiry. Lowering size budgets prunes on the next write; lowering frame budgets can reject older larger frames, requiring a new checkpoint and snapshot. /api/meta exposes realtimeBackfill and durableRealtime capability booleans, distinguishing the two modes. These budgets bound retained logical data, not total database/WAL size or process RSS. Indexes, database pages and transient serialization/authorization memory add overhead. Each write prunes expired entries and evicts the oldest prefix until both size budgets fit; idle expired rows are reclaimed on the next write. Cursors represent the last consumed sequence: a cursor strictly below the discarded/expired prefix requires reset, while a cursor equal to its last sequence is still caught up. This also keeps fresh head checkpoints usable on an idle journal after every retained entry expires. Reads reject stale checkpoints even before physical cleanup. Clock differences can shorten useful retention; keep replica clocks synchronized. A backwards clock never revives a pruned cursor. Pages copy at most 128 frames (8 MiB encoded with default frame budget), plus temporary database and JSON storage. Admission/concurrency budgets remain application responsibilities.
Unlike the local store, retention is shared across collections: a busy collection can cause another collection’s checkpoint to require reset. Invalid, expired, evicted, collection-renamed or wrong-collection checkpoints return the same 409 resetRequired envelope below. Obtain a new checkpoint before reloading a snapshot. A cursorless request does not reserve retention. Snapshot reload and idempotent application of invalidations are still required; WS/SSE messages do not carry journal cursors.
Enable this option on every writer in the deployment. Writes from older or non-enabled binaries, raw SQL, migrations, the Data facade, hook side-writes, authentication helpers and custom channels are outside this journal’s REST scope. Changing capture coverage requires clients to discard checkpoints and reload; the journal cannot detect writes performed by code that bypasses it. This is durable REST invalidation recovery, not exactly-once delivery or an audit/event-sourcing log. Database backups must include the journal tables. Restoring or rewinding a database requires clients to discard checkpoints and reload snapshots; restored history can reuse earlier journal positions. Before serving a restored database, with all instances stopped, replace _replay_state.secret with a newly generated cryptographically random 32-byte secret encoded as 64 hexadecimal characters. This forces old cursors to return 409, including clients unaware of the restore. Normal database durability settings apply. Startup creates _replay_state and _replay_events in SQLite main or the PostgreSQL current schema; internal SQL qualifies that schema so temporary tables and search-path fallbacks cannot redirect journal operations.
Optional record invalidation backfill (SQLite)
Build with -Drealtime-backfill=true to register GET /api/realtime/backfill?topic=notes&cursor=...&limit=128. The default build omits the route, capture code and retained store. This first slice supports single-process SQLite only; an active PostgreSQL backend returns 501. PostgreSQL does not allocate the backfill store or capture/retain invalidations, even when the binary includes this capability for SQLite deployments. The 501 uses the standard error envelope with code not_implemented. If an identity becomes invalid while a page is being authorized, the entire page is discarded and the endpoint returns 401 (unauthorized), without items or a new cursor. Reauthenticate and retry the previous checkpoint. Operational failures similarly return an error without advancing the checkpoint. It is not a durable log or a historical record-payload replay API.
The endpoint accepts one collection name (topic), an optional opaque cursor, and limit from 1 to 128. Use an Authorization: Bearer token for private collections, plus X-Account-Id when selecting a tenant account. This endpoint does not authenticate from cookies. Custom channels and per-record topics are not supported. The response is {items, nextCursor, hasNext, resetRequired}. Items use the normal event envelope but contain only {id} in record; refetch current record data through REST. Current token/session/two-factor policy, view rules, abilities and tenant scope are checked for each item, including for retained deletion snapshots. Identity verification and reader acquisition are intentionally repeated per item so a revocation committed mid-page aborts the entire response, not merely the next request. Collection metadata is also reacquired to observe schema changes. Deletion snapshots are private and never returned. Collection identity is pinned so a dropped/recreated collection cannot expose its predecessor’s events.
Without a cursor the endpoint returns an empty page and a checkpoint at the current capture position. Clients should:
- Obtain this checkpoint before loading their initial REST snapshot.
- Load the snapshot, then consume pages from the checkpoint. Apply invalidations idempotently: refetch creates/updates, remove deletes. An event may already have been reflected in the snapshot.
- Save each
nextCursor, even on empty pages;limitbounds this collection’s scanned entries, not the number of authorized matches. Continue whilehasNextis true. - Repeat periodically and after WS/SSE reconnect. Live transport frames do not carry these cursors; keep the backfill checkpoint separately.
The store has 16 collection slots, each independently retaining at most 256 events or 64 KiB of encoded frames (at most 1 MiB encoded data across all slots). Rings are allocated lazily; on 64-bit targets their metadata is about 6 KiB per active slot, plus the collection id and small store header. A busy collection cannot evict another slot’s events or cause it to paginate over unrelated traffic. Pages copy only the requested collection’s frames, at most 128 before authorization. Serialization and concurrent requests require additional transient memory; these are retained-data limits, not an RSS cap.
Only a record write can replace an occupied slot: when it needs a new slot and all 16 are occupied, the least recently captured/polled slot is replaced. A cursorless GET can allocate a free slot but never displaces an occupied one. If its collection has no slot and the table is full, even a cursorless GET returns the 409 reset envelope below. Fall back to snapshot reloads on reconnect; do not immediately retry checkpoint acquisition in a loop. A subsequent write to that collection can establish its slot.
A replaced slot’s cursors require reset even if that same collection later gets another slot: cursors bind the slot generation as well as the collection id and process epoch. Workloads actively using more than 16 collections may therefore need frequent snapshot reloads. An oversized event or failed capture invalidates only that collection’s older checkpoints rather than silently skipping a write. Restart, another process, relevant event/slot eviction, invalid cursors and failed capture return 409 with {resetRequired:true, items:[], nextCursor:null, hasNext:false}. Discard the old checkpoint and repeat the initial snapshot sequence. Capture positions and pagination are collection-local; slot replacement is still a shared capacity limit.
Maximum event size is 64 KiB, including the JSON envelope. This is also the per-collection retention budget, deliberately kept small for bounded memory use. Create/update captures contain only an id, but a delete capture includes the entire private authorization snapshot. A single delete frame over 64 KiB clears that collection’s retained history and invalidates every older checkpoint; every such large-record delete therefore requires all affected clients to reload their snapshot. The deletion itself still succeeds, and other collections’ rings are unaffected. Applications regularly deleting large text/JSON records should expect this reset behavior; this small backfill buffer is not a substitute for a durable change log. The limit applies to encoded bytes, not just a field’s length.
This is a record-write invalidation feed, not a materialized-query change feed: authorization/tenant changes, relation changes, TTL expiry, raw SQL and external writers do not generate complete invalidations for previously visible results. Reload snapshots when identity, policy or query dependencies change. A currently unauthorized record is omitted, not emitted as a synthetic delete. Do not use this API as an audit trail, durable synchronization protocol, or an exactly-once side-effect trigger.
WebSocket connection
Connect to ws://<host>/api/realtime (the upgrade is gated to that exact path and the connection Origin is validated against the server’s allowlist). The allowlist is ZIGBASE_REALTIME_ORIGINS / --realtime-origins (CSV). It is empty by default, which denies cross-origin browser upgrades — set your app origin(s) only if your frontend is served from a different origin. Same-origin upgrades (the embedded admin UI, or a frontend served from this same binary — the Origin authority equals the request Host) are always allowed, so the common single-binary deployment needs no configuration. A request with no Origin header (a non-browser client) is allowed regardless; delivery is still gated per-record by each collection’s viewRule.
Authenticating
Send the JWT explicitly as a frame — the auth cookie is intentionally NOT used over WebSocket (defense against cross-site WebSocket hijacking):
{ "action": "auth", "token": "<jwt>" }
The server replies { "type": "auth", "status": "ok" | "error" }.
Subscribing
{ "action": "subscribe", "topic": "posts" }
{ "action": "subscribe", "topic": "posts/RECORD_ID", "filter": "status = 'published'" }
A topic is either a whole collection (<collection>) or a single record (<collection>/<id>). filter is optional and uses the filter grammar. Unsubscribe with { "action": "unsubscribe", "topic": "..." }. The server acknowledges subscribe/ unsubscribe with { "type": "ack", "action": "...", "topic": "..." }. On connect it sends { "type": "connect", "clientId": "..." }.
Authentication is required to subscribe to any collection whose viewRule is not "@public". A socket may subscribe anonymously only to a public (@public) collection; for a locked, owner-scoped, or expression-gated collection you must send a successful auth frame first, otherwise subscribe is rejected with { "type": "error", "message": "authentication required to subscribe" }. (Delivery is also re-authorized per record, so auth-before-subscribe is a layered, not the only, check.)
The server enforces a global cap on concurrent WebSocket connections; once reached, new upgrades are rejected with HTTP 503.
Event frames
When a subscribed record changes, the server pushes:
{
"type": "event",
"topic": "posts",
"action": "create",
"record": { "id": "...", "title": "..." }
}
action is one of create, update, delete. For delete, record is id-only. Delete events are authorized per subscriber against a snapshot of the just-deleted record, so an owner-scoped (or otherwise gated) viewRule only notifies subscribers who were allowed to view that record — a delete on someone else’s record is not leaked to other subscribers. Existing WebSocket and SSE sessions are reverified before subscriptions and delivery. New two-factor requirements and session revocation therefore stop private delivery without waiting for the original token to expire; send a fresh auth frame after completing authentication to restore access.
Malformed or unknown client frames produce { "type": "error", "message": "..." }.
SSE transport
Everything above — the frame grammar (connect/auth/ack/event/signal/message/error), per-record delivery authorization, subscription rules, the Origin policy, and the shared connection cap (10,000 by default) — applies identically over Server-Sent Events. SSE is a second pipe under the same hub, not a second protocol.
Connect (downlink). GET /api/realtime/sse with Accept: text/event-stream — the header must be EXACT for non-browser clients (the server dispatches SSE upgrades on an exact match; EventSource always sends it). Every hub frame arrives as one data: <json> event (default event name → EventSource.onmessage; no id:/event:/retry: fields). The first event is the standard connect frame: {"type":"connect","clientId":"<32 chars>"}.
Verbs (uplink). POST /api/realtime/sse/:clientId with the same JSON verb body the WS socket sends (auth / subscribe / unsubscribe). The response body is the exact frame the WS socket would have written — the frame body is the protocol; the HTTP status is just framing:
| Condition | Response |
|---|---|
unknown, expired, or just-closed clientId | 404 standard error envelope — non-oracle: byte-identical for never-existed vs. just-closed |
| body fails to parse as a verb | 400, body {"type":"error","message":"bad message"} |
| verb processed | 200, body = {"type":"auth","status":"ok"|"error"}, {"type":"ack",…}, or {"type":"error","message":…} |
Error-frame outcomes return 200 deliberately — WS keeps the connection open and replies a frame; clients share one frame-handling path across transports.
Auth. Identical to WS: the ONLY identity path is the auth verb with the token in the POST body — a bearer token never appears in any URL, and the auth cookie is intentionally NOT used. The clientId is a crypto-random capability delivered only on the Origin-gated stream; no CORS headers are emitted. Deployment note: unlike the WS handle, the uplink clientId rides the URL path (POST /api/realtime/sse/:clientId), so it can land in HTTP access/proxy logs — the mitigation is that it’s a 165-bit CSPRNG capability that dies with the connection, and a leaked id only permits griefing that connection (unsubscribe/auth-clear), never data exfiltration (deliveries flow to the victim’s held-open stream socket, not the uplink POST response).
Heartbeats. The server writes the SSE comment : ping on every protocol-timeout tick (invisible to EventSource). Default interval: the listener’s 40s timeout; override with --sse-heartbeat-seconds N / ZIGBASE_SSE_HEARTBEAT_SECONDS (1..=255; validated at startup).
Limits. WS and SSE share ONE process-wide connection cap (10,000 by default; comptime .realtime.max_connections accepts a positive u32; upgrades past it → 503) and the 256-subscriptions-per-connection cap. Browser note: HTTP/1.1 EventSource is limited to ~6 streams per origin by browsers.
Slow-consumer backpressure. A client that reads slowly or stalls without closing would otherwise let the server buffer its outbound frames without bound (an OOM/DoS risk). Each realtime connection therefore has a per-connection outbound high-water-mark: once its queued outbound frames exceed the bound, the server disconnects that consumer (the standard pub/sub choice — dropping individual frames would silently corrupt the client’s view; a disconnect forces a clean reconnect + re-fetch). Applies to both WS and SSE. Default 1024 frames; tune with --realtime-outbound-hwm N / ZIGBASE_REALTIME_OUTBOUND_HWM (0 disables the bound).
No-SDK example.
const es = new EventSource('/api/realtime/sse');
let clientId;
es.onmessage = async (e) => {
const m = JSON.parse(e.data);
if (m.type === 'connect') {
clientId = m.clientId;
await fetch('/api/realtime/sse/' + clientId, {
method: 'POST',
body: JSON.stringify({ action: 'subscribe', topic: 'posts' }),
});
} else if (m.type === 'event') {
console.log(m.action, m.record);
}
};
Admin stats. GET /api/realtime/stats — superuser-only, read-only realtime health for the admin UI: { "connections": n, "max_connections": n, "max_subs": n, "outbound_hwm": n } (reserved connections including upgrades in progress, the effective connection cap, static subscription cap, and configured outbound high-water-mark). 401 unauthenticated, 403 non-superuser.
Query workbench (opt-in)
Build with -Dquery-workbench=true; otherwise both endpoints return 404. Both require a current superuser bearer token: 401 for absent/invalid bearer credentials (cookies alone are insufficient), 403 for a non-superuser.
| Method | Path | Contract |
|---|---|---|
| GET | /api/query-workbench/stats | Bounded {items} of method, route template, opaque structural shape, execution count, summed/max statement-step nanoseconds, slow/repeated/failed counts, plus finalized-statement lifecycle/call/held timing; includes capacity and dropped counts. |
| POST | /api/query-workbench/explain | JSON {collection, equalityField?, orderField?, descending?}; 200 {items, backend, scope, executesQuery:false, includesAuthorizationPredicates:false, truncated}. |
Explain accepts schema-validated field names, not SQL, expressions or values. It only plans a generated limited SELECT; never executes that SELECT or ANALYZE. Input is capped at 4 KiB; output at 32 plan rows, 512 UTF-8 bytes per detail. Unknown keys/fields or invalid input return 400; unknown collection 404; PostgreSQL planning 501. Plans intentionally reveal schema/index names to operators, not ordinary users. Metrics retain no SQL or parameter text; structural shape repetition is not proof of N+1. See scope, tuning and privacy limits. Each item includes backend and measurement; the top-level measurement is backend-specific-see-items. SQLite step durations retain their existing meaning. PostgreSQL step durations cover the first step’s complete client protocol exchange, including network/server wait and result buffering, not CPU or pool-wait time. Buffered row iteration adds no clock reads or execution counts. Backend identity separates shape/repeat aggregation; telemetry limits do not cap query result memory. Lifecycle items add finalizedStatements, statementLifetimeNanoseconds, maxStatementLifetimeNanoseconds, measuredCallNanoseconds, and heldNanoseconds. They cover successful prepare through finalize within one originating scope, including reset/reuse. Measured calls are prepare/step/reset/finalize; held time also includes binding, column access, scheduling and instrumentation, not just application work. These are elapsed durations, not CPU time. Existing execution counts and slow thresholds remain step-based; a statement may execute zero or many times. droppedStatements is independent of droppedExecutions. No raw exec, failed-prepare or full-request timing is implied by statement metrics. Scope aggregates add responseStatusClasses (returned HTTP status classes), handlerErrors (errors escaping a measured handler), and four fixed poolWaits buckets for SQLite/PostgreSQL reader/writer mutex acquisition. Each wait bucket has backend, role, acquisitions, totalNanoseconds, and maxNanoseconds. poolWaitMeasurement is pool-mutex-acquisition: it excludes connection creation, SQL/database locks, and connection ownership time. These counts include uncontended acquisitions and are not proof of contention on their own. The additive jobs array covers declared durable/scheduled handler attempts under jobMeasurement: "handler-attempt-scope"; retry backoff and queue residence are excluded. Job aggregates identify attribution and jobName; SQL items use the same attribution kind with method: "JOB", routeTemplate: null, and the declared jobName. HTTP and job aggregates share maxScopeEntries, while droppedRouteScopes and droppedJobScopes count omissions separately. SQL shapes share maxEntries. An error count does not mean retries are exhausted. No job payloads or dynamic app.submit names are captured; memory jobs and submit handlers are unmeasured. GET /api/meta includes capabilities.queryWorkbench and optional endpoints.queryWorkbench; this is compile-time discovery, not access authority.
Admission diagnostics
GET /api/admission/stats is available only in applications compiled with .admission = .{ .max_requests = N } or byte-only .admission = .{ .max_job_bytes = N } (the latter requires -Dcoordinated-admission=true). It requires superuser authentication: 401 without a valid identity, 403 for other users, and 404 when disabled.
{"limit":3,"active":1,"high_water":3,"rejected":42,"work_limit":16,"jobs":2,"work_high_water":16,"jobs_rejected":5,"job_bytes_limit":null,"job_bytes":0,"job_bytes_high_water":0,"job_bytes_rejected":0}
Counters are coherent and process-local, reset at restart. With HTTP admission, active includes this diagnostics request; high_water records peak admitted HTTP work and rejected is a saturating unsigned 64-bit count. The endpoint itself obeys admission and can return 503 with code overloaded and Retry-After: 1 before authentication. Only the exact built-in GET /api/health liveness probe is exempt. See HTTP admission and backpressure for configuration, scope, and retry guidance. This exact liveness GET also skips unused multipart-body parsing independently of admission configuration; HEAD, POST, and similar paths do not get that bypass.
Byte-only configuration omits max_requests: limit is null and active, high_water, and rejected stay zero. Its diagnostics and other HTTP callbacks do not acquire admission permits, even when the retained-byte budget is full.
With -Dcoordinated-admission=true and .admission.max_work, work_limit is the shared ceiling for active + jobs; jobs counts queued/running memory jobs and app.submit tasks, including retry backoff. work_high_water records peak combined occupancy, and saturating jobs_rejected counts shared-budget job refusals, not independent ring-full errors. Without shared admission, work_limit is null and the three shared counters are zero. These are process-local work counts, not memory measurements; durable jobs are excluded.
Optional .admission.max_job_bytes adds job_bytes_limit (nullable), job_bytes, job_bytes_high_water, and saturating job_bytes_rejected. These count precisely queue-owned payload/name copy lengths; they exclude inline borrowed payloads, pre-enqueue serialization, handler allocations and allocator overhead. Byte refusals return error.QueueFull to enqueue/submit, not HTTP overload by themselves. Without this ceiling its counters remain zero. It is independent of max_work: byte-only configuration leaves shared work counters zero. If both limits are full, only the work-count rejection is counted because it is checked first.
Meta
| Method | Path | Description |
|---|---|---|
| GET | /api/meta | Public, unauthenticated capability probe. No auth. |
{
"zigbase": "0.13.0",
"commit": "087ca67",
"api": 1,
"capabilities": {
"admin": true,
"analytics": true,
"collectionsFrozen": false,
"devMode": false,
"magicLink": true,
"mailUnsubscribe": true,
"mailWebhook": true,
"oauth2": true,
"postgres": false,
"queryWorkbench": false,
"realtimeBackfill": false,
"durableRealtime": false,
"s3": false,
"senders": true,
"tenancy": true,
"vector": false,
"webauthn": true
},
"endpoints": {
"health": "/api/health",
"state": "/api/state",
"realtimeSse": "/api/realtime/sse",
"realtimeBackfill": null,
"queryWorkbench": null
},
"limits": {
"maxUploadSize": 52428800
}
}
/api/meta reports process-constant build-and-config facts with no database access and no subject — it is deliberately separate from /api/health (a liveness probe hit on a tight interval, kept small) and /api/state (the per-subject, DB-backed feature-flag projection). Its endpoints object cross-links the other two rather than duplicating their contract.
zigbase and commit are the same build_options-sourced values as GET /api/health’s versions.zigbase/versions.commit — the two can never disagree, since they come from one source of truth.
api is the meta-contract version, starting at 1. It is bumped only when a field is removed or changes meaning; adding a field is backwards-compatible and does not bump it. A consumer that only reads known keys is forward-compatible with new capabilities.
capabilities is an object of booleans, one per optional route group or build flag this binary carries:
| Key | Meaning |
|---|---|
admin | The admin SPA + its API surface (/_/) is mounted. |
analytics | The analytics event-capture + rollup API is mounted. |
collectionsFrozen | This deployment was built with App(.{ .collections_frozen = true }): runtime collection DDL (POST/PATCH/DELETE /api/collections) is categorically disabled — schema evolves via .migrations + a redeploy. |
devMode | The binary was built with -Ddev-mode (dev-only seams: fake clock, fake entropy, fake field crypto). Must never be true in production. |
magicLink | The passwordless magic-link auth method route group is mounted. |
mailUnsubscribe | The public one-click unsubscribe route (RFC 8058) is mounted. |
mailWebhook | The mail bounce/complaint webhook route is mounted. |
oauth2 | The OAuth2+PKCE auth method route group is mounted. |
postgres | The binary was compiled with -Dpostgres (the pure-Zig PostgreSQL backend is linked in). |
realtimeBackfill | The backfill route is compiled in (process-local SQLite unless durableRealtime is true). |
durableRealtime | Transactional REST replay is enabled, with database-backed cross-instance checkpoints. |
s3 | The binary was compiled with -Ds3 (the S3-compatible storage backend is linked in). |
senders | The verified-senders email route group is mounted. |
tenancy | Multi-tenancy is configured (App(.{ .tenancy = ... })). |
vector | The binary was compiled with -Dvector (vector search — sqlite-vec / pgvector). |
webauthn | The WebAuthn/passkey auth method route group is mounted. |
The headline use case: to find out whether runtime schema changes are possible, read capabilities.collectionsFrozen; never string-match the 403 a frozen deployment returns from /api/collections. That 403’s message text is explicitly not contract (see Error codes) — the boolean here is.
endpoints cross-links the other two process-level endpoints so a client never has to hardcode their paths: health is always /api/health; realtimeSse is always /api/realtime/sse; state is the mount for the Feature state (public) endpoint — it is the configured (possibly remapped) path, or null when that route is disabled (.features = .{ .public_route = .disabled }), which is genuinely undiscoverable any other way.
limits.maxUploadSize is the configured max upload size in bytes (app.max_upload_size, default 50 << 20 = 50 MiB) — already discoverable by uploading past the limit, so exposing it directly just saves the round trip.
Security invariant: almost every field in this response is a fact an unauthenticated client could already establish by probing — each capability corresponds to a route group that already answers 404 or 200 anonymously. devMode is the one deliberate exception: it gates no route, so it is not independently probe-discoverable; it stays because a dev build shouldn’t be public-facing in the first place, so advertising it is judged acceptable and useful. /api/meta never exposes a config value, filesystem path, hostname, connection string, or credential; it follows the same rule as GET /api/health.
See also: Health (liveness + component versions) and Feature state (public) (per-subject resolved flags/experiments).
Health
With admission control enabled, only exact GET /api/health bypasses the admission limit. HEAD and other methods or path variants can be shed under load; configure liveness probes to use GET.
| Method | Path | Description |
|---|---|---|
| GET | /api/health | Liveness probe + the active database backend + component versions. No auth. |
{
"status": "ok",
"backend": "sqlite",
"versions": {
"zigbase": "0.10.0",
"commit": "abc1234",
"sqlite": "3.46.0",
"sqliteVec": "0.1.6",
"zap": "0.10.6",
"facil": "0.7.0"
}
}
backend is "sqlite" or "postgres" — the kind of database the server is running on. It is deliberately read-only and carries no secrets: the connection string, host, and credentials are never exposed. The admin UI reads this to show a small backend badge in the sidebar.
versions reports the baked-in component versions of the running binary (sqlite is the live linked version; the rest come from the build) — non-secret build provenance for auditing a deployed server over HTTP, the same values as --version and the boot log. Since the endpoint is unauthenticated, treat these as publicly disclosed. See framework.md § Version transparency for the full list.
For build-and-config facts (which optional route groups this binary carries, whether collections are frozen, the upload limit) rather than a liveness check, see Meta — kept separate so this liveness probe stays cheap on a tight poll interval.
See also
- tutorial.md — build an app on ZigBase, end to end.
- fields.md — the complete field-type & options catalog.
- abilities.md — per-record ability flags and the
abilitiesendpoint. - tenancy.md — multi-tenancy, account scopes, and the account-activate endpoint.
- recipes.md — provisioning, access rules, hooks, custom routes, jobs.
- framework.md — embedding ZigBase as a Zig library.