Append

Last updated 29 October 2026

View .md

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

Its 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-Id names the writer, such as a host or a job; it is kept per stream.
  • Producer-Seq starts at 0 and goes up by exactly one per append.
  • Producer-Epoch goes up when the writer restarts and has lost its count; a new epoch starts its sequence at 0 again. 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.

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).
402 PAYMENT_REQUIREDThe account’s balance is zero. Appends pause; reads and tails continue.After a top-upAdd credit on Usage & billing, and send the append again (When the balance runs out).
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).
403 with Producer-OutcomeThe append’s producer epoch is older than one the stream has seen: a newer instance of the writer took over.NoStop this writer. A restarted writer moves to a higher epoch from sequence 0 (producer write answers).
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.
409 CONTENT_TYPE_MISMATCHThe append’s Content-Type is not the stream’s.With the stream’s content typeSend exactly the content type tonbo stream show prints (see Content types).
409 with Stream-Closed: trueThe stream is closed, and closing is final.NoAppend to another stream; this one stays readable until it is deleted.
409 PRODUCER_SEQ_CONFLICTThe append’s Producer-Seq skips ahead of the next one the stream expects.With the expected numberSend the sequence number its Producer-Expected-Seq names.
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.
413The body is over the request body limit.NoSplit it into several (see Limits); a snapshot goes up in parts.
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.