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.

POST /v1/streams/{id}/{seq}
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}
      ]}'
202 Accepted
{"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:

response headers
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?".

200 OK - the retry of an already-stored batch
{"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:

422 Unprocessable Entity
{
  "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.

POST /v1/streams/{id}/publish
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}
This path is not idempotent by default

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.

publish protobuf-encoded bytes
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:

413 Payload Too Large
{
  "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.

StrategyEvents/s at 1000 req/minWhen
1 event per publish~16Low volume, lowest latency per event
100 events per publish~1,600General purpose
1000 events per publish~16,000High-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.