novadocs
Reference

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/euorders%2Feu); routing matches the escaped path, so an encoded slash never splits a key. A path with an empty, ., or .. segment is 404.

Endpoints

MethodPathPurposeRequestResponse
POST/v1/token/exchangeTrade an api-key for a token{key_id, secret, bucket | scope, ttl_seconds}200 {token, expires_at_unix_ms}
POST/v1/streams/{bucket}/{key}/recordsOne-shot appendappend envelope200 {start, end, tail}
GET/v1/streams/{bucket}/{key}/recordsRead one pagequery: one start + knobs200 {records, tail}
GET/v1/streams/{bucket}/{key}/records/tailCommitted tail200 position
GET/v1/streams/{bucket}/{key}/records/subscribeSSE subscriptionquery start or Last-Event-ID200 text/event-stream
POST/v1/streams/{bucket}/{key}/trimAdvance the trim watermark{seq_num} or {timestamp_ms}204
POST/v1/streams/{bucket}/{key}/fenceInstall, rotate, or clear the fencing token{fencing_token}200 {tail}
POST/v1/streams/{bucket}/{key}Create a streamstream config (may be empty)201 stream
PATCH/v1/streams/{bucket}/{key}Reconfigure a streamthe fields to change200 stream
GET/v1/streams/{bucket}/{key}Get a stream200 stream + fencing_token
DELETE/v1/streams/{bucket}/{key}Delete a stream204
GET/v1/streams/{bucket}List a bucket's streams?start_after&limit200 {streams, next}
POST/v1/buckets/{bucket}Create a bucketbucket config (may be empty)201 bucket
GET/v1/buckets/{bucket}Get a bucket200 bucket
PUT/v1/buckets/{bucket}Replace a bucket's configbucket config200 bucket
DELETE/v1/buckets/{bucket}Delete an empty bucket204
GET/v1/bucketsList buckets?start_after&limit200 {buckets, next}
GET/healthzGateway liveness200

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:

FrameFormMeaning
batchid: <next seq_num> + data: {"records":[…],"tail":{…}}one batch; tail is the position after the batch's last record
heartbeat: hbevery 15s; ignore
expiryevent: 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

HeaderDirectionUse
Authorization: Bearer <jwt>requestthe credential on every route except /v1/token/exchange
Content-Type: application/jsonbothrequest bodies and every JSON response
Last-Event-IDrequestSSE resume cursor
Retry-After: 1responseon every 503
Allowresponseon 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.

StatusWire codeWhen
400InvalidArgumentmalformed envelope or query, unknown enum, record/batch over a cap, bad headers or timestamps, missing start
401Unauthenticatedno credential, bad token, unknown key id or wrong secret on the exchange
403PermissionDeniedno grant covers the action; revoked credential; exchange ask exceeds the credential
404NotFoundstream or bucket not found
409AlreadyExistsstream or bucket exists (a different creation token)
410OutOfRange (no detail)records trimmed away
412 + tailOutOfRange + Sequencematch_seq_num miss; nothing appended
412 + fencing_tokenFailedPrecondition + Fencefencing-token mismatch
429ResourceExhaustedsessions, subscriptions, or the fleet at a cap; a Gateway limit ceiling
500anything unmappedlogged by novad as a bug
501Unimplementedthe exchange on a deployment with no symmetric signing key
503 + Retry-AfterFailedPrecondition, Unavailable, Abortedconvergible: 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 is 404. 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> and Vary: Origin; its preflight gets Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS, Access-Control-Allow-Headers: Authorization, Content-Type, Last-Event-ID, Access-Control-Max-Age: 600, and 204. A denied origin's preflight is 403; its plain requests are served without CORS headers.
  • Limits-as-claims. When enforcement is armed, nova_aux.limits on the verified token is enforced per sub, per Gateway instance (the effective global limit is roughly claim × replicas): rps on every request, write_Bps on the declared request body, read_Bps on served bytes (an SSE stream pauses in debt), connections on concurrent subscriptions. A breach is 429 with {"error":"<why>"}, logged. Enforcement requires edge verification; the Gateway refuses to start otherwise.
  • Edge verification. When armed, the Gateway verifies the bearer before dispatch (401 in the same error shape) and stamps its expiry as the request deadline; either way the bearer is forwarded verbatim and novad verifies 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=false proxies everything.
  • GET /healthz answers 200 outside the middleware stack.

Source documents

  • internal/transport/http/dataplane/server.go, codec.go, errors.go, sse.go
  • internal/gateway/
  • docs/design/021-http-surface.md, docs/design/022-gateway.md

On this page