This guide moves an app from LiveKit (Cloud or self-hosted) to Livqeno:
livekit-client in the browser, livekit-server-sdk (or the Python and Go
server SDKs) on the backend, plus Egress, Ingress and webhooks. Read the
overview first for the general approach.
LiveKit and Livqeno share a lot of vocabulary: rooms, participants with an
identity, tracks you attach(), simulcast, adaptive stream. The client
port is mostly renaming. The server side and the media services (Egress,
Ingress) need the most thought.
Concept mapping
| LiveKit | Livqeno | Notes |
|---|---|---|
| API key + secret | Project + API key | One key per project and environment, used as a bearer token against the API. |
Room (created on first join, or with RoomServiceClient.createRoom) | Room, created by your backend | livqeno.rooms.create({ name, getOrCreate: true }), then make tokens for room.id. |
AccessToken + VideoGrant | livqeno.tokens.create() | Made by the API, not signed locally. Returns a grant with token, endpoint and iceServers. |
roomJoin, canSubscribe, canPublish, canPublishData, canPublishSources | join, subscribe, publish, publishData, publishAudio / publishVideo | Anything you don't turn on is off. |
identity, metadata, attributes in the token | identity, metadata, attributes | Change them mid-call with rooms.updateParticipant() or, with updateOwnMetadata, setMetadata() / setAttributes() (Metadata). |
wsUrl (your LiveKit host) | endpoint in the grant | Comes back from the API; don't hard-code it. |
new Room() + room.connect(url, token) | createRTCClient(grant).join() | |
RoomEvent.TrackSubscribed etc. | 'trackSubscribed' etc. | Plain string event names. See Events. |
localParticipant.setCameraEnabled / setMicrophoneEnabled | room.enableCamera() / room.enableMicrophone() / track.mute() | |
setScreenShareEnabled(true) | room.enableScreenShare() | Video only. |
publishData(payload, { reliable, topic, destinationIdentities }) | room.sendData(payload, { reliable, topic, destinationIdentities }) | Same options, and the receiver gets the sender (Data messages). |
adaptiveStream, dynacast | adaptiveStream (on by default); unused layers pause automatically | Manual: track.setLayer(). See Tracks. |
ExternalE2EEKeyProvider | createSharedKeyProvider() / createKeyProvider() | The room must also be created with e2ee: true. See E2EE. |
RoomServiceClient | Server SDK rooms + REST | See Moderation. |
| Egress (room composite, track, web, segments) | No equivalent for calls; HLS + replay for broadcast-mode live streams | See Gaps. |
Egress StreamOutput (RTMP) | Restream | Live streams only. |
| Ingress (RTMP, WHIP) | Ingest | Live streams only. |
WebhookReceiver | Webhooks | HMAC-SHA256 Swalvo-Signature, not a JWT. |
@livekit/components-react | @livqeno/react | <LivqenoRoom>, hooks, ParticipantView. See React. |
| LiveKit Cloud regions | Automatic placement | See Regions. |
Server: make a token
import { AccessToken } from 'livekit-server-sdk';
const at = new AccessToken(process.env.LIVEKIT_API_KEY, process.env.LIVEKIT_API_SECRET, {
identity: user.id,
ttl: '10m',
});
at.addGrant({ roomJoin: true, room: roomName, canPublish: true, canSubscribe: true, canPublishData: true });
res.json({ token: await at.toJwt(), url: process.env.LIVEKIT_URL });from livekit import api
token = (
api.AccessToken(os.environ["LIVEKIT_API_KEY"], os.environ["LIVEKIT_API_SECRET"])
.with_identity(user_id)
.with_grants(api.VideoGrants(room_join=True, room=room_name))
.to_jwt()
)at := auth.NewAccessToken(os.Getenv("LIVEKIT_API_KEY"), os.Getenv("LIVEKIT_API_SECRET"))
at.SetVideoGrant(&auth.VideoGrant{RoomJoin: true, Room: roomName}).
SetIdentity(userID).
SetValidFor(time.Hour)
token, err := at.ToJWT()import { Livqeno } from '@livqeno/server';
const livqeno = new Livqeno({ apiKey: process.env.LIVQENO_API_KEY, baseUrl: process.env.LIVQENO_API_URL });
const room = await livqeno.rooms.create({ name: roomName, getOrCreate: true });
const grant = await livqeno.tokens.create({
room: room.id, // the id, not the name
identity: user.id,
permissions: { join: true, subscribe: true, publish: true, publishData: true },
expiresIn: 600,
});
res.json(grant); // token, endpoint and iceServers: no separate URL to sendimport os
from livqeno import Livqeno, CreateTokenParams, TokenPermissions
livqeno = Livqeno(api_key=os.environ["LIVQENO_API_KEY"], base_url=os.environ["LIVQENO_API_URL"])
room = livqeno.rooms.create(room_name, get_or_create=True)
grant = livqeno.tokens.create(
CreateTokenParams(
room=room["id"],
identity=user_id,
permissions=TokenPermissions(join=True, subscribe=True, publish=True, publish_data=True),
expires_in=600,
)
)client := livqeno.New(os.Getenv("LIVQENO_API_KEY"), livqeno.WithBaseURL(os.Getenv("LIVQENO_API_URL")))
room, err := client.Rooms.Create(ctx, livqeno.CreateRoomParams{Name: roomName, GetOrCreate: true})
if err != nil {
return err
}
grant, err := client.Tokens.Create(ctx, livqeno.CreateTokenParams{
Room: room.ID,
Identity: userID,
Permissions: &livqeno.TokenPermissions{
Join: livqeno.Bool(true), Subscribe: livqeno.Bool(true),
Publish: livqeno.Bool(true), PublishData: livqeno.Bool(true),
},
ExpiresIn: 600,
})import com.livqeno.server.model.*;
Room room = livqeno.rooms().create(CreateRoomParams.builder().name(roomName).getOrCreate(true).build());
IssuedToken grant = livqeno.tokens().create(CreateTokenParams.builder()
.room(room.id())
.identity(userId)
.permissions(TokenPermissions.builder().join(true).subscribe(true).publish(true).publishData(true).build())
.expiresIn(600)
.build());$room = $client->rooms->create(['name' => $roomName, 'getOrCreate' => true]);
$grant = $client->tokens->create([
'room' => $room->id,
'identity' => $userId,
'permissions' => ['join' => true, 'subscribe' => true, 'publish' => true, 'publishData' => true],
'expiresIn' => 600,
]);Tokens last 30 seconds to 6 hours (default 10 minutes). For long calls, pass
refreshToken to createRTCClient so the SDK fetches a new one in time
(RTC authentication).
Client: join, publish and subscribe
import { Room, RoomEvent } from 'livekit-client';
const room = new Room({ adaptiveStream: true, dynacast: true });
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
grid.appendChild(track.attach());
});
room.on(RoomEvent.TrackUnsubscribed, (track) => track.detach().forEach((el) => el.remove()));
await room.connect(url, token);
await room.localParticipant.enableCameraAndMicrophone();import { createRTCClient } from '@livqeno/rtc';
const grant = await fetch('/join-room', { method: 'POST' }).then((r) => r.json());
const client = createRTCClient(grant); // adaptiveStream is on by default
const room = await client.join(); // the room comes from the token
room.on('trackSubscribed', (track, participant) => {
const el = track.attach();
el.dataset.identity = participant.identity;
grid.appendChild(el);
});
room.on('trackUnsubscribed', (track) => track.detach().forEach((el) => el.remove()));
await room.enableCamera();
await room.enableMicrophone();Two behaviours to know:
join()resolves when you're admitted, a moment before media is connected. Drive UI from theconnectedevent, orawait room.waitUntilConnected().trackSubscribedgives you(track, participant). There's no separate publication object:track.kindis'camera','microphone'or'screenShare'.
room.disconnect() becomes await room.leave().
Mute and unmute
await room.localParticipant.setMicrophoneEnabled(false);
await room.localParticipant.setMicrophoneEnabled(true);const mic = await room.enableMicrophone();
await mic?.mute(); // stays published; others get trackMuted
await mic?.unmute();
await room.disableMicrophone(); // or unpublish and release the deviceScreen share
await room.localParticipant.setScreenShareEnabled(true);
await room.localParticipant.setScreenShareEnabled(false);await room.enableScreenShare();
await room.disableScreenShare();LiveKit can capture tab or system audio with the screen
(setScreenShareEnabled(true, { audio: true })). Livqeno sends screen
video only today.
Data messages
const payload = new TextEncoder().encode(JSON.stringify({ type: 'raise-hand' }));
await room.localParticipant.publishData(payload, { reliable: true, topic: 'hands' });
room.on(RoomEvent.DataReceived, (payload, participant, kind, topic) => {
console.log(participant?.identity, topic, new TextDecoder().decode(payload));
});await room.sendData(JSON.stringify({ type: 'raise-hand' }), { reliable: true, topic: 'hands' });
room.on('dataReceived', (payload, participant, topic) => {
console.log(participant?.identity, topic, new TextDecoder().decode(payload));
});If you built chat on top of LiveKit data messages (or text streams), move it
to Chat, which keeps history, threads, reactions and read receipts.
Attach a conversation to the call with roomId when you create it. See the
Chat quickstart.
Server-side moderation
import { RoomServiceClient } from 'livekit-server-sdk';
const svc = new RoomServiceClient(host, apiKey, apiSecret);
await svc.mutePublishedTrack(roomName, identity, trackSid, true);
await svc.updateParticipant(roomName, identity, undefined, { canPublish: false });
await svc.removeParticipant(roomName, identity);
await svc.deleteRoom(roomName);# Mute every audio track of one participant
curl -X POST "$LIVQENO_API_URL/v1/rooms/$ROOM_ID/participants/alice/mute" \
-H "Authorization: Bearer $LIVQENO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"kind": "audio", "muted": true}'
# Take away publishing mid-call
curl -X PATCH "$LIVQENO_API_URL/v1/rooms/$ROOM_ID/participants/alice/permissions" \
-H "Authorization: Bearer $LIVQENO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"publish": false}'
# Disconnect one person, then close the room
curl -X POST "$LIVQENO_API_URL/v1/rooms/$ROOM_ID/participants/alice/remove" \
-H "Authorization: Bearer $LIVQENO_API_KEY"
curl -X DELETE "$LIVQENO_API_URL/v1/rooms/$ROOM_ID" \
-H "Authorization: Bearer $LIVQENO_API_KEY"The TypeScript and Python server SDKs wrap these:
await livqeno.rooms.muteParticipant(roomId, 'alice', { kind: 'audio', muted: true });
await livqeno.rooms.updateParticipantPermissions(roomId, 'alice', { publish: false });
await livqeno.rooms.updateParticipant(roomId, 'alice', { metadata: '{"role":"viewer"}' }); // LiveKit's updateParticipant metadata
await livqeno.rooms.removeParticipant(roomId, 'alice');
await livqeno.rooms.delete(roomId);Python has the same methods in snake_case (mute_participant,
update_participant_permissions, update_participant,
remove_participant). See RTC API.
Egress: recording and RTMP
import { EgressClient, EncodedFileOutput, StreamOutput, StreamProtocol } from 'livekit-server-sdk';
const egress = new EgressClient(host, apiKey, apiSecret);
// Record the room to a file
const rec = await egress.startRoomCompositeEgress(roomName, {
file: new EncodedFileOutput({ filepath: 'recordings/{room_name}.mp4' }),
});
await egress.stopEgress(rec.egressId);
// Push the room to YouTube
await egress.startRoomCompositeEgress(roomName, {
stream: new StreamOutput({ protocol: StreamProtocol.RTMP, urls: [`rtmps://a.rtmp.youtube.com/live2/${key}`] }),
});// RTMP out and recording belong to live streams, which own their own room.
const stream = await livqeno.liveStreams.create({
title: 'Launch',
hostIdentity: 'host-1',
deliveryMode: 'BROADCAST', // HLS for viewers, and an HLS replay when it ends
});
const hostGrant = await livqeno.liveStreams.addHost(stream.id, { identity: 'host-1' });
await livqeno.liveStreams.start(stream.id);
const destination = await livqeno.liveStreams.restreams.create(stream.id, {
url: 'rtmps://a.rtmp.youtube.com/live2', // URL and key are separate fields
streamKey: process.env.YOUTUBE_STREAM_KEY!,
label: 'YouTube',
});
await livqeno.liveStreams.restreams.delete(stream.id, destination.id); // stop that destination
await livqeno.liveStreams.end(stream.id); // → live_stream.recording_ready webhook with recordingUrlstream, err := client.LiveStreams.Create(ctx, livqeno.CreateLiveStreamParams{
Title: "Launch",
HostIdentity: "host-1",
})
if err != nil {
return err
}
if _, err := client.LiveStreams.Start(ctx, stream.ID); err != nil {
return err
}
_, err = client.LiveStreams.Restreams.Create(ctx, stream.ID, livqeno.CreateRestreamParams{
URL: "rtmps://a.rtmp.youtube.com/live2",
StreamKey: os.Getenv("YOUTUBE_STREAM_KEY"),
Label: "YouTube",
})What doesn't carry over: recording a plain call, track and track-composite egress, web (URL) egress, custom layouts and templates, and writing files to your own bucket. The replay of a broadcast-mode stream is an HLS playlist hosted by Livqeno. See Gaps.
Ingress
import { IngressClient, IngressInput } from 'livekit-server-sdk';
const ingressClient = new IngressClient(host, apiKey, apiSecret);
const ingress = await ingressClient.createIngress(IngressInput.RTMP_INPUT, {
roomName,
participantIdentity: 'obs',
participantName: 'OBS',
});
console.log(ingress.url, ingress.streamKey);// One ingest per live stream. 'RTMP' (rtmp:// and rtmps://) or 'WHIP'.
const ingress = await livqeno.liveStreams.ingress.create(stream.id, { protocol: 'RTMP' });
console.log(ingress.rtmpsUrl, ingress.streamKey); // the key is shown only nowLivqeno passes H.264 through without transcoding, so the encoder must send H.264 with no B-frames, up to 1080p30 at 8 Mbps. There's no URL (pull) input and no transcoding to simulcast layers. See OBS & encoder ingest.
End-to-end encryption
import { ExternalE2EEKeyProvider, Room } from 'livekit-client';
const keyProvider = new ExternalE2EEKeyProvider();
await keyProvider.setKey(passphrase);
const room = new Room({
e2ee: { keyProvider, worker: new Worker(new URL('livekit-client/e2ee-worker', import.meta.url)) },
});
await room.setE2EEEnabled(true);import { createRTCClient, createSharedKeyProvider } from '@livqeno/rtc';
// Backend: livqeno.rooms.create({ name, e2ee: true }), fixed for the room's life.
const client = createRTCClient({ ...grant, e2ee: { keyProvider: createSharedKeyProvider(passphrase) } });
const room = await client.join();An E2EE room refuses unencrypted publishing, and refuses HLS, recording,
restream and ingest. Web and Flutter only; React Native fails with
E2EE_NOT_SUPPORTED.
Webhooks
import { WebhookReceiver } from 'livekit-server-sdk';
const receiver = new WebhookReceiver(apiKey, apiSecret);
const event = await receiver.receive(rawBody, req.get('Authorization'));
if (event.event === 'participant_joined') {
// event.room, event.participant
}import { verifyWebhook } from '@livqeno/server';
// rawBody: the bytes as received (express.raw()), not a parsed object
const event = verifyWebhook(rawBody, req.get('Swalvo-Signature'), process.env.LIVQENO_WEBHOOK_SECRET!); // throws if invalid
if (event.type === 'room.participant_joined') {
// event.data: { roomId, identity, connectionId, at }
}event, err := livqeno.VerifyWebhookRequest(r, os.Getenv("LIVQENO_WEBHOOK_SECRET"))
if err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
// event.ID (dedupe on it), event.Type, event.Dataimport hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t = parts.get("t", "")
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts.get("v1", ""), expected)Java and PHP have WebhookVerifier.verify and Webhook::constructEvent.
See Webhooks.
| LiveKit webhook | Livqeno webhook |
|---|---|
room_started / room_finished | room.started / room.ended |
participant_joined / participant_left | room.participant_joined / room.participant_left |
track_published / track_unpublished | room.track_published / room.track_unpublished |
egress_started / egress_ended | live_stream.broadcast_ready, live_stream.egress_failed, live_stream.recording_ready, live_stream.restream_active / _failed |
ingress_started / ingress_ended | live_stream.ingress_connected / _disconnected / _failed |
Feature gaps
What LiveKit has that Livqeno doesn't, as of today:
| LiveKit feature | On Livqeno |
|---|---|
| Room composite, track and track-composite egress to files | Not available. No recording of calls. |
| Web (URL) egress, custom layouts and templates | Not available. Restream uses a fixed 1280×720 grid. |
| Egress or ingress on an ordinary room | Restream, HLS and ingest work on live streams only. |
| URL (pull) ingress, ingress transcoding to simulcast layers | Not available. Ingest is H.264 pass-through. |
| Text and byte streams, RPC | Not available. |
Hidden participants, roomAdmin grants | Not available as token grants. |
| Agents framework, SIP | Not available. |
Migration checklist
- Inventory. Find every
livekit-clientandlivekit-server-sdkimport, everyRoomServiceClient,EgressClientandIngressClientcall, and your webhook handler. - Credentials. Create a Livqeno project and an API key per environment (API credentials).
- Token service. Replace
AccessTokenwithrooms.create({ name, getOrCreate: true })+tokens.create(). Return the whole grant; drop the separate LiveKit URL. Map grants to permissions. - Client. Rename events (
RoomEvent.X→ string names), swapconnect()forcreateRTCClient(grant).join(), and move anytopicinto your payload. Pick the provider per room. - Moderation. Replace
RoomServiceClientcalls with the server SDK and the REST routes above. - Egress and ingress. Move rooms that use RTMP egress or ingress to live streams. Keep rooms that need file recording on LiveKit for now.
- Webhooks. Register a Livqeno endpoint, verify
Swalvo-Signature, deduplicate onLivqeno-Event-Id, map events with the table above. - Dual-run, then cut over. Ramp new rooms to Livqeno while watching observability. Remove LiveKit once no room has used it for a full token lifetime.
Pricing
Livqeno's rates are on Usage. Compare them with LiveKit Cloud's current pricing page; this guide doesn't quote it.