Skip to content

Realtime API

The Realtime API is the room-scoped long-lived connection protocol. It carries chat, playback commands, playback progress, WebRTC signaling, and subscriptions to cacheable room resources.

Use HTTP/OpenAPI or gRPC for one-shot reads and writes. Use Realtime when a client needs low-latency push, less polling, or continuously synchronized room state.

WebSocket URL:

wss://<host>/ws/rooms/<roomId>

Authentication options:

MethodClientsNotes
Authorization: Bearer <accessToken>Native clients, CLI, server-side SDKsSigned-in users
Authorization: Bearer <guest_token>Native clients, CLI, server-side SDKsGuests; first call POST /api/auth/guest-token
?ticket=<ticket>Browsers and clients that cannot set headersFirst call POST /api/tickets to create a short-lived, one-time, room-bound ticket

WebSocket only handles binary protobuf frames. Clients send synctv.client.ClientMessage, and the server returns synctv.client.ServerMessage. Text frames, ping, pong, and other non-business frames are not processed as business messages.

const roomId = 'room_1';
const ticket = ticketResponse.ticket;
const ws = new WebSocket(`wss://app.example.com/ws/rooms/${roomId}?ticket=${ticket}`);
ws.binaryType = 'arraybuffer';
ws.onmessage = async (event) => {
const bytes =
event.data instanceof Blob
? new Uint8Array(await event.data.arrayBuffer())
: new Uint8Array(event.data);
const message = ServerMessage.decode(bytes);
handleServerMessage(message);
};
function sendClientMessage(message) {
ws.send(ClientMessage.encode(message).finish());
}

A guest is not a signed-in user. The server does not create a user row, member row, or login session for a guest. Guest identity is represented only by a room-bound guest token, and public guest IDs use the gst_ prefix.

A guest can enter only when all of these are true:

  • Global user.enable_guest is enabled.
  • Room allowGuestJoin is enabled.
  • The room has no password and is not closed or banned.
  • The guest token still matches the room guest version. Disabling guests, adding a password, or revoking guest access invalidates older tokens.

Guests receive only basic realtime room state by default. They cannot read or manage media resources or send chat. A room may grant guest-safe capabilities through guestAddedPermissions, such as member list, chat history, or WebRTC signaling; this ceiling is independent from member permissions. Even with extra room permissions, guests cannot use account, media-resource management, member administration, room administration, Provider credential, or management-plane APIs.

Terminal window
curl -sS "$BASE_URL/api/auth/guest-token" \
-H "Content-Type: application/json" \
-d '{"roomId":"room_1"}'

Native WebSocket clients use the returned token:

Authorization: Bearer <guest_token>

For gRPC MessageStream, send the same token:

x-room-id: room_1
authorization: Bearer <guest_token>
ScenarioWhy Realtime
Synchronized playbackPlay, pause, seek, and media changes need low-latency propagation
ChatChat message events are delivered after explicit subscription
PlaybackExpired URLs, Provider credential changes, and media switches need refreshes
Room membersJoins, leaves, and permission changes need UI sync
WebRTC signalingoffer, answer, and ICE candidates need room-scoped forwarding; targets use public_actor_id:conn_id, where signed-in users are usr_* and guests are gst_*

Before sending signaling messages, WebRTC clients call GET /api/rooms/<roomId>/webrtc/ice-servers to fetch ICE servers. That endpoint uses the same room actor permission model as Realtime: signed-in members and guests both authenticate with Authorization: Bearer <token>, and both need use_webrtc.

Common client messages:

ClientMessage fieldPurpose
chatSend chat
heartbeatClient heartbeat
playback_progressReport current playback position and play/pause state
playback_updateSend a playback state update command
webrtc_offer / webrtc_answer / webrtc_ice_candidateForward WebRTC signaling
webrtc_join / webrtc_leaveJoin or leave a WebRTC session
observeResourceSubscribe to a realtime resource
unobserveResourceUnsubscribe from a realtime resource

Common server messages:

ServerMessage fieldPurpose
chatChat message received
heartbeatAckHeartbeat acknowledgement
errorGeneral business error
playbackState / playingChangedPlayback state or playback target changed
roomSettingsRoom settings changed
mediaAdded / mediaUpdated / mediaRemovedMedia changed
playlistCreated / playlistUpdated / playlistDeletedPlaylist changed
permissionChanged / userJoined / userLeft / roomMembersMember or permission changed
chatEvent or resourceChanged.chatEventChat message event after explicit subscription
resourceObserved / resourceChanged / resourceObserveErrorResource observation result

Use synctv-proto/proto/client.proto as the source of truth for complete fields. End-to-end examples are in SDK and API Examples.

All Realtime API business messages live in the ClientMessage.message and ServerMessage.message oneof fields. Dispatch by oneof type, not text fields or log strings.

function handleServerMessage(message) {
if (message.chatEvent) {
appendChatEvent(message.chatEvent);
return;
}
if (message.chat) {
appendChat(message.chat);
return;
}
if (message.playbackState) {
updatePlayback(message.playbackState);
return;
}
if (message.resourceChanged) {
if (message.resourceChanged.chatEvent) {
appendChatEvent(message.resourceChanged.chatEvent);
return;
}
handleResourceChanged(message.resourceChanged);
return;
}
if (message.error) {
handleRealtimeError(message.error);
}
}

After connecting, subscribe to the resources needed by the current view. See Realtime Resource Observation.

Chat message events use their own cursor: in bidirectional streams, send observeResource.chatEvents.afterEventSequence to replay events after a known event; HTTP SSE GET /api/rooms/<roomId>/watch/chat-events accepts the afterEventSequence query parameter and reads the browser’s Last-Event-ID header on reconnect. The server sets the SSE id: field, so clients can persist that value for recovery.

Observations are not retained across WebSocket connections. After disconnect, clients should:

  1. Request a new WebSocket ticket, or reconnect with a valid access token.
  2. Re-send observeResource for currently visible views.
  3. Include the locally saved version so the server can decide whether to send a snapshot.
  4. For playback, also include cached mediaId, playlistId, target, and playbackClientProfile.
  5. Keep the local cache when resourceObserved.changed=false; replace it when resourceChanged arrives.

If the connection has been down for a long time, fetch critical resources through HTTP/gRPC first, then restore observations. This avoids showing stale UI for too long.

Resource observation errors are returned as ServerMessage.resourceObserveError:

ScenarioClient action
Empty observeId or longer than 128 charactersFix client code. Do not retry the same request
Missing resourceFix client code
More than 64 observations on one connectionUnobserve unused resources or merge list views
Snapshot service unavailable or load failedShow a temporary error and retry with backoff
Permission, missing resource, or invalid input errorRe-fetch room state and navigate back when needed

If sending fails or the server closes the connection, do not assume observations remain active. Rebuild all observations after reconnecting.