Getting started

What is Rundown Studio?Create an account

Rundown

Rundown basicsColumnsTemplatesSettingsTrashCell historyMentionsText variablesRunning a showImport CSV rundown

Event

Event basicsSharing events

API

Getting startedAPI reference ↗Build a rundown from CSVWorking with cell contentLive updates over SSEAdvanced usageError referenceMigrating from v0API v0 (deprecated)

Integrations

Companion ModuleQLab

Sharing and outputs

Read-only rundownEditable rundownOutputPrompterPDF exportCSV export

Account

Your teamSubscription and invoices

Updates

Changelog
Docs API

Live updates over SSE

Live updates over SSE

Subscribe to a rundown and get every change pushed to you in real time — no polling. The event model, how to pick what you receive, computing the countdown locally, and staying connected.

A long-lived Server-Sent Events (SSE) stream pushes every change to a rundown to you as it happens — cues edited, the show advanced, columns and cells changed, the live countdown ticking over. It replaces polling: open one connection and react to events instead of re-fetching on a timer.

GET https://api-v1.rundownstudio.app/rundowns/:id/events

The stream is part of the standard API — connect directly at the base URL above, the same host as every other endpoint. No separate host, no redirect hop.

The stream sees every write, whatever caused it — the dashboard UI, another API client, or the scheduler advancing the show on its own. It is the same source of truth the app itself runs on.

Connecting

A browser’s built-in EventSource is the simplest client:

const es = new EventSource(
  'https://api-v1.rundownstudio.app/rundowns/YOmm4UG2SGSomuOci44c/events?token=' + token
)

Authenticate with ?token=. The events endpoint is part of the control plane, so — like status and countdown — it accepts the token in the query string. That is the right choice here because EventSource cannot set request headers at all. (Raw clients that can set headers may use Authorization: Bearer … instead — e.g. curl -N -H "Authorization: Bearer $TOKEN" against the events URL above.) See Authentication.

A bad token is rejected with 401 (auth.token_missing / auth.invalid_token).

The event model

Every frame on the stream is one of three kinds:

  1. Lifecycle eventsready, heartbeat, disconnect. They describe the connection itself, not the rundown (covered under the handshake and staying connected).
  2. The status event — always streamed, the live show status. It is the spine of the channel and drives your countdown.
  3. Change eventsrundown, cue, column, cell. These are the rundown contents changing, and they are what you configure with ?events=.

status is always on

status is not something you subscribe to or configure — it streams on every connection, always carries the full current status (the same shape GET /rundowns/:id/status returns), and has no “fat” or “thin” variant. You can’t turn it off and you can’t thin it; consuming this channel without live status doesn’t make sense. It is delivered as a bare snapshot, not a {change, id} envelope:

event: status
data: {"server_time":1747000000000,"state":"running","active_cue":{"id":"Ii6WeSEQmEqyKY6YAu8c","title":"Opening monologue","started_at":1747000000000,"paused_at":null,"duration_ms":60000},"next_cue":{"id":"Ec0mmeOaGq68oiASGoSG","title":"Guest intro"}}

Change events carry a {change, id} envelope

Each change event names the resource that changed and carries a small envelope:

event: cue
data: {"change":"modified","id":"G0M48GugUIem6YOSIOyO"}
  • change is added, modified, or removed.
  • id is the resource’s id.

That bare envelope is the thin form. A fat event adds the full resource body under a key named for the resource — and that body is the exact shape the REST endpoint returns for it:

event: cue
data: {"change":"modified","id":"G0M48GugUIem6YOSIOyO","cue":{ …full cue… }}

A removed event is always just the envelope — a deletion has no body, at either fidelity.

cell is the exception — a cell is addressed by a compound (cue_id, column_id) key, not a single id, so its envelope reflects that:

event: cell
data: {"change":"modified","cue_id":"G0M48GugUIem6YOSIOyO","column_id":"EoeiemGEmeMOY8GwUK6s","cell":{ … }}

(See Working with cell content for the shape of the cell body.)

Choosing what you receive

The ?events= parameter configures the change eventsrundown, cue, column, cell. (status and the lifecycle events always stream regardless; they are not part of this vocabulary.) The default, when you omit the parameter, is:

rundown:fat, cue:thin

So out of the box you get full rundown changes and lightweight cue notifications (an id you re-fetch if you care). column and cell are opt-in — request them explicitly.

?events= replaces that set; there is no add/remove syntax. List exactly the change events you want. A bare resource name means fat; append :thin to opt down:

# default plus columns and cells (all fat)
?events=rundown,cue:thin,column,cell

# minimal: just cue ids (status still streams, as always)
?events=cue:thin

rundown, cue, column, and cell are all fat-capable (bare = fat, :thin opts down). Use thin for high-volume resources you only sometimes need the body of — react to the id, fetch on demand. A malformed parameter is a 400 (bad_request) before the stream opens, with a message listing the valid resources and fidelities — never a silently-degraded stream.

The connection handshake

Every connection opens the same way, and re-opens the same way after a reconnect — two events first:

event: ready
data: {"disconnect_at":1747043200000,"heartbeat_interval_ms":15000}

event: status
data: {"server_time":1747000000000,"state":"running","active_cue":{ … },"next_cue":{ … }}
  1. ready is the first event — a machine-readable hello carrying disconnect_at (epoch ms, when the server intends to cycle this connection — see below) and heartbeat_interval_ms.
  2. The first status is a full snapshot of current state (the shape shown above). Treat it as the truth and resync everything to it. This is what makes recovery cheap: there is no replay buffer and no cursor — a reconnect just re-sends the current snapshot, so you can never silently miss state across a gap.

