API Reference
GET /v1/stream
Subscribe to a video's chat over Server-Sent Events, including the frame sequence, backfill, and gapless resume.
The only endpoint you need to read chat. Open it once and leave it open — messages arrive as they happen, and the server does the polling.
GET /v1/stream?videoId=zvwJ29RFVww
Accept: text/event-streamQuery parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
videoId |
string |
yes | — | A full YouTube URL or a bare 11-character id |
backfill |
number |
no | 50 |
How many recent messages to prime the connection with |
backfill is a hint, not a contract — resume is by cursor, so an
unreasonable value is normalized rather than rejected:
| Sent | Used |
|---|---|
50 |
50 |
100000 |
500 (the ceiling) |
-5 |
0 |
2.9 |
2 |
| absent | 50 |
abc |
rejected: 400 INVALID_REQUEST |
Request headers
| Header | Description |
|---|---|
Accept |
text/event-stream |
Last-Event-ID |
The last message id you received. Sent automatically by EventSource; see Resuming |
Response
200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: noThe body is an SSE stream. Failures before the stream opens are ordinary
JSON errors with a real status code; once it is open they arrive as an
error frame instead. See Errors.
The frame sequence
retry: 3000
event: init
data: {"videoId":"zvwJ29RFVww","chatType":"liveChatRenderer","cursor":4211}
event: message
id: 4212
data: {"type":"chat","id":"ChwKGkNJ...","author":"Some Viewer", ...}
event: message
id: 4213
data: { ... }
: keepalive
event: end
data: {"reason":"no more continuations"}In order:
| # | Frame | Count | Purpose |
|---|---|---|---|
| 1 | retry: |
once | The reconnect delay a browser should use |
| 2 | init |
once | Video metadata and your starting cursor |
| 3 | message |
up to backfill |
History, oldest first |
| 4 | message |
ongoing | Live messages as they are published |
| — | : keepalive |
every 15 s | A comment line; keeps idle connections from being reaped |
| 5 | end or error |
at most once | Terminal; the body then closes |
init
| Field | Type | Description |
|---|---|---|
videoId |
string |
The resolved 11-character id |
chatType |
"liveChatRenderer" | "liveChatReplayRenderer" |
Live chat, or a finished stream's replay |
cursor |
number |
What the server considers you to already hold |
Everything after init has id > cursor, so the seam between history and
live has neither a gap nor a duplicate.
message
The data payload is a Message — the same shape for
every event type. The id: line is the cursor, not the message id: a
per-room counter used for resuming.
end
| Reason | Meaning |
|---|---|
"no more continuations" |
The chat is over. YouTube returned no next token — normal for a replay that ran to the end |
"room released" |
Nobody was watching any more, so the server stopped polling |
error
event: error
data: {"code":"UPSTREAM_ERROR","message":"..."}Terminal, and only ever sent after the response has started. code is from
the same set as HTTP errors — see Errors.
Resuming
Every message frame carries id: <cursor>. Send the last one back as
Last-Event-ID and the server resumes from exactly there instead of sending
the last N messages:
GET /v1/stream?videoId=zvwJ29RFVww
Accept: text/event-stream
Last-Event-ID: 4213A browser's EventSource does this for you on every automatic reconnect.
This is gapless because the server persists a message before publishing it —
nothing can be visible on the live stream that a reconnect could not read
back. A malformed or negative Last-Event-ID is treated as absent, so a bad
header costs you nothing but the backfill.
The cursor is per room and is not a global message number. Treat it as an opaque resume token. It survives a server restart only when the server is running against a persistent store.
With curl
curl -N -H 'Accept: text/event-stream' \
'http://localhost:3000/v1/stream?videoId=zvwJ29RFVww&backfill=5'-N disables curl's buffering; without it you will see nothing until the
connection closes.
With EventSource
const source = new EventSource(
'http://localhost:3000/v1/stream?videoId=zvwJ29RFVww',
)
source.addEventListener('init', (event) => {
const init = JSON.parse(event.data)
console.log(init.chatType, init.cursor)
})
source.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
console.log(`${message.author}: ${message.message}`)
})
source.addEventListener('end', (event) => {
console.log('ended:', JSON.parse(event.data).reason)
source.close()
})EventSource reconnects on its own and replays your Last-Event-ID, but it
will keep reconnecting after an end frame — close it yourself, as above.
For anything beyond a demo, use the
client library: it handles reconnection budgets, rate-limit
backoff, message pacing and the end/error split for you.
One poller, many viewers
Opening this endpoint twice for the same video does not double the load on
YouTube. The server runs one poller per videoId no matter how many viewers
are attached, and every viewer sees the same messages with the same cursors.
When the last viewer disconnects the poller is not stopped immediately — it survives a grace period of about 90 seconds, so a page refresh rejoins the existing poller instead of paying for a fresh bootstrap.
Errors before the stream
| Status | Code | Cause |
|---|---|---|
400 |
INVALID_REQUEST |
videoId missing, or backfill not a number |
400 |
INVALID_VIDEO_ID |
videoId was neither a recognized URL nor a bare id |
404 |
NO_LIVE_CHAT |
No chat renderer with a continuation token was found |
429 |
RATE_LIMITED |
YouTube is throttling the server; a Retry-After header says for how long |
502 |
SW_JS_FAILED |
Could not read innertube credentials from sw.js |
502 |
NO_WORKING_CLIENT |
No innertube client/endpoint combination worked |