Skip to content

Open Watch Party protocol v1 ​

This document defines the Open Watch Party (OWP) v1 wire protocol and the behaviour of a relay. It is the reference for anyone who builds a relay, or a client that speaks the protocol directly.

The JSON Schemas in schema/ define the shape of each message. This document defines what the messages mean, and it wins where the two disagree. Client behaviour beyond the wire is in SDK.md. A relay built from this document, and a client built from it and SDK.md, interoperate with every conforming implementation.

The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL are to be interpreted as described in RFC 2119 and RFC 8174 when, and only when, they appear in all capitals.

Contents ​

  1. Overview (non-normative)
  2. Conventions
  3. Endpoints and transport
  4. Versioning and extension
  5. Relays, room codes and routing
  6. Messages: common rules
  7. Client → relay messages
  8. Relay → client messages
  9. Room snapshot
  10. Media descriptor
  11. Rooms and roles
  12. Playback state machine
  13. Limits and policy
  14. Persistence and restart
  15. Privacy
  16. Client behaviour

1. Overview (non-normative) ​

This section sketches how the protocol works before the rules begin.

People in a room watch one title together. Each person plays their own copy in their own app, and the room keeps the copies in sync.

The relay owns the timeline, the readiness barrier and the roles. Clients follow the relay, so a faulty client desyncs only its own user. Each participant has one WebSocket, which carries JSON text frames.

The relay's clock is the room's clock, and clients estimate it from ping and pong. Every change sends the whole room. The room carries the media descriptor only when the descriptor changes.

The descriptor names a title, never a source, and each client finds its own file. Positions are in the reference file's timeline. If there is a local offset, only the client applies it. There are no accounts.

A room lives on one relay for its whole life. The first character of the room's code names that relay.

2. Conventions ​

This section defines the terms, value types and notation that the rest of the document uses.

Terms ​

  • A relay implements §3–§15.
  • A client is an app that connects to a relay.
  • A connection is one WebSocket.
  • A participant is a person in a room, identified by a peer ID. A participant is connected while a connection is attached to it, and disconnected otherwise.
  • A member is a connection attached to a participant. Any other connection is outside a room.
  • The host has host rights. A controller may send play, pause, seek and rate. Both roles are defined in §11.3.

Numbers and strings ​

An int is a JSON number with an integral value and a magnitude of at most 2^53 − 1 (9007199254740991). Senders MUST write ints without a fraction or an exponent. Receivers MUST accept any integral number in range, so 3.0 is the int 3. A number is any JSON number.

String length counts Unicode code points. Bytes are the UTF-8 bytes of the JSON text as received or sent. Every message is valid UTF-8, as §3.1 requires.

Forbidden characters ​

A forbidden character is a character of either of these kinds:

  • General category Cc, which is U+0000–U+001F and U+007F–U+009F.
  • The Bidi_Control property, which is U+061C, U+200E, U+200F, U+202A–U+202E and U+2066–U+2069.

A forbidden character is allowed in no name, client, relay name, descriptor title, series.title or file.name.

Time ​

Times and durations are int milliseconds. Relay time is the relay's monotonic clock, counted from an origin the relay chooses, typically process start. Relay time is meaningless across restarts. serverTime and atMs are relay times.

Media time is a position in the reference file. positionMs and durationMs are media times.

Notation ​

Optional fields are marked ?. An optional field is either absent or a value of the field's type. null is a value only where the type lists it, and elsewhere it is a wrong type.

A policy value is written as its /v1/info key followed by the public value in parentheses, as in "the stall grace (stallGraceMs, 500)".

3. Endpoints and transport ​

A relay serves the protocol on one WebSocket endpoint and two plain HTTP endpoints. This section defines all three, and the limits a relay applies to connections.

3.1 The WebSocket: GET /v1/ws ​

The protocol runs over WebSocket (RFC 6455) at the path /v1/ws. Public relays use wss://. A relay MUST NOT refuse a connection because of its Origin header.

Messages ​

Each message is one JSON object, encoded in UTF-8 and sent in one WebSocket text message. The relay reassembles fragments first, and the limits apply to the whole message.

Three rules cover encoding, size and binary messages:

  • Clients MUST send valid UTF-8 (RFC 3629). A text message that is invalid anywhere MUST make the relay close the connection with 1007, without an error. This includes the parts the relay passes through: the media descriptor and the ping id.
  • A message over maxMessageBytes (8192) MUST make the relay close the connection with 1009, again without an error.
  • A binary message gets invalid_message, with re null.

In the other direction, clients MUST accept relay messages of at least 16384 bytes. Each relay message is one JSON object in one text message.

Message rate ​

Each connection has a token bucket. The bucket starts full at messageBurst (30) tokens and refills at messagesPerSecond (10) tokens per second. Every data message takes one token, whether it is valid or not. Control frames take none.

When the bucket is empty, the relay drops the message and answers it with rate_limited. The answer's re is set as §6.2 says. The connection stays open.

Keepalive ​

The relay sends a WebSocket ping frame every 30 s on every connection. It closes a connection from which no frame of any kind, whether data, ping or pong, has arrived for 60 s. Both values are protocol constants and are never scaled, not even by the test mode of §13.3. Client WebSocket stacks answer ping frames themselves.

Connection limits ​

The relay refuses a connection over either of two limits at upgrade, without upgrading it:

  • When one of the client IP's address groups already holds maxConnectionsPerIp (10) × its group factor open connections, the answer is HTTP 429.
  • When the relay already holds maxConnections (10000) open connections, the answer is HTTP 503.

When both apply, the answer is 503.

The client IP ​

The client IP is the TCP peer address. Behind a proxy that the operator configures as trusted, it is the first X-Forwarded-For address instead, falling back to the peer address when that header is missing. A relay MUST NOT trust that header unless it is configured to.

Behind a trusted proxy, an upgrade gets HTTP 400 when the first X-Forwarded-For value is not an IP address or carries a zone. An IPv4-mapped IPv6 address counts as the IPv4 address.

Address groups ​

The relay keeps its per-IP limits per address group. These limits cover connections, join lookups under §7.2 and rooms under §11.10. Each group has a factor:

addressgroupfactor
IPv4the address itself1
IPv6its /641
IPv6its /564, the key ipv6Factor56
IPv6its /4816, the key ipv6Factor48

A group's limit is the per-IP value × its factor. The factor multiplies the bucket size and the refill rate alike. A request counts in every group only when it is within the limit in all of them. A refused request counts in none.

Shutdown ​

On graceful shutdown, the relay first writes its persisted state, if it keeps one as §14 allows. It then closes every connection with status 1001.

3.2 GET /v1/info ​

GET /v1/info returns 200 with Content-Type: application/json and the body defined in §5.3. The response MUST carry Access-Control-Allow-Origin: *.

3.3 GET /health ​

While the relay accepts connections, GET /health returns 200 with Content-Type: text/plain and the body ok. The response MUST carry Access-Control-Allow-Origin: *.

4. Versioning and extension ​

This section says how the protocol may change within v1, and what a receiver does with values it doesn't know.

The major version is in the path, as in /v1/ws and /v1/info. A change that a v1 peer cannot safely ignore is a new major version. The same relay serves it beside v1, at /v2/….

Additive changes ​

Within v1, changes are additive. They add new optional fields, message types, descriptor ID namespaces, keys in /v1/info, limits and policy, and values of the open enums error.code, closed.reason and action.kind.

Every receiver MUST ignore unknown members at every depth. A client MUST ignore a relay message of unknown type. It MUST treat the other unknown values like this:

  • An unknown error.code is a refusal of the message named in re.
  • An unknown closed.reason is the end of the client's membership.
  • An unknown action.kind is an action the client does not describe.

