WebSocket Protocol Specification
Overview
OpenWatchParty uses a JSON-over-WebSocket protocol for real-time communication between clients and the session server.
Endpoint: ws(s)://<host>:3000/ws
Protocol Version
The WebSocket protocol has its own version, independent of the product release. The current version is 1.
| Version | Status | Notes |
|---|---|---|
1 |
Current | Initial negotiated version. |
Negotiation: clients declare their version in the protocol_version field of the auth message. A missing field is treated as 1, so clients that predate negotiation keep working. The server echoes protocol_version in auth_success only when the client declared one, which keeps the previous auth_success payload for older clients. The version is also reported by GET /health (protocol_version) and by the plugin’s /OpenWatchParty/Token response.
Mismatch policy: the server only speaks version 1. A declared version other than 1, or a malformed protocol_version, is rejected with an error whose code is PROTOCOL_VERSION_UNSUPPORTED; the WebSocket is then closed with close code 1008.
Compatibility rules:
- Protocol version 1 supports additive, feature-negotiated message extensions. Clients advertise supported features in
auth;auth_success.featuresacknowledges the server-supported subset. Current extensions includehost_transfer,participant_status,shared_playback_control,media_queueandstart_when_ready. - The web client validates known message and payload schemas strictly. An unknown server message type or unknown field is warned about and dropped by the client; the session continues. An unknown client request type receives
UNKNOWN_MESSAGE_TYPE. Do not assume new fields on an existing payload are ignored by every client. - A server may send an optional extension only to clients that advertised the corresponding feature, or use a new message whose absence leaves legacy behavior intact. Version 1 remains unchanged for these additive extensions; changing an existing field’s meaning or removing/retyping it requires a compatibility review and protocol version change.
The minimum supported client protocol version is 1.
Message Format
All messages follow this structure:
{
"type": "message_type",
"room": "room_id",
"client": "client_id",
"payload": { ... },
"ts": 1678900000000,
"server_ts": 1678900000100
}
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Message type |
room |
string | No | Room ID (if applicable) |
client |
string | No | Sender client ID |
payload |
object | No | Message-specific data |
ts |
number | Yes | Client timestamp (ms since epoch) |
server_ts |
number | No | Server timestamp (added by server) |
Client → Server Messages
auth
Authenticate with a JWT token (if authentication is enabled), declare the client protocol version, and advertise optional features.
{
"type": "auth",
"payload": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"protocol_version": 1,
"features": ["host_transfer", "participant_status", "shared_playback_control", "media_queue", "start_when_ready"]
},
"ts": 1678900000000
}
| Payload Field | Type | Required | Description |
|---|---|---|---|
token |
string | No | JWT issued by the /OpenWatchParty/Token endpoint |
protocol_version |
number | No | Client protocol version; defaults to 1 when omitted |
features |
array of strings | No | Optional client capabilities. The server currently recognizes host_transfer, participant_status, shared_playback_control, media_queue and start_when_ready |
On a protocol version mismatch the server answers with error (PROTOCOL_VERSION_UNSUPPORTED) and closes the connection.
Unknown feature names, non-string array entries, and a non-array features value are ignored. A client is eligible to become host only when it declared host_transfer, and gets queue_state only when it declared media_queue; the web client sends the room queue messages only to a server that acknowledged media_queue. The web client sends participant_status to a server that declared participant_status, or one that already sent participant_statuses for its room. A client gets start_state only when it declared start_when_ready, and the web client sends readiness and start_now (see Start when ready) only to a server that acknowledged it.
list_rooms
Request the list of active rooms.
{
"type": "list_rooms",
"ts": 1678900000000
}
Response: room_list
create_room
Create a new watch party room.
{
"type": "create_room",
"payload": {
"name": "Movie Night",
"start_pos": 0.0,
"media_id": "abc123def456"
},
"ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
name |
string | Room display name |
start_pos |
number | Initial position (seconds) |
media_id |
string | Jellyfin media ID (optional) |
Response: room_state
Effects:
- Client becomes host
- After
room_state, the host receivesroom_capabilitiesif it negotiated shared playback control, then itsparticipant_listandparticipant_statuses - Members that negotiated
media_queuereceive the initialqueue_state - Broadcast
room_listto all clients
join_room
Join an existing room.
{
"type": "join_room",
"room": "uuid-room-id",
"ts": 1678900000000
}
Response: room_state
Effects:
- Client added to
room.clients - Client removed from
room.ready_clients - Broadcast
participants_updateto other participants - Broadcast
participant_list, thenparticipant_statuses, to everyone in the room, including the new client - Send
room_capabilitiesto negotiated members; this reports whether the current host also supports shared guest playback - Send the new client
queue_stateif it negotiatedmedia_queue - Broadcast
room_listto all clients, with the room’s new count (and the previous room’s, when the client moved)
leave_room
Leave the current room.
{
"type": "leave_room",
"room": "uuid-room-id",
"ts": 1678900000000
}
Effects:
- If the host leaves and another member declared
host_transfer: promote the earliest-joined supporting member, preserve media and playback state, clearpending_play, and broadcastclient_left,host_changed, thenparticipant_listandparticipant_statuses - If the host leaves and no remaining member supports transfer, or the room is empty: close the room and broadcast
room_closedas before - Otherwise: broadcast
client_left, thenparticipant_listandparticipant_statuses(the leaving client’s status is dropped) - Broadcast
room_listto all
A non-host WebSocket disconnect has the same room behavior as leave_room. When a
transfer-capable host disconnects, the server reserves the host role for 10 seconds,
keyed by the authenticated Jellyfin user ID. A new connection for that user can join
the room during the grace period and reclaim the role with its new client ID. If the
grace period expires first, the server promotes the earliest transfer-capable member,
or closes the room when no such member remains. An explicit leave_room still
transfers or closes the room immediately. A disconnect broadcasts room_list only
when the client was in a room.
close_room
Explicitly close the current room. The message has no payload.
{
"type": "close_room",
"ts": 1678900000000
}
Only the current host may close a room. Success broadcasts room_closed with reason Host closed the room and removes every member. A guest receives HOST_PERMISSION_REQUIRED; a client outside a room receives NOT_IN_ROOM. Either error leaves the room unchanged.
ready
Indicate client is ready to receive playback commands.
{
"type": "ready",
"room": "uuid-room-id",
"payload": {
"media_id": "abc123def456"
},
"ts": 1678900000000
}
Effects:
- Client added to
room.ready_clients - If
pending_playexists andall_ready(): triggers scheduled play - A
readywhosemedia_idnames another item than the room’s was sent before the room moved to another queue item: it is ignored
player_event
Send a playback event. Guests may send play and pause only when both they and the current host negotiated shared_playback_control in auth. Otherwise guest commands receive HOST_PERMISSION_REQUIRED. seek, buffering and state_update remain host only.
The server sends room_capabilities with { "features": ["shared_playback_control"] } (or an empty array) only to clients that negotiated this extension. It follows room creation/join, host transfer/reclaim and authentication changes. Legacy clients receive no new fields or message types. A new client uses shared controls only after both the server acknowledgement and the room announcement; otherwise it keeps the guest seek/play lock and private pause behavior.
{
"type": "player_event",
"room": "uuid-room-id",
"payload": {
"action": "play",
"position": 120.5
},
"ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
action |
string | "play", "pause", "seek", or "buffering" |
position |
number | Current position (seconds) |
Behavior by action:
| Action | Server Behavior |
|---|---|
play |
If all_ready(): broadcast with target_server_ts = now + 1000ms (+ 3000ms when it counts down, see Start when ready). Otherwise: create pending_play |
pause |
Broadcast with target_server_ts = now + 300ms |
seek |
Broadcast with target_server_ts = now + 300ms |
buffering |
Broadcast with target_server_ts = now + 300ms (treat as paused) |
Effects:
- Updates
room.state - Starts a monotonic command-cooldown deadline (
command_cooldown_until) - Broadcasts the canonical event in server order; negotiated play/pause senders receive it too as an acknowledgement. Applying it must suppress playback-event feedback. Legacy host senders retain their previous exclusion behavior.
A guest’s play or pause holds the room’s play state: until its cooldown ends, a host state_update with the other play state is ignored, since the host sent it before applying the command. For a pending play the hold lasts until it starts, then for the same cooldown. Any host player_event ends the hold.
PendingPlay retains the initiating client ID. Both readiness completion and the readiness timeout emit the scheduled event under that ID, including the sender in the broadcast. Readiness updates do not turn a guest’s play into a host command.
Only the host seeks, so a guest’s play or pause cannot move the room: its position is replaced by where the room is (the last position, moved on by the time since it took effect while playing).
state_update
Periodic playback state update (host only).
{
"type": "state_update",
"room": "uuid-room-id",
"payload": {
"position": 125.3,
"play_state": "playing"
},
"ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
position |
number | Current position (seconds) |
play_state |
string | "playing" or "paused" |
Server filtering:
- A changed
play_stateis accepted immediately unless it conflicts with an active guest-command hold; the server uses monotonic deadlines for that hold. - If
play_stateis unchanged, the server ignores updates during the 2-second monotonic command cooldown or within 500 ms of the last accepted state update. - Unchanged-state position updates that move backward by 0.5–2 seconds or forward by less than 0.5 seconds are filtered as HLS jitter or insignificant movement.
ping
Latency measurement and clock synchronization.
{
"type": "ping",
"payload": {
"client_ts": 1678900000000
},
"ts": 1678900000000
}
Response: pong. Pings are available before authentication. Only a
client_ts representable as an unsigned 64-bit integer is echoed; extra payload
fields and the request’s room and client fields are not reflected. A missing
or invalid client_ts produces a bounded pong without a payload field. The web
client discards that pong as a schema error and logs a warning; normal clock-sync
pings always include a valid timestamp.
chat_message
Send a text message to the room.
{
"type": "chat_message",
"room": "uuid-room-id",
"payload": {
"text": "Hello everyone!"
},
"ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
text |
string | Message text (max 500 characters) |
Effects:
- Message broadcast to all clients in the room (including sender)
- Rate limited by existing 30 msg/sec limit
Error responses:
"Chat message cannot be empty"- Empty or whitespace-only text"Chat message too long (max 500 characters)"- Text exceeds limit"Room ID required for chat"- Missing room ID
participant_status
Report how this client is doing, for the room’s participants list. Informational only: it never changes playback.
{
"type": "participant_status",
"payload": {
"status": "in_sync"
},
"ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
status |
string | One of playing, paused (the host), in_sync, catching_up, buffering, loading, blocked (autoplay blocked: needs to press Play), not_watching |
Effects:
- The status is stored for the client’s current room; a change is sent to the room as
participant_statuses - An unknown status, extra fields, an unchanged status, or a client outside a room are ignored silently (no error)
- A room gets at most one
participant_statusesevery 250 ms for status changes: the first change is sent at once, and later changes within the 250 ms are sent together when they end, with the latest statuses - The web client sends it only to a server that sent
participant_statusesfor its room (an older server answers unknown types with an error), once a status has held for a second, and again after reconnecting
Room queue
queue_add, queue_remove, queue_move, queue_advance and queue_countdown change the server-authoritative room queue, bounded to 20 items by count, watched ones included. This count limit is not a byte-size or process-memory bound. Only the host sends them: other members get HOST_PERMISSION_REQUIRED, a client outside a room NOT_IN_ROOM, and a malformed payload INVALID_QUEUE_PAYLOAD. A command that changes nothing (an item already queued, an unknown id, the room’s own item, a countdown already running) is ignored without an error. Members that declared media_queue get the result as queue_state (see below).
{
"type": "queue_add",
"payload": {
"items": [{ "id": "0123456789abcdef0123456789abcdef", "title": "S1:E2 · The Storm" }]
},
"ts": 1678900000000
}
| Message | Payload | Effect |
|---|---|---|
queue_add |
items: 1 to 20 { id, title, series? }. id is a Jellyfin item id like media_id (32 hexadecimal characters); title is cleaned like a name and cut to 100 characters; series, optional, is the Jellyfin id of an episode’s series. The server keeps it to tell whether the next item goes on the same series and never sends it back in queue_state |
Appends the items not queued yet. A full queue evicts its oldest watched item for each new item; the current and upcoming items are not evicted. Items that still do not fit are left out |
queue_remove |
id |
Removes an item other than the room’s own |
queue_move |
id, to (the item’s new index) |
Moves an upcoming item to another upcoming place; the room’s item and the watched ones keep theirs |
queue_advance |
id |
Moves the room to that queued item |
queue_countdown |
start (boolean) |
Starts a 10-second countdown toward the next item, or cancels it |
Moving to another item (queue_advance, or a countdown that ends):
- The room’s
media_idbecomes the item’s; the position goes back to 0, paused, and the ready set is cleared - A pending play at 0, started by the host, plays the room once every member sent
readyfor the new item, or when the usual wait for them ends (see Start when ready); it counts down unless the previous and the new item have the sameseries - Every member gets
room_statewith the newmedia_id, so clients withoutmedia_queuefollow it as on joining; members withmedia_queuethen getqueue_state, and every client getsroom_list - Until that pending play starts, the host’s position updates and play events don’t move it, because they may still describe the previous item; a pause still cancels it
Countdown: the deadline and its timer live on the server, so a host tab that the browser slows down in the background doesn’t delay the move. A generation identifies each scheduled countdown; canceling, replacing or ending it invalidates its task, so an older timer cannot move the room later. It starts only toward a next item and ends when the host cancels it, on any move to another item, when the next item is removed, or when the server accepts a pause from the host or a guest (members with media_queue then get queue_state right after that player_event). The host’s web client starts it when its video reaches the credits (Jellyfin’s Outro media segment) or, at the latest, 15 seconds before the end: Jellyfin closes the player when a video ends, which takes the member out of the room, so the room moves on while the video still plays. It cancels it when its video pauses or the host seeks back before the credits, and doesn’t start it while a room play or pause is on its way to its player.
Start when ready
With start_when_ready, a play waits until every member can play, and the clients show who the room waits for and why.
- A member is ready once it sent
readyfor the room’s media and its lastreadinessdidn’t say otherwise. A member without the feature counts as before:readyonly. - How long a play waits: 20 seconds when the member who asked for it negotiated the feature, 2 seconds otherwise (an older client would show nothing while it waits). Then it starts without the members still not ready. A member that leaves stops being awaited, and a pending play starts as soon as the others are ready. A pause cancels the wait; the host leaving does too.
- Countdown: the play that starts the room’s item is scheduled 3 seconds ahead instead of 1, when the member who asked for it negotiated the feature: the room’s first play when it was created paused (a host state update that reports playing starts the item too), and after a move to another queued item, unless that item goes on the same
seriesas the previous one. Members without the feature start at the sametarget_server_ts, without a countdown.
readiness
Why this client can’t play yet, or that it can. Sent when it changes.
{ "type": "readiness", "room": "uuid-room-id", "payload": { "ready": false, "reason": "transcoding" }, "ts": 1678900000000 }
| Payload Field | Type | Description |
|---|---|---|
ready |
boolean | Whether the client can play now |
reason |
string | Only when ready is false: loading (the room’s media isn’t shown yet), transcoding (it comes through the server’s HLS), buffering (the video stalls or seeks) or autoplay_blocked (the browser stopped the last play) |
A ready for the room’s media ends loading and transcoding. A malformed payload gets INVALID_READY, and a client outside a room NOT_IN_ROOM.
start_now
Starts a waiting play at once, without the members not ready yet. Payload {}. Members who may play send it: the host, or a guest when the host and the guest negotiated shared_playback_control; others get HOST_PERMISSION_REQUIRED, a non-member NOT_ROOM_MEMBER, and any other payload INVALID_PLAYBACK_PAYLOAD. With nothing waiting it does nothing.
Server → Client Messages
client_hello
Sent immediately after WebSocket connection, before the client authenticates.
Its envelope server_ts lets the web client establish an initial clock offset
while JWT token acquisition or authentication is still in progress.
{
"type": "client_hello",
"client": "uuid-client-id",
"payload": {
"client_id": "uuid-client-id"
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
auth_success
Sent after a successful auth with a JWT, and for feature-aware clients in insecure mode.
{
"type": "auth_success",
"client": "uuid-client-id",
"payload": {
"user_name": "Alice",
"protocol_version": 1,
"features": ["host_transfer", "participant_status", "shared_playback_control", "media_queue", "start_when_ready"]
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
protocol_version is present only when the client declared it in auth; the server echoes the negotiated version.
features is present only when the client declared the field. It contains the subset supported by the server, so malformed or unknown declarations produce an empty array. Feature-aware insecure clients receive the same acknowledgement after identity handling.
room_list
List of active rooms.
{
"type": "room_list",
"payload": [
{
"id": "uuid-room-id",
"name": "Movie Night",
"count": 3,
"media_id": "abc123def456"
}
],
"ts": 1678900000000,
"server_ts": 1678900000000
}
room_state
Full room state. Sent after create_room or join_room, and to every member when the room moves to another queue item.
{
"type": "room_state",
"room": "uuid-room-id",
"client": "uuid-client-id",
"payload": {
"name": "Movie Night",
"host_id": "uuid-host-id",
"participant_count": 3,
"media_id": "abc123def456",
"state_server_ts": 1678900000000,
"target_server_ts": null,
"state": {
"position": 120.5,
"play_state": "playing"
}
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
room_capabilities
Room-specific capabilities for clients that advertised
shared_playback_control. Sent after room creation or join, after the host
changes or reclaims the role, and when authentication changes negotiated
capabilities. features contains shared_playback_control only when the
current host also supports it; otherwise it is an empty array. The server does
not send this message to legacy clients or clients that did not advertise the
feature. This announcement is additive in protocol version 1.
{
"type": "room_capabilities",
"room": "uuid-room-id",
"payload": { "features": ["shared_playback_control"] },
"ts": 1678900000000,
"server_ts": 1678900000000
}
participants_update
Participant count update.
{
"type": "participants_update",
"room": "uuid-room-id",
"payload": {
"participant_count": 4
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
participant_list
Display names of the room’s participants, in join order. Sent to the host when the room is created, and to everyone in the room after a client joins or leaves.
{
"type": "participant_list",
"room": "uuid-room-id",
"payload": {
"participants": [
{ "name": "Alice", "is_host": true },
{ "name": "Bob", "is_host": false }
]
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
participants[].name |
string | Display name of the participant |
participants[].is_host |
boolean | Whether the participant is the room host |
It is a separate message so that room_state, participants_update and client_left keep their payloads: clients validate those strictly. A client that does not know participant_list ignores it, and the web client keeps showing the participant count until it receives one.
host_changed
Sent before the updated participant_list when host transfer succeeds or when the
same authenticated host reclaims the role after reconnecting.
{
"type": "host_changed",
"room": "uuid-room-id",
"payload": {
"host_id": "uuid-new-host-id",
"host_name": "Bob"
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
Clients from 0.6.0 treat an unknown message type as an invalid schema and stop processing that message, so they safely ignore host_changed. They are never selected as the new host because they did not declare host_transfer.
participant_statuses
Each participant’s last reported participant_status, in the same order as the latest participant_list. Sent right after every participant_list and to the whole room whenever a status change.
{
"type": "participant_statuses",
"room": "uuid-room-id",
"payload": {
"statuses": ["playing", null]
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
statuses[] |
string or null | The participant’s status, or null if they have not reported one (an older client, say) |
It is a separate message because participant_list rejects unknown fields: clients that do not know participant_statuses ignore it. The web client ignores a list of statuses whose length does not match its participants list.
queue_state
The room’s authoritative queue, sent only to members that declared media_queue: after each change, on creating the room and on joining it (after room_state, participant list/statuses and room_capabilities), and after a move to another item (after room_state).
{
"type": "queue_state",
"room": "uuid-room-id",
"payload": {
"items": [
{ "id": "0123456789abcdef0123456789abcdef", "title": "S1:E1 · Pilot" },
{ "id": "fedcba9876543210fedcba9876543210", "title": "S1:E2 · The Storm" }
],
"current": 0,
"last_added": "fedcba9876543210fedcba9876543210",
"countdown_until": 1678900010000
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
items |
array | Up to 20 { id, title }, watched ones first |
current |
number or null | Index of the room’s media_id in items, or null when it isn’t queued |
last_added |
string or null | The last item queue_add appended, or null before the first. It stays after that item is removed: the host’s web client fills the queue on from it, so an episode the host removed doesn’t come back after a reload or a host change |
countdown_until |
number or null | Server time (ms) at which the room moves to the next item, or null |
start_state
A play the room holds for its members (Start when ready), sent only to members that declared start_when_ready.
{
"type": "start_state",
"room": "uuid-room-id",
"payload": {
"state": "waiting",
"position": 1300.5,
"waiting_for": [{ "client": "uuid-client-id", "name": "Ana", "reason": "transcoding" }],
"deadline_server_ts": 1678900020000
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
state |
Other fields | Sent |
|---|---|---|
waiting |
position (where the play starts), waiting_for (the members not ready, in the room’s order, with their reason), deadline_server_ts (when it starts anyway) |
When a play starts waiting, and again when the list or a reason changes, a member joins or a member leaves |
starting |
target_server_ts, countdown (boolean), without (the members still not ready, as in waiting_for) |
When a waiting play is scheduled, before its player_event; and after the player_event of a play that counts down without waiting |
idle |
none | When a pause or the host leaving ends the wait |
player_event
Playback command relayed from host.
{
"type": "player_event",
"room": "uuid-room-id",
"payload": {
"action": "play",
"position": 120.5,
"target_server_ts": 1678900001000
},
"ts": 1678900000000,
"server_ts": 1678900001000
}
| Payload Field | Type | Description |
|---|---|---|
action |
string | "play", "pause", "seek", or "buffering" |
position |
number | Reference position (seconds) |
target_server_ts |
number | Target server timestamp for execution |
Client processing:
- Enable
isSyncinglock (2s) - Calculate adjusted position with elapsed time
- Schedule action at
target_server_ts
state_update
Periodic state update relayed from host.
{
"type": "state_update",
"room": "uuid-room-id",
"payload": {
"position": 125.3,
"play_state": "playing"
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
room_closed
Room was closed because the host explicitly closed it, no transfer-capable member remained after the host left or after its reconnect grace period expired, or the room became empty.
{
"type": "room_closed",
"ts": 1678900000000
}
client_left
A participant left the room.
{
"type": "client_left",
"room": "uuid-room-id",
"client": "uuid-left-client-id",
"payload": {
"participant_count": 2
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
| Payload Field | Type | Description |
|---|---|---|
participant_count |
number | Updated participant count after the client left |
pong
Response to ping. The optional payload contains only an unsigned 64-bit
client_ts copied from the request; server_ts in the envelope is the server
clock sample. If client_ts is missing, malformed or outside that range, the
server omits the payload entirely. The web client’s strict schema validator
warns and drops such a pong, so it is not used as a clock sample. The server
never reflects arbitrary payload fields or request room/client identifiers.
{
"type": "pong",
"payload": {
"client_ts": 1678900000000
},
"ts": 1678900000050,
"server_ts": 1678900000050
}
Client-side RTT calculation:
const rtt = Date.now() - payload.client_ts;
const serverOffset = server_ts + (rtt / 2) - Date.now();
chat_message
Chat message broadcast from server.
{
"type": "chat_message",
"room": "uuid-room-id",
"client": "uuid-sender-id",
"payload": {
"username": "Alice",
"text": "Hello everyone!"
},
"ts": 1678900000000,
"server_ts": 1678900000050
}
| Payload Field | Type | Description |
|---|---|---|
username |
string | Sender’s display name |
text |
string | Message text |
Client processing:
- Add message to local chat history (max 100 messages)
- If chat panel not visible, increment unread badge
- Render message in chat UI
error
Error response.
{
"type": "error",
"payload": {
"code": "ROOM_NOT_FOUND",
"message": "Error description"
},
"ts": 1678900000000,
"server_ts": 1678900000000
}
payload.code is a stable machine-readable identifier in SCREAMING_SNAKE_CASE.
payload.message remains present for display and compatibility with existing clients.
| Code | Meaning |
|---|---|
AUTHENTICATION_REQUIRED |
Authentication is required or the token is missing |
AUTHENTICATION_FAILED |
Token validation failed |
AUTHENTICATION_EXPIRED |
The authenticated session expired; the WebSocket is then closed |
AUTHENTICATION_TIMEOUT |
Authentication was not completed in time; the WebSocket is then closed |
PROTOCOL_VERSION_UNSUPPORTED |
The declared protocol_version is unsupported or malformed; the WebSocket is then closed |
RATE_LIMITED |
The message rate limit was exceeded; the WebSocket is then closed |
MESSAGE_TOO_LARGE |
The WebSocket message exceeds the protocol size limit |
UNSUPPORTED_MESSAGE_FORMAT |
The client sent a non-text message, including a binary message |
INVALID_JSON |
The text message is not a valid protocol JSON envelope |
UNKNOWN_MESSAGE_TYPE |
The message type is unknown |
ROOM_ID_REQUIRED |
A room-scoped operation omitted room |
ROOM_NOT_FOUND |
The requested room does not exist |
ROOM_FULL |
The requested room reached its participant limit |
NOT_ROOM_MEMBER |
The client is not a member of the requested room |
HOST_PERMISSION_REQUIRED |
A non-host client sent a seek, a buffering event, a state_update, close_room or a queue message |
INVALID_PLAYBACK_PAYLOAD |
A playback payload is absent, malformed, or outside accepted bounds |
NOT_IN_ROOM |
leave_room, close_room or a queue message was sent while the client had no room |
INVALID_READY |
A ready transition is missing required room context |
INVALID_CHAT_PAYLOAD |
The chat payload does not contain a string text field |
CHAT_MESSAGE_EMPTY |
Chat text is empty |
CHAT_MESSAGE_TOO_LONG |
Chat text exceeds the configured limit |
INVALID_QUEUE_PAYLOAD |
A queue message’s payload is absent, malformed, or outside accepted bounds |
Sequence Diagram: Complete Session
Client A Server Client B
│ │ │
├── WebSocket connect ────►│ │
│◄─── client_hello ────────┤ │
│◄─── room_list ───────────┤ │
│ │ │
├── create_room ──────────►│ │
│◄─── room_state ──────────┤ │
│ ├─── room_list (broadcast) │
│ │ │
│ │◄── WebSocket connect ────┤
│ ├─── client_hello ────────►│
│ ├─── room_list ───────────►│
│ │ │
│ │◄── join_room ────────────┤
│◄─ participants_update ───┤─── room_state ──────────►│
│◄─ participant_list ──────┼─── participant_list ────►│
│◄─── room_list ───────────┼─── room_list ───────────►│
│ │ │
│ │◄── ready ────────────────┤
│ │ │
├── player_event (play) ──►│ │
│ │ all_ready() = true │
│◄─ player_event ──────────┼─── player_event ────────►│
│ target_ts = T+1000 │ target_ts = T+1000 │
│ │ │
│ [T+1000ms] │ [T+1000ms]│
│ video.play() │ video.play()│
│ │ │
├── state_update ─────────►│ │
│ ├─── state_update ────────►│
│ │ │
├── ping ─────────────────►│ │
│◄─── pong ────────────────┤ │
│ │ │
├── close_room ───────────►│ │
│ ├─── room_closed ─────────►│
│◄─── room_list ───────────┼─── room_list ───────────►│
│ │ │
Invite Links
A host can share a short-lived invite link instead of asking guests to pick the room from the room list. The link carries a room-scoped invite ticket; it never contains the Jellyfin API token or the session JWT.
Minting a ticket
The session server exposes an HTTP endpoint beside /health:
POST /invite
Authorization: Bearer <session JWT>
Content-Type: application/json
{ "room_id": "uuid-room-id", "ttl_seconds": 3600 }
| Field | Type | Description |
|---|---|---|
room_id |
string | Room the ticket is scoped to; the caller must be its host |
ttl_seconds |
number | Requested lifetime; optional. Clamped to 60-86400 seconds, default 3600 |
The endpoint is rate limited per authenticated user. Responses:
| Status | Body | Description |
|---|---|---|
| 200 | { "ticket": "...", "expires_at": 1678903600 } |
Ticket minted; expires_at is Unix seconds |
| 401 | { "error": "Authentication required" } |
Missing or invalid session JWT |
| 403 | { "error": "Only the room host can create invite links" } |
The caller is not the room host |
| 404 | { "error": "Room not found" } |
The room does not exist (or has closed) |
| 429 | { "error": "Rate limit exceeded" } |
Too many ticket requests from this user, or 1024 other users are already within their 60-second windows |
| 503 | { "error": "..." } |
Invite links require a shared JWT secret |
The ticket is an HS256 JWT signed with the session server’s shared secret:
| Claim | Description |
|---|---|
room |
Room the ticket is scoped to |
typ |
Token marker: invite (a session token is rejected as an invite and vice versa) |
nonce |
Unique ticket identifier |
aud, iss, iat, exp |
Standard claims; the ticket expires at exp and cannot be extended |
Joining with a ticket
join_room accepts an optional invite_ticket payload field:
{
"type": "join_room",
"room": "uuid-room-id",
"payload": {
"user_name": "Ana",
"invite_ticket": "eyJhbGciOiJIUzI1NiIs..."
},
"ts": 1678900000000
}
The session server validates the signature, the expiry and the room before joining. Failures use the existing error codes and leave the client’s membership untouched:
| Condition | Error code | Message |
|---|---|---|
| Malformed, tampered or unverifiable ticket | AUTHENTICATION_FAILED |
Invalid invite ticket |
| Ticket minted for a different room | AUTHENTICATION_FAILED |
Invite ticket does not match this room |
| Expired ticket | AUTHENTICATION_EXPIRED |
Invite ticket has expired |
| Room closed after minting | ROOM_NOT_FOUND |
Room not found (normal join path) |
A valid ticket does not bypass the normal join rules: a full room still answers ROOM_FULL, and a closed room is still rejected by the room lookup.
Client link format
The web client opens the Jellyfin Web root with the ticket in a query parameter:
https://jellyfin.example/web/?owp_invite=<ticket>
The client reads owp_invite on load, waits for the WebSocket authentication to finish, then sends join_room for the room named in the ticket and removes the parameter from the URL. Invalid or expired tickets show a toast and leave the client on the normal room list.