> Fetch the index of every Tonbo docs page as Markdown at https://tonbo.dev/llms.txt

# 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](https://ursula.tonbo.io/docs/specs/durable-stream/), [extensions](https://ursula.tonbo.io/docs/specs/extensions/)). Each row below cites the section it rests on. Creating, listing and deleting streams is the [Management API](https://tonbo.dev/docs/reference/management.md#create-stream)’s, not the stream URL’s ([below](#management)).

## Operations

| Operation | Request | Page |
| --- | --- | --- |
| [Append](https://tonbo.dev/docs/reference/streams/append.md#append) | `POST {stream}` | Append and close |
| [Close](https://tonbo.dev/docs/reference/streams/append.md#close) | `POST {stream} with Stream-Closed: true` | Append and close |
| [Read](https://tonbo.dev/docs/reference/streams/read.md#read) | `GET {stream}?offset=…` | Read, tail and HEAD |
| [Long-poll](https://tonbo.dev/docs/reference/streams/read.md#long-poll) | `GET {stream}?offset=…&live=long-poll` | Read, tail and HEAD |
| [Tail with Server-Sent Events](https://tonbo.dev/docs/reference/streams/read.md#sse) | `GET {stream}?offset=…&live=sse` | Read, tail and HEAD |
| [HEAD](https://tonbo.dev/docs/reference/streams/read.md#head) | `HEAD {stream}` | Read, tail and HEAD |
| [Read a cacheable chunk](https://tonbo.dev/docs/reference/streams/read.md#immutable-chunk) | `GET {stream}/incarnation/{token}/chunk/{offset}` | Read, tail and HEAD |
| [Read the snapshot at a cacheable URL](https://tonbo.dev/docs/reference/streams/read.md#immutable-snapshot) | `GET {stream}/incarnation/{token}/snapshot/{snapshot_offset}` | Read, tail and HEAD |
| [Publish a snapshot](https://tonbo.dev/docs/reference/streams/snapshots.md#publish-snapshot) | `PUT {stream}/snapshot/{snapshot_offset}` | Snapshots and bootstrap |
| [Stage a part of a snapshot](https://tonbo.dev/docs/reference/streams/snapshots.md#stage-part) | `POST {stream}/snapshot/{snapshot_offset}/parts` | Snapshots and bootstrap |
| [Publish a snapshot from its parts](https://tonbo.dev/docs/reference/streams/snapshots.md#commit-parts) | `PUT {stream}/snapshot/{snapshot_offset} with Stream-Snapshot-Parts` | Snapshots and bootstrap |
| [Discard staged parts](https://tonbo.dev/docs/reference/streams/snapshots.md#discard-parts) | `DELETE {stream}/snapshot/parts` | Snapshots and bootstrap |
| [Read a snapshot](https://tonbo.dev/docs/reference/streams/snapshots.md#read-snapshot) | `GET {stream}/snapshot/{snapshot_offset}` | Snapshots and bootstrap |
| [Advance retention](https://tonbo.dev/docs/reference/streams/snapshots.md#retention) | `PUT {stream}/retention/{retained_offset}` | Snapshots and bootstrap |
| [Bootstrap](https://tonbo.dev/docs/reference/streams/snapshots.md#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](https://tonbo.dev/docs/reference/management.md#get-stream)) and `tonbo stream show` prints:

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

The origin is the Streams origin of the stream’s region ([Regions and URLs](https://tonbo.dev/docs/streams/manage.md#regions)), 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](https://tonbo.dev/docs/platform/api-keys.md#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](https://tonbo.dev/docs/streams/access.md#public) 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`](https://tonbo.dev/docs/reference/streams/read.md#sse-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](https://tonbo.dev/docs/platform/api-keys.md#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](https://tonbo.dev/docs/streams/access.md)). |
| `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](https://ursula.tonbo.io/docs/specs/extensions/#24-snapshot-metadata-on-head-and-stream-incarnation)) |

| 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](https://ursula.tonbo.io/docs/specs/extensions/#24-snapshot-metadata-on-head-and-stream-incarnation)) |
| `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](https://ursula.tonbo.io/docs/specs/extensions/#10-server-metadata)) |
| `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](https://ursula.tonbo.io/docs/specs/extensions/#10-server-metadata)) |
| `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](https://tonbo.dev/docs/reference/streams/read.md#immutable-chunk) marked `Stream-Immutable`, which is `public, max-age=300, immutable`. ([protocol 8.1](https://ursula.tonbo.io/docs/specs/durable-stream/#81-catch-up-and-long-poll-reads)) |
| `Retry-After` header | seconds | On 429 and 503 | How long to wait before sending the request again. ([extensions 11](https://ursula.tonbo.io/docs/specs/extensions/#11-error-bodies)) |

## 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](https://ursula.tonbo.io/docs/specs/durable-stream/#7-content-types), [extensions JSON Message Text](https://ursula.tonbo.io/docs/specs/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](https://ursula.tonbo.io/docs/specs/durable-stream/#6-offsets), [extensions 8](https://ursula.tonbo.io/docs/specs/extensions/#8-url-encoding)). An offset means nothing to a stream deleted and created again, so keep the stream’s [`Stream-Incarnation`](#header-stream-incarnation-response) 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](https://tonbo.dev/docs/reference/management.md), 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](https://ursula.tonbo.io/docs/specs/durable-stream/#51-create-stream)) | `403` with the code `STREAM_LIFECYCLE` ([Troubleshooting](https://tonbo.dev/docs/streams/troubleshooting/stream.md#lifecycle)). Create the stream with [`POST /v1/streams`](https://tonbo.dev/docs/reference/management.md#create-stream), which names its content type, region and expiry, then append to its URL ([Create a stream](https://tonbo.dev/docs/streams/manage.md#create)). |
| `PUT {stream}` with `Stream-Closed: true` ([protocol 5.1](https://ursula.tonbo.io/docs/specs/durable-stream/#51-create-stream)) | Create the stream, then [close](https://tonbo.dev/docs/reference/streams/append.md#close) it. |
| `Stream-TTL` ([protocol 5.1](https://ursula.tonbo.io/docs/specs/durable-stream/#51-create-stream)) | 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](https://ursula.tonbo.io/docs/specs/durable-stream/#51-create-stream)) | `expires_at` of [`POST /v1/streams`](https://tonbo.dev/docs/reference/management.md#create-stream). `HEAD` sends neither header: [`GET /v1/streams/{name}`](https://tonbo.dev/docs/reference/management.md#get-stream) shows the expiry. |
| `DELETE {stream}` ([protocol 5.4](https://ursula.tonbo.io/docs/specs/durable-stream/#54-delete-stream)) | `403` with the code `STREAM_LIFECYCLE`. Delete the stream with [`DELETE /v1/streams/{name}`](https://tonbo.dev/docs/reference/management.md#delete-stream) or `tonbo stream delete`. |

## Error codes

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

```json
{"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](https://ursula.tonbo.io/docs/specs/extensions/#11-error-bodies)), of which [Troubleshooting](https://tonbo.dev/docs/streams/troubleshooting/stream.md) 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](https://ursula.tonbo.io/docs/specs/extensions/#111-write-outcomes)):

- 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.

| 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](https://tonbo.dev/docs/streams/limits.md).

## 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](https://github.com/tonbo-io/ursula/tree/v0.11.3).