The relay MUST answer a client message of an unknown type with unsupported, as §6.2 says.

The other enums are closed: phase, player, match.state, control and the media kind. A new value in one of them is v2.

JSON text rules ​

Two rules apply to the JSON text itself:

  • Senders MUST NOT send two members with the same name in one object.
  • Senders MUST NOT send a string escape that yields an unpaired surrogate. A \uXXXX escape in the range \uD800–\uDFFF is valid only as a high surrogate (\uD800–\uDBFF) directly followed by a low-surrogate escape (\uDC00–\uDFFF).

The relay refuses a message that breaks either rule with invalid_message, at the steps §6.2 gives.

5. Relays, room codes and routing ​

This section defines room codes, the public relay list, the /v1/info body and how a client picks the relay for each request.

5.1 Room codes ​

A room code is 6 symbols from the alphabet 23456789ABCDEFGHJKMNPQRSTUVWXYZ. The alphabet has 31 symbols, with no 0, 1, I, L or O.

The relay always sends codes in upper case. Clients MAY display them in groups, as in K7M-Q2X. A client MAY send a code in join or resume as the user typed it.

The relay normalizes a received code in three steps:

  1. It removes the separators U+0020, U+0009, U+000A, U+000D, U+00A0, U+2007, U+202F, U+002D, U+2010–U+2014 and U+2212.
  2. It converts ASCII a–z to upper case.
  3. It looks the result up exactly. A result that is not 6 alphabet symbols names no room.

At creation, the relay draws the code with a CSPRNG. It draws the first symbol uniformly from its creation prefixes, which §5.2 defines, and the rest uniformly from the alphabet. The code MUST differ from the codes of every room the relay holds, including rooms restored under §14. The relay redraws on a collision.

A relay MUST answer join and resume for every room it holds, whatever the code's first symbol.

5.2 The relay list ​

The relay list names the public relays. It is the file relays.json at the repository root, served unchanged at https://openwatch.party/relays.json. Its schema is relays.schema.json.

Relays publish no list of their own. /v1/info describes only the relay that serves it, as §5.3 shows.

This example list of three relays leaves the symbols QRSTUVWXYZ to no relay:

json
{
  "relays": [
    { "id": "a", "name": "Relay A", "url": "wss://a.relay.example/v1/ws", "prefixes": "2345678" },
    { "id": "b", "name": "Relay B", "url": "wss://b.relay.example/v1/ws", "prefixes": "9ABCDEF" },
    { "id": "c", "name": "Relay C", "url": "wss://c.relay.example/v1/ws", "prefixes": "GHJKMNP" }
  ]
}
fieldtyperule
relaysarray of relay entries1 or more entries, in the order clients try them for create under §5.4
relays[].idstring1–32 characters of a–z, 0–9 and -, starting with a letter or digit, unique in the list and never reused for another relay
relays[].namestring1–64 characters with no forbidden characters (§2), for display
relays[].urlstringthe URL of the relay's WebSocket endpoint, written as the rules below the table say
relays[].prefixesstring1–31 distinct upper-case symbols of the code alphabet, which are the first symbols this relay owns

The url field ​

A relay's url follows these rules:

  • It is an absolute wss:// URL that ends in /v1/ws, without a query or fragment.
  • It may carry a path prefix before that ending, as in wss://h/owp/v1/ws.
  • It holds printable ASCII only, U+0021–U+007E, so it has no spaces, control characters or non-ASCII characters. A non-ASCII host name is written in its xn-- form.
  • It has no / in the host, and no ? or # anywhere.

A relay's HTTP endpoints derive from its WebSocket URL. Replace wss:// with https://, or ws:// with http://, and replace the trailing /v1/ws with /v1/info or /health.

Prefixes ​

No symbol may appear in the prefixes of two relays. A list may leave symbols unassigned. No relay creates codes with an unassigned symbol, and a later list may give it to a new relay. A code whose first symbol no relay of the list owns cannot be joined through the list.

A relay creates codes only with its creation prefixes. These are distinct symbols of the code alphabet, set by the relay's operator, and all 31 by default. A public relay's operator MUST keep its creation prefixes within its entry's prefixes. The relay doesn't check this.

Creation prefixes differ from the list's prefixes only while symbols move between relays. The old relay first stops creating codes with the moving symbols. At least maxRoomAgeMs (86400000) later, the list moves them.

5.3 /v1/info body ​

This is the body that GET /v1/info returns. Its schema is info.schema.json.

json
{
  "protocol": 1,
  "id": "b",
  "limits": {
    "maxParticipants": 8, "maxMessageBytes": 8192, "maxMediaBytes": 4096, "maxExtBytes": 1024,
    "maxNameLength": 32, "maxClientLength": 64, "minRate": 0.5, "maxRate": 2.0,
    "maxConnections": 10000, "maxConnectionsPerIp": 10, "maxRooms": 10000, "maxRoomsPerIp": 3,
    "ipv6Factor56": 4, "ipv6Factor48": 16, "messageBurst": 30, "messagesPerSecond": 10,
    "joinBurst": 20, "joinsPerSecond": 1, "joinBurstTotal": 2000, "joinsPerSecondTotal": 200
  },
  "policy": {
    "stallGraceMs": 500, "stallReleaseMs": 400, "waitMs": 15000, "mediaWaitMs": 30000, "exclusionMs": 60000,
    "startDelayMinMs": 750, "startDelayMaxMs": 2000, "startDelayRttFactor": 1.5, "defaultRttMs": 500,
    "seekCoalesceMs": 200, "pauseToleranceMs": 2000, "hostPromotionMs": 30000, "resumeMs": 300000,
    "maxRoomAgeMs": 86400000
  }
}
fieldtyperule
protocolint1
idstringthis relay's ID, set by its operator, which for a public relay is its id in the relay list (§5.2)
limitsobjectevery key of §13.1 and §13.2 with this relay's value
policyobjectevery key of §13.3 with this relay's value

A relay MUST report every key listed in §13. The policy values are the durations in effect, after any test-mode scaling under §13.3.

/v1/info serves operators and test suites. Clients read nothing from it, as §13 explains.

5.4 Choosing the relay (client) ​

This section says which relay a client connects to for each request. A client takes its relays from one of two sources: the public list or a custom source.

The public list ​

With the public list, the client ships a copy of the repository's relays.json. It refreshes that copy only from https://openwatch.party/relays.json. It uses a fetched list only when the list is valid by §5.2, and otherwise keeps the one it has.

Refreshing is reading that URL again. SDK.md §3 defines when a client refreshes, and the HTTP limits.

A custom source ​

A custom source is one self-hosted relay's base URL, such as https://relay.example.com. The client uses that relay alone for every create, join and resume, and fetches no list. The relay's WebSocket URL is the base URL with https:// changed to wss://, or http:// to ws://, plus /v1/ws.

Creating ​

To create with several relays, the client connects to all of them at once. It creates on the first one to answer its clock sync, as SDK.md §6 describes.

On relay_full, the client creates on the first relay it has not tried, in list order. It fails when every relay answered relay_full.

Joining and resuming ​

To join, the client connects to the relay whose prefixes contain the code's first symbol, compared case-insensitively. To resume, it goes back to the relay the party joined on.

When no relay owns the first symbol, the client refreshes the list, at most once per join. If still no relay owns it, joining fails as room_not_found would.

On room_not_found answering a join, the client refreshes the list within that same allowance. If the first symbol now belongs to a relay with another url, the client retries the join there once.

A custom source's relay gets every join, whatever the first symbol, and its room_not_found is final.

6. Messages: common rules ​

This section gives the rules that every message follows, and the order in which the relay validates a client's message.

6.1 Envelope ​

Every message, in both directions, is a JSON object with a string member type that names the message. type values are case-sensitive.

