The HTTP surface
The paths, a password spent once for a token, and three probes that deliberately take no credential.
$ tessaridb ./data --http 0.0.0.0:8000
Authentication is HTTP Basic or a bearer token, and nothing else. Given a
certificate (--tls-cert and --tls-key, from 0.21.0-beta) this surface speaks
HTTPS — TLS 1.3 and nothing else — and without one it speaks plain HTTP, which a
node that belongs to a cluster does only when told to. See
Encryption in transit.
A store with no users is open and runs anything; the first
DEFINE USER closes it, and from then on a request with no credential is
answered 401 with WWW-Authenticate: Basic realm="TessariDB".
Sign in once
Basic works on every route, but it makes the node hash a password on every request — deliberately expensive, and capped at 24 at a time. Exchange it for a token instead:
$ curl -s -u ada -X POST http://127.0.0.1:8000/session
{"token":"5f3a…","expires_in":43200}
$ curl -s -H 'Authorization: Bearer 5f3a…' \
--data-binary 'SELECT * FROM users;' http://127.0.0.1:8000/script
DELETE /session hands one back. A token expires after twelve hours, lives only
in the memory of the node that issued it, and stops working the moment the
account behind it changes — a rotated password, a changed role or a removed
user kills every token that user held. Only a password may be exchanged for a
token; presenting one to get another is refused, or expiry would mean nothing.
A WebSocket cannot carry a header, so /watch takes "token" in its first
message instead — see watching.
POST /password changes your own and takes Basic only, never a token: it
asks for the current password as a second proof, and a token is not one. Any
role may use it, and it ends every token that user holds.
Running a script
$ curl -s --data-binary 'USE NAMESPACE prod; USE DATABASE orders; SELECT * FROM users;' \
http://127.0.0.1:8000/script
The body is the script. With Content-Type: application/json the body is an
envelope carrying a script and its parameters instead:
{ "script": "SELECT * FROM users WHERE name = $who;", "parameters": { "who": "ada" } }
The shape is decided by what the caller declares, never by sniffing the body. A server that guessed would classify a JSON-shaped script as an envelope, and the day somebody's script started with a brace it would break.
The routes
| method | path | credential | answers |
|---|---|---|---|
GET | /health | none by design | 200 / 503 json |
GET | /ready | none by design | 200 / 503 json |
GET | /metrics | none by design; a session adds the topics it may read | 200 Prometheus text, 401 for a refused credential |
POST | /session | password only | 200 json — a token |
DELETE | /session | token | 200 json |
POST | /password | password only | 200 json — your own, and ends your tokens |
POST | /script | session | 200 json |
GET | /backup | session | 200 octet-stream |
GET | /backup?from=<n> | session | 200 octet-stream |
GET | /backup?as=state | session | 200 octet-stream — a snapshot |
GET | /backup?as=script | session | 200 TessariQL text — a script |
GET | /watch | session | 101 upgrade |
GET | /wire | in the wire session | 101 upgrade — the wire protocol over a WebSocket |
PUT | /files/{ns}/{db}/{bucket}/{path…} | session | 201 json |
POST | /files/{ns}/{db}/{bucket}/{path…} | session | 201 json — the same write as PUT |
GET | /files/{ns}/{db}/{bucket}/{path…} | session | 200 / 404 octet-stream |
GET | /files/{ns}/{db}/{bucket} | session | 200 json — a listing |
HEAD | /files/{ns}/{db}/{bucket}/{path…} | session | 200 / 404 |
DELETE | /files/{ns}/{db}/{bucket}/{path…} | session | 204 json |
POST | /series/{ns}/{db}/{series} | session | 200 json — how many events landed |
GET | /vault | session | 200 json — the store's seal status |
POST | /vault/unseal | session | 200 json — the body is the passphrase |
POST | /vault/seal | session | 200 json |
POST | /vault/passphrase | session | 200 json — the body is {"current": …, "new": …} |
GET | /vault/{ns}/{db}/{vault} | session | 200 json — that vault's status and custody |
POST | /vault/{ns}/{db}/{vault}/unseal, /seal, /passphrase | session | 200 json — a vault with its own passphrase; one in the store's custody is 400 |
Four things worth knowing before you write a client
HEAD costs what GET costs. The node reads the whole object and throws
the body away. Do not present a HEAD-backed exists() as a cheap probe — it
is the same read with the answer discarded.
A trailing slash names a file, not a bucket. /files/ns/db/bucket lists the
bucket; /files/ns/db/bucket/ reads a file called /. Normalise the slash away
in your client, because list the bucket and read the file named / must not
be one keystroke apart.
GET /backup on a store with no users is unauthenticated and returns a
snapshot of the whole store. That is the open-store rule at its loudest rather than a defect, and
it is the strongest practical argument for declaring an owner as the first thing
a deployment does.
Do not probe /. The console owns it, and in a build without the console
the same request is a 404. Use /health, which exists in every build.
Names and values
The {ns}, {db} and {bucket} segments must match [A-Za-z0-9_]+. They are
names, interpolated into a statement, and that check is what makes the
interpolation safe. The {path…} is a value, carried as a parameter — a
file may be called anything, and slashes inside it are part of the name rather
than directories. Same rule as everywhere else: a name is never
supplied, a value always may be.
Appending events to a series
POST /series/{ns}/{db}/{series} writes a batch of events in one
transaction and answers how many landed:
curl -s -H "Authorization: Bearer $TOKEN" \
--data-binary "[{ 'sensor': 'boiler', 'v': 71.5, 'at': datetime '2026-09-29T10:00:00Z' }]" \
http://127.0.0.1:8000/series/plant/metrics/readings
{"appended":1}
The body is one TessariQL value: an array of objects whose values are all
literals. Nothing in it is evaluated, so a function call, a parameter or a path
anywhere inside is a 400. It is not JSON, for the same reason a parameter is
not: JSON cannot say a datetime, a decimal or a uuid. Each event goes through
your session like a CREATE, so grants, tenancy, the series' checks and its
rollups apply as they would to a script. One refused event fails the batch and
nothing of it is kept. A table that is not a series and one that is not there
both answer 404.
Appending is not idempotent: sent twice, a batch lands twice. Every client's
append sends it once and leaves the retry to you. What the route is for is on
Time-series.
Statuses
| you get | it means |
|---|---|
400 | the request is wrong — the parser refused the script, a segment is not a name, or the store will not take a value; repeating it cannot succeed |
401 | sign in — no credential against a closed store, or one refused |
403 | you did sign in, and the role, tenancy or grant does not cover this |
405 | wrong method for that route |
409 | a conflict: run the transaction again (retry), or re-read, because the state the request assumed is not the state there is (conflict) |
413 | a request body larger than 16 MiB — the wire protocol's frame size; refused before it is read when its length is declared |
426 | /watch without the upgrade headers |
404 | no such route — or a file that is not in the bucket |
429 | a sign-in the node is not taking right now: that identity is being made to wait |
502 | on a cluster, a request this node carried to the node that can answer it (a write it does not lead, say) and that hop failed — the body says why; retry, or send it to that node |
500 | a substrate or decoding failure. Anything answering 500 is a bug, and the node logs it at error while a 404 is logged as ordinary traffic |
503 | the request was fine and this node cannot serve it now — no leadership, no majority, no copy within the staleness bound, no backup folder — or /health or /ready saying no, or the node already answering as many requests as it will, with Retry-After: 1 |
401 and 403 must be kept apart by any client: 401 means sign in, 403
means signing in again will never help. Collapsing them makes a client retry
credentials that cannot work, forever.
Every error body names its class. From node 0.30.0-beta the body is
{"error": "<the store's words>", "code": "<class>"}, and the status is derived
from the class, so the two cannot disagree: invalid 400, unauthenticated 401,
forbidden 403, throttled 429, elsewhere 307, retry and conflict 409,
unavailable 503, internal 500. The class is the same one the wire carries —
the protocol page lists what each asks of the caller —
and it is what to branch on; the words change between releases.
Watching
GET /watch upgrades to a WebSocket and the connection starts being told about
changes instead of answering scripts. It is the same split as
the Rust client's follow, for the same reason: one connection
does one of the two things.
A browser cannot set a header on a WebSocket, so what authenticates the feed
travels in its first message — "token" when there is one, and a "user" and
"password" pair otherwise:
{ "namespace": "prod", "database": "orders", "from": 0, "token": "5f3a…" }
An Authorization header wins when the client can send one, because then the
credential never travelled through a message body at all. Prefer the token: a
password in a feed's opening message is a password in whatever logged it.
A feed over one table can be narrowed: "condition" is TessariQL text and "parameters" an object of names to
TessariQL literals, read as /script reads its parameters:
{ "namespace": "prod", "database": "chat", "table": "msgs", "from": 0,
"condition": "chat = $chat", "parameters": { "chat": "'a'" }, "token": "5f3a…" }
A record that stops matching arrives as "became": "removed". A feed that skipped changes sends
{ "progress": 41 } (with "cursor" on a split table): resume one past it.
The ceilings here are 64 KiB, and they are not the wire protocol's 16 MiB.
One WebSocket frame the node will read is capped at 64 KiB, and so is one
reassembled message — separately, because a message may legally arrive as many
fragments, so bounding a frame alone would bound nothing. A client sending more
than that over /watch is disconnected rather than answered, and the smaller
number is the one to size against if you write to both surfaces.