Search documentation

Search pages and headings

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-stream

Query 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: no

The 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: 4213

A 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