Each message type in §7 and §8 has a schema, schema/<type>.schema.json. Shared definitions are in common.schema.json.

6.2 Validation and refusals ​

The relay has already checked size and UTF-8 under §3.1. It then checks each client message in this order, and stops at the first refusal:

  1. Rate limit. A message that finds the connection's bucket of §3.1 empty gets rate_limited.
  2. Envelope. The message gets invalid_message in any of these cases:
    • It is not valid JSON, or not an object.
    • It has no string type member, or it has more than one type member.
    • Its type value has an unpaired surrogate escape (§4).
  3. Type. A type name that is not defined for client → relay messages gets unsupported. The names of relay → client types count as unknown.
  4. Shape. The message gets invalid_message when it breaks any of the shape rules listed below this list.
  5. Membership. A room command from a connection outside a room gets not_in_room. Room commands are every type except create, join, resume and ping. A create, join or resume from a member gets already_in_room.
  6. The message-specific checks of §7 run last, in the order each message's section lists them: access, per-IP allowances, permission (forbidden), then room state.

Shape rules ​

At step 4, a message breaks the shape rules when any of these holds:

  • A required field is missing, or a field has the wrong type.
  • A value is out of range, or a string breaks its rule.
  • A closed enum has an unknown value.
  • An object at any depth, media and ext included, has two members of the same name, which §4 forbids.
  • A string anywhere else, member names included, has an unpaired surrogate escape, which §4 also forbids.

Member names are compared after decoding escapes, so "a" and "\u0061" are the same name. Unknown members are ignored, never refused.

Refusals and replies ​

A refused message changes no room state and gets exactly one error, the message of §8.5. The error's re is the message's type when the envelope passes step 2, and null otherwise. This holds for every refusal, rate_limited included. A refusal never closes the connection.

An accepted message gets no acknowledgement. Its effects are the reply: joined, pong, room or closed, as §7 defines for each message. leave gets no reply.

6.3 name and client ​

name is a display name of 1–32 characters (maxNameLength) with no forbidden characters (§2). It need not be unique, and the relay stores it unchanged.

client names the app and its version as <app>/<version>, as in Example/1.31.0. It is 1–64 characters in all (maxClientLength), with no forbidden characters. Its two parts are split at the first /:

  • <app> is everything before that first slash, and is at least 1 character long.
  • <version> is everything after it, and is at least 1 character long. It may contain more slashes.

A client without a slash, or with an empty part, gets invalid_message.

<app> MUST name the real app sending the message. Every participant sees client. The relay's deny list of §11.9, its usage counts of §15 and the terms of use are all keyed on the app name.

7. Client → relay messages ​

This section defines each message a client sends: its fields, who may send it, its refusals and its effect.

Every field is required unless marked ?. Each message's section lists its fields, then its refusals in check order, then its effect. The refusals listed come after §6.2 steps 1–5. The snapshot each message causes follows §9.2.

typefieldsallowed for
createname, clienta connection outside a room
joincode, name, clienta connection outside a room
resumecode, peerId, tokena connection outside a room, for the participant it names
pingidany connection
statusepoch, player, match, offsetMs, rttMs?members
play—controllers
pausepositionMs?controllers
seekpositionMscontrollers
rateratecontrollers
setMediamedia, positionMs?the host
clearMedia—the host
setControlmodethe host
transferHostpeerIdthe host
kickpeerIdthe host
locklockedthe host
leave—members
end—the host

7.1 create ​

fieldtyperule
namestring§6.3
clientstring§6.3

Refusals, in order:

  1. client_blocked when the deny list of §11.9 refuses the app.
  2. rate_limited when an address group of the client IP is the creator of its limit of rooms (§11.10).
  3. relay_full when the relay holds maxRooms (10000) rooms, as §11.10 describes.

If no refusal applies, the relay makes a new room with a new code (§5.1) and the defaults of §9.3. The sender becomes the room's first participant (§11.1) and its host. The relay sends joined to the sender with the first snapshot, whose rev is 1.

7.2 join ​

fieldtyperule
codestringany string, matched as §5.1 says
namestring§6.3
clientstring§6.3

Refusals, in order:

  1. client_blocked when the deny list of §11.9 refuses the app.
  2. rate_limited when the client IP or the relay has no join allowance left, as described below.
  3. room_not_found when no room has this code.
  4. room_full when the room has maxParticipants (8) participants, disconnected ones included.
  5. room_locked when the room is locked.

The join allowance works with token buckets:

  • Each address group has a bucket of joinBurst (20) tokens, refilled at joinsPerSecond (1) per second, both times the group's factor.
  • The relay has one bucket of joinBurstTotal (2000) tokens, refilled at joinsPerSecondTotal (200) per second.

A join that reaches step 2 takes one token from each bucket when every one has a token, whether or not the code exists. Otherwise it takes none and is refused.

If no refusal applies, the sender becomes a new participant at the end of the join order (§11.1), and the host promotion of §11.4 runs. The relay sends joined to the sender and a snapshot to the other connected members.

7.3 resume ​

fieldtyperule
codestringmatched as §5.1 says
peerIdstringany string
tokenstringany string

Refusals, in order:

  1. resume_failed when no room has this code, when the room has no participant with this peerId, or when token is not that participant's token.
  2. client_blocked when the deny list of §11.9 refuses the participant's stored client.

A participant is missing when it never existed, or when it left, was kicked or was removed. The relay compares tokens in constant time, and the refusal is the same in each case of step 1. resume never takes a join token from the buckets of §7.2.

If no refusal applies, the relay does this:

  • If the participant is connected, the relay closes its old connection with status 4000 and sends that connection nothing else.
  • The sender's connection becomes the participant's.
  • The participant keeps its peer ID, token, name, client, place in the join order, host flag, player, match, offsetMs, reported rttMs, exclusion and status epoch.
  • Its reservation timer stops (§11.5).
  • If it is the host, the host's absence ends (§11.4). Otherwise, host promotion runs.
  • waitingOn is not recomputed. The resumer's next status does that.

The relay sends joined to the sender and a snapshot to the other connected members, even when no snapshot field changed.

7.4 ping ​

fieldtyperule
idstring or numberchosen by the client and echoed unchanged

ping is allowed on any connection, in a room or not. It has no refusals beyond §6.2 steps 1–4, and no effect on the room.

At once, the relay answers the sender with the pong of §8.4. It carries the same id value, with its bytes echoed, and a serverTime read as late as possible before sending.

7.5 status ​

fieldtyperule
epochint≥ 0: the room epoch this report is about
playerstringone of the four values in the first table below
matchobject or nullnull while the client hasn't tried to match, and otherwise the match object below
match.statestringone of the four states in the second table below
match.deltaMs?intown file's duration − durationMs of the media, positive when the own file is longer
offsetMsint−604800000 ≤ offsetMs ≤ 604800000 (± 7 days, §13.1): the local offset this participant plays with, of either sign, as §16.4 defines it
rttMs?int≥ 0: the client's current best round trip to the relay, as §16.2 defines it

player values ​

valuemeaning
idlenot in the player, because there is no media or the user is not playing it
loadingopening, searching, seeking, waiting on the user's choice after a mismatch, or waiting for the user to start a player that refused to play
readyclose enough to the room's position to follow it without seeking, as §16.4 says, and able to play at once
bufferingwas playing and is now stalled

match.state values ​

valuemeaning
searchingcandidates are being tried, or the user has not decided about a mismatch
matchedthe chosen file's duration is within 1500 ms of durationMs
mismatchedthe user plays a file outside that margin, with an offset
unavailableno file plays, because none was found or the user declined

deltaMs may accompany any state. It gives the difference for the file chosen or offered.

Effect ​

