Getting started
What is Rundown Studio?Create an accountRundown
Rundown basicsColumnsTemplatesSettingsTrashCell historyMentionsText variablesRunning a showImport CSV rundownEvent
Event basicsSharing eventsAPI
Getting startedAPI reference ↗Build a rundown from CSVWorking with cell contentLive updates over SSEAdvanced usageError referenceMigrating from v0API v0 (deprecated)Integrations
Companion ModuleQLabSharing and outputs
Read-only rundownEditable rundownOutputPrompterPDF exportCSV exportAccount
Your teamSubscription and invoicesUpdates
ChangelogLive 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:
- Lifecycle events —
ready,heartbeat,disconnect. They describe the connection itself, not the rundown (covered under the handshake and staying connected). - The
statusevent — always streamed, the live show status. It is the spine of the channel and drives your countdown. - Change events —
rundown,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"}
changeisadded,modified, orremoved.idis 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 events — rundown, 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":{ … }}
readyis the first event — a machine-readable hello carryingdisconnect_at(epoch ms, when the server intends to cycle this connection — see below) andheartbeat_interval_ms.- The first
statusis 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 2× 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 stream | GET …/rundowns/:id/events?token=… with EventSource |
| Authenticate | ?token= query param (EventSource can’t set headers) |
| Live status | status always streams — full snapshot, not configurable |
| Get the default change events | Omit ?events= → rundown:fat, cue:thin |
| Also receive cells / columns | ?events=rundown,cue:thin,cell,column (opt-in, replaces the set) |
| Get a full body | Bare resource name (= fat); :thin for envelope-only |
| Drive a live countdown | Compute locally from each status, anchored to server_time |
| Recover from a drop | Re-create EventSource; resync from the next status |
Handle disconnect: auth_* | Refresh the token, then reconnect |
| Detect a dead socket | Watchdog at ~2× heartbeat_interval_ms, reset on any frame |