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.

GET /v1/streams/{id}
curl -N "https://runnev.dev/v1/streams/$STREAM?cursor=head" \
  -H "Accept: text/event-stream" \
  -H "Authorization: Bearer $RUNNEV_API_KEY"

Response headers

200 OK
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-store tells 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: no tells 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:

server-sent events
: 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:

eventMeaning
batchA batch of events. data is the JSON batch object.
endThe 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:

ValueStarts at
?cursor=head (default)New batches only, from now on
?cursor=0The oldest surviving batch
?cursor=NThe 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.

resume after sequence 41823
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 are no-store for 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:

poll instead of stream
# 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:

javascript
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.