The relay stores player, match and offsetMs, and it stores epoch as the participant's status epoch. It stores rttMs when present, and otherwise keeps the last one. The peer-event rules of §12.5 then run, and stall handling follows §12.9.

A participant has reported unavailable for the current epoch when its latest status has an epoch equal to the room's and a match.state of unavailable.

The relay sends a snapshot when any snapshot field changed. That field can be this participant's player, match or offsetMs, or a room field that §12.5 changed. A status that changes only epoch or rttMs sends nothing.

7.6 play ​

play has no fields.

Refusals, in order:

  1. forbidden when the sender is not a controller.
  2. invalid_message when the room has no media, which means phase is idle.

In paused, the relay sets the action play by the sender (§9.1). The room then enters waiting at positionMs with timeout waitMs (15000), as §12.4 describes. In playing or waiting, nothing happens.

7.7 pause ​

fieldtyperule
positionMs?int≥ 0: where the sender's player stopped, in media time

Refusals, in order:

  1. forbidden when the sender is not a controller.
  2. invalid_message when the room has no media.

In playing or waiting, the room pauses as §12.1 defines, and the relay sets the action pause by the sender. The new positionMs depends on the field:

  • When the field is present and |positionMs − P(now)| ≤ pauseToleranceMs (2000), the new position is min(positionMs, durationMs).
  • Otherwise, the new position is P(now).

In paused, nothing happens.

7.8 seek ​

fieldtyperule
positionMsint≥ 0: the target in media time

Refusals, in order:

  1. forbidden when the sender is not a controller.
  2. invalid_message when the room has no media.

The target is min(positionMs, durationMs). The relay applies the seek now, or holds it under §12.8. Applying a seek first sets the action seek by its sender, and then depends on the phase:

  • In paused, the room sets positionMs to the target and stays paused.
  • In playing or waiting, the room enters waiting at the target with timeout waitMs (15000).

The snapshot goes out at the moment the seek is applied.

7.9 rate ​

fieldtyperule
ratenumberminRate (0.5) ≤ rate ≤ maxRate (2.0)

Refusals, in order:

  1. forbidden when the sender is not a controller.
  2. invalid_message when the room has no media.

If no refusal applies, the relay sets the action rate by the sender. In playing, it then works in this order:

  1. If now ≥ atMs, it first re-anchors the timeline: positionMs becomes P(now), and atMs becomes now.
  2. It sets rate to the new rate.
  3. It reschedules the end-of-media timer (§12.11).

In paused and waiting, only the rate changes, and it applies when playback starts.

7.10 setMedia ​

fieldtyperule
mediaobjecta media descriptor (§10), at most maxMediaBytes (4096) bytes as received
positionMs?int≥ 0: where the new media starts, 0 by default

A sender other than the host gets forbidden.

Otherwise, the message works in any phase. It has these effects, in order:

  1. epoch increases by 1, and the relay stores media.
  2. The held seek of §12.8, if there is one, is dropped, and every exclusion of §12.7 is cleared.
  3. positionMs becomes min(positionMs or 0, durationMs).
  4. The relay sets the action media by the sender.
  5. The room enters waiting at that position with timeout mediaWaitMs (30000). Every connected participant counts as loading until it reports status with the new epoch (§12.3).

As §8.3 says, the room this publishes carries the new media.

7.11 clearMedia ​

clearMedia has no fields. A sender other than the host gets forbidden.

When the room has no media, nothing happens: there is no error and no snapshot. Otherwise, the command is effective, and it has these effects in order:

  1. epoch increases by 1, and the media becomes null.
  2. Every playback timer stops (§12.12), a held seek is dropped, and every exclusion is cleared.
  3. phase becomes idle, positionMs becomes 0 and waitingOn becomes empty. rate and atMs are unchanged.
  4. The relay sets the action media by the sender.

As §8.3 says, the room this publishes carries media null.

7.12 setControl ​

fieldtyperule
modestringhost or everyone

A sender other than the host gets forbidden. Otherwise, control becomes mode, with the meaning §11.3 gives.

7.13 transferHost ​

fieldtyperule
peerIdstringa participant of the room, connected or not

Refusals, in order:

  1. forbidden when the sender is not the host.
  2. invalid_message when no participant has this peer ID.

Naming the current host changes nothing. Otherwise, the named participant becomes host, and the relay sets the action host by the sender. If the new host is disconnected, its absence starts now (§11.4).

7.14 kick ​

fieldtyperule
peerIdstringa participant of the room, connected or not, the host itself included

Refusals, in order:

  1. forbidden when the sender is not the host.
  2. invalid_message when no participant has this peer ID.

If no refusal applies, the kick works in two steps:

  1. If the target is connected, it gets closed with reason kicked. Its connection stays open, outside any room.
  2. The target is removed as §11.6 describes, so its peer ID and token stop working.

The relay sends closed to the target and a snapshot to the remaining connected members, unless the room was deleted.

7.15 lock ​

fieldtyperule
lockedbooleantrue refuses new joins, and false allows them

A sender other than the host gets forbidden. Otherwise, the room's locked becomes the value sent, with the effect §11.7 describes.

7.16 leave ​

leave has no fields and no refusals beyond §6.2.

The relay removes the sender as §11.6 describes. The sender's connection stays open, outside any room, and the relay never sends it anything about the room again. The relay sends a snapshot to the remaining connected members, unless the room was deleted, and nothing to the sender.

7.17 end ​

end has no fields. A sender other than the host gets forbidden.

Otherwise, the relay sends closed with reason ended to every connected member, the sender included. The room is deleted as §11.8 describes, and every connection stays open, outside any room.

8. Relay → client messages ​

This section defines the messages the relay sends. The relay sends messages to one connection in the order it produced them. For one event of §9.2, each connection gets at most one snapshot.

typefields
joinedcode, you (peerId, token), room, media, serverTime
roomthe snapshot fields of §9, action?, media?
pongid, serverTime
errorcode, message, re
closedreason

8.1 joined ​

joined answers an accepted create, join or resume, and starts the connection's membership.

fieldtyperule
codestringthe room code, upper case
youobjectthis connection's participant
you.peerIdstring§11.1
you.tokenstring§11.1, kept by the client to resume
roomobjectthe snapshot of §9.1, without type, action or media
mediaobject or nullthe room's media descriptor exactly as the host sent it, or null without media
serverTimeintrelay time when the message was sent

8.2 room ​

A room message is the snapshot. It has type room, every field of §9.1, and two optional fields:

fieldtyperule
action?objectthe action set by the event that published this snapshot (§9.1), absent when that event set none
media?object or nullpresent only on the snapshot that starts a new epoch, as §8.3 says

8.3 Media in room ​

A room carries media exactly when it is the snapshot that setMedia or an effective clearMedia publishes, the one whose epoch is new. The field then holds the descriptor exactly as the host sent it, or null after clearMedia. Every other room leaves it out.

A participant that was disconnected at that moment gets the current media in joined when it resumes.

"Exactly as the host sent it", here and in joined, means the same JSON value: the same members and values, nested and unknown members included. A relay SHOULD forward the bytes it received.

8.4 pong ​

The relay sends this message in answer to a ping, as §7.4 describes.

fieldtyperule
idstring or numberthe id of the ping, unchanged
serverTimeintrelay time when the message was sent

8.5 error ​

The relay sends this message when it refuses a client's message.

fieldtyperule
codestringan error code of Appendix A, which is an open enum (§4)
messagestringEnglish text for logs and developers, which clients MUST NOT parse
restring or nullthe type of the refused message, or null when its envelope was invalid (§6.2 step 2) or it was binary

8.6 closed ​

