Streams HTTP API

Last updated 2 November 2026

View .md

Every request a stream’s URL serves, with each header, query parameter and answer it takes, so a program can append to and read a stream with any HTTP client.

A stream speaks the open Durable Streams protocol and the extensions of Ursula, the server Tonbo runs streams on, at release 0.11.3 (protocol, extensions). Each row below cites the section it rests on. Creating, listing and deleting streams is the Management API’s, not the stream URL’s (below).

Operations

OperationRequestPage
AppendPOST {stream}Append and close
ClosePOST {stream} with Stream-Closed: trueAppend and close
ReadGET {stream}?offset=…Read, tail and HEAD
Long-pollGET {stream}?offset=…&live=long-pollRead, tail and HEAD
Tail with Server-Sent EventsGET {stream}?offset=…&live=sseRead, tail and HEAD
HEADHEAD {stream}Read, tail and HEAD
Read a cacheable chunkGET {stream}/incarnation/{token}/chunk/{offset}Read, tail and HEAD
Read the snapshot at a cacheable URLGET {stream}/incarnation/{token}/snapshot/{snapshot_offset}Read, tail and HEAD
Publish a snapshotPUT {stream}/snapshot/{snapshot_offset}Snapshots and bootstrap
Stage a part of a snapshotPOST {stream}/snapshot/{snapshot_offset}/partsSnapshots and bootstrap
Publish a snapshot from its partsPUT {stream}/snapshot/{snapshot_offset} with Stream-Snapshot-PartsSnapshots and bootstrap
Discard staged partsDELETE {stream}/snapshot/partsSnapshots and bootstrap
Read a snapshotGET {stream}/snapshot/{snapshot_offset}Snapshots and bootstrap
Advance retentionPUT {stream}/retention/{retained_offset}Snapshots and bootstrap
BootstrapGET {stream}/bootstrapSnapshots and bootstrap

The stream’s URL

Every request goes to the stream’s URL, which the Management API returns as url when it creates or shows the stream (Read a stream) and tonbo stream show prints:

{streams-origin}/{account-id}/s.{name}

The origin is the Streams origin of the stream’s region (Regions and URLs), then your account’s id and the stream’s name after s., as they are, without encoding. Paths under it name the stream’s snapshots, retention, bootstrap and cacheable reads. The examples keep the URL in STREAM, an API key in TONBO_API_KEY, an offset in OFFSET and a Stream-Incarnation in INCARNATION.

Credentials

A request carries an API key or a Streams token as Authorization: Bearer. A key works directly; a token comes from the token exchange, which can narrow it to some streams and to reading, and is the only way for an Agent’s key. A read of a public stream needs neither; every write does. A read-only key or token reads, tails and reads snapshots, and every other request answers 403. An SSE tail opened with a token ends with a credential-expired event at its expiry.

Headers on every request

ParameterValueRequiredWhat it does
Authorization headerBearer and a key or tokenRequired, except to read a public streamAn API key, sent directly, or a Streams token made from one by the token exchange. A key that acts as an Agent is always exchanged first. A missing, expired or revoked credential answers 401; one that does not reach the stream, or is read-only and the request writes, 403 (Access).
Stream-Incarnation headertokenOptionalApplies the request only to the stream that answered this value: once the stream has been deleted and created again, or its history rewritten, the request answers 412 with the current value and changes nothing. A write is compared when it applies. A value the stream never gave, or two values, answer 400. With it, a producer may not reach an epoch the stream forgot. Compare values only for equality. (extensions 2.4)
FieldValueWhenWhat it means
Stream-Incarnation headertokenOn every success, and on 404, 410 and 416 of a read when the stream existsWhich stream answered. It changes when the stream is deleted and created again under the same name, or when its history is rewritten, as by a restore, and at no other time. tonbo stream show prints it as Incarnation. Keep it with any offset you keep. (extensions 2.4)
Ursula-Protocol headerintegerOn the server's own answersThe protocol's release line: 11 for every 0.11 release, the one Tonbo runs. A refusal Tonbo's gateway sends itself, such as a 401, a 402, an access 403 or a rate 429, carries neither this nor Ursula-Cluster-Id. (extensions 10)
Ursula-Cluster-Id header1 to 64 of A-Z a-z 0-9 . _ -On the server's own answersThe deployment that holds the stream. It never changes for a stream's region: a different one means a different deployment, not a new history of the same stream. (extensions 10)
Cache-Control headerdirectiveOn every answerno-store, except on an answer to a request without a credential, of a public stream's cacheable read marked Stream-Immutable, which is public, max-age=300, immutable. (protocol 8.1)
Retry-After headersecondsOn 429 and 503How long to wait before sending the request again. (extensions 11)

