Append

bucketpathstringrequired

Bucket ID.

streampathstringrequired

Stream ID within the bucket.

Content-Typeheaderstringrequired

Must match the stream's content type (set when the stream was created with PUT) after normalization: parameters are trimmed, lowercased and joined with ; . Required when the body is non-empty. A mismatch returns 409.

Stream-Closedheaderstring

Set to true to close the stream after this append.

Stream-Seqheaderstring

A compare-and-set token. The append is accepted only when the value is bytewise greater than the last Stream-Seq accepted on this stream; it is then stored. An equal or smaller value answers 409 STREAM_SEQ_CONFLICT with Stream-Next-Offset at the tail and Stream-Last-Seq, the stream's last accepted value. Without the header the append is not checked and the stored value is unchanged. Printable ASCII, at most 256 bytes. See how Stream-Seq is checked.

Producer-Idheaderstring

Stable producer identity (UUID, hostname, etc.) for exactly-once writes. Dedup state is per-stream. At most 256 bytes.

Producer-Epochheaderstring

Producer epoch. Bumped on producer restart. Must be ≥ the last epoch the server accepted for this Producer-Id. A new epoch resets the seq counter. Max value 2^53 − 1.

Producer-Seqheaderstring

Producer sequence number. Starts at 0 for a new epoch and must increase by exactly 1 per append. Exact (epoch, seq) duplicates are silently deduplicated. Max value 2^53 − 1.

Producer-Digestheaderstring

Optional, with producer headers: the BLAKE3-256 hash of the body as 64 lowercase hex digits. The server checks it against the body (400 on a mismatch, nothing appended) and makes it part of the write's identity, so a different body under an applied key answers 403 with Producer-Outcome: key-conflict. See write keys.

Stream-Incarnationheaderstring

Optional precondition: the stream's incarnation token, as a previous response's Stream-Incarnation header returned it. The request applies only if the stream is still that incarnation, checked atomically with the write; otherwise it answers 412 and changes nothing. Without the header, the request behaves exactly as described here. A malformed value answers 400. See Stream-Incarnation.

bodybodybinaryrequired

The bytes to append. Must not be empty unless Stream-Closed: true is set (close-only request).

Response

StatusMeaning
204Append successful (default success response, no body).
200Append successful with body - returned when a Producer-Id was supplied and the append was not deduplicated, so the response carries producer ack headers.
307In a cluster, this node does not lead or host the stream's group. Location is the same request on the leader, or on a node that hosts the group. The node answers before it reads the body, and a redirect to the leader carries x-ursula-raft-leader-id, x-ursula-raft-group and x-ursula-raft-term. A redirect to a node that hosts the group carries x-ursula-raft-group alone. See routing answers.
400Empty body without Stream-Closed: true, missing content type, invalid JSON or UTF-8, a JSON message nested deeper than 127 levels, a Producer-Id or Stream-Seq longer than 256 bytes, a Producer-Digest that does not match the body, a Producer-Seq above 0 of a later epoch that is not claimed yet (with Producer-Expected-Seq: 0), or a header removed in 0.6.0 (see the release notes).
403A producer refusal. The response carries Producer-Epoch and one Producer-Outcome: epoch-closed;by=claim or stale-epoch for a write of an older epoch, key-conflict for another body under an applied key, or producer-expired for a write with Stream-Incarnation that could reach an epoch the stream forgot (at or below its epoch floor). See producer write answers.
404Stream not found.
409Content type does not match the stream (CONTENT_TYPE_MISMATCH), stream is already closed (STREAM_CLOSED, with Stream-Closed, also for a close with producer headers that is not a repeat of the producer's own close), Stream-Seq not greater than the stored value (STREAM_SEQ_CONFLICT, with Stream-Last-Seq naming the stored value), or Producer-Seq out of order (PRODUCER_SEQ_CONFLICT, with Producer-Expected-Seq).
412Stream-Incarnation did not match: the stream was deleted and recreated. Nothing was appended; the response's Stream-Incarnation is the current token.
413Body larger than 32 MiB. Ursula-Max-Request-Bytes names the limit, and a gateway with a smaller limit names its own.
503Write admission refused the append (WAL_DISK_PRESSURE, RAFT_LOG_PRESSURE or INGRESS_BODY_BYTES_LIMIT_REACHED), or the stream's Raft group refused it with cold-write or uncommitted-bytes backpressure (COLD_BACKPRESSURE or RAFT_UNCOMMITTED_BACKPRESSURE). A node also answers 503 NoLeader when it knows no leader for the group yet (with x-ursula-raft-group), when it knows no node that hosts the group, and when it lost the group's leadership while it read the body. Retry after the duration in Retry-After.

Every error answer carries a JSON body with its code and a human-readable message, such as {"error":{"code":"STREAM_CLOSED","message":"..."}}. See errors.

Any answer other than a 2xx or a producer refusal of extensions Section 5.3 leaves the outcome unknown: repeat the same write. See write outcomes.

Response headers include Stream-Incarnation and Stream-Next-Offset, except on a repeat of a producer's older write, which answers 204 with Producer-Outcome: receipt-evicted and no range (exactly-once writes). When a Producer-Id was supplied, the response carries Producer-Epoch, Producer-Seq (the epoch's highest applied seq) and Stream-Offset, where the write's bytes start, so the producer can confirm what was durably recorded. A claim that closed an earlier epoch also carries Producer-Closed-Epoch and Producer-Closed-Seq. Stream-Closed: true is set if this request closed the stream. ETag is set on reads only, not on appends.

curl -X POST http://127.0.0.1:4437/demo/hello \
  -H 'Content-Type: application/octet-stream' \
  --data-binary 'hello world'
curl -i -X POST http://127.0.0.1:4437/demo/hello \
  -H 'Content-Type: application/json' \
  --data-binary '{"captured_at":"2026-07-18T10:30:00.100Z","event":"click"}'
curl -i -X POST http://127.0.0.1:4437/demo/hello \
  -H 'Content-Type: application/json' \
  --data-binary '[{"event":"click"},{"event":"navigation"}]'
curl -X POST http://127.0.0.1:4437/demo/hello \
  -H 'Stream-Closed: true'

JSON message text

Ursula validates every JSON body and stores each message as you wrote it, minus insignificant whitespace (spaces, tabs and line breaks outside strings), followed by one LF. Member order, duplicate members, number spelling such as 1.50e3 or 1e400, and string escapes, including escapes of unpaired surrogates, are kept byte for byte, and every read returns that text. A top-level array is split into one message per element. A message may nest at most 127 levels. See JSON message text. Releases before this rule re-serialized messages; see the release notes.

To append only if no other write has committed since you last read the stream, see conditional writes.