fieldtyperule
reasonstringone of the three reasons in the next table
reasonmeaning
endedthe host ended the room
kickedthe host removed this participant
expiredthe room reached its maximum age, as §11.8 says

After closed, the connection is outside any room and stays open. The relay sends it nothing more about that room, and it may create, join or resume again.

9. Room snapshot ​

The snapshot is the state of a room that every member sees. This section defines its fields, when the relay sends it and what a new room holds.

9.1 Fields ​

The snapshot has two schemas: snapshot.schema.json for the fields as they appear in joined.room, and room.schema.json for the room message.

json
{
  "type": "room",
  "rev": 42,
  "code": "K7MQ2X",
  "epoch": 3,
  "phase": "paused",
  "positionMs": 61234,
  "atMs": 918273645,
  "rate": 1.0,
  "control": "host",
  "locked": false,
  "waitingOn": [],
  "participants": [
    {
      "peerId": "p1", "name": "Edde", "client": "Example/1.31.0", "host": true, "connected": true,
      "player": "ready", "match": { "state": "matched", "deltaMs": -120 }, "offsetMs": 0
    }
  ],
  "action": { "kind": "pause", "by": "p1" }
}

Every field below is always present. A room message adds action and media, as §8.2 shows.

fieldtyperule
revint≥ 1, as §9.2 describes
codestringthe room code, upper case
epochint≥ 0, increased by 1 on every setMedia and every effective clearMedia
phasestringidle (no media), paused, waiting (will play once the barrier releases) or playing
positionMsint0 ≤ positionMs ≤ the media's durationMs, so at most 604800000, and 0 in idle
atMsint≥ 0, in relay time. In playing, the moment playback runs from positionMs. Clients ignore it in other phases.
ratenumberminRate ≤ rate ≤ maxRate
controlstringhost or everyone
lockedboolean
waitingOnarray of stringspeer IDs the barrier waits for, in join order, and empty except in waiting
participantsarrayevery participant, connected or not, in join order, and never empty
participants[].peerIdstring§11.1
participants[].namestring§6.3
participants[].clientstring§6.3
participants[].hostbooleantrue for exactly one participant
participants[].connectedbooleanwhether a connection is attached
participants[].playerstringas last reported in status, and idle until the first
participants[].matchobject or nullas last reported, without deltaMs when the status didn't carry it, and null until the first status
participants[].offsetMsintas last reported, so within ± 604800000, and 0 until the first status

Actions ​

An action says who caused a change, so that apps can show it, for example in a toast. An event sets an action as the second table below says, and the room messages that event publishes carry it as action.

fieldtyperule
action.kindstringone of the kinds in the second table below, from an open enum (§4)
action.bystring or nullthe peer ID that caused it, or null for the relay. It may name a participant no longer in the room.
kindset byby
playplay from pausedthe sender
pausepause from playing or waitingthe sender
seekan applied seekthe seek's sender
rateratethe sender
mediasetMedia, effective clearMediathe sender
stalla stall grace that ran out under §12.9the stalled participant
endedend of media under §12.11null
hosttransferHostthe sender
hosthost promotion under §11.4, or a removed host's replacement under §11.6null

Nothing else sets an action. An event that sets an action always publishes a snapshot, even when no snapshot field changed. An event that sets several actions carries the last one.

Each snapshot that carries an action reports a new one. joined.room never carries one, and the relay neither persists nor repeats an action.

9.2 When snapshots are sent and rev ​

An event is one inbound message, one connection closing, or one timer firing.

After every event that changes any snapshot field, the relay publishes one snapshot. rev increases by 1, and every connected member gets the snapshot, the actor included. One event that changes several fields publishes one snapshot.

An event that changes no snapshot field publishes none, with two exceptions. resume always publishes, as §7.3 says, and so does an event that sets an action under §9.1.

create, join and resume deliver the snapshot to the sender inside joined. The other connected members get room.

rev starts at 1 at creation and increases by exactly 1 per published snapshot. On one connection, each snapshot therefore has a higher rev than the one before it. A client drops a room whose rev is not higher than that of the last snapshot it accepted on that connection.

joined resets the comparison. The client accepts the snapshot it carries, whatever its rev, and compares later room messages with it. This covers a relay restart under §14 that makes rev go back.

9.3 A new room ​

A new room starts with these values:

fieldvalue
rev1
epoch0
phaseidle
positionMs0
atMsthe relay time of creation
rate1
controlhost
lockedfalse
waitingOnempty

It has one participant, the creator, with host true, connected true, player idle, match null and offsetMs 0.

10. Media descriptor ​

The room plays a title described by a media descriptor, or descriptor for short. Each client resolves the descriptor to its own file, as §16.5 says. The schema is descriptor.schema.json.

json
{
  "kind": "episode",
  "title": "Pilot",
  "ids": { "imdb": "tt0959621", "tvdb": "349232" },
  "series": { "title": "Breaking Bad", "year": 2008, "ids": { "imdb": "tt0903747", "tmdb": "1396", "tvdb": "81189" } },
  "season": 1,
  "episode": 1,
  "durationMs": 3487000,
  "file": { "sizeBytes": 2147483648, "oshash": "8e245d9679d31e12", "name": "Breaking.Bad.S01E01.1080p.mkv" },
  "ext": { "com.example": { "server": "1a2b3c", "item": "4711" } }
}
fieldtyperule
kindstringmovie, episode or video, where a video is a file with no catalogue identity, such as a home video, a recording or a clip
titlestringthe movie's, episode's or video's title, 1 or more characters with no forbidden characters (§2)
year?intrelease year
ids?objecta map from namespace to ID, described below, holding the episode's own IDs for an episode
series?objectREQUIRED when kind is episode
series.titlestring1 or more characters with no forbidden characters, as defined in §2
series.year?intfirst-air year
series.ids?objecta map from namespace to the series' IDs
season?int≥ 0, as the setter's source numbers it, with 0 for specials
episode?int≥ 0, as the setter's source numbers it
durationMsint1 ≤ durationMs ≤ 604800000 (7 days, §13.1): the reference file's duration, from its player or from reliable metadata
file?objecthints that pick the same release among candidates
file.sizeBytes?int≥ 0
file.oshash?stringthe OpenSubtitles hash: 16 lower-case hex digits
file.name?stringthe file name without directories, 1 or more characters, following the file-name rules below
ext?objectapp-private hints, at most maxExtBytes (1024) bytes as received

IDs ​

Each ids member maps a namespace to a non-empty string, even where the catalogue uses numbers, as in "tmdb": "1396". The v1 namespaces are imdb, tmdb, tvdb, plex, anilist, anidb, kitsu and mal.

  • A plex ID is a plex:// GUID.
  • A movie's tmdb is a movie ID, and a series' is a TV ID.

Unknown namespaces are ignored. Adding a namespace is a documentation change.

App-private hints ​

Each ext member name is the owning app's reverse-DNS name, such as com.example. It has two or more dot-separated labels of a–z, 0–9 and -. The values are JSON defined by the owner, and apps ignore members they do not own.

File name ​

file.name MUST NOT contain /, \ or a forbidden character, such as NUL. It MUST NOT be . or ... Other names that start with a dot, such as .Arrival.mkv or ..., are allowed. A name is a hint for matching, never a path.

Size ​

The whole descriptor is at most maxMediaBytes (4096) bytes. Descriptor and ext sizes are measured as the JSON text of the value as received.

Nothing that fetches media ​

A sender MUST NOT put any of these in any field, ext included: URLs, magnet links, info hashes, stream links, or anything else that locates or fetches media. A receiver never treats descriptor content as a path, URL or host. ids values are identifiers, not locators.

What the relay checks ​

The relay checks the schema, the table above, and the rules for ids, ext names, file names and size. It refuses a violation with invalid_message under §6.2. It does not check the rule against fetching media.

