Append
Last updated 29 October 2026
Append messages to a stream from any program, exactly once even when it retries, and close the stream when its writer is done.
A stream speaks the open Durable Streams protocol at its URL, so any HTTP client works, and a client library of the protocol can append and read with a Tonbo key. 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 stream named events is at:
https://streams.tonbo.dev/<account-id>/s.eventsIts last segment is the stream’s name after s., and the segment before it is your account’s id. Every request carries the key, or a token made from it, as Authorization: Bearer, unless the stream is public and the request only reads (see Access). Reading the stream is on Read and tail, and summarizing it on Snapshots and retention.
Content types and JSON mode
A stream’s content type is chosen when it is created, application/octet-stream when none is named, and never changes. Every append sends exactly it as Content-Type; any other answers 409 with the code CONTENT_TYPE_MISMATCH and appends nothing.
A stream of application/json is in JSON mode. Each append must be valid JSON, and Tonbo keeps each message as you wrote it, without insignificant whitespace; a body that is a JSON array appends one message per element (see JSON message text). Reads return the messages as newline-delimited JSON, application/x-ndjson, one message per line. Any other content type is bytes: a read returns them as they were appended.
Append
POST to the stream’s URL appends the body. Send each append with a producer id, an epoch and a sequence number, so a retry never appends it twice:
curl -sS -i -X POST "$STREAM" \
-H "Authorization: Bearer $TONBO_API_KEY" \
-H "Content-Type: application/json" \
-H "Producer-Id: quickstart" \
-H "Producer-Epoch: 0" \
-H "Producer-Seq: 0" \
-d '{"n":0}'Producer-Idnames the writer, such as a host or a job; it is kept per stream.Producer-Seqstarts at0and goes up by exactly one per append.Producer-Epochgoes up when the writer restarts and has lost its count; a new epoch starts its sequence at0again. Keep the epoch where the writer keeps its state and add one at each restart: it must never be below the last epoch the stream saw from that producer.
The next append in sequence answers 200 with Stream-Offset, where it starts, and Stream-Next-Offset, where it ends. The same append sent again, after a timeout or a 5xx, answers 204 with the same two offsets and appends nothing, so when an answer is lost, send the same request again. A sequence number that skips ahead answers 409, and a write of an older epoch 403: a newer instance of the writer has taken its place, and this one should stop. Ursula, the open-source server Tonbo runs streams on, documents every case in exactly-once writes and producer write answers. A writer that has not appended for 7 days is forgotten, and its next append starts a new sequence. What happens to an append says, answer by answer, whether it applied.
Batch messages
A JSON array appends all its elements in one request, one message each, in order. An append costs one write per 10 KiB of its body and at least one, so a hundred small messages in one append cost one write where a hundred appends cost a hundred (see the worked example), and they count once against the account’s requests per second. Each body is at most 8 MiB (see Limits).
Without producer headers
An append without producer headers answers 204 and is not deduplicated, which suits writers whose repeats do no harm. To append only when nothing else has been appended since you last read, use Stream-Seq (see conditional writes). The append reference lists every header and answer.
Close
An append with Stream-Closed: true closes the stream, with its body as the last message or with no body at all:
curl -sS -i -X POST "$STREAM" \
-H "Authorization: Bearer $TONBO_API_KEY" \
-H "Stream-Closed: true"It answers 204 with Stream-Closed: true and the end of the stream as Stream-Next-Offset. Closing cannot be undone. The stream keeps what it holds, stays readable and still counts toward the account until it is deleted, and every later append answers 409 with Stream-Closed: true. Readers at the end learn it at once (see A closed stream).
When an append is refused
The table says, for each refusal an append can get, whether to send it again. With producer headers, the same request sent again never applies twice, so when in doubt, send it again unchanged. 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). |
402 PAYMENT_REQUIRED | The account’s balance is zero. Appends pause; reads and tails continue. | After a top-up | Add credit on Usage & billing, and send the append again (When the balance runs out). |
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). |
403 with Producer-Outcome | The append’s producer epoch is older than one the stream has seen: a newer instance of the writer took over. | No | Stop this writer. A restarted writer moves to a higher epoch from sequence 0 (producer write answers). |
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. |
409 CONTENT_TYPE_MISMATCH | The append’s Content-Type is not the stream’s. | With the stream’s content type | Send exactly the content type tonbo stream show prints (see Content types). |
409 with Stream-Closed: true | The stream is closed, and closing is final. | No | Append to another stream; this one stays readable until it is deleted. |
409 PRODUCER_SEQ_CONFLICT | The append’s Producer-Seq skips ahead of the next one the stream expects. | With the expected number | Send the sequence number its Producer-Expected-Seq names. |
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. |
413 | The body is over the request body limit. | No | Split it into several (see Limits); a snapshot goes up in parts. |
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. |