Read and tail
Last updated 29 October 2026
Read a stream from any offset, catch up to its end, and wait there for what is appended next with a long-poll or Server-Sent Events.
Any number of readers read one stream, each from its own offset, and reads are free. The examples use curl with the stream’s URL in STREAM and an API key in TONBO_API_KEY, as the quickstart sets them; a public stream needs no key (see Public streams).
Read
GET with an offset reads from that offset to the end:
curl -sS -i "$STREAM?offset=-1" \
-H "Authorization: Bearer $TONBO_API_KEY"| Offset | Reads from |
|---|---|
-1 | The start of the stream. |
A Stream-Next-Offset | Where an earlier read or append ended: what came after it. |
now | The current end, for a tail that wants only new messages. |
A read answers 200 with what it found and Stream-Next-Offset, the offset to read from next; with Stream-Up-To-Date: true it reached the end. One answer carries at most 8 MiB: an answer without Stream-Up-To-Date stopped short, so read again from its Stream-Next-Offset. Offsets are opaque strings that sort in stream order; keep them as they are (see offsets). A read normally comes from the replica that answers; add consistency=leader to see every append acknowledged before it (see leader consistency). The read reference has every parameter and answer of a read.
Tail
A read at the end with live waits for the next append. A long-poll answers once it has something:
curl -sS -i "$STREAM?offset=$NEXT&live=long-poll&timeout_ms=30000" \
-H "Authorization: Bearer $TONBO_API_KEY"It answers 200 with what was appended, or 204 when timeout_ms passes first (1,000 by default, at most 60,000); read again from its Stream-Next-Offset. A Server-Sent Events read stays open instead:
curl -sS -N "$STREAM?offset=$NEXT&live=sse" \
-H "Authorization: Bearer $TONBO_API_KEY" \
-H "Accept: text/event-stream"It sends an event: data with what was appended, then an event: control whose JSON names streamNextOffset. A data event’s lines are transport framing, not message boundaries: a JSON client joins them and splits the messages at each newline. A binary stream’s data comes base64-encoded (see binary SSE). The server sends no heartbeats, so a proxy may close a quiet connection: open it again from the last streamNextOffset. The SSE event format has the details. Each open tail counts toward the account’s live reads in the region (see Limits).
To a web page
A page should never hold an API key. Either your server mints the page a read-only token for only the streams it shows, which the page tails directly (see A token for a web page), or your server holds one tail and passes each message on to the pages that watch, reopening the tail from its last offset when it drops, and catching up a page that reconnects with a read from the last message it showed. The second keeps one open tail however many pages watch; Live progress to a web UI walks through it. A public stream needs no token, so a page reads it directly.
A closed stream
Once its writer has closed a stream, a reader at the end learns it at once: a read answers 200 with nothing and Stream-Closed: true, a long-poll 204 with Stream-Closed: true without waiting, and an SSE read a control event with streamClosed: true, after which the server ends the connection; a HEAD answers Stream-Closed: true too. Everything before the end stays readable.
HEAD
HEAD reads a stream’s state without its contents:
curl -sS -I "$STREAM" \
-H "Authorization: Bearer $TONBO_API_KEY"It answers 200, or 404 for a stream that does not exist, and it sees every append acknowledged before it was sent. Its headers, of which the HEAD reference lists a few more:
| Header | Meaning |
|---|---|
Content-Type | The stream’s content type, which every append sends. |
Stream-Next-Offset | The end of the stream: where the next append starts. |
Stream-Closed | true once the stream is closed; absent before. |
Stream-Retained-Offset | The earliest offset still readable; reads before it answer 410. It is the start of the stream until retention advances. |
Stream-Snapshot-Offset, Stream-Snapshot-Digest | The offset and digest of the latest snapshot; absent when there is none. |
Stream-Incarnation | Which stream answered: it changes when the stream is deleted and created again (see below). |
Ursula-Max-Request-Bytes | The largest request body the stream accepts. |
A stream deleted and created again
A stream deleted and then created again under the same name is a new stream at the same URL, with new offsets. Each answer carries Stream-Incarnation, which changes when that happens, and tonbo stream show prints it as Incarnation. Send it back as a request header, and the request applies only to that stream: once the stream has been replaced it answers 412 and changes nothing (see guarding against a recreated stream).
Reads you can cache
A stream’s history before its end, and its latest snapshot, never change. Two reads name them by URLs that stay valid for as long as the stream does, with the stream’s Stream-Incarnation in the path, kept in INCARNATION:
curl -sS -i "$STREAM/incarnation/$INCARNATION/chunk/$OFFSET" \
-H "Authorization: Bearer $TONBO_API_KEY"
curl -sS -i "$STREAM/incarnation/$INCARNATION/snapshot/$OFFSET" \
-H "Authorization: Bearer $TONBO_API_KEY"The first reads the stream from OFFSET to the next 4 MiB boundary, or to its end, with Stream-Next-Offset for the next chunk; the second reads the latest snapshot at its offset. An answer that can never change carries Stream-Immutable: true; a chunk that holds the open end of the stream does not, so read on from there with an ordinary read. Use them when your program keeps its own cache of what it read: every reader of a stream asks for the same URLs, and an answer with the marker can be kept for good. An answer to a request with a key or token is never marked for a shared cache, so only your program keeps it. A stream deleted and created again has another incarnation, so an old URL answers 412 instead of returning the old stream’s bytes (see immutable reads).
When a read is refused
The table says, for each refusal a read can get, whether to send it again; a read changes nothing, so sending it again is always safe. What readers see says what a read promises, and Troubleshooting lists every refusal.
| Answer | When | Send again | What to do |
|---|---|---|---|
401 | The key or token is missing, expired or revoked, or the request sent a key that acts as an Agent, which answers the code exchange_required. | With a valid key or a new token | Send a valid key, or exchange the key for a new token, as Authorization: Bearer. An Agent’s key is always exchanged first (see A short-lived token). |
403 | The key is narrowed and does not reach this stream, or is read-only and the request appends. | No | Use a key that reaches the stream, or one that is not read-only (see Narrow a key to streams). |
404 | The stream does not exist yet, is still being created, or was deleted or expired; a request with a missing or expired token may answer the same. | Once it is active | Check it with tonbo stream show <name>, create it first, and wait until it is active. |
410 | The offset is before the stream’s retention, which a snapshot replaced. | From the answer’s offset | Read the snapshot, or read from the answer’s Stream-Next-Offset, the earliest offset still readable. |
412 | The request carried Stream-Incarnation, and the stream was deleted and created again since. | No | Treat it as a new stream: read it from the start, with the answer’s Stream-Incarnation. |
429 | The account sent more requests per second, or held more tails open at once, than its region allows. | After Retry-After | Wait as long as Retry-After says, then send it again. |
503 | The region cannot take the request, or check an API key sent directly, for a moment. | Unchanged, after Retry-After | Wait as long as Retry-After says, then send the same request again; with producer headers, a repeated append applies once. |