The relay reads only durationMs. The rest of the descriptor is opaque to it and forwarded unchanged.

11. Rooms and roles ​

This section defines participants and their states, the host and controller roles, and how participants leave and rooms end.

11.1 Participants ​

A participant has a peer ID, a token, a name, a client, a place in the join order, and the status fields of §7.5.

  • A peer ID is 1–16 characters of A–Z, a–z, 0–9, _ and -. It is unique in the room and never reused within one room's life.
  • A token is 32 bytes from a CSPRNG, written in base64url without padding as 43 characters.
  • The join order is the order in which participants entered the room. resume keeps a participant's place.

A room holds at most maxParticipants (8) participants, connected or not.

11.2 Membership states ​

A participant is in one of three states:

  • A connected participant has a connection attached.
  • A disconnected participant has none, and is reserved for resume.
  • A removed participant has left the snapshot, and its peer ID and token stop working.

11.3 Host and controllers ​

A room always has exactly one host, and the creator is the first. The host-only commands are setMedia, clearMedia, setControl, transferHost, kick, lock and end.

Controllers may send play, pause, seek and rate. The room's control decides who is a controller:

  • With control host, the host is the only controller.
  • With control everyone, every participant is a controller.

These four commands from a non-controller, and host-only commands from anyone but the host, get forbidden.

11.4 Host absence and promotion ​

The host is absent while it is disconnected. Absence starts at one of these moments:

  • When the host disconnects.
  • When a disconnected participant becomes host, through transferHost or the removal of the previous host.
  • When the relay restores the room after a restart under §14.

Absence ends when the host connects again.

When the host has been absent for hostPromotionMs (30000) and at least one participant is connected, the connected participant earliest in join order becomes host. The relay sets the action host by null. With nobody connected, the host stays.

The relay checks for promotion when the absence reaches hostPromotionMs. It checks again after every join and resume while the absence is that long or longer.

11.5 Disconnect ​

When a member's connection closes for any reason other than a resume takeover, the relay works in this order:

  1. The participant stays, with connected false. Its player, match and offsetMs are kept.
  2. Its stall grace of §12.9 stops, and its reservation timer starts. After resumeMs (300000) disconnected, it is removed as §11.6 describes, unless it resumed.
  3. If it is the host, its absence under §11.4 starts.
  4. The peer-event rules of §12.5 run.

The relay sends a snapshot to the connected members.

11.6 Removal ​

Three things remove a participant: leave, kick and an expired reservation. Removal works in order:

  1. The participant leaves participants and waitingOn. Its peer ID and token stop working, and its stall grace and reservation timer stop.
  2. If no participant remains, the room is deleted as §11.8 describes, and the later steps do not run.
  3. If the participant was the host, the host passes at once to the connected participant earliest in join order. With nobody connected, it passes to the participant earliest in join order. The relay sets the action host by null, and a disconnected new host is absent from now (§11.4).
  4. The peer-event rules of §12.5 run.

The relay sends one snapshot to the remaining connected members.

11.7 Lock ​

A locked room refuses join with room_locked, but resume still works. A kicked participant cannot come back into a locked room. Into an unlocked room, it can join again with the code.

11.8 End, expiry and deletion ​

A room is deleted in three cases:

  • When its last participant is removed by leave, kick or an expired reservation under §11.6.
  • By end, as §7.17 describes.
  • At maxRoomAgeMs (86400000, 24 hours) after creation. Every connected member first gets closed with reason expired.

Deletion has these effects:

  • Every timer stops, and the relay forgets the media, the participants and the tokens.
  • The room leaves the persisted state of §14.
  • The code names no room afterwards, so it answers room_not_found and resume_failed, and it may be drawn again.
  • Connections that were members stay open, outside any room.

11.9 Refused clients (client_blocked) ​

A relay MAY keep a deny list of app names, matched against the <app> of §6.3. The operator's terms of use decide its entries. The relay compares the app name with each entry case-insensitively for ASCII, and exactly otherwise.

These messages get client_blocked:

  • A create or join whose client names a denied app.
  • A resume of a participant whose stored client names a denied app.

A relay MUST apply a changed deny list without a restart. A change does not affect connections already in rooms.

11.10 Rooms per IP and room cap ​

The relay remembers, in memory only, the client IP that created each room, and counts rooms per address group of that IP as §3.1 defines. While a group is the creator of maxRoomsPerIp (3) × its group factor rooms that the relay holds, a create from it gets rate_limited. Rooms restored under §14 count for no IP.

While the relay holds maxRooms (10000) rooms, restored ones included, create gets relay_full, and the client creates on another relay as §5.4 says. Restoring rooms after a restart is never refused.

12. Playback state machine ​

This section defines the room's playback phases and position, the readiness barrier, and the timers that drive them.

12.1 Phases ​

A room is in one of four phases:

phasemeaning
idleThere is no media. play, pause, seek and rate from a controller get invalid_message.
pausedThe position holds at positionMs.
waitingThe position holds at positionMs while the barrier waits for waitingOn.
playingThe position advances from positionMs at atMs with rate.

Pausing at p stops every playback timer (§12.12) and sets phase to paused, positionMs to p and waitingOn to empty. It leaves atMs and rate unchanged.

12.2 Position ​

P(t) is the room's position at relay time t:

  • In playing, it is min(durationMs, floor(positionMs + max(0, t − atMs) × rate)), computed in IEEE 754 double precision with the floor of the whole sum.
  • In paused, waiting and idle, it is positionMs.

durationMs is the current media's. Every positionMs the relay stores is between 0 and durationMs.

A playing room with atMs in the future is a scheduled group start. Clients hold at positionMs and start at atMs.

12.3 Counted and ready ​

The barrier works with three derived terms: the effective player, counted and ready.

A participant's effective player is its reported player when its latest status has the room's epoch. It is loading when its latest status is for another epoch, or when it has sent none.

A participant is counted when all of these hold:

  • It is connected.
  • It is not excluded under §12.7.
  • Its effective player is not idle.
  • It has not reported unavailable for the current epoch, as §7.5 defines.

A participant is ready when its status epoch is the room's epoch and its player is ready. So a counted participant whose latest status is for another epoch is not ready.

After setMedia, everyone connected counts as loading until it reports on the new media. A status in flight for the old media changes nothing.

12.4 Entering waiting ​

To enter waiting at p with timeout T, the relay works in this order:

  1. Every playback timer stops.
  2. phase becomes waiting, and positionMs becomes p.
  3. The wait is a stall wait if a stall grace of §12.9 caused it, and not otherwise.
  4. waitingOn becomes the counted participants that are not ready, in join order.
  5. The barrier timer of §12.7 starts with T.
  6. If waitingOn is empty, the room releases at once (§12.6).

12.5 Peer-event rules ​

These rules run after every status, disconnect (§11.5) and removal (§11.6). They run in this order:

  1. Nobody in the player. If phase is playing or waiting and no connected participant has an effective player other than idle, the room pauses at P(now) without setting an action, and the rules stop.
  2. Barrier. If phase is waiting, waitingOn is recomputed as the counted participants that are not ready, in join order. Then:
    • If waitingOn is non-empty, the stall release timer stops.
    • If it is empty and the wait is not a stall wait, the room releases at once.
    • If it is empty in a stall wait and the stall release timer is not running, that timer starts with stallReleaseMs (400). When it fires, the room releases if it is still waiting with waitingOn empty.

These rules run only after these events. They do not run after commands, join, resume or an exclusion ending, and the effects of those show at the next peer event.

12.6 Release ​

