Streams HTTP API
Last updated 2 November 2026
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
| Operation | Request | Page |
|---|---|---|
| Append | POST {stream} | Append and close |
| Close | POST {stream} with Stream-Closed: true | Append and close |
| Read | GET {stream}?offset=… | Read, tail and HEAD |
| Long-poll | GET {stream}?offset=…&live=long-poll | Read, tail and HEAD |
| Tail with Server-Sent Events | GET {stream}?offset=…&live=sse | Read, tail and HEAD |
| HEAD | HEAD {stream} | Read, tail and HEAD |
| Read a cacheable chunk | GET {stream}/incarnation/{token}/chunk/{offset} | Read, tail and HEAD |
| Read the snapshot at a cacheable URL | GET {stream}/incarnation/{token}/snapshot/{snapshot_offset} | Read, tail and HEAD |
| Publish a snapshot | PUT {stream}/snapshot/{snapshot_offset} | Snapshots and bootstrap |
| Stage a part of a snapshot | POST {stream}/snapshot/{snapshot_offset}/parts | Snapshots and bootstrap |
| Publish a snapshot from its parts | PUT {stream}/snapshot/{snapshot_offset} with Stream-Snapshot-Parts | Snapshots and bootstrap |
| Discard staged parts | DELETE {stream}/snapshot/parts | Snapshots and bootstrap |
| Read a snapshot | GET {stream}/snapshot/{snapshot_offset} | Snapshots and bootstrap |
| Advance retention | PUT {stream}/retention/{retained_offset} | Snapshots and bootstrap |
| Bootstrap | GET {stream}/bootstrap | Snapshots 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
| Parameter | Value | Required | What it does |
|---|---|---|---|
Authorization header | Bearer and a key or token | Required, except to read a public stream | An 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 header | token | Optional | Applies 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) |
| Field | Value | When | What it means |
|---|---|---|---|
Stream-Incarnation header | token | On every success, and on 404, 410 and 416 of a read when the stream exists | Which 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 header | integer | On the server's own answers | The 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 header | 1 to 64 of A-Z a-z 0-9 . _ - | On the server's own answers | The 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 header | directive | On every answer | no-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 header | seconds | On 429 and 503 | How 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’s | At 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:
| Status | Codes |
|---|---|
400 | INVALID_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 |
401 | INVALID_CREDENTIAL, CREDENTIAL_EXPIRED |
402 | PAYMENT_REQUIRED |
403 | PRODUCER_EPOCH_CLOSED, PRODUCER_EPOCH_STALE, PRODUCER_EXPIRED, PRODUCER_KEY_CONFLICT, FORBIDDEN, ACCOUNT_REVOKED |
404 | STREAM_NOT_FOUND, BUCKET_NOT_FOUND, BUCKET_ERASED, SNAPSHOT_NOT_FOUND, NOT_FOUND |
405 | METHOD_NOT_ALLOWED, UNSUPPORTED |
408 | BODY_TOO_SLOW |
409 | STREAM_CLOSED, STREAM_SEQ_CONFLICT, CONTENT_TYPE_MISMATCH, STREAM_ALREADY_EXISTS_CONFLICT, PRODUCER_SEQ_CONFLICT, SNAPSHOT_CONFLICT, IMPORT_CONFLICT |
410 | STREAM_GONE |
412 | INCARNATION_MISMATCH |
413 | REQUEST_TOO_LARGE, SNAPSHOT_TOO_LARGE |
416 | OFFSET_OUT_OF_RANGE, RANGE_NOT_SATISFIABLE |
429 | PRODUCER_LIMIT, STAGED_PARTS_LIMIT, RATE_LIMITED, LIVE_READS_EXHAUSTED, UPLOADS_EXHAUSTED, UPLOAD_BYTES_EXHAUSTED |
500 | INTERNAL, CREATED_AT_OUT_OF_RANGE, HISTORY_MISMATCH |
501 | NO_COLD_STORE |
502 | COLD_STORE_FAILED, UPSTREAM_FAILED |
503 | NO_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 |
504 | UPSTREAM_TIMEOUT |
Write outcomes
Whether a write applied, when its answer leaves doubt, follows from its answer alone (extensions 11.1):
- A
2xxwith the write’s typed answer applied it: a producer write’sStream-OffsetandStream-Next-Offset, an append’s or a close’sStream-Next-Offset, a snapshot’sStream-Snapshot-Digest, a part’sStream-Snapshot-Part, retention’sStream-Retained-Offset. A2xxwithout it says nothing. - A
4xxother than408refused it: nothing of it applied, and nothing will. - A
408or a5xxrefused 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.
| Status | Codes that prove nothing applied |
|---|---|
408 | BODY_TOO_SLOW |
501 | NO_COLD_STORE |
503 | NO_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.