matrix rtc
What a call needs from a deployment, in the order a client asks for it, and the two ways to provide the piece this server does not carry: the media. Both are supported; ADR 0004 records why both exist.
The pieces
A MatrixRTC call (Element Call, Element X, Element Web) touches four things:
- The homeserver — room state for the call membership, delayed events (MSC4140) to expire it, sticky events (MSC4354) so a membership can lapse without becoming permanent state, to-device signalling, and transport discovery (MSC4143). All served here; docs/dashboard.md's M7 row is the inventory.
- A LiveKit SFU — carries the media. Not bundled (#4 lists media servers under what not to build); run livekit-server.
- A JWT service — mints the token a client presents to the SFU.
Either the built-in one (
[rtc.livekit]) or lk-jwt-service deployed beside this server. - A TURN relay, optionally, for the client-side leg that cannot
reach the SFU directly.
[turn]inspindle.example.toml; not MatrixRTC-specific.
The flow a client drives, once it is in a room and has decided to call:
client ──GET /_matrix/client/v1/rtc/transports──▶ homeserver
◀── [{type: livekit, livekit_service_url: S}] ──
client ──POST /user/{me}/openid/request_token──▶ homeserver
◀── {access_token, matrix_server_name, expires_in} ──
client ──POST S/sfu/get {room, openid_token, device_id}──▶ JWT service
│
(external service only) ├─GET /_matrix/federation/v1/openid/userinfo─▶ homeserver
│◀────────────── {sub: @me:server} ───────────
◀────────────────── {url: wss://sfu, jwt} ─────┘
client ──websocket, jwt──▶ SFU
The OpenID token is the credential in that exchange. It is short-lived (an hour), it opens nothing on this server, and the JWT service — built in or external — is what it is for.
Option A: the built-in JWT service
One binary, one secret to keep in step with the SFU.
[server]
name = "example.org"
public_base_url = "https://matrix.example.org"
[rtc.livekit]
url = "wss://livekit.example.org"
key = "APIxxxxxxxx"
secret = "..." # the SFU's matching API secret
# token_ttl_seconds = 900
key and secret are the pair the SFU was started with (--keys APIxxxxxxxx: ..., or keys: in livekit.yaml). With the section set,
this server:
- advertises itself as a
livekittransport, first in the list, athttps://matrix.example.org/_spindle/rtc/livekit— on/rtc/transportsand in.well-known/matrix/clientalike; - answers
POST /_spindle/rtc/livekit/sfu/getwith{url, jwt}inlk-jwt-service's shape, so a client cannot tell the two apart; - mints only for a Matrix room the user is joined to at that moment,
checked against its own membership index. Never joined, only invited,
or since left: refused with
M_FORBIDDEN, and a room that does not exist is refused identically; - mints for its own users only. A token whose
matrix_server_nameis another server is refused rather than verified over federation; - rate limits minting per user (docs/rate-limits.md).
The token's grants are the least a participant needs: join this one
room, publish, subscribe. roomCreate is withheld because in LiveKit it
also permits deleting the room, which ends everyone's call; the SFU's
default auto_create: true makes the room on first join instead. If
your SFU has auto_create off, use option B or create rooms another
way.
Revocation. A minted token cannot be revoked: it is stateless, and
the SFU never asks this server again. A user who leaves the room after
minting holds their token until it expires. token_ttl_seconds is the
whole of that guarantee, which is why it defaults to fifteen minutes and
why the default is not an hour. lk-jwt-service issues an hour; a
client that needs that gets it by setting 3600 here, on purpose.
The secret is LiveKit's and is shared with nothing else. It is not this server's signing key and is not derived from it; the two rotate apart, and a leak of one is not a leak of the other. It is never logged.
Option B: lk-jwt-service beside the server
The reference shape, and what Element and Tuwunel document. Run
lk-jwt-service with the SFU's key and secret, put it behind your
reverse proxy at some public path, and name that path here:
[server]
name = "example.org"
public_base_url = "https://matrix.example.org"
[rtc]
foci = [
{ type = "livekit", livekit_service_url = "https://matrix-rtc.example.org/livekit/jwt" },
]
The service redeems each OpenID token against this server's
GET /_matrix/federation/v1/openid/userinfo, resolved through
.well-known/matrix/server like any federation request, so the
federation listener has to be reachable from wherever the service runs.
Nothing else on this server is involved: the service mints for whatever
room the client names, with no membership check on this side.
Recent lk-jwt-service releases (0.6 and later; Element Server Suite
ships 0.6.0) also take over a client's delayed leave event when the
client asks (/delegate_delayed_leave, or a delay_id on /get_token):
while the participant stays connected to the SFU the service restarts the
delay, and when they drop it sends it. It does that with
POST /_matrix/client/unstable/org.matrix.msc4140/delayed_events/{delay_id}/{restart|send}
and no access token, the delay ID being the capability, so that route has
to reach this server from wherever the service runs, like openid/userinfo.
The two options compose. With both configured, the built-in service is
listed first and the operator's foci follow in the order written;
clients read the list as a priority order.
What to check
curl https://matrix.example.org/.well-known/matrix/clientnames the transport underorg.matrix.msc4143.rtc_foci. If it does not, neither[rtc.livekit]nor[rtc] fociis set.GET /_matrix/client/versionslistsorg.matrix.msc4140,org.matrix.msc4143andorg.matrix.msc4354underunstable_features; Element Call checks them before it will rely on the server.- A send with
?org.matrix.msc4354.sticky_duration_ms=30000comes back fromGET .../event/{id}carryingmsc4354_sticky.duration_ms, and a client that joins the room afterwards finds it underrooms.join.{room}.msc4354_sticky.eventsin its first/sync, withunsigned.msc4354_sticky_duration_ttl_mscounting down. Durations above an hour are capped to it. - For option A: a joined user's
POST .../sfu/getreturns ajwtwhose decodedvideo.roomis the Matrix room ID and whoseexp - nbfistoken_ttl_seconds. A user who has left gets403 M_FORBIDDEN. - For option B:
GET /_matrix/federation/v1/openid/userinfo?access_token=…with a fresh token returns{"sub": "@you:example.org"}; with an expired or invented one,401 M_UNKNOWN_TOKEN.
The tests that pin each of these: crates/spindle-server/tests/openid.rs,
livekit_jwt.rs, rtc_transports.rs, rtc_membership.rs,
delayed_events.rs and sticky_events.rs.
Running Element Call's own suite
contrib/element-call/run.sh stands up Element Call's two-homeserver
Playwright stack (LiveKit, lk-jwt-service, Element Web, nginx, all
upstream's and pinned) with Spindle in both of Synapse's seats, and runs
their specs against it. docker-compose-spindle.yml is the whole
override; spindle.toml and spindle-othersite.toml stand in for
backend/playwright_homeserver*.yaml. Results go through
scripts/element-call-check.py against contrib/element-call/allowlist.txt,
the same ratchet shape as Complement's. The element-call-e2e job in
.github/workflows/compliance.yml runs it nightly and on demand.
Calls with no SFU
A room of a handful of people can call peer to peer, full mesh, with no media server in the path; that is a different client build and its own page, docs/p2p-calls.md, with the rig that proves it against this server.
What is not here
- Remote users on the built-in service. MSC4195's current draft adds a federation endpoint for a remote participant's token; until clients speak it, a federated caller needs the external service.
- MSC4195's homeserver token endpoint
(
/_matrix/client/v1/rtc/livekit/get_token). Shipping clients post to/sfu/get; the homeserver