To release, the relay works in this order:

  1. The barrier and stall release timers stop.
  2. phase becomes playing, and waitingOn becomes empty.
  3. atMs becomes now + clamp(floor(startDelayRttFactor × maxRtt), startDelayMinMs, startDelayMaxMs). With the public values, that is now + clamp(floor(1.5 × maxRtt), 750, 2000).
  4. No action is set.
  5. The end-of-media timer is scheduled (§12.11).

maxRtt is the highest rttMs reported by a counted participant. A counted participant that never reported one counts with defaultRttMs (500). When no participant is counted, maxRtt is defaultRttMs.

12.7 Barrier timeout and exclusion ​

The barrier timer starts each time the room enters waiting, with waitMs (15000), or with mediaWaitMs (30000) after setMedia. Leaving waiting stops it.

When the barrier timer fires, every participant then in waitingOn is excluded until now + exclusionMs (60000), and the room releases. Excluded participants catch up by themselves, and they do not hold the room until the exclusion ends.

An exclusion survives disconnect and resume. setMedia and an effective clearMedia clear every exclusion. An exclusion ends silently when its time passes, and the end takes effect at the next computation that looks at it.

12.8 Seek coalescing ​

The relay applies at most one seek per seekCoalesceMs (200), and records when it last applied a seek in the room. A seek is handled in one of two ways:

  • When no seek was applied in the last seekCoalesceMs, the seek is applied at once, as §7.8 describes.
  • Otherwise, it becomes the room's held seek, replacing any held seek. It is applied at last applied + seekCoalesceMs, with the target and sender it carries, under the rules of the phase at that moment.

setMedia, an effective clearMedia and deletion drop the held seek. Permission is checked when the seek arrives, and a held seek is applied even if its sender has lost control since.

12.9 Stall ​

A participant is stalled when it is counted and its latest status has the room's epoch and player buffering.

In playing, a status that leaves its sender stalled starts the sender's stall grace of stallGraceMs (500), unless one is running. Further buffering statuses do not restart it. These stop the grace:

  • A ready status from the participant.
  • The participant's disconnect or removal.
  • The room leaving playing.

When the grace runs out while the room is playing and the participant is still stalled, the relay sets the action stall by that participant. The room enters waiting at P(now) with timeout waitMs (15000), as a stall wait. Otherwise, nothing happens.

A stall wait releases stallReleaseMs (400) after waitingOn empties, as §12.5 says.

12.10 Nobody in the player ​

Section 12.5 step 1 looks at every connected participant with an effective player other than idle. Excluded participants and those that reported unavailable are included. So an excluded participant that is still watching keeps the room going.

12.11 End of media ​

In playing, when P(t) reaches durationMs, the room pauses there, and the relay sets the action ended by null at that moment.

The end-of-media timer fires at atMs + ceil((durationMs − positionMs) / rate). It is scheduled on every release and on every rate while playing. It stops whenever the room leaves that phase.

If the timer is scheduled while P(now) is already durationMs, the room ends at once, in the same event. That event publishes one snapshot: paused at durationMs, with the action ended.

The relay never advances to other media. The host's app decides what plays next, and sets it with setMedia.

A play at the end enters waiting, and the event that releases it pauses the room at the end again. So to play again, the host seeks first.

12.12 Timers ​

timerperstartsstopson firingsection
barrierroomentering waitingleaving waitingexclude waitingOn, release§12.7
stall releaseroomwaitingOn empties in a stall waitwaitingOn non-empty, or leaving waitingrelease if still waiting with waitingOn empty§12.5
end of mediaroomrelease, or rate in playingleaving playingpause at durationMs, ended§12.11
held seekrooma coalesced seeksetMedia, clearMedia or deletionapply the seek§12.8
stall graceparticipant§12.9§12.9enter a stall wait§12.9
host absenceroom§11.4the host connects, or another participant becomes hostpromote§11.4
reservationparticipantdisconnectresume or removalremove§11.6
maximum ageroomcreationdeletionclosed expired, delete§11.8

"Every playback timer stops" means that the barrier, stall release and end-of-media timers stop, and every stall grace stops. A held seek survives a phase change.

A timer that fires runs as one event of §9.2. A timer stopped or restarted before its event runs does nothing.

13. Limits and policy ​

This section lists the limits and durations of v1, with the values of the public relays. They come in two kinds.

Protocol values are the same on every v1 relay. The schemas encode them, and changing one is a v2 change.

Policy values are each relay's choice, and /v1/info reports them. Only resumeMs is fixed, at 300000 on every v1 relay, and it is scaled only in a relay's test mode, described in §13.3.

Clients act on no policy value but resumeMs, which §16.1 relies on. They assume 300000 and read nothing from /v1/info, so changing any other policy value needs no client release.

The public values below are the public relays' values and the relay's built-in defaults.

13.1 Protocol limits (limits, fixed in v1) ​

keyvaluemeaningsection
maxMessageBytes8192largest inbound message§3.1
maxMediaBytes4096largest media descriptor§10
maxExtBytes1024largest ext§10
maxNameLength32longest name§6.3
maxClientLength64longest client§6.3
minRate0.5lowest rate
maxRate2.0highest rate

Two more bounds are fixed in v1 and encoded in the schemas, but not reported in /v1/info. durationMs is at most 604800000 under §10, and offsetMs is between −604800000 and 604800000 under §7.5.

13.2 Policy limits (limits) ​

keypublic valuemeaningsection
maxParticipants8participants per room, disconnected ones included, at most 20 as explained below§11.1
maxConnections10000open WebSocket connections on the relay§3.1
maxConnectionsPerIp10open WebSocket connections per address group, times its factor§3.1
maxRooms10000rooms the relay holds, beyond which create gets relay_full§11.10
maxRoomsPerIp3rooms an address group created that the relay still holds, times its factor§11.10
ipv6Factor564factor of an IPv6 /56 group§3.1
ipv6Factor4816factor of an IPv6 /48 group§3.1
messageBurst30per-connection message bucket size§3.1
messagesPerSecond10per-connection refill per second§3.1
joinBurst20per-group join bucket size, times its factor§7.2
joinsPerSecond1per-group join refill per second, times its factor§7.2
joinBurstTotal2000relay-wide join bucket size§7.2
joinsPerSecondTotal200relay-wide join refill per second§7.2

maxParticipants is at most 20, so that every relay message stays within the 16384 bytes that clients accept under §3.1.

13.3 Policy durations (policy) ​

keypublic valuescaledmeaningsection
stallGraceMs500yesbuffering this long while playing starts a stall wait§12.9
stallReleaseMs400yesa stall wait releases this long after waitingOn empties§12.5
waitMs15000yesbarrier timeout after play, seek and a stall§12.7
mediaWaitMs30000yesbarrier timeout after setMedia§12.7
exclusionMs60000yeshow long a participant that timed out doesn't hold the room§12.7
startDelayMinMs750yesshortest start delay§12.6
startDelayMaxMs2000yeslongest start delay§12.6
startDelayRttFactor1.5nostart delay per ms of the highest round trip§12.6
defaultRttMs500noround trip assumed for a participant that reported none§12.6
seekCoalesceMs200yesminimum time between applied seeks§12.8
pauseToleranceMs2000nolargest distance at which pause keeps the sender's position§7.7
hostPromotionMs30000yeshost absence before promotion§11.4
resumeMs300000yeshow long a disconnected participant stays reserved, 300000 on every v1 relay§11.5, §13
maxRoomAgeMs86400000yeslongest life of a room§11.8

startDelayRttFactor is a number, and every other policy value is an int.

Test mode ​

A relay MAY offer a test mode that multiplies every scaled duration by one factor, rounded down to whole milliseconds, so that a conformance suite runs fast. /v1/info reports the scaled values. The public relays run unscaled.

Some values are never scaled: media positions, durationMs, reported rttMs, the keepalive of §3.1 and the rate-limit buckets.

