This guide moves a web app from 100ms (@100mslive/hms-video-store or
the React SDK on the client, auth tokens and the REST API on the server) to
Livqeno. Read the overview first for the general
approach.
The biggest modelling change: 100ms puts behaviour in a dashboard template (roles, what each role may publish, recording and streaming settings). Livqeno has no templates. What a user may do is in the token your backend makes, and large-audience and streaming features live on live streams.
Concept mapping
| 100ms | Livqeno | Notes |
|---|---|---|
| App access key + app secret | Project + API key | The API key never leaves your backend. |
| Management token (for the REST API) | The API key, as a bearer token | No separate management token to sign. |
| Template | No equivalent | Room options (e2ee) are set when the room is created; everything else is per token. |
Room (room_id, created via dashboard or API) | Room | livqeno.rooms.create({ name, getOrCreate: true }), then make tokens for room.id. |
| Room codes, Prebuilt UI | No prebuilt video UI | Build with @livqeno/react components and hooks. Chat has UI components. |
| Role (host, guest, viewer…) | permissions in the token | join, subscribe, publish, publishAudio, publishVideo, publishData. |
| Change role | PATCH /v1/rooms/{roomId}/participants/{identity}/permissions | Or the stage for live streams. |
| Auth token (JWT you sign with the app secret) | livqeno.tokens.create() | Made by the API. Returns a grant with token, endpoint and iceServers. |
Peer, user_id | Participant, identity | |
hmsActions.join({ userName, authToken }) | createRTCClient(grant).join() | |
selectPeers + attachVideo | trackSubscribed + track.attach() | Livqeno is event-based, not a reactive store. React hooks are in @livqeno/react. |
setLocalAudioEnabled / setLocalVideoEnabled | track.mute() / room.enableCamera() / room.disableCamera() | |
setScreenShareEnabled(true) | room.enableScreenShare() | Video only. |
sendBroadcastMessage | room.sendData(), or Chat for saved messages | No direct or group (per-role) targeting with sendData(). |
| Simulcast (template setting) | On by default | track.setLayer() to pick a quality. |
| Recording (browser composite, track, SFU) | No equivalent for calls | See Gaps. |
startRTMPOrRecording({ rtmpURLs }) | Restream | Live streams only. |
startHLSStreaming() | A live stream with deliveryMode: 'BROADCAST' | See Scaling the audience. |
| RTMP-in (stream key) | Ingest | WHIP or RTMP(S). Live streams only. |
| Webhooks | Webhooks | HMAC-SHA256 signed. |
| Noise suppression / virtual background plugins | Not available. See Effects removed. | |
| Region (set on the room) | Automatic placement | See Regions. |
| Remove peer, end room (REST) | POST /v1/rooms/{roomId}/participants/{identity}/remove, DELETE /v1/rooms/{roomId} |
Server: make a token
import jwt from 'jsonwebtoken';
import { randomUUID } from 'node:crypto';
const now = Math.floor(Date.now() / 1000);
const token = jwt.sign(
{
access_key: process.env.HMS_ACCESS_KEY,
room_id: roomId,
user_id: user.id,
role: 'host',
type: 'app',
version: 2,
iat: now,
nbf: now,
},
process.env.HMS_APP_SECRET,
{ algorithm: 'HS256', expiresIn: '24h', jwtid: randomUUID() },
);import time, uuid
import jwt # PyJWT
now = int(time.time())
token = jwt.encode(
{
"access_key": hms_access_key, "room_id": room_id, "user_id": user_id,
"role": "host", "type": "app", "version": 2,
"iat": now, "nbf": now, "exp": now + 86400, "jti": str(uuid.uuid4()),
},
hms_app_secret,
algorithm="HS256",
)import { Livqeno } from '@livqeno/server';
const livqeno = new Livqeno({ apiKey: process.env.LIVQENO_API_KEY, baseUrl: process.env.LIVQENO_API_URL });
// What a 100ms role allowed now lives in the token.
const ROLES = {
host: { join: true, subscribe: true, publish: true, publishData: true },
guest: { join: true, subscribe: true, publish: true, publishAudio: true, publishVideo: false },
viewer: { join: true, subscribe: true },
};
const room = await livqeno.rooms.create({ name: roomName, getOrCreate: true });
const grant = await livqeno.tokens.create({
room: room.id,
identity: user.id,
permissions: ROLES.host,
expiresIn: 3600,
});
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=3600,
)
)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: 3600,
})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(3600)
.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' => 3600,
]);Tokens last at most 6 hours, not 24. Pass refreshToken to
createRTCClient for longer sessions (RTC authentication).
See Permissions for common permission sets.
Client: join, publish and subscribe
import { HMSReactiveStore, selectPeers } from '@100mslive/hms-video-store';
const hms = new HMSReactiveStore();
hms.triggerOnSubscribe();
const hmsActions = hms.getActions();
const hmsStore = hms.getStore();
await hmsActions.join({ userName: 'Alice', authToken: token });
hmsStore.subscribe((peers) => {
for (const peer of peers) {
if (peer.videoTrack) hmsActions.attachVideo(peer.videoTrack, videoElementFor(peer.id));
}
}, selectPeers);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
// Audio and video both arrive here; attach() returns an autoplaying element.
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();With 100ms, the template decides whether a role publishes on join. With
Livqeno nothing is published until your app calls enableCamera() /
enableMicrophone(). hmsActions.leave() becomes await room.leave().
Mute and unmute
await hmsActions.setLocalAudioEnabled(false);
await hmsActions.setLocalAudioEnabled(true);const mic = await room.enableMicrophone();
await mic?.mute();
await mic?.unmute();A host muting someone else (100ms setRemoteTrackEnabled) is a backend call
in Livqeno: POST /v1/rooms/{roomId}/participants/{identity}/mute with
{ "kind": "audio", "muted": true }. Only your API key can do it, so add a
backend endpoint that checks the caller is a host.
Screen share
await hmsActions.setScreenShareEnabled(true);
await hmsActions.setScreenShareEnabled(false);await room.enableScreenShare();
await room.disableScreenShare();Messages
await hmsActions.sendBroadcastMessage('hello');
// Also: sendDirectMessage(message, peerId), sendGroupMessage(message, roles)await room.sendData(JSON.stringify({ type: 'chat', text: 'hello' }));
room.on('dataReceived', (payload, participant) => {
console.log(participant?.identity, new TextDecoder().decode(payload));
});import { createChatClient } from '@livqeno/chat';
const chat = createChatClient(chatGrant); // from livqeno.chat.createToken() on your backend
await chat.connect({ room: conversationId });
chat.on('message', (message) => console.log(message.senderId, message.text));
await chat.sendMessage({ text: 'hello' });100ms room messages last only for the session. Livqeno's sendData() is the
same (and reaches only people connected at that moment). For a chat panel
with history, use Chat and attach the conversation to the call with
roomId when you create it. Hand raise, polls and other "session store"
patterns become sendData() messages that your app interprets.
Recording, HLS and RTMP
// Browser-composite recording and/or RTMP out, started from the client
await hmsActions.startRTMPOrRecording({
meetingURL,
rtmpURLs: [`rtmp://a.rtmp.youtube.com/live2/${youtubeKey}`],
record: true,
});
await hmsActions.stopRTMPAndRecording();
// HLS for a large audience
await hmsActions.startHLSStreaming();
await hmsActions.stopHLSStreaming();// Backend only. A live stream owns its room; BROADCAST adds HLS for viewers
// and an HLS replay when it ends (live_stream.recording_ready webhook).
const stream = await livqeno.liveStreams.create({
title: 'Town hall',
hostIdentity: 'host-1',
deliveryMode: 'BROADCAST',
});
const hostGrant = await livqeno.liveStreams.addHost(stream.id, { identity: 'host-1' });
await livqeno.liveStreams.start(stream.id);
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',
});
// Viewers: livqeno.liveStreams.createViewerToken(stream.id, identity, { acceptPlayback: true })
await livqeno.liveStreams.end(stream.id);import os
from livqeno import CreateLiveStreamParams, AddHostParams, CreateRestreamParams
stream = livqeno.live_streams.create(
CreateLiveStreamParams(title="Town hall", host_identity="host-1", delivery_mode="BROADCAST")
)
host_grant = livqeno.live_streams.add_host(stream["id"], AddHostParams(identity="host-1"))
livqeno.live_streams.start(stream["id"])
livqeno.live_streams.restreams.create(
stream["id"],
CreateRestreamParams(
url="rtmps://a.rtmp.youtube.com/live2",
stream_key=os.environ["YOUTUBE_STREAM_KEY"],
label="YouTube",
),
)The host joins with LiveStream.join(hostGrant) from @livqeno/live
and turns on camera and mic on stream.room. Restream and HLS can't be
started from a client, and there is no recording of an ordinary room. See
Gaps.
RTMP-in maps to livqeno.liveStreams.ingress.create(stream.id, { protocol: 'RTMP' }),
which returns rtmpsUrl and a streamKey for OBS. See
OBS & encoder ingest.
Webhooks
// 100ms sends the custom headers you configure in its dashboard with each
// webhook; you check them yourself (check their docs for the current options).
if (req.get('x-webhook-secret') !== process.env.HMS_WEBHOOK_SECRET) return res.sendStatus(401);
const { type, data } = req.body;
if (type === 'peer.join.success') {
// data.room_id, data.user_id
}import { createHmac, timingSafeEqual } from 'node:crypto';
// Signed with HMAC-SHA256 over `<t>.<raw body>`. Use the RAW body.
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.get('Swalvo-Signature') ?? '', process.env.LIVQENO_WEBHOOK_SECRET!)) {
return res.sendStatus(400);
}
const event = JSON.parse(rawBody);
if (event.type === 'room.participant_joined') {
// event.data: { roomId, identity, connectionId, at }
}import 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)use Livqeno\LivqenoException;
use Livqeno\Webhook;
$webhook = new Webhook(getenv('LIVQENO_WEBHOOK_SECRET'));
try {
$event = $webhook->constructEvent(file_get_contents('php://input'), $_SERVER['HTTP_LIVQENO_SIGNATURE'] ?? null);
} catch (LivqenoException $e) {
http_response_code(400);
exit;
}Every server SDK ships the same check (verifyWebhook in TypeScript,
verify_webhook in Python, livqeno.VerifyWebhookRequest,
WebhookVerifier.verify, Webhook::constructEvent). See Webhooks.
| 100ms webhook (check their docs for the full list) | Livqeno webhook |
|---|---|
session.open.success / session.close.success | room.started / room.ended |
peer.join.success / peer.leave.success | room.participant_joined / room.participant_left |
| Track add / remove events | room.track_published / room.track_unpublished |
| Recording events | live_stream.recording_ready for broadcast-mode streams only |
| HLS events | live_stream.broadcast_ready, live_stream.egress_failed |
| RTMP-out events | live_stream.restream_active / live_stream.restream_failed |
| RTMP-in events | live_stream.ingress_connected / _disconnected / _failed |
Feature gaps
What 100ms has that Livqeno doesn't, as of today:
| 100ms feature | On Livqeno |
|---|---|
| Templates and dashboard-defined roles | Not available. Permissions are set per token and can be changed per participant by your backend. |
| Prebuilt video UI, room codes | Not available. Build the UI with @livqeno/react. |
| Browser-composite, track and SFU recording | Not available for calls. Broadcast-mode live streams get an HLS replay. |
| Recording to your own storage bucket | Not available. |
| Starting HLS, RTMP or recording from the client | Not available. Your backend starts live streams and restreams. |
| HLS or RTMP on an ordinary room | Restream, HLS and ingest work on live streams only. |
| Per-role messages | sendData() can target identities (destinationIdentities) but not roles. Keep your own role-to-identity list, or use Chat conversations. |
| Polls, whiteboard, session store | Not available as features. Build them on sendData() or Chat. |
| Transcription and AI summaries | Not available. |
| E2EE on React Native | Not yet. |
Migration checklist
- Inventory. List every 100ms template and role, every
hmsActionscall your app makes, your token endpoint, your REST calls (rooms, recordings, live streams) and your webhook handler. - Roles → permission sets. Write down, for each role, which of
publish,publishAudio,publishVideo,publishData,subscribeit needs. Viewer-only roles at scale become live-stream viewers. - Credentials. Create a Livqeno project and an API key per environment (API credentials).
- Token service. Replace the JWT you sign with
rooms.create({ name, getOrCreate: true })+tokens.create(). Return the whole grant. AddrefreshTokenfor sessions over 6 hours. - Client. Replace the reactive store with event handlers (or
@livqeno/reacthooks). Pick the provider per room. - Host controls. Move remote mute, role change and remove-peer to backend endpoints that call the Livqeno REST routes.
- Streaming. Move HLS, RTMP-out and RTMP-in rooms to live streams. Keep rooms that need recording on 100ms until you have a plan for them.
- 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 100ms once no room has used it for a full token lifetime.
Pricing
Livqeno's rates are on Usage. Compare them with 100ms's current pricing page; this guide doesn't quote it.