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 with Stream-Next-Offset at the tail. 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.

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. When the node raised the redirect before it proposed the append, the response carries x-ursula-not-applied (NotLeader or GroupNotHosted).
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, or a header removed in 0.6.0 (see the release notes).
403Producer-Epoch is older than the last epoch accepted for this Producer-Id. The response's Producer-Epoch is the current epoch.
404Stream not found.
409Content type does not match the stream, stream is already closed, Stream-Seq not greater than the stored value, or Producer-Seq out of order.
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.
503Write admission refused the append (the JSON error and x-ursula-not-applied name WalDiskPressure, RaftLogPressure or IngressBodyBytesLimitReached), or the stream's Raft group refused it with cold-write or uncommitted-bytes backpressure (x-ursula-not-applied names ColdBackpressure or RaftUncommittedBackpressure). A node also answers 503 when it knows no leader for the group yet (NotLeader) and when it knows no node that hosts the group (GroupNotHosted). When it found that before it proposed the append, the response carries x-ursula-not-applied with that name. Retry after the duration in Retry-After.

A response with both x-ursula-not-applied and x-ursula-not-applied-checked, the vouch that a gateway at 0.7.3 or newer adds, proves that this attempt appended nothing, whatever the status. The headers say nothing about earlier attempts, and the marker alone proves nothing. See refusals that applied nothing.

Response headers include Stream-Incarnation and Stream-Next-Offset, except on a duplicate whose receipt has left the stream's receipt window (exactly-once writes). When a Producer-Id was supplied, the server echoes the accepted Producer-Epoch and Producer-Seq so the producer can confirm what was durably recorded. 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.