Publishing
Publishing appends a batch to a stream at a sequence number you choose. Choosing the sequence is what gives you idempotency for free. This page covers the primary write path, the convenience path, raw binary mode, limits, and batching.
The primary path: explicit sequence
Put the sequence in the URL. It must be the current cursor + 1 to extend
the stream, or any value at or below the cursor to retry an earlier write.
curl https://runnev.dev/v1/streams/$STREAM/41823 \
-H "Authorization: Bearer $RUNNEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{"events":[
{"type":"order.paid","id":"o_5521","amount":1999},
{"type":"order.paid","id":"o_5522","amount":4500}
]}'
{"stream_id":"cBczepiZSW8GJaCae0xIj7","seq":41823,"accepted":2,"cursor":41823,"duplicate":false}
The response also carries the cursor in a header, so you can advance your own state without parsing the body:
x-runnev-cursor: 41823
x-request-id: req_0Kj2wq8ULn4mAe1s
Why explicit sequences give you idempotency
A write is keyed on (stream_id, seq). If a publish times out and you do
not know whether it landed, resend the exact same request. If the first one had
succeeded, the retry returns 200 with "duplicate": true and
stores nothing new. If it had not, the retry stores it as normal and returns
202. Either way you end with exactly one copy, and you never had to ask
"did that go through?".
{"stream_id":"cBczepiZSW8GJaCae0xIj7","seq":41823,"accepted":0,"cursor":41830,"duplicate":true}
This is the recommended pattern for any producer that must not double-write: derive the sequence from your own monotonic source (a database row version, an offset, a counter) and let retries be safe by construction.
The sequence window
You cannot leave holes. A sequence more than 1024 ahead of the current cursor is rejected so that a bug cannot strand a stream at an unreachable head:
{
"error": {
"type": "invalid_request_error",
"code": "sequence_too_far_ahead",
"message": "seq 45000 is more than 1024 ahead of the current cursor 41823.",
"request_id": "req_1Ab2cd3EFgh4Ij5k",
"doc_url": "https://runnev.dev/docs/errors#sequence_too_far_ahead"
}
}
A sequence at or below the cursor is treated as a possible retry: identical bytes are
a no-op duplicate, different bytes for an existing sequence are rejected with
invalid_sequence rather than silently overwriting history.
The convenience path: server-assigned sequence
When you do not have a natural sequence source and a single writer is publishing,
POST /v1/streams/{id}/publish lets the server assign the next sequence.
curl https://runnev.dev/v1/streams/$STREAM/publish \
-H "Authorization: Bearer $RUNNEV_API_KEY" \
-d '{"events":[{"type":"tick"}]}'
# 202 {"stream_id":"...","seq":41824,"accepted":1,"cursor":41824,"duplicate":false}
Because the server picks the sequence, a retried publish after an
uncertain timeout can create a second batch. Send an Idempotency-Key
header to make a retry safe, or use the explicit-sequence path when you have a
natural sequence source.
Raw binary batches
For a stream created with "mode":"raw", publish an
application/octet-stream body. Runnev stores the bytes verbatim and never
parses them; bring protobuf, msgpack, Avro, CBOR, or a framing of your own.
curl https://runnev.dev/v1/streams/$STREAM/9001 \
-H "Authorization: Bearer $RUNNEV_API_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary @order.pb
# 202 {"stream_id":"...","seq":9001,"accepted":1,"cursor":9001,"duplicate":false}
On the subscribe side, raw batches arrive with their bytes base64-encoded in the SSE
data field; a direct GET of a batch returns the raw bytes
with the original content type. The SDKs expose the decoded bytes directly.
Size limit
A single publish body is capped at 8 MiB. Larger bodies are rejected:
{
"error": {
"type": "invalid_request_error",
"code": "payload_too_large",
"message": "Publish body is 10.4 MiB; the limit is 8 MiB. Split it across sequences.",
"request_id": "req_5Mn6op7QRst8Uv9w",
"doc_url": "https://runnev.dev/docs/errors#payload_too_large"
}
}
Batching for throughput
A batch is the unit of both delivery and rate accounting, so the way you group events matters. One event per publish is simple but caps you at the request-rate limit; a few hundred events per publish moves far more data under the same limit.
| Strategy | Events/s at 1000 req/min | When |
|---|---|---|
| 1 event per publish | ~16 | Low volume, lowest latency per event |
| 100 events per publish | ~1,600 | General purpose |
| 1000 events per publish | ~16,000 | High-volume firehose |
A good default is to flush a batch when it reaches either 100 events or 50 ms of buffering, whichever comes first. That keeps latency bounded while amortising the per-request cost. Keep an eye on the 8 MiB body limit as your average event size grows. Rate limits and the header trio are covered under Rate limits.
Forward compatibility
Runnev ignores query parameters it does not recognise. A request to
POST /v1/streams/{id}/{seq}?trace=abc123&shard=7 is handled exactly
as if the extra parameters were absent. This is a deliberate, permanent promise: it
lets you append your own tracing, sharding, or cache-busting parameters to any Runnev
URL without fear that a future API version will start rejecting them. New optional
behaviour is always opt-in through parameters we define; unknown ones are never an
error.
Retry and backoff
Retry on 429 and on 5xx. Honour the Retry-After
header on 429. Otherwise back off exponentially with full jitter:
sleep = random(0, min(cap, base * 2^attempt)), with
base = 200 ms and cap = 20 s. Because the
explicit-sequence path is idempotent, retrying it is always safe. The SDKs implement
exactly this.