After that, a heartbeat arrives on the interval ready advertised:

event: heartbeat
data: {"at":1747000015000}

It’s a keep-alive (it keeps proxies and the TCP socket warm) and your liveness signal — use it to detect a dead connection (see staying connected).

Computing the countdown locally

Do not open a stream per clock tick — each status event already carries everything a ticking countdown needs. Run the clock locally and let status events re-anchor it whenever state changes (a cue advances, the show pauses):

state == "running":  remaining = active_cue.started_at + active_cue.duration_ms - server_time
state == "paused":   remaining = active_cue.started_at + active_cue.duration_ms - active_cue.paused_at
state == "stopped":  no active cue — nothing to count down

Anchor to the snapshot’s server_time (epoch ms), not the client clock: measure how much local time has elapsed since the event and subtract that from remaining. This is the same model as the polling guide — the stream just delivers each new snapshot the instant state changes, instead of you polling for it.

Staying connected

The stream is designed to be reconnected, and a robust consumer must do so deliberately. Three rules:

1. Re-create EventSource when the server closes the stream. The server ends a connection with an explicit frame:

event: disconnect
data: {"reason":"lifetime"}

A CLOSED EventSource does not auto-reconnect (its built-in retry only covers transient network blips) — you must create a new one yourself.

2. Resync from the first status after every (re)connect. You don’t reconcile a gap — the fresh ready + status snapshot is your new baseline. Optionally pre-empt a planned cycle for a zero-gap handover: open the next connection a little before disconnect_at and switch over once its first status lands.

3. Branch on the disconnect reason:

  • lifetime — a routine cycle the server runs periodically (about every 12 h). Just reconnect with the same token.
  • auth_expired / auth_revoked — your token is no longer valid. Refresh the token first, then reconnect.

Guard against silently-dead sockets. A connection can die without any frame at all (a laptop lid closes, a proxy half-drops the socket) — no error, no disconnect. That’s what the heartbeat is for: arm a watchdog at roughly heartbeat_interval_ms, reset it on any frame, and if it ever fires, tear the connection down and reconnect.

A consumer that follows all three:

const RUNDOWN = 'YOmm4UG2SGSomuOci44c'
let token = await getToken()           // your refreshable token source
let es, watchdog, watchdogMs = 35000   // until 'ready' tells us the real interval

function connect () {
  const url = 'https://api-v1.rundownstudio.app/rundowns/' + RUNDOWN
            + '/events?token=' + encodeURIComponent(token)
  es = new EventSource(url)

  es.addEventListener('ready', (e) => {
    const { heartbeat_interval_ms } = JSON.parse(e.data)
    watchdogMs = 2 * heartbeat_interval_ms + 5000
    armWatchdog()
  })

  // Rule 2: each (re)connect's first status is the new baseline.
  es.addEventListener('status', (e) => {
    applyStatus(JSON.parse(e.data))      // re-anchor the local countdown
  })

  es.addEventListener('cue', (e) => {
    const { change, id, cue } = JSON.parse(e.data)
    applyCueChange(change, id, cue)
  })

  // Heartbeat resets the dead-socket watchdog.
  es.addEventListener('heartbeat', armWatchdog)

  // Rules 1 + 3: the server is closing - reconnect deliberately.
  es.addEventListener('disconnect', async (e) => {
    const { reason } = JSON.parse(e.data)
    if (reason === 'auth_expired' || reason === 'auth_revoked') {
      token = await refreshToken()       // Rule 3: refresh before reconnecting
    }
    reconnect()
  })

  // A CLOSED socket won't auto-recover - force it.
  es.onerror = () => { if (es.readyState === EventSource.CLOSED) reconnect() }
}

function armWatchdog () {
  clearTimeout(watchdog)
  watchdog = setTimeout(reconnect, watchdogMs)
}

function reconnect () {
  clearTimeout(watchdog)
  if (es) es.close()
  connect()
}

connect()

What you can and can’t see

column and cell events are filtered to the public grid, exactly matching what the REST API returns: private or soft-deleted columns, and heading/group cues, are never emitted. If a column is made private (or un-private), you see it surface as a removed (or added) event against your view — there’s no separate “visibility changed” signal.

Connection limits

Concurrent /events connections are capped per team: 2 on restricted access, 6 on unrestricted (the same access bucket as your REST rate limits). Opening one past the cap returns 429 (rate_limit.too_many_connections); closing a stream frees its slot immediately.

Quick reference

You want to…Do this
Open the streamGET …/rundowns/:id/events?token=… with EventSource
Authenticate?token= query param (EventSource can’t set headers)
Live statusstatus always streams — full snapshot, not configurable
Get the default change eventsOmit ?events=rundown:fat, cue:thin
Also receive cells / columns?events=rundown,cue:thin,cell,column (opt-in, replaces the set)
Get a full bodyBare resource name (= fat); :thin for envelope-only
Drive a live countdownCompute locally from each status, anchored to server_time
Recover from a dropRe-create EventSource; resync from the next status
Handle disconnect: auth_*Refresh the token, then reconnect
Detect a dead socketWatchdog at ~2× heartbeat_interval_ms, reset on any frame