Content types and JSON mode

A stream’s content type is fixed when it is created, and every append sends exactly it. A stream of application/json is in JSON mode: each append is validated as JSON, nested at most 127 deep; a top-level array appends one message per element; and each message is kept as it was written, without insignificant whitespace, its members, numbers and strings untouched. Reads return its messages as application/x-ndjson, one per line (protocol 7, extensions JSON Message Text). Any other content type is bytes, read back as they were appended; an SSE tail sends them base64-encoded unless the type is text/*.

Offsets

An offset names a position in the stream. It is an opaque, case-sensitive string: keep it as an answer gave it, in Stream-Next-Offset or streamNextOffset. Two offsets of one stream compare as strings in stream order, and an offset stays valid for as long as the stream holds it. -1 is the start of the stream and now its current end; neither is ever an answer’s offset. URL-encode an offset in a query or a path (protocol 6, extensions 8). An offset means nothing to a stream deleted and created again, so keep the stream’s Stream-Incarnation with any offset you keep.

Through the Management API

A stream’s URL never creates or deletes it, and has no lifetime of its own. What the protocol does there, Tonbo does through the Management API, the console and the CLI:

The protocol’sAt a Tonbo stream
PUT {stream}, with Content-Type, Location and a first body (protocol 5.1)403 with the code STREAM_LIFECYCLE (Troubleshooting). Create the stream with POST /v1/streams, which names its content type, region and expiry, then append to its URL (Create a stream).
PUT {stream} with Stream-Closed: true (protocol 5.1)Create the stream, then close it.
Stream-TTL (protocol 5.1)A stream has no sliding lifetime. Give it a fixed one with expires_at at its creation, at least 60 seconds after it; Tonbo deletes it within a minute after that.
Stream-Expires-At (protocol 5.1)expires_at of POST /v1/streams. HEAD sends neither header: GET /v1/streams/{name} shows the expiry.
DELETE {stream} (protocol 5.4)403 with the code STREAM_LIFECYCLE. Delete the stream with DELETE /v1/streams/{name} or tonbo stream delete.

Error codes

A refusal is a status and, in most cases, a JSON body that names it:

{"error":{"code":"CONTENT_TYPE_MISMATCH","message":"…"}}

Match code, never message, which is for people and may change. A code you do not know is read by its status and headers, and the headers keep their meaning: a 413 names its limit, a 503 carries Retry-After, and a producer refusal carries Producer-Outcome. A body may be missing, and a HEAD answer has none. The codes of each status (extensions 11), of which Troubleshooting explains the ones a program meets:

StatusCodes
400INVALID_REQUEST, INVALID_HEADER, INVALID_QUERY, INVALID_OFFSET, QUERY_NOT_ACCEPTED, INVALID_RESOURCE, INVALID_BUCKET_ID, INVALID_STREAM_ID, INVALID_PRODUCER, PRODUCER_DIGEST_MISMATCH, MISSING_CONTENT_TYPE, EMPTY_APPEND, INVALID_RECORD_BOUNDARIES, NOT_JSON_BOUNDARY, INVALID_RETENTION, INVALID_SNAPSHOT, INVALID_COLD_FLUSH, IMPORT_INVALID, INVALID_PARTS_COMMIT, UNKNOWN_PART, SNAPSHOT_DIGEST_MISMATCH, BODY_UNREADABLE, UNSUPPORTED
401INVALID_CREDENTIAL, CREDENTIAL_EXPIRED
402PAYMENT_REQUIRED
403PRODUCER_EPOCH_CLOSED, PRODUCER_EPOCH_STALE, PRODUCER_EXPIRED, PRODUCER_KEY_CONFLICT, FORBIDDEN, ACCOUNT_REVOKED
404STREAM_NOT_FOUND, BUCKET_NOT_FOUND, BUCKET_ERASED, SNAPSHOT_NOT_FOUND, NOT_FOUND
405METHOD_NOT_ALLOWED, UNSUPPORTED
408BODY_TOO_SLOW
409STREAM_CLOSED, STREAM_SEQ_CONFLICT, CONTENT_TYPE_MISMATCH, STREAM_ALREADY_EXISTS_CONFLICT, PRODUCER_SEQ_CONFLICT, SNAPSHOT_CONFLICT, IMPORT_CONFLICT
410STREAM_GONE
412INCARNATION_MISMATCH
413REQUEST_TOO_LARGE, SNAPSHOT_TOO_LARGE
416OFFSET_OUT_OF_RANGE, RANGE_NOT_SATISFIABLE
429PRODUCER_LIMIT, STAGED_PARTS_LIMIT, RATE_LIMITED, LIVE_READS_EXHAUSTED, UPLOADS_EXHAUSTED, UPLOAD_BYTES_EXHAUSTED
500INTERNAL, CREATED_AT_OUT_OF_RANGE, HISTORY_MISMATCH
501NO_COLD_STORE
502COLD_STORE_FAILED, UPSTREAM_FAILED
503NO_LEADER, WAL_DISK_PRESSURE, RAFT_LOG_PRESSURE, INGRESS_BODY_BYTES_LIMIT_REACHED, COLD_BACKPRESSURE, RAFT_UNCOMMITTED_BACKPRESSURE, LIVE_READ_BACKPRESSURE, HISTORY_PENDING, APPLY_STOPPED, GROUP_RESTARTING, OUTCOME_UNKNOWN, JSON_BOUNDARY_UNVERIFIED, SNAPSHOT_COMMITS_BUSY, SNAPSHOT_COMMIT_IN_PROGRESS, AUTHENTICATION_UNAVAILABLE, AUTHORIZATION_UNAVAILABLE, NO_UPSTREAM, UPSTREAM_UNREACHABLE, UPLOAD_BUDGET_EXHAUSTED
504UPSTREAM_TIMEOUT

Write outcomes

Whether a write applied, when its answer leaves doubt, follows from its answer alone (extensions 11.1):

  • A 2xx with the write’s typed answer applied it: a producer write’s Stream-Offset and Stream-Next-Offset, an append’s or a close’s Stream-Next-Offset, a snapshot’s Stream-Snapshot-Digest, a part’s Stream-Snapshot-Part, retention’s Stream-Retained-Offset. A 2xx without it says nothing.
  • A 4xx other than 408 refused it: nothing of it applied, and nothing will.
  • A 408 or a 5xx refused it only when its code is one of the list below. Any other, and a request that got no answer, may have applied: send the same request again. With producer headers it applies once; without them, it may apply twice.
StatusCodes that prove nothing applied
408BODY_TOO_SLOW
501NO_COLD_STORE
503NO_LEADER, WAL_DISK_PRESSURE, RAFT_LOG_PRESSURE, INGRESS_BODY_BYTES_LIMIT_REACHED, COLD_BACKPRESSURE, RAFT_UNCOMMITTED_BACKPRESSURE, APPLY_STOPPED, GROUP_RESTARTING, HISTORY_PENDING, JSON_BOUNDARY_UNVERIFIED, SNAPSHOT_COMMITS_BUSY, AUTHENTICATION_UNAVAILABLE, AUTHORIZATION_UNAVAILABLE, NO_UPSTREAM, UPSTREAM_UNREACHABLE, UPLOAD_BUDGET_EXHAUSTED

A read changes nothing, so sending one again is always safe. A refusal speaks for its own request only: whether an earlier attempt under the same producer key applied, only a receipt or a producer 403 says.

Limits

Each account gets at least 50 requests a second and 16 open tails at once in each region, for its streams and databases together, past which a request answers 429 with Retry-After. A request body is at most 8 MiB, as HEAD reports in Ursula-Max-Request-Bytes, and one read answer at most 8 MiB. Every limit is on Limits.

Versions

Tonbo runs the Ursula 0.11 line, which every answer from the server names in Ursula-Protocol. Releases of one line keep the protocol; an answer may gain headers and an error body may gain codes and members, which a client ignores. This reference follows the specifications at v0.11.3.