This guide moves a web app from Twilio Programmable Video
(twilio-video in the browser, the twilio helper library on the server)
to Livqeno. Read the overview first for the general
approach.
Concept mapping
| Twilio Video | Livqeno | Notes |
|---|---|---|
| Account SID + API Key SID / Secret | Project + API key | One API key per project and environment. It never leaves your backend. |
Room (created on first connect by name, or with the Rooms REST API) | Room, created by your backend | livqeno.rooms.create({ name, getOrCreate: true }), then make tokens for room.id. |
| Room types (Group, Go, P2P) | One room type | Every room goes through Livqeno's media server (SFU). There's no peer-to-peer mode. |
AccessToken + VideoGrant | livqeno.tokens.create() | Made by the API, not signed locally. Returns a grant with token, endpoint and iceServers. |
identity | identity | Same idea: your user id, unique in the room. |
| Participant SID | identity | Livqeno addresses participants by identity. |
connect(token, { name }) | createRTCClient(grant).join() | The room comes from the token. |
LocalAudioTrack / LocalVideoTrack | room.enableMicrophone() / room.enableCamera() | One call captures and publishes. |
trackSubscribed on a participant | trackSubscribed on the room | (track, participant). |
track.disable() / enable() | track.mute() / unmute() | Others get trackMuted / trackUnmuted. |
Screen share via getDisplayMedia + publishTrack | room.enableScreenShare() | Video only. |
LocalDataTrack | room.sendData() + dataReceived | Needs the publishData permission. |
| Conversations API (chat) | Chat | Separate token: livqeno.chat.createToken(). |
| Bandwidth Profile, track priority | adaptiveStream (on by default) and track.setLayer() | See Tracks. |
| Dominant speaker | activeSpeakersChanged | Loudest first, up to eight. |
| Network Quality API | connectionQualityChanged | See Reconnection & network quality. |
| Recording Rules, Compositions | No equivalent for calls | See Gaps. |
Status callbacks (StatusCallback URL) | Webhooks | Configured per project and environment in the dashboard. |
X-Twilio-Signature | Swalvo-Signature | HMAC-SHA256 over <timestamp>.<raw body>. |
Media regions (gll, us1, …) | Automatic placement | See Regions. |
| Video Insights | Observability API | livqeno.connections.list(), livqeno.errors.list(). |
| Remove participant (REST) | POST /v1/rooms/{roomId}/participants/{identity}/remove | |
| Complete a room (REST) | DELETE /v1/rooms/{roomId} | Disconnects everyone. |
Server: make a token
const twilio = require('twilio');
const { AccessToken } = twilio.jwt;
const { VideoGrant } = AccessToken;
const token = new AccessToken(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_API_KEY_SID,
process.env.TWILIO_API_KEY_SECRET,
{ identity: user.id, ttl: 3600 },
);
token.addGrant(new VideoGrant({ room: roomName }));
res.json({ token: token.toJwt() });from twilio.jwt.access_token import AccessToken
from twilio.jwt.access_token.grants import VideoGrant
token = AccessToken(account_sid, api_key_sid, api_key_secret, identity=user_id, ttl=3600)
token.add_grant(VideoGrant(room=room_name))
jwt = token.to_jwt()import { Livqeno } from '@livqeno/server';
const livqeno = new Livqeno({ apiKey: process.env.LIVQENO_API_KEY, baseUrl: process.env.LIVQENO_API_URL });
// Rooms are explicit. getOrCreate makes concurrent joins safe.
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); // forward the whole grant: token, endpoint, iceServersimport 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,
]);Two things change for the client, not just the server:
- Return the whole grant, not only the token string. The client SDK
needs
endpointandiceServersfrom it. Don't build ICE servers yourself. - Permissions are explicit. Twilio's
VideoGrantlets anyone in the room publish. In Livqeno, anything you don't turn on is off. A view-only user gets{ join: true, subscribe: true }. See Permissions.
Client: join, publish and subscribe
import { connect } from 'twilio-video';
const room = await connect(token, { name: roomName, audio: true, video: true });
function onParticipant(participant) {
participant.on('trackSubscribed', (track) => grid.appendChild(track.attach()));
participant.on('trackUnsubscribed', (track) => track.detach().forEach((el) => el.remove()));
}
room.participants.forEach(onParticipant);
room.on('participantConnected', onParticipant);import { createRTCClient } from '@livqeno/rtc';
const grant = await fetch('/join-room', { method: 'POST' }).then((r) => r.json());
const client = createRTCClient(grant); // reads token, endpoint and iceServers
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();join() resolves once you're admitted to the room, a moment before media is
connected. Drive "connected" UI from the connected event, or await room.waitUntilConnected(). See Rooms & participants.
Leaving: Twilio's room.disconnect() becomes await room.leave().
Mute and unmute
room.localParticipant.audioTracks.forEach((pub) => pub.track.disable());
room.localParticipant.audioTracks.forEach((pub) => pub.track.enable());const mic = await room.enableMicrophone();
await mic?.mute();
await mic?.unmute();
// Or turn the device off entirely (the mic light goes off):
await room.disableMicrophone();To mute someone else from your backend (moderation), call
POST /v1/rooms/{roomId}/participants/{identity}/mute with
{ "kind": "audio", "muted": true }. The user's app gets
localTrackMutedByServer.
Screen share
import { LocalVideoTrack } from 'twilio-video';
const stream = await navigator.mediaDevices.getDisplayMedia();
const screenTrack = new LocalVideoTrack(stream.getVideoTracks()[0], { name: 'screen' });
await room.localParticipant.publishTrack(screenTrack);
// stop:
room.localParticipant.unpublishTrack(screenTrack);
screenTrack.stop();await room.enableScreenShare(); // opens the browser's picker and publishes
await room.disableScreenShare();
// Remote side: a screen share arrives as a track whose kind is 'screenShare'.
room.on('trackSubscribed', (track) => {
if (track.kind === 'screenShare') stage.appendChild(track.attach());
});Data and chat messages
import { LocalDataTrack } from 'twilio-video';
const dataTrack = new LocalDataTrack();
await room.localParticipant.publishTrack(dataTrack);
dataTrack.send(JSON.stringify({ type: 'raise-hand' }));
// Receiving: a RemoteDataTrack fires 'message'
track.on('message', (data) => console.log(data));// Ephemeral messages to everyone in the call. Needs publishData in the token.
await room.sendData(JSON.stringify({ type: 'raise-hand' }));
room.on('dataReceived', (payload, participant) => {
console.log(participant?.identity, new TextDecoder().decode(payload));
});Add the dataReceived listener right after joining: it is what sets up your
side of the data channel. For saved messages (history, read receipts),
use Chat instead of sendData(). If you used the Twilio
Conversations API next to Video, Chat is its replacement. See
Chat quickstart.
Recording
const twilioClient = require('twilio')(apiKeySid, apiKeySecret, { accountSid });
// Record everyone in the room
await twilioClient.video.v1.rooms(roomSid).recordingRules.update({
rules: [{ type: 'include', all: true }],
});
// Later: compose the recordings into one file
await twilioClient.video.v1.compositions.create({
roomSid,
audioSources: ['*'],
videoLayout: { grid: { video_sources: ['*'] } },
format: 'mp4',
});// There is no recording for calls. The only recording is the HLS replay of a
// broadcast-mode live stream, announced by a webhook when the stream ends:
//
// { "type": "live_stream.recording_ready",
// "data": { "streamId": "stream_…", "recordingUrl": "https://…/vod.m3u8", "durationSec": 3600, "at": "…" } }
const stream = await livqeno.liveStreams.create({
title: 'Town hall',
hostIdentity: 'alice',
deliveryMode: 'BROADCAST',
});If you record ordinary group calls, Livqeno can't replace that today. Keep recorded rooms on Twilio, or record outside Livqeno, until it can. See Feature gaps.
RTMP out
Twilio Video has no built-in RTMP output as far as we know. If you built one (for example, a headless browser that joins the room and pushes to YouTube), Livqeno's restream replaces it, but only for live streams, not for plain rooms:
const stream = await livqeno.liveStreams.create({ title: 'Launch', hostIdentity: 'alice' });
const hostGrant = await livqeno.liveStreams.addHost(stream.id, { identity: 'alice' });
await livqeno.liveStreams.start(stream.id);
await livqeno.liveStreams.restreams.create(stream.id, {
url: 'rtmps://a.rtmp.youtube.com/live2',
streamKey: process.env.YOUTUBE_STREAM_KEY!,
label: 'YouTube',
});The host joins with LiveStream.join(hostGrant) from @livqeno/live.
See Build a live stream.
Webhooks
const twilio = require('twilio');
// Status callbacks are form-encoded and signed over the full URL + params.
const ok = twilio.validateRequest(
process.env.TWILIO_AUTH_TOKEN,
req.header('X-Twilio-Signature'),
'https://example.com/twilio/status',
req.body,
);
if (ok && req.body.StatusCallbackEvent === 'participant-connected') {
// ...
}import { createHmac, timingSafeEqual } from 'node:crypto';
// JSON body, signed over `<t>.<raw body>`. Read the RAW body, not parsed JSON.
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const a = Buffer.from(parts.v1 ?? '');
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
if (verify(rawBody, req.header('Swalvo-Signature') ?? '', process.env.LIVQENO_WEBHOOK_SECRET!)) {
const event = JSON.parse(rawBody);
if (event.type === 'room.participant_joined') {
// event.data: { roomId, identity, connectionId, at }
}
}import hashlib, hmac, os, 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)Every server SDK ships this check (verifyWebhook in TypeScript,
verify_webhook in Python, livqeno.VerifyWebhookRequest,
WebhookVerifier.verify, Webhook::constructEvent). See Webhooks.
| Twilio status callback | Livqeno webhook |
|---|---|
room-created | room.started (first participant joins) |
room-ended | room.ended |
participant-connected | room.participant_joined |
participant-disconnected | room.participant_left (with a reason) |
track-added | room.track_published |
track-removed | room.track_unpublished |
recording-*, composition-* | None |
track-enabled / track-disabled | None: mute is a client event only |
E2EE
As far as we know, Twilio's Group Rooms route media through Twilio's servers
without end-to-end encryption (check their docs for your room type).
Livqeno rooms can be end-to-end encrypted on the web and Flutter: create the
room with e2ee: true and give the SDK a key. Recording, HLS, restream and
ingest are refused for such a room. See End-to-end encryption.
Feature gaps
What Twilio Video has that Livqeno doesn't, as of today:
| Twilio feature | On Livqeno |
|---|---|
| Recording Rules, per-track recordings | Not available. No room recording of any kind. |
| Compositions | Not available. The only composite output is a live stream's restream grid (1280×720, fixed layout). |
| Peer-to-peer rooms | Not available. Every room uses the SFU. |
| PSTN / SIP participants | Not available. |
Mute and unmute webhooks (track-enabled / track-disabled) | Client events only. |
| System or tab audio in screen share | Not sent yet. |
| Large rooms | 50 participants per room by default. Capacity has been tested to 100 participants with simulated media, not on real networks (Known limitations). |
| Browser coverage | Only Chromium is tested with real media. |
Migration checklist
- Inventory. Find every use of
twilio-video,AccessToken,VideoGrant, the Rooms/Recordings/Compositions REST APIs and your status-callback handlers. - Credentials. Create a Livqeno project, an API key per environment,
and store them as
LIVQENO_API_KEYandLIVQENO_API_URL(API credentials). - Token service. Add a Livqeno branch to your token endpoint:
rooms.create({ name, getOrCreate: true }), thentokens.create(). Return the whole grant. Map each Twilio "role" in your app to an explicit permission set. - Client. Wrap join, mute, screen share and data in one interface with a Twilio and a Livqeno implementation. Choose per room, so a call never mixes providers.
- Chat. If you used Conversations, move it to Chat: conversations, members, then a chat token.
- Webhooks. Register a Livqeno endpoint, verify
Swalvo-Signature, deduplicate onLivqeno-Event-Id, and map events with the table above. - Recording. Keep recorded rooms on Twilio until you have a plan for them. Don't cut over a recorded product on the assumption it's coming.
- Dual-run. Send a small share of new rooms to Livqeno. Watch connections and errors and your own call-quality metrics.
- Cut over. Make Livqeno the default for new rooms. Remove the Twilio path once no Twilio room has been active for a full token lifetime.
Pricing
Livqeno's rates are on Usage. Compare them with Twilio's current pricing page; this guide doesn't quote it.