Reference
HTTP reference
Every endpoint of the HTTP surface, the JSON envelopes, SSE framing, status mapping, and Gateway-only behavior
The HTTP surface is one route table served by two hosts: the data bridge on novad (--http-listen) and the Gateway (novagateway). Keys are single percent-encoded path segments (orders/eu → orders%2Feu); routing matches the escaped path, so an encoded slash never splits a key. A path with an empty, ., or .. segment is 404.
Endpoints
| Method | Path | Purpose | Request | Response |
|---|---|---|---|---|
POST | /v1/token/exchange | Trade an api-key for a token | {key_id, secret, bucket | scope, ttl_seconds} | 200 {token, expires_at_unix_ms} |
POST | /v1/streams/{bucket}/{key}/records | One-shot append | append envelope | 200 {start, end, tail} |
GET | /v1/streams/{bucket}/{key}/records | Read one page | query: one start + knobs | 200 {records, tail} |
GET | /v1/streams/{bucket}/{key}/records/tail | Committed tail | — | 200 position |
GET | /v1/streams/{bucket}/{key}/records/subscribe | SSE subscription | query start or Last-Event-ID | 200 text/event-stream |
POST | /v1/streams/{bucket}/{key}/trim | Advance the trim watermark | {seq_num} or {timestamp_ms} | 204 |
POST | /v1/streams/{bucket}/{key}/fence | Install, rotate, or clear the fencing token | {fencing_token} | 200 {tail} |
POST | /v1/streams/{bucket}/{key} | Create a stream | stream config (may be empty) | 201 stream |
PATCH | /v1/streams/{bucket}/{key} | Reconfigure a stream | the fields to change | 200 stream |
GET | /v1/streams/{bucket}/{key} | Get a stream | — | 200 stream + fencing_token |
DELETE | /v1/streams/{bucket}/{key} | Delete a stream | — | 204 |
GET | /v1/streams/{bucket} | List a bucket's streams | ?start_after&limit | 200 {streams, next} |
POST | /v1/buckets/{bucket} | Create a bucket | bucket config (may be empty) | 201 bucket |
GET | /v1/buckets/{bucket} | Get a bucket | — | 200 bucket |
PUT | /v1/buckets/{bucket} | Replace a bucket's config | bucket config | 200 bucket |
DELETE | /v1/buckets/{bucket} | Delete an empty bucket | — | 204 |
GET | /v1/buckets | List buckets | ?start_after&limit | 200 {buckets, next} |
GET | /healthz | Gateway liveness | — | 200 |
A method the path does not support answers 405 with an Allow header. Every JSON response is Content-Type: application/json.
Envelopes
Position
{"seq_num": 12, "timestamp_ms": 1724500000000}64-bit fields are JSON numbers. At a boundary (end, tail) seq_num is one past the last record and timestamp_ms that record's time, 0 when none is known.
Record
{"seq_num": 0, "timestamp_ms": 1724500000000, "body": "hello",
"headers": [{"name": "content-type", "value": "text/plain"}]}On reads, a byte field rides its plain name when it is valid UTF-8 and its _b64 sibling (body_b64, name_b64, value_b64) otherwise, decided per field, never lossy. ?format=base64 carries every field base64-encoded in the plain name. headers is omitted when empty.
Append request
{"records": [{"body": "…" | "body_b64": "…", "headers": […], "timestamp_ms": 0}],
"match_seq_num": 4,
"fencing_token": "writer-1"}Each record carries exactly one of body / body_b64; each header exactly one of name / name_b64 and at most one of value / value_b64 (a missing value is empty; a missing name is 400). records must not be empty. match_seq_num and fencing_token are optional.
Read query
Exactly one start: seq_num, timestamp_ms, or tail_offset (unsigned; timestamp_ms signed). Knobs: limit, max_bytes, until_ms, clamp (true/false), format (raw default, or base64).
Stream
{"stream_id": "uuid", "bucket": "apps", "key": "orders/eu",
"class": "standard", "producer_policy": "any",
"retention_max_age_ms": 0, "retention_max_bytes": 0,
"batch_max_bytes": 8388608, "batch_max_records": 0, "batch_max_delay_ms": 250,
"throughput_max_bytes_per_second": 0,
"timestamping_mode": "client-prefer", "timestamping_uncapped": false,
"created_at_unix_ms": 1724500000000}The create body takes the same config fields plus creation_token; an empty string enum defers to the bucket's defaults; zero bounds mean unbounded. Enum values: class standard | express; producer_policy any | fenced; timestamping_mode client-prefer | client-require | arrival. The get-one response adds fencing_token ("" when unfenced); listings do not. List cursors (next, start_after) are opaque base64url strings.
Bucket
{"name": "apps", "location": "", "create_stream_on_append": true, "create_stream_on_read": false,
"default_class": "express", "default_producer_policy": "any",
"default_retention_max_age_ms": 0, "default_retention_max_bytes": 0,
"default_batch_max_bytes": 8388608, "default_batch_max_records": 0, "default_batch_max_delay_ms": 250,
"default_throughput_max_bytes_per_second": 0,
"default_timestamping_mode": "client-prefer", "default_timestamping_uncapped": false,
"created_at_unix_ms": 1724500000000, "creation_token": ""}The name rides the path; a body naming a different bucket is 400. Bucket list cursors are names.
SSE framing
GET …/records/subscribe answers 200 with Content-Type: text/event-stream and Cache-Control: no-cache, then streams:
| Frame | Form | Meaning |
|---|---|---|
| batch | id: <next seq_num> + data: {"records":[…],"tail":{…}} | one batch; tail is the position after the batch's last record |
| heartbeat | : hb | every 15s; ignore |
| expiry | event: auth-expired + data: {} | the token expired; refresh, reconnect with Last-Event-ID |
The id is the resume cursor: Last-Event-ID: <n> on reconnect starts at n and overrides seq_num / timestamp_ms in the query. One of the three is required (400 otherwise). Empty batches are never emitted. Delivery is pull-paced with one batch in flight; a consumer that stops reading for 60s is disconnected. At the per-host connection cap (4096) the request is 503. On star shutdown the stream simply ends; reconnect elsewhere with the cursor. A mid-stream break has no error frame; the fresh request carries the real answer.
Headers
| Header | Direction | Use |
|---|---|---|
Authorization: Bearer <jwt> | request | the credential on every route except /v1/token/exchange |
Content-Type: application/json | both | request bodies and every JSON response |
Last-Event-ID | request | SSE resume cursor |
Retry-After: 1 | response | on every 503 |
Allow | response | on 405 |
Errors
{"error": "<reason>", "tail": {"seq_num": 5, "timestamp_ms": 0}, "fencing_token": "writer-1"}tail appears only on a match_seq_num miss, fencing_token only on a fencing mismatch.
| Status | Wire code | When |
|---|---|---|
400 | InvalidArgument | malformed envelope or query, unknown enum, record/batch over a cap, bad headers or timestamps, missing start |
401 | Unauthenticated | no credential, bad token, unknown key id or wrong secret on the exchange |
403 | PermissionDenied | no grant covers the action; revoked credential; exchange ask exceeds the credential |
404 | NotFound | stream or bucket not found |
409 | AlreadyExists | stream or bucket exists (a different creation token) |
410 | OutOfRange (no detail) | records trimmed away |
412 + tail | OutOfRange + Sequence | match_seq_num miss; nothing appended |
412 + fencing_token | FailedPrecondition + Fence | fencing-token mismatch |
429 | ResourceExhausted | sessions, subscriptions, or the fleet at a cap; a Gateway limit ceiling |
500 | anything unmapped | logged by novad as a bug |
501 | Unimplemented | the exchange on a deployment with no symmetric signing key |
503 + Retry-After | FailedPrecondition, Unavailable, Aborted | convergible: a lease moving, a draining star, a settling chain, a deleted stream's fence, SSE at cap |
NOT_OWNER never reaches an HTTP caller: the bridge hops to the leader and the Gateway follows the redirect; a hop that finds the lease moved again degrades to 503.
Gateway-only behavior
- Hosts. With a zone configured,
{bucket}.{zone}injects the bucket into the path (/v1/streams/{key}/…on the host becomes/v1/streams/{bucket}/{key}/…); any other path on a bucket host is404.api.{zone}and hosts outside the zone serve path-style unchanged. - CORS. Explicit origin allowlist, default deny. An allowed origin gets
Access-Control-Allow-Origin: <origin>andVary: Origin; its preflight getsAccess-Control-Allow-Methods: GET, POST, DELETE, OPTIONS,Access-Control-Allow-Headers: Authorization, Content-Type, Last-Event-ID,Access-Control-Max-Age: 600, and204. A denied origin's preflight is403; its plain requests are served without CORS headers. - Limits-as-claims. When enforcement is armed,
nova_aux.limitson the verified token is enforced persub, per Gateway instance (the effective global limit is roughly claim × replicas):rpson every request,write_Bpson the declared request body,read_Bpson served bytes (an SSE stream pauses in debt),connectionson concurrent subscriptions. A breach is429with{"error":"<why>"}, logged. Enforcement requires edge verification; the Gateway refuses to start otherwise. - Edge verification. When armed, the Gateway verifies the bearer before dispatch (
401in the same error shape) and stamps its expiry as the request deadline; either way the bearer is forwarded verbatim andnovadverifies it again. - Plan-driven serving. With
--serve-plan(default on) settled history is fetched from object storage by the Gateway itself (presigned URLs, or raw locations under--storage-bucket) and only the tail is proxied through the fleet; any obstacle falls back to the proxied read.--serve-plan=falseproxies everything. GET /healthzanswers200outside the middleware stack.
Source documents
internal/transport/http/dataplane/server.go,codec.go,errors.go,sse.gointernal/gateway/docs/design/021-http-surface.md,docs/design/022-gateway.md