Subscribing
Subscribing is a single long-lived GET that streams batches as
server-sent events. This page is the wire format in detail, the keepalive contract,
cursor resume, and the operational realities of connections that stay open for hours.
The request
Send Accept: text/event-stream. That header is what turns a plain
metadata GET into a subscription.
curl -N "https://runnev.dev/v1/streams/$STREAM?cursor=head" \
-H "Accept: text/event-stream" \
-H "Authorization: Bearer $RUNNEV_API_KEY"
Response headers
content-type: text/event-stream; charset=utf-8
cache-control: no-store
x-accel-buffering: no
x-request-id: req_0Kj2wq8ULn4mAe1s
connection: keep-alive
Two of these headers matter enough to explain:
cache-control: no-storetells every cache in the path, including any CDN, not to buffer or store the response. A cached event stream is a broken event stream.x-accel-buffering: notells reverse proxies that honour it (nginx and several others) to flush each write immediately instead of accumulating a buffer. Without it, a proxy can hold your batches until its buffer fills, adding seconds of latency or stalling an idle stream entirely.
The wire format
Batches arrive as SSE events. Each has an id (the sequence), an
event type, and a data line with the JSON batch:
: runnev
id: 41823
event: batch
data: {"seq":41823,"ts":"2026-08-04T11:02:13.418Z","events":[{"type":"order.paid","id":"o_5521"}]}
id: 41824
event: batch
data: {"seq":41824,"ts":"2026-08-04T11:02:16.902Z","events":[{"type":"order.created","id":"o_5523"}]}
: keepalive
Event types you will see:
| event | Meaning |
|---|---|
batch | A batch of events. data is the JSON batch object. |
end | The stream was deleted. The connection then closes. |
| (comment) | A line starting with : is a keepalive or banner. Ignore it. |
The keepalive frame
When a stream is idle, Runnev sends a comment frame (: keepalive) every
15 seconds. It carries no data and is not an event; its only job is
to keep the connection observably alive so that neither the client nor any
intermediary mistakes a quiet stream for a dead one. Treat the arrival of any bytes,
including a comment, as proof of liveness, and treat a gap longer than about 30
seconds with no bytes at all as a reason to reconnect.
Cursor and resume
The cursor query parameter controls where a subscription starts:
| Value | Starts at |
|---|---|
?cursor=head (default) | New batches only, from now on |
?cursor=0 | The oldest surviving batch |
?cursor=N | The first batch after sequence N |
To resume after a disconnect, reconnect with ?cursor= set to the sequence
of the last batch you fully processed. Runnev also honours the standard
Last-Event-ID request header, which browsers' native
EventSource sends automatically on reconnect; if both are present, the
query parameter wins.
curl -N "https://runnev.dev/v1/streams/$STREAM?cursor=41823" \
-H "Accept: text/event-stream" \
-H "Authorization: Bearer $RUNNEV_API_KEY"
Long-lived connections
A healthy subscriber holds one response open indefinitely. This is the normal, supported operating mode, not an edge case: we have run single subscriptions past 40 hours in production, and the SDKs are built to keep one open and reconnect transparently when the network forces it. If you are putting infrastructure between your client and Runnev, configure it for that reality:
- Disable response buffering on any reverse proxy in the path.
Runnev sets
x-accel-buffering: no; make sure your proxy respects it or disable buffering explicitly. - Raise idle and read timeouts above the 15-second keepalive interval, with margin. A 10-second proxy read timeout will sever every idle stream. A minute or more is sensible.
- Do not cache
/v1. The responses areno-storefor a reason; a caching layer that ignores that will break both publishing and subscribing.
The field report on long HTTP responses goes through the specific settings we had to change in nginx, Caddy, a major CDN, and an AWS ALB.
The non-streaming fallback
A client that cannot hold a streaming response, or would rather poll, can skip SSE
entirely. The same GET without the
Accept: text/event-stream header returns the stream's metadata, including
its current cursor. Poll the cursor, and when it advances, fetch the new
batches by sequence:
# 1. how far has the stream advanced?
curl "https://runnev.dev/v1/streams/$STREAM/cursor" \
-H "Authorization: Bearer $RUNNEV_API_KEY"
# {"cursor":41825,"updated_at":"2026-08-04T11:03:01.220Z"}
# 2. fetch the batches you are missing, by sequence
curl "https://runnev.dev/v1/streams/$STREAM/batches/41824" \
-H "Authorization: Bearer $RUNNEV_API_KEY"
Streaming is cheaper and lower latency, but polling is always available and needs nothing more than a plain request/response client.
Deduplicating on reconnect
Delivery is at-least-once, so a reconnect around a blip can redeliver the last batch.
Because every batch carries its seq, dedup is one comparison:
let last = -1;
for await (const batch of runnev.subscribe(streamId, { cursor: saved })) {
if (batch.seq <= last) continue; // already processed
handle(batch);
last = batch.seq;
persist(last); // so a restart resumes from here
}
Persist last wherever you keep durable state, and pass it as the cursor
when you start up. That is the whole recipe for a subscriber that survives restarts
and network faults without losing or double-processing data.