14. Persistence and restart ​

A relay MAY persist rooms across restarts. This section says what such a relay writes, what it never writes, and how it restores rooms on start.

Writing the state ​

A relay that persists rooms writes a changed room within 5 s, every playing room at least every 5 s, and all rooms at graceful shutdown. It writes atomically, renaming a new file over the old one. The state is one file, or one file per room.

The state holds these fields:

for eachfields
roomthe code, rev, epoch, media, position (P at writing), rate, control, locked, wall-clock creation time and peer-ID sequence
participantthe peer ID, the SHA-256 hash of the token, name, client, join order, host flag, match and offsetMs

The relay MUST NOT persist tokens, client IPs, rttMs, actions, exclusions or timers. Deleted rooms leave the state within 5 s. The state is readable by the relay's user only.

Restoring ​

On start, the relay restores every room whose maximum age has not passed:

  • The room is paused at the persisted position, except that an idle room stays idle.
  • waitingOn is empty, and atMs is the restore time.
  • rev continues from the persisted value.
  • Participants are disconnected, with player idle, and their tokens work.

After a crash, the state can be up to 5 s old. rev can then go back, which §9.2 covers, and a room that was playing restores a position at most 5 s old.

Reservation timers and host absence start at restore. A restored room therefore lives until its participants' reservations expire, unless someone resumes. Maximum age still counts from the original creation.

Held seeks, stall graces, exclusions and rttMs are gone. Until participants report again, defaultRttMs counts for them in §12.6, and their effective player of §12.3 is loading.

Without persistence, rooms are lost, and resume gets resume_failed.

15. Privacy ​

This section says what a relay keeps about the people who use it. The relay reads titles and positions, because it needs them to run a room, but it never reads media (§10).

These rules bind the operator of a public relay. Self-hosting operators SHOULD follow them.

Logs and memory ​

The relay logs none of the following: media descriptors or any part of them, participant names, room codes, peer IDs, tokens, positions or message contents. client appears only in the counts below. Logs may hold error codes and counts.

IPs stay in memory only, for the limits of §3.1, §7.2 and §11.10 and for the usage allowance below. They MUST NOT reach logs or disk, on the relay or on a proxy.

Room data lives in memory and in the state of §14, and it is deleted with the room under §11.8.

Metrics ​

Metrics are counts only. They include rooms, participants, connections, rooms created, accepted joins and resumes, inbound messages, refused upgrades by reason, error replies by code, and participants per <app> name.

Per-app series exist only for the 50 names with the highest counts in that UTC day's usage record, (other) excluded. Ties go to the name that sorts first. Every other participant counts under (other).

Usage record ​

Public relays keep counts per UTC day and per <app> of accepted create, join and resume. The record holds no IPs, participant names, titles, room codes or tokens. A day is deleted 400 days later.

Each IPv4 address or IPv6 /64 adds at most 3 names per day:

  • A new app name from an address that has added 3 counts under (other).
  • A name already recorded counts under itself, from any address.

Which addresses added names is kept in memory only, never on disk, and cleared at the end of the day.

Expired days and the per-address memory are dropped within a minute of their deadline. They leave usage.json at the next save, so within two minutes, whatever the traffic.

A relay MAY cap the names per day. Names beyond the cap count under (other).

16. Client behaviour ​

This section states what a client MUST do on the wire. SDK.md §5, §6 and §11 define the algorithms and values behind it, for clock sync, relay choice and following the room.

16.1 Lifecycle ​

  1. Connect. To create, connect to a relay chosen as §5.4 says. To join, connect to the relay that owns the code's first symbol. To resume, connect back to the relay the party joined on. Both follow §5.4. Start clock sync at once (§16.2).
  2. Send create or join, and receive joined. It carries the code, the peer ID and token, the snapshot and the media.
  3. While in the room, send status (§16.3), receive room, and follow the room (§16.4).
  4. After a dropped connection, reconnect to the same relay and resume with the code, peer ID and token within resumeMs, 300000 under §13. The rules for resuming follow this list.
  5. Send leave, or receive closed.

A client MUST NOT resume after a close with status 4000, which §7.3 describes, after closed, or after leave.

The answer to a resume decides what happens next:

  • After resume_failed or client_blocked, the membership is over.
  • Any other error answering it, such as rate_limited or an unknown code, refuses only that attempt. The client MAY retry within resumeMs.

16.2 Clock sync ​

A client estimates relay time from ping and pong on every connection, as SDK.md §5 defines.

The rttMs in status is the client's best round trip. That is the lowest pong round trip among the samples its clock uses, where a round trip is the receipt time minus the send time.

16.3 Status ​

A member MUST send status at these times:

  • Right after joined.
  • After every change of epoch, player, match or offsetMs.
  • At least every 15 s.

The epoch it sends is the room epoch of the last joined or accepted room that the client processed. §9.2 says which room messages a client accepts. ready means the player plays that epoch's media.

16.4 Following the room ​

The target at relay time t is P(t) + offsetMs (§12.2), as a position in the client's own file. In other words, the local position is the room position plus the user's offset for a differing file. A matched file plays at offset 0 unless the user sets one. The relay never applies offsets.

A client reports ready only when its player can play at once and is close enough to the target that following it needs no seek. It reports loading while getting there, as §7.5 defines.

SDK.md §11 defines how SDKs follow the target, with their thresholds.

16.5 Matching ​

A client reports match as §7.5 defines. SDK.md §9 defines how SDKs find, rank and choose files, and SDK.md §9.3 defines when two descriptors name the same title.

Appendix A. Error codes ​

These are the codes that the error message of §8.5 carries.

codeanswersmeaningsection
invalid_messageany messagemalformed JSON or envelope (re null), a shape violation, a binary message (re null), play/pause/seek/rate without media, transferHost/kick naming no participant§6.2
unsupporteda message of unknown typethis relay doesn't implement the type§4
rate_limitedany messagethe connection's message bucket is empty§3.1
rate_limitedcreatethe address group created its limit of rooms§11.10
rate_limitedjointhe join allowance of the address group or of the relay is used up§7.2
relay_fullcreatethe relay holds maxRooms rooms, so the client creates on another relay§11.10, §5.4
room_not_foundjoinno room with this code§7.2
room_fulljoinmaxParticipants reached§7.2
room_lockedjointhe room is locked§7.2
forbiddencontroller and host-only commandsthe sender lacks the role§11.3
not_in_roomroom commandsthe connection is outside a room§6.2
already_in_roomcreate, join, resumethe connection is a member§6.2
resume_failedresumeunknown room or participant, or wrong token§7.3
client_blockedcreate, join, resumethis relay refuses the app§11.9

Appendix B. WebSocket close codes and HTTP refusals ​

The relay closes a connection with these WebSocket close codes, and refuses an upgrade with these HTTP statuses.

codesent bymeaningclient action
1001relaythe relay is shutting downreconnect and resume
1007relayan inbound text message wasn't valid UTF-8fix the sender, then reconnect and resume
1009relayan inbound message exceeded maxMessageBytesfix the sender, then reconnect and resume
4000relayreplaced: a resume on another connection took over this participantMUST NOT resume
HTTP 400relay, at upgradebehind a trusted proxy, the first X-Forwarded-For value isn't an IP address or carries a zonefix the proxy
HTTP 429relay, at upgradetoo many connections from this IP or its network under §3.1retry later
HTTP 503relay, at upgradethe relay is at maxConnectionsretry later or use another relay to create

A connection can also end without a defined code: when the keepalive of §3.1 drops it, when the network drops it, or when the client does not read what the relay sends. The client then reconnects and resumes.

The WebSocket layer may also close with 1002 on framing errors, and answer HTTP 400 to a malformed handshake.