Read and tail

Last updated 29 October 2026

View .md

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"
OffsetReads from
-1The start of the stream.
A Stream-Next-OffsetWhere an earlier read or append ended: what came after it.
nowThe 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 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:

HeaderMeaning
Content-TypeThe stream’s content type, which every append sends.
Stream-Next-OffsetThe end of the stream: where the next append starts.
Stream-Closedtrue once the stream is closed; absent before.
Stream-Retained-OffsetThe earliest offset still readable; reads before it answer 410. It is the start of the stream until retention advances.
Stream-Snapshot-Offset, Stream-Snapshot-DigestThe offset and digest of the latest snapshot; absent when there is none.
Stream-IncarnationWhich stream answered: it changes when the stream is deleted and created again (see below).
Ursula-Max-Request-BytesThe 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.

AnswerWhenSend againWhat to do
401The 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 tokenSend 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).
403The key is narrowed and does not reach this stream, or is read-only and the request appends.NoUse a key that reaches the stream, or one that is not read-only (see Narrow a key to streams).
404The 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 activeCheck it with tonbo stream show <name>, create it first, and wait until it is active.
410The offset is before the stream’s retention, which a snapshot replaced.From the answer’s offsetRead the snapshot, or read from the answer’s Stream-Next-Offset, the earliest offset still readable.
412The request carried Stream-Incarnation, and the stream was deleted and created again since.NoTreat it as a new stream: read it from the start, with the answer’s Stream-Incarnation.
429The account sent more requests per second, or held more tails open at once, than its region allows.After Retry-AfterWait as long as Retry-After says, then send it again.
503The region cannot take the request, or check an API key sent directly, for a moment.Unchanged, after Retry-AfterWait as long as Retry-After says, then send the same request again; with producer headers, a repeated append applies once.