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

# 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](https://tonbo.dev/docs/getting-started/quickstarts/stream.md#key) sets them. A stream named `events` is at:

```text
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](https://tonbo.dev/docs/streams/access.md)). Reading the stream is on [Read and tail](https://tonbo.dev/docs/streams/read.md), and summarizing it on [Snapshots and retention](https://tonbo.dev/docs/streams/snapshots.md).

## 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](https://ursula.tonbo.io/docs/api/append/#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:

```shell
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](https://ursula.tonbo.io/docs/concepts/exactly-once-writes/) and [producer write answers](https://ursula.tonbo.io/docs/specs/extensions/#53-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](https://tonbo.dev/docs/streams/guarantees.md#answers) 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](https://tonbo.dev/docs/streams/pricing.md#batch-messages)), and they count once against the account’s requests per second. Each body is at most 8 MiB (see [Limits](https://tonbo.dev/docs/streams/limits.md)).

## 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](https://ursula.tonbo.io/docs/concepts/conditional-writes/)). The [append reference](https://ursula.tonbo.io/docs/api/append/) 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:

```shell
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](https://tonbo.dev/docs/streams/read.md#closed)).

## 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](https://tonbo.dev/docs/streams/troubleshooting.md) 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](https://tonbo.dev/docs/streams/access.md#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](https://tonbo.dev/billing), and send the append again ([When the balance runs out](https://tonbo.dev/docs/databases/pricing.md#zero-balance)). |
| `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](https://tonbo.dev/docs/streams/access.md#narrow)). |
| `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](https://ursula.tonbo.io/docs/specs/extensions/#53-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](https://tonbo.dev/docs/streams/append.md#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](https://tonbo.dev/docs/streams/limits.md)); 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. |
