LiveKit
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
This page was written on 2026-10-24 (checked on 2026-10-24) against LiveKit server 1.13.10, livekit-client 2.23.0,
livekit-server-sdk 2.19.2, @livekit/components-react 2.9.24, LiveKit Egress 1.15.0 and LiveKit Ingress 1.5.0, on
Node.js 24 and Chrome with fake capture devices. The server, the token endpoint, two browser pages, a webhook receiver,
an end-to-end encrypted room, a track-composite recording and a WHIP ingress were all run locally with Docker; the React
component was type-checked against the published package, as stated after each example.
LiveKit is an open-source (Apache-2.0) WebRTC platform built around a selective forwarding unit written in Go. It packages what a team building a videoconference otherwise assembles by hand: signalling, ICE with a built-in TURN server, simulcast and bandwidth estimation, speaker detection, data messaging, recording and ingest services, client SDKs for every platform and an access-token system. The same APIs run on a server you operate or on LiveKit Cloud.
Which scenario uses it:
-
Huddle (videoconference) uses LiveKit as its "LiveKit variant". The room, the participants, the media routing, the in-call chat and the recording all move from code you wrote (see mediasoup) to configuration and SDK calls. Only a small token endpoint stays on Huddle’s own backend.
-
Pixel Arena (game) can use LiveKit data messages for spectator or voice-chat rooms, but a game server that needs an authoritative tick should use the data channels described in Data Channels.
-
Chatterbox (chat) does not need it, except for an optional voice or video room next to the text chat. Its wire protocol is defined on Message Design and Serialization.
Voice agents (a program that joins a room as a participant and talks to people) are not repeated here:
LiveKit Agents covers the agent server, AgentSession, SIP and dispatch. This
page covers the room layer underneath, which that page relies on. For the architecture comparison with other SFUs see
Topologies: Mesh, SFU and MCU.
The model: rooms, participants, tracks
LiveKit has four nouns that appear in every API.
| Term | Meaning |
|---|---|
Room |
A named session. It is created on demand when the first participant joins (or explicitly through the room API) and is
deleted after |
Participant |
One connection to a room, identified by an |
Track and publication |
A track is a media stream (audio, video) with a |
Subscription |
A participant’s decision (automatic by default) to receive a published track. The SFU forwards only subscribed tracks. |
A client opens two connections to the server: a WebSocket for signalling (join, publish, subscribe, mute, metadata, data) on the HTTP port, and a WebRTC transport pair for media and data. LiveKit uses a publisher peer connection to send and a subscriber peer connection to receive, so a track added on one side never needs a renegotiation of the other, which avoids the glare problem that perfect negotiation has to solve in a mesh.
DTLS-SRTP is mandatory in WebRTC, so every hop is encrypted, but the hop ends at the SFU: the server decrypts and re-encrypts the media it forwards. That is "encrypted in transit" and not "end-to-end"; the end-to-end option is covered in the encryption section below and on End-to-End Encryption.
Running LiveKit with Docker
The server is a single static binary distributed as the livekit/livekit-server image. Pin a tag instead of latest:
the tag v1.13.10 used here is the release of 2026-10-14. A development setup needs one configuration file, one API key
pair and a set of published ports.
port: 7880
bind_addresses:
- ""
redis:
address: redis:6379
rtc:
tcp_port: 7881
port_range_start: 41310
port_range_end: 41339
use_external_ip: false
node_ip: 127.0.0.1
keys:
huddle-dev-key: change-me-change-me-change-me-32chars
room:
auto_create: true
empty_timeout: 300
turn:
enabled: false
webhook:
api_key: huddle-dev-key
urls:
- http://host.docker.internal:41351/livekit-webhook
logging:
level: info
The key under keys is the API key and its value is the API secret. Use at least 32 characters for the secret and
never reuse the placeholder from this page. Redis is optional for a single node; it is included here because egress and
ingress find the server through it, and it is also what lets several LiveKit nodes form a cluster.
name: huddle-livekit
services:
redis:
image: redis:8.8.3-alpine
livekit:
image: livekit/livekit-server:v1.13.10
command: ["--config", "/etc/livekit.yaml"]
depends_on: [redis]
volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro
ports:
- "7880:7880"
- "7881:7881"
- "41310-41339:41310-41339/udp"
ingress:
image: livekit/ingress:v1.5.0
depends_on: [livekit]
volumes:
- ./ingress.yaml:/etc/ingress.yaml:ro
environment:
INGRESS_CONFIG_FILE: /etc/ingress.yaml
ports:
- "8080:8080"
- "1935:1935"
- "41340:41340/udp"
egress:
image: livekit/egress:v1.15.0
depends_on: [livekit]
cap_add: [SYS_ADMIN]
environment:
EGRESS_CONFIG_FILE: /etc/egress.yaml
volumes:
- ./egress.yaml:/etc/egress.yaml:ro
- ./rec:/out
Source: LiveKit docs: Running LiveKit locally and
config-sample.yaml in the livekit repository. The
ports entries of the page are the unmodified defaults (7880, 7881 and a UDP range); the example’s own range of thirty
UDP ports is enough for fifteen participants because each participant uses two ports.
Ports and addresses
| Port | Config key | Purpose |
|---|---|---|
7880 (TCP) |
|
HTTP API and the signalling WebSocket. Put it behind a reverse proxy that terminates TLS: browsers on an HTTPS page
require |
7881 (TCP) |
|
ICE over TCP, the fallback when UDP is blocked. |
50000-60000 (UDP) |
|
One range of host candidates; each participant uses two ports. |
7882 (UDP) |
|
Alternative: multiplex all UDP traffic on one port. When set, the range is not used. This is the easiest option behind a firewall or in Kubernetes. |
3478 (UDP), 5349 (TLS) |
|
The embedded TURN server, off by default. Without a load balancer the TLS port must be 443. |
Two settings decide whether a browser can reach the media port, and both are the usual cause of a room that "connects but has no video":
-
The address the server advertises in ICE candidates.
use_external_ip: truediscovers the public address with STUN and is right for a cloud VM. On a laptop with Docker, setuse_external_ip: falseandnode_ipto127.0.0.1(for a browser on the same machine) or to the LAN address (for phones on the same Wi-Fi). Docker’s bridge address is never reachable from the browser. -
The published UDP range must be the same on both sides (
41310-41339:41310-41339/udp), because the advertised candidate carries the container’s port number. Mapping to different host ports breaks ICE silently.
LiveKit’s embedded TURN server issues short-lived credentials to the clients in the join response, so the browser never
holds a static password (the same principle explained on ICE, STUN and
TURN). Enable it with turn.enabled: true, a domain, tls_port 443 (or 5349 behind a load balancer) and a
certificate; the credential lifetime is turn.ttl_seconds, 300 seconds by default and capped at 24 hours. TURN over TLS on 443 is the fallback that gets through restrictive corporate
firewalls.
Access tokens
A LiveKit server never authenticates users: it trusts a signed JWT issued by your backend with the API secret. The token
carries the participant identity and a video grant describing what that participant may do. The browser asks Huddle’s
backend for a token after the user logs in; the API secret never reaches the browser.
import http from 'node:http';
import { AccessToken, RoomServiceClient } from 'livekit-server-sdk';
const API_KEY = process.env.LIVEKIT_API_KEY ?? 'huddle-dev-key';
const API_SECRET = process.env.LIVEKIT_API_SECRET ?? 'change-me-change-me-change-me-32chars';
const LIVEKIT_URL = process.env.LIVEKIT_URL ?? 'ws://localhost:7880';
async function createToken(room, identity, isHost) {
const at = new AccessToken(API_KEY, API_SECRET, {
identity,
name: identity,
ttl: '10m',
metadata: JSON.stringify({ app: 'huddle' }),
});
at.addGrant({
room,
roomJoin: true,
canPublish: true,
canSubscribe: true,
canPublishData: true,
canUpdateOwnMetadata: true,
roomAdmin: isHost,
});
return at.toJwt(); // a Promise<string> since livekit-server-sdk 2.0
}
http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === '/token') {
const identity = url.searchParams.get('identity');
if (!identity) { res.writeHead(400).end('identity required'); return; }
const token = await createToken(url.searchParams.get('room') ?? 'huddle-demo', identity, url.searchParams.get('host') === '1');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ url: LIVEKIT_URL, token }));
} else if (url.pathname === '/rooms') {
const svc = new RoomServiceClient(LIVEKIT_URL.replace('ws', 'http'), API_KEY, API_SECRET);
const rooms = await svc.listRooms();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(await Promise.all(rooms.map(async (r) => ({
room: r.name,
participants: (await svc.listParticipants(r.name)).map((p) => p.identity),
})))));
} else {
res.writeHead(404).end();
}
}).listen(3000);
Source: LiveKit docs: Authentication, tokens and
LiveKit server SDK for JavaScript reference. A real endpoint
authenticates the caller first (see Authentication and
Authorization) and derives identity, the room and the host flag from that session, not from query parameters as this
demo does.
The grants that matter for a videoconference:
| Grant | Effect |
|---|---|
|
Permit joining the named room. A token without |
|
Whether the participant may publish, and which sources ( |
|
Whether the participant may receive tracks. |
|
Whether data messages, text streams, byte streams and RPC may be sent. |
|
Whether the participant may change its own name, metadata and attributes. Without it, |
|
Moderation through the room API (mute, remove, update permissions). Give it to the host only. |
|
Create or delete rooms, list rooms, use egress, manage ingress, or join invisibly (a recorder, a moderator). |
The token also carries attributes (string key-value pairs visible to everyone), metadata and, since agents, a
roomConfig that applies only when the token creates the room. When no TTL is given the SDKs default to six hours; a
short TTL (ten minutes here) is the revocation mechanism on a self-hosted server, because tokens cannot be revoked there.
Expiry applies to the initial connection; the server issues refreshed tokens to connected clients so reconnects keep
working. On LiveKit Cloud, removing a participant or changing permissions also revokes the old token for ten minutes.
The client SDK
livekit-client is a TypeScript library for the browser (and React Native, with a separate package for the native
WebRTC layer). Its central class is Room. The sequence is always the same: create a Room with options, register event
handlers, connect(url, token), then publish.
<!doctype html>
<meta charset="utf-8">
<title>Huddle (LiveKit)</title>
<div id="grid"></div>
<script src="/livekit-client.js"></script>
<script>
const { Room, RoomEvent } = LivekitClient;
window.joinHuddle = async function joinHuddle(identity, roomName) {
const { url, token } = await (await fetch(`/token?room=${roomName}&identity=${identity}`)).json();
const room = new Room({ adaptiveStream: true, dynacast: true });
window.room = room;
window.events = [];
window.messages = [];
const log = (e) => window.events.push(e);
room
.on(RoomEvent.ConnectionStateChanged, (s) => log(`state:${s}`))
.on(RoomEvent.ParticipantConnected, (p) => log(`joined:${p.identity}`))
.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
log(`subscribed:${participant.identity}:${track.kind}`);
document.getElementById('grid').append(track.attach()); // <video> or <audio>
})
.on(RoomEvent.DataReceived, (payload, participant, kind, topic) => {
window.messages.push({ from: participant?.identity, topic, text: new TextDecoder().decode(payload) });
})
.on(RoomEvent.ActiveSpeakersChanged, (speakers) => log(`speakers:${speakers.map((s) => s.identity)}`));
await room.connect(url, token);
await room.localParticipant.setCameraEnabled(true);
await room.localParticipant.setMicrophoneEnabled(true);
};
window.sendChat = (text) =>
window.room.localParticipant.publishData(new TextEncoder().encode(text), { reliable: true, topic: 'chat' });
</script>
Source: LiveKit client SDK for JavaScript reference and
LiveKit docs: Data messages. The UMD bundle is served from
node_modules/livekit-client/dist/livekit-client.umd.js; with a bundler use import { Room, RoomEvent } from
'livekit-client'. Two pages opened in one Chrome with fake devices each ended with the other’s audio and video tracks
subscribed and live, the chat message from alice arrived at bob with the topic chat, and the active-speaker event
fired for the synthetic beep of the fake microphone.
Connecting and the connection state
room.connect(url, token, options) resolves when signalling and the transport are up. room.state is one of
disconnected, connecting, connected, reconnecting and signalReconnecting; RoomEvent.ConnectionStateChanged
reports the transitions and RoomEvent.Reconnecting, Reconnected and Disconnected carry the lifecycle. Reconnection is
automatic and tries first to resume (same peer connection, same subscriptions) and then to restart ICE; a full
reconnect re-publishes the local tracks. Room.prepareConnection(url, token) warms up DNS and TLS while the user is still
on the pre-join screen. RoomConnectOptions has autoSubscribe (default true) and rtcConfig for custom ICE servers,
and Room options include peerConnectionTimeout, websocketTimeout and maxRetries.
Tracks and devices
localParticipant.setCameraEnabled(true), setMicrophoneEnabled(true) and setScreenShareEnabled(true) capture and
publish in one call; the last one needs a user gesture and shows the browser’s picker, as described on
Screen Sharing. Capture and publish defaults live in the room options:
-
videoCaptureDefaults(deviceId,facingMode,resolution) andaudioCaptureDefaults(echoCancellation,noiseSuppression,autoGainControl). -
publishDefaults(simulcast,videoCodec,videoEncoding,screenShareEncoding,dtx,red,forceStereo,scalabilityMode).
room.switchActiveDevice('videoinput', deviceId) changes a device without re-publishing, and
Room.getLocalDevices('audioinput') lists devices (labels are empty before permission, a property of
Media Capture and Devices). Remote audio is subject to the
browser’s autoplay rules: call room.startAudio() from a click handler when room.canPlaybackAudio is false, and listen
to RoomEvent.AudioPlaybackStatusChanged.
Events a Huddle UI needs: ParticipantConnected/ParticipantDisconnected, TrackPublished, TrackSubscribed,
TrackUnsubscribed, TrackMuted/TrackUnmuted, ActiveSpeakersChanged, ParticipantMetadataChanged,
ParticipantAttributesChanged, RoomMetadataChanged, MediaDevicesChanged, TrackSubscriptionFailed and
DataReceived.
Data: messages, text streams, RPC and attributes
LiveKit offers five ways to move non-media data, all through the SFU and all governed by canPublishData:
| API | Use in Huddle |
|---|---|
|
Small, fire-and-forget messages: reactions, "who is typing". |
|
In-call chat. Text streams are chunked, so a long message does not hit the data-channel message size limit. |
|
A file drop to the room or to named participants. |
|
Request-response between two participants: "raise hand" to the host, with a typed error on timeout. |
|
State that late joiners must also see (hand raised, role). It is synchronised by the server and surfaced by
|
// host page
room.registerRpcMethod('huddle.raiseHand', async (data) => {
console.log(`${data.callerIdentity} asks to speak: ${data.payload}`);
return JSON.stringify({ ok: true });
});
room.registerTextStreamHandler('chat', async (reader, info) => {
console.log(`${info.identity}: ${await reader.readAll()}`);
});
// participant page
const reply = await room.localParticipant.performRpc({
destinationIdentity: 'bob',
method: 'huddle.raiseHand',
payload: JSON.stringify({ seat: 3 }),
});
await room.localParticipant.sendText('hi bob', { topic: 'chat' });
await room.localParticipant.setAttributes({ hand: 'up' }); // needs canUpdateOwnMetadata
Source: LiveKit docs: Data messages, text streams, RPC and participant attributes.
Run between two headless pages, the RPC returned {"ok":true}, the text arrived on the chat topic and the attribute
was visible on the other participant. The first attempt without canUpdateOwnMetadata in the token was rejected by the
server, which is how that grant entered the token example above.
Quality: simulcast, adaptive stream and dynacast
An SFU does not transcode, so quality adaptation means choosing which encoding to forward. LiveKit’s defaults already do the right thing for a meeting; the options exist to tune it.
-
Simulcast is on by default: the publisher sends several resolutions of the camera (the
VideoPresetsh180,h360,h720family) and the server forwards the best one that fits each subscriber’s downlink. For VP9 and AV1 the server uses SVC layers (scalabilityMode, default L3T3_KEY) instead of separate streams. See Codecs, Simulcast and SVC. -
Adaptive stream (
new Room({ adaptiveStream: true })) makes the subscriber report the size of each attached<video>element, so the server sends a 180p layer for a thumbnail and pauses a track whose element is hidden. It works only throughtrack.attach(); if you render a track yourself, setadaptiveStream: { pauseVideoInBackground: false }or control quality manually. -
Dynacast (
dynacast: true) is the publisher side of the same idea: when no subscriber consumes the high layer, the publisher stops encoding it and saves CPU and uplink. With VP9 and AV1 it can pause only the whole stream. -
Manual control on a
RemoteTrackPublication:setSubscribed(false),setVideoQuality(VideoQuality.LOW)andsetVideoDimensions({ width, height }). Use them for a "speaker view" with one large tile and many thumbnails when not relying on adaptive stream.
Statistics (getRTCStatsReport() of a local or remote track, and the connection-quality
event RoomEvent.ConnectionQualityChanged) feed the quality indicator described on
Stats, Quality and Bandwidth.
Webhooks
The server notifies your backend of room events with signed HTTP POSTs. The signature is an Authorization header holding
a JWT whose claim is the SHA-256 hash of the exact request body, so the body must be passed to the receiver as the raw
text before any JSON parsing.
import http from 'node:http';
import { WebhookReceiver } from 'livekit-server-sdk';
const receiver = new WebhookReceiver('huddle-dev-key', 'change-me-change-me-change-me-32chars');
http.createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/livekit-webhook') { res.writeHead(404).end(); return; }
const chunks = [];
for await (const c of req) chunks.push(c);
try {
const event = await receiver.receive(Buffer.concat(chunks).toString('utf8'), req.headers.authorization);
console.log(event.event, event.room?.name ?? '', event.participant?.identity ?? '');
res.writeHead(200).end();
} catch (e) {
console.error('rejected webhook:', e.message);
res.writeHead(401).end();
}
}).listen(3001);
Source: LiveKit docs: Webhooks and the
WebhookReceiver class of the server SDK. Joining a room with two browsers produced room_started,
participant_joined and track_published lines for each participant, and a request with a forged Authorization
header was rejected with 401. Events also exist for track_unpublished, participant_left, room_finished,
egress_started, egress_updated, egress_ended and ingress_started/ingress_ended. Webhooks can be delivered more
than once and out of order, so handlers must be idempotent.
End-to-end encryption
Because the SFU decrypts SRTP, a compromised or curious server can see the media. LiveKit’s E2EE adds a second layer: the
sender encrypts each encoded frame with a key the server never has (the SFrame approach standardised as RFC 9605), and
the receivers decrypt after the SFU has forwarded it. The browser hook is an encoded transform; LiveKit runs it in a Web
Worker that you supply, which is also why the SDK ships livekit-client.e2ee.worker.js. Chrome’s older
createEncodedStreams() and the standard RTCRtpScriptTransform are both handled by the SDK, so the application does
not choose between them. The cryptographic and key-management background is on
End-to-End Encryption.
import { Room, ExternalE2EEKeyProvider, isE2EESupported } from 'livekit-client';
if (!isE2EESupported()) throw new Error('This browser cannot encrypt frames end to end');
const keyProvider = new ExternalE2EEKeyProvider();
await keyProvider.setKey(roomKeyFromYourKeyExchange); // never hard-code a key, never send it through LiveKit
const room = new Room({
encryption: {
keyProvider,
worker: new Worker(new URL('livekit-client/e2ee-worker', import.meta.url)),
},
});
await room.setE2EEEnabled(true);
await room.connect(url, token);
Source: LiveKit docs: End-to-end encryption. In the verification run the
worker was served as a plain script (new Worker('/livekit-client.e2ee.worker.js')) and both pages reported
isEncrypted for the other participant and subscribed to its tracks; the option is named encryption in
livekit-client 2.23.0, while the earlier name e2ee is deprecated but still accepted.
What E2EE does and does not cover:
-
It encrypts audio and video frames, and (when
encryptionis set) data messages and streams. Signalling, room and participant metadata, names and the fact that a track exists remain readable by the server, which only has TLS between you and it. -
LiveKit gives you the mechanism and a shared-key provider. Generating, distributing and rotating keys (per room, or per participant with a custom
BaseKeyProvider) is your job; a common design sends the key with the token over your own authenticated channel. Everyone who ever held the key can decrypt recordings of the room. -
Server-side features that need the media in the clear stop working: egress and agents cannot read an encrypted room unless they are given the key, and a participant that joins without the key sees encrypted frames and errors on
RoomEvent.EncryptionError. Plan the key hand-off before promising recording.
The React components
@livekit/components-react provides a context-based UI layer on top of livekit-client. It requires React 18 or
later and livekit-client 2.20.1 or later as peer dependencies, and ships its CSS in @livekit/components-styles.
import '@livekit/components-styles';
import { useState } from 'react';
import { Track } from 'livekit-client';
import {
LiveKitRoom,
RoomAudioRenderer,
ControlBar,
GridLayout,
ParticipantTile,
useTracks,
useDataChannel,
useConnectionState,
} from '@livekit/components-react';
function Stage() {
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ onlySubscribed: false },
);
return (
<GridLayout tracks={tracks} style={{ height: 'calc(100vh - 120px)' }}>
<ParticipantTile />
</GridLayout>
);
}
function HandRaise() {
const [hands, setHands] = useState<string[]>([]);
const { send } = useDataChannel('hands', (msg) => {
const who = msg.from?.identity ?? 'unknown';
setHands((h) => (h.includes(who) ? h : [...h, who]));
});
return (
<button onClick={() => send(new TextEncoder().encode('up'), { reliable: true })}>
Raise hand ({hands.length} up)
</button>
);
}
function Status() {
return <span>{useConnectionState()}</span>;
}
export function HuddleRoom({ serverUrl, token }: { serverUrl: string; token: string }) {
return (
<LiveKitRoom
serverUrl={serverUrl}
token={token}
connect
audio
video
options={{ adaptiveStream: true, dynacast: true }}
data-lk-theme="default"
>
<Status />
<Stage />
<HandRaise />
<ControlBar />
<RoomAudioRenderer />
</LiveKitRoom>
);
}
Source: LiveKit docs: React components reference and
components-js on GitHub. The file compiles without errors under
tsc --strict against @livekit/components-react 2.9.24, livekit-client 2.23.0 and React 19.
The pieces, from highest to lowest level:
-
LiveKitRoomcreates theRoom, connects whenconnectis true, publishes the microphone and camera whenaudioandvideoare set, provides the room context and exposesonConnected,onDisconnected,onError,onMediaDeviceFailureandonEncryptionError. ChangingtokenorserverUrlreconnects. -
VideoConferenceis a ready-made meeting UI (grid, focus layout, chat, control bar). Use it for a prototype and replace it with your own layout when Huddle needs a lobby or a custom stage;PreJoinis the matching device-check screen. -
GridLayout,CarouselLayout,FocusLayout,ParticipantTile,ControlBar,ChatandRoomAudioRenderer(which plays all remote audio and handles autoplay) are the building blocks. -
Hooks:
useTracks,useParticipants,useRemoteParticipants,useLocalParticipant,useRoomContext,useConnectionState,useIsSpeaking,useSpeakingParticipants,useChatanduseDataChannel.
With Next.js, create the token in a route handler or server action and render HuddleRoom in a client component.
Egress: recording and streaming
Recording happens on the server through a separate service, livekit/egress, which joins the room as a hidden
recorder, receives media like any subscriber and writes it with GStreamer. It finds the LiveKit server through Redis and
is started through the server API with the API key pair (a token with the roomRecord grant can also use it). The types:
-
Room composite renders a web layout in Chrome inside the egress container, so it is CPU heavy but looks like the meeting; web records any URL.
-
Participant and track composite combine a participant’s, or chosen, audio and video tracks without a browser, which is far cheaper.
-
Track writes one track unmodified (with the passthrough preset for video).
-
Auto egress is configured on the room (
CreateRoom) and starts recording when the room starts.
Outputs are MP4 or OGG files, HLS segments and thumbnails to local disk or S3, GCS, Azure and compatible storage, and
live RTMP streams to platforms that accept them. The file path template accepts variables such as {room_name} and
{time}.
import { EgressClient, RoomServiceClient, EncodedFileOutput, EncodedFileType } from 'livekit-server-sdk';
const host = 'http://localhost:7880';
const [key, secret] = ['huddle-dev-key', 'change-me-change-me-change-me-32chars'];
const [participant] = await new RoomServiceClient(host, key, secret).listParticipants('standup');
const audio = participant.tracks.find((t) => t.type === 0).sid;
const video = participant.tracks.find((t) => t.type === 1).sid;
const egress = new EgressClient(host, key, secret);
const output = new EncodedFileOutput({ fileType: EncodedFileType.MP4, filepath: '/out/huddle-{room_name}-{time}.mp4' });
let info = await egress.startTrackCompositeEgress('standup', output, { audioTrackId: audio, videoTrackId: video });
console.log('started', info.egressId);
await new Promise((r) => setTimeout(r, 8000));
info = await egress.stopEgress(info.egressId);
const [done] = await egress.listEgress({ egressId: info.egressId });
console.log(done.status, done.fileResults.map((f) => f.filename)); // EGRESS_COMPLETE once the file is finalised
Source: LiveKit docs: Egress overview and
LiveKit docs: Self-hosting egress. Running this against the
egress container above for eight seconds produced an MP4 of about one megabyte whose reported duration was the
recording length. The current egress documentation groups participant, track-composite and track requests under a
generic StartEgress request with a media source; the helper methods used here belong to the same server SDK and
remain available in 2.19.2. Egress needs the Linux SYS_ADMIN capability for Chrome and plenty of CPU for a room
composite, and a recording must be stopped (or the room ended) for the file to be finalised: never kill the container
mid-recording.
Ingress and WHIP
Ingress brings external media into a room: a broadcaster using OBS, a hardware encoder, a media file or an HLS URL
appears as a participant. It supports RTMP(S), WHIP (RFC 9725), and pull from HTTP or SRT URLs. WHIP is the
standardised HTTP signalling for WebRTC ingest: the encoder POSTs an SDP offer with Content-Type: application/sdp to
the endpoint and receives 201 Created with the SDP answer and a Location header for the session; the same
mechanism is explained on WHIP, WHEP and Live Streaming.
WHEP, its playback counterpart, is still an Internet-Draft; LiveKit viewers receive the stream as a normal subscribed
track instead.
import { IngressClient, IngressInput } from 'livekit-server-sdk';
const ingress = new IngressClient('http://localhost:7880', 'huddle-dev-key', 'change-me-change-me-change-me-32chars');
const info = await ingress.createIngress(IngressInput.WHIP_INPUT, {
name: 'huddle-stage',
roomName: 'standup',
participantIdentity: 'stage-camera',
participantName: 'Stage camera',
enableTranscoding: false, // forward the encoder's stream untouched
});
// the WHIP endpoint is <whip_base_url>/<streamKey>, for example http://localhost:8080/w/<streamKey>
const endpoint = `http://localhost:8080/w/${info.streamKey}`;
// in a page (or any WHIP encoder):
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const pc = new RTCPeerConnection();
stream.getTracks().forEach((t) => pc.addTrack(t, stream));
await pc.setLocalDescription(await pc.createOffer());
await new Promise((r) => (pc.iceGatheringState === 'complete' ? r() : (pc.onicegatheringstatechange = () => pc.iceGatheringState === 'complete' && r())));
const resp = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/sdp' }, body: pc.localDescription.sdp });
await pc.setRemoteDescription({ type: 'answer', sdp: await resp.text() }); // resp.status === 201
Source: LiveKit docs: Ingress overview and
RFC 9725: WebRTC-HTTP Ingestion Protocol (WHIP). Against the ingress container
the POST returned 201 with a Location of the form /w/<streamKey>/<resourceId>, ICE reached connected, and a
third page that had joined the room received the track from the participant stage-camera. In that run the url field
of the returned ingress was empty because whip_base_url was not set in the ingress configuration; set whip_base_url
(and rtmp_base_url) to the public address so that info.url can be handed to the broadcaster. The ingress service
needs its own UDP port for WHIP media (published as 41340 above), at least four CPU cores and four gigabytes of memory
per instance, and the same Redis as the server. The stream key is a bearer credential: treat it like a password, and
delete the ingress when the broadcast ends.
LiveKit Cloud and self-hosting
The SDK and API are identical; only the URL and credentials change. LiveKit Cloud is the managed service: you create a
project, copy its URL (a wss:// address on the livekit.cloud domain) and API key pair, and use them where this page
uses ws://localhost:7880 and the dev keys.
| Self-hosted | LiveKit Cloud | |
|---|---|---|
Architecture |
One node per room; scale by adding nodes behind Redis, with rooms spread across them. |
A global mesh of SFUs; each participant connects to the nearest edge. |
Egress, ingress |
Separate services you deploy and scale. |
Included with the project. |
Agents, telephony |
You run agent servers and bring a SIP trunk. |
Managed agent hosting, inference and phone numbers are available. |
Observability, token revocation |
Your own metrics and short token TTLs. |
Dashboard and analytics; revocation of removed participants for ten minutes. |
Data residency and cost |
Your hosts, your bandwidth bill. |
Region pinning; usage-based pricing (see the vendor pricing page). |
Source: LiveKit docs: LiveKit Cloud. A single self-hosted node is documented as suitable for roughly three thousand participants on a room, while Cloud lists no per-room limit; multi-node self-hosting is covered on Distributed setup and, for the network side, on Scaling WebRTC and TURN. Start on Cloud or on the Docker setup of this page, and keep all LiveKit URLs and keys in configuration so the switch is a deployment change.
The Huddle LiveKit variant
Huddle has three media modes: a mesh for up to four participants, an SFU mode on mediasoup (see mediasoup) and this variant. The mapping between the hand-built SFU and LiveKit shows what the platform removes:
| Concern | mediasoup mode (you build it) | LiveKit mode (you configure it) |
|---|---|---|
Signalling |
A WebSocket protocol for transports, producers and consumers. |
Built in: the |
Room and participant state |
Maps in the Huddle backend. |
The server’s room, participants, attributes and webhooks. |
Authentication |
Huddle’s own join message. |
A signed token with grants from one endpoint. |
Simulcast and layer choice |
Router settings and |
Default behaviour plus |
TURN |
A separate coturn with an ephemeral secret. |
The embedded TURN server, or coturn through |
Chat, hand raise |
Messages on the signalling socket. |
|
Recording |
A PlainTransport and FFmpeg. |
Egress service. |
The flow of a Huddle LiveKit session is: the user logs in; the page asks /token for a token for room standup; the
page connects with adaptiveStream and dynacast; a pre-join screen calls Room.prepareConnection and checks devices;
the host’s token has roomAdmin; chat is a text stream on topic chat; hand raise is an attribute plus an RPC to the
host; the host starts a track-composite egress; and a webinar viewer receives a token with canPublish: false. Huddle’s
backend keeps only the token endpoint, the room-creation policy and the webhook receiver.
Security
-
The API secret is a root credential. Whoever has it can mint tokens for any identity and room and drive the room API. Keep it in a secret store, rotate it by listing two keys under
keysduring the transition, and never ship it to a browser or a mobile app. -
Tokens are bearer credentials. Issue them after authentication, with the smallest grants and a short TTL, and put the identity and room into the token yourself, not from client input. A token in the WebSocket URL ends up in proxy logs, which is one more reason for a short TTL (see Authentication and Authorization).
-
Terminate TLS in front of the server. The signalling port must be reached as
wss://from an HTTPS page, andgetUserMediarequires a secure context anyway. Restrict the UDP range and the TURN ports in the firewall to exactly those listed above. -
Verify webhooks with the SDK receiver and the raw body, and make handlers idempotent.
-
Treat stream keys, egress and room-API permissions as admin power. Do not give
roomRecord,roomCreateoringressAdminto meeting participants. A hidden participant (hidden: true) can observe a room without being listed, so only issue it to trusted back-end processes. -
E2EE is not automatic. Without
encryptionthe server sees the media; with it the key distribution is yours. -
Cross-origin and iframes. Embedding the room in an iframe needs
Permissions-Policy(camera,microphone,display-capture) on the embedding page. -
Resource limits. Cap participants (
room.max_participants), and rate-limit the token endpoint so that it cannot be used to create unbounded rooms.
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
Connects, but no remote video, ICE stays |
The advertised address or UDP ports are wrong. Set |
|
The page loaded over HTTPS uses |
|
Wrong key pair, clock skew, or a TTL shorter than the time between fetching and connecting. Refetch the token on reconnect instead of caching it. |
|
The grant is missing: |
|
In |
No audio in a remote tile |
Autoplay policy. Render |
Video stays at 180p or is black in a custom layout |
Adaptive stream cannot see the element because the track was not rendered with |
Webhook always rejected |
The body was parsed and re-serialised before verification, or a proxy altered it. Pass the raw text. |
E2EE participant sees garbage or errors |
Different keys, a key set after connecting, or a participant without |
Egress stuck in |
The egress service cannot reach Redis or the server, lacks |
Ingress session works but |
|
Version and history notes
-
Server: 1.13.10 (2026-10-14) is the baseline; releases are frequent and the image tags
v1.13,v1.13.10andlatestare published, so pin a patch tag. Clients negotiate a protocol version, and the project’s guidance is to keep server and client SDKs reasonably close. -
livekit-client2.x replaced the earlier 1.x API:room.localParticipantis the same, but several options moved (e2eetoencryption, per-participantregisterRpcMethodtoroom.registerRpcMethod), and text and byte streams (sendText,sendFile) were added besidepublishData. -
livekit-server-sdk2.x madeAccessToken.toJwt()asynchronous. Its egress documentation was reorganised around a singleStartEgressrequest with source and output descriptions. -
@livekit/components-react2.x is the current line; it declareslivekit-client2.20.1 or later as a peer. -
Ingress and egress are versioned separately from the server (1.5.0 and 1.15.0 at the time of writing).
-
Standards: LiveKit builds on the W3C WebRTC recommendation, JSEP (RFC 9429), BUNDLE (RFC 9143), WHIP (RFC 9725) and, for end-to-end encryption, the SFrame specification (RFC 9605). The transport-wide congestion control feedback it relies on was an Internet-Draft that is widely implemented; RFC 8888 is the published congestion-control feedback format.