This guide moves an app from Agora to Livqeno. It covers the Video /
Voice SDK (agora-rtc-sdk-ng on the web), Signaling (RTM) and Agora Chat,
Cloud Recording, Media Push, Media Gateway and the Notifications service
(NCS). Read the overview first for the general approach.
Concept mapping
| Agora | Livqeno | Notes |
|---|---|---|
| App ID + App Certificate | Project + API key | The API key never leaves your backend. |
| Channel (exists once someone joins it) | Room, created by your backend | livqeno.rooms.create({ name, getOrCreate: true }), then make tokens for room.id. |
uid (number) or user account (string) | identity (string) | Use String(uid). Livqeno never assigns one for you, so there is no uid: 0. |
RtcTokenBuilder.buildTokenWithUid | livqeno.tokens.create() | Made by the API, not signed locally. Returns a grant with token, endpoint and iceServers. |
| Token privileges (join, publish audio / video / data) | permissions: join, subscribe, publish, publishAudio, publishVideo, publishData | Anything you don't turn on is off. |
Channel profile rtc | A room | |
Channel profile live, host / audience roles (setClientRole) | A live stream: hosts, co-hosts and viewers | Or a room with view-only tokens. Moving an audience member up is the stage. |
createMicrophoneAndCameraTracks + client.publish | room.enableMicrophone() / room.enableCamera() | One call captures and publishes. |
user-published + client.subscribe | trackSubscribed | Livqeno subscribes automatically. |
track.setMuted() / setEnabled() | track.mute() / room.disableMicrophone() | |
createScreenVideoTrack | room.enableScreenShare() | Video only. Sent next to the camera from the same client. |
Dual stream (enableDualStream, setRemoteVideoStreamType) | Simulcast, on by default, and track.setLayer() | See Tracks. |
Data stream (sendStreamMessage, native SDKs) | room.sendData() + dataReceived | Needs publishData. |
| Signaling (RTM): message channels, presence | Chat: conversations, presence, typing | Separate chat token. |
| Agora Chat (IM): groups, threads, reactions | Chat | Threads, reactions, receipts, attachments, moderation, push. |
| Cloud Recording | No equivalent for calls | See Gaps. |
| Media Push (RTMP converter) | Restream | Live streams only. Up to 5 destinations, fixed grid. |
| Media Gateway (OBS / encoder in) | Ingest | WHIP or RTMP(S). Live streams only. |
| NCS (Notifications) | Webhooks | Swalvo-Signature, HMAC-SHA256. |
Built-in media encryption (setEncryptionConfig) | E2EE | Keys stay on devices. Web and Flutter. |
| AI noise suppression / virtual background extensions | Not available. See Effects removed. | |
Area code (AgoraRTC.setArea) | Automatic placement | See Regions. |
| Agora Analytics | Observability API | |
| Kicking a user (banning rule REST API) | POST /v1/rooms/{roomId}/participants/{identity}/remove | Plus token revocation. |
Server: make a token
const { RtcTokenBuilder, RtcRole } = require('agora-token');
const token = RtcTokenBuilder.buildTokenWithUid(
process.env.AGORA_APP_ID,
process.env.AGORA_APP_CERTIFICATE,
channelName,
uid,
RtcRole.PUBLISHER,
3600, // token expiry, seconds
3600, // privilege expiry, seconds
);# RtcTokenBuilder2 from Agora's AgoraDynamicKey sources (check their docs
# for the import path in your setup).
from RtcTokenBuilder2 import RtcTokenBuilder, Role_Publisher
token = RtcTokenBuilder.build_token_with_uid(
app_id, app_certificate, channel_name, uid, Role_Publisher, 3600, 3600
)import { Livqeno } from '@livqeno/server';
const livqeno = new Livqeno({ apiKey: process.env.LIVQENO_API_KEY, baseUrl: process.env.LIVQENO_API_URL });
// A channel name becomes a room name. getOrCreate makes concurrent joins safe.
const room = await livqeno.rooms.create({ name: channelName, getOrCreate: true });
const grant = await livqeno.tokens.create({
room: room.id, // the id, not the name
identity: String(uid),
permissions: { join: true, subscribe: true, publish: true, publishData: true },
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(channel_name, get_or_create=True)
grant = livqeno.tokens.create(
CreateTokenParams(
room=room["id"],
identity=str(uid),
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: channelName, GetOrCreate: true})
if err != nil {
return err
}
grant, err := client.Tokens.Create(ctx, livqeno.CreateTokenParams{
Room: room.ID,
Identity: strconv.Itoa(uid),
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(channelName).getOrCreate(true).build());
IssuedToken grant = livqeno.tokens().create(CreateTokenParams.builder()
.room(room.id())
.identity(String.valueOf(uid))
.permissions(TokenPermissions.builder().join(true).subscribe(true).publish(true).publishData(true).build())
.expiresIn(3600)
.build());$room = $client->rooms->create(['name' => $channelName, 'getOrCreate' => true]);
$grant = $client->tokens->create([
'room' => $room->id,
'identity' => (string) $uid,
'permissions' => ['join' => true, 'subscribe' => true, 'publish' => true, 'publishData' => true],
'expiresIn' => 3600,
]);Things to know when you port the token endpoint:
- One expiry, no separate privilege expiry.
expiresInis 30 seconds to 6 hours. PassrefreshTokentocreateRTCClientand the SDK fetches a new one before the old one runs out (RTC authentication). This replaces Agora'stoken-privilege-will-expirehandler. - Audience members get
{ join: true, subscribe: true }and nothing else. For a host/audience product with many viewers, use a live stream instead of a room.
Client: join, publish and subscribe
import AgoraRTC from 'agora-rtc-sdk-ng';
const client = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' });
await client.join(appId, channelName, token, uid);
const [micTrack, camTrack] = await AgoraRTC.createMicrophoneAndCameraTracks();
await client.publish([micTrack, camTrack]);
client.on('user-published', async (user, mediaType) => {
await client.subscribe(user, mediaType);
if (mediaType === 'video') user.videoTrack.play(`tile-${user.uid}`);
if (mediaType === 'audio') user.audioTrack.play();
});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
// No subscribe call: every published track arrives here.
room.on('trackSubscribed', (track, participant) => {
const el = track.attach(); // a <video> or <audio> element, autoplaying
document.getElementById(`tile-${participant.identity}`)?.appendChild(el);
});
room.on('trackUnsubscribed', (track) => track.detach().forEach((el) => el.remove()));
await room.enableCamera();
await room.enableMicrophone();Event names map like this: user-joined / user-left →
participantJoined / participantLeft; user-published / user-unpublished
→ trackSubscribed / trackUnsubscribed; connection-state-change →
connectionStateChanged; volume-indicator → activeSpeakersChanged;
network-quality → connectionQualityChanged. The full list is in
Events.
To receive without sending video, set autoSubscribe (default on) and
simply don't enable the camera. To stop receiving one track, call
track.setEnabled(false) on the remote track.
Mute and unmute
await micTrack.setMuted(true);
await micTrack.setMuted(false);
// Or stop capture entirely:
await micTrack.setEnabled(false);const mic = await room.enableMicrophone();
await mic?.mute();
await mic?.unmute();
// Or stop capture and release the device:
await room.disableMicrophone();Screen share
const screenTrack = await AgoraRTC.createScreenVideoTrack({ encoderConfig: '1080p_1' });
// The Web SDK publishes one video track per client, so apps usually swap the
// camera out or use a second client for the screen (check Agora's docs).
await client.unpublish(camTrack);
await client.publish(screenTrack);await room.enableScreenShare(); // camera keeps publishing alongside it
await room.disableScreenShare();
room.on('trackSubscribed', (track) => {
if (track.kind === 'screenShare') stage.appendChild(track.attach());
});Dual stream vs. simulcast
await client.enableDualStream();
client.setRemoteVideoStreamType(remoteUid, 1); // 1 = low-quality stream// Simulcast is on by default, and adaptiveStream picks a quality from each
// tile's size. To force one:
room.on('trackSubscribed', (track) => {
if (track.kind === 'camera') track.setLayer('low'); // 'low' | 'medium' | 'high' | 'auto'
});Messages: data streams, RTM and Agora Chat
There are two Livqeno equivalents, depending on whether the message should be kept:
- Ephemeral, in-call (Agora data stream, RTM message channel used for
signaling like "raise hand"):
room.sendData(). - Saved (RTM message history, Agora Chat): Chat.
import AgoraRTM from 'agora-rtm';
const rtm = new AgoraRTM.RTM(appId, userId);
await rtm.login({ token: rtmToken });
await rtm.subscribe(channelName);
rtm.addEventListener('message', (event) => console.log(event.publisher, event.message));
await rtm.publish(channelName, 'hello');await room.sendData(JSON.stringify({ type: 'raise-hand' }));
room.on('dataReceived', (payload, participant) => {
console.log(participant?.identity, new TextDecoder().decode(payload));
});import { createChatClient } from '@livqeno/chat';
// chatGrant comes from your backend (below)
const chat = createChatClient(chatGrant);
await chat.connect({ room: conversationId });
chat.on('message', (message) => console.log(message.senderId, message.text));
await chat.sendMessage({ text: 'hello' });On your backend, a chat user must be a member of a conversation before their token reaches it. This replaces RTM's separate RTM token:
const conversation = await livqeno.chat.createConversation({
name: channelName,
roomId: room.id, // optional: attach the chat to the call's room
getOrCreate: true,
});
await livqeno.chat.addMember(conversation.publicId, { userId: String(uid) });
const chatGrant = await livqeno.chat.createToken({ userId: String(uid), conversations: [conversation.publicId] });RTM presence maps to chat presence and typing. RTM storage (channel and user metadata) and locks have no direct equivalent: see Gaps.
Cloud Recording
// Cloud Recording is a REST flow: acquire a resource, start, later stop.
const base = `https://api.agora.io/v1/apps/${appId}/cloud_recording`;
const headers = { Authorization: `Basic ${restCredential}`, 'Content-Type': 'application/json' };
const { resourceId } = await fetch(`${base}/acquire`, {
method: 'POST', headers,
body: JSON.stringify({ cname: channelName, uid: recorderUid, clientRequest: {} }),
}).then((r) => r.json());
const { sid } = await fetch(`${base}/resourceid/${resourceId}/mode/mix/start`, {
method: 'POST', headers,
body: JSON.stringify({ cname: channelName, uid: recorderUid, clientRequest: { token, storageConfig } }),
}).then((r) => r.json());
// ...later
await fetch(`${base}/resourceid/${resourceId}/sid/${sid}/mode/mix/stop`, {
method: 'POST', headers,
body: JSON.stringify({ cname: channelName, uid: recorderUid, clientRequest: {} }),
});// No start/stop recording API, and no recording of calls.
// A broadcast-mode live stream is recorded automatically. When it ends you get:
//
// { "type": "live_stream.recording_ready",
// "data": { "streamId": "stream_…", "recordingUrl": "https://…/vod.m3u8", "durationSec": 3600, "at": "…" } }
const stream = await livqeno.liveStreams.create({
title: 'Weekly class',
hostIdentity: 'teacher-1',
deliveryMode: 'BROADCAST',
});If you record ordinary calls with Cloud Recording (mixed or individual mode), Livqeno can't replace that today. Keep recorded channels on Agora until it can. The replay of a broadcast-mode stream is an HLS playlist hosted by Livqeno, not a file written to your own bucket.
Media Push (RTMP out)
// Media Push: create a converter that mixes the channel and pushes RTMP.
// Check Agora's docs for the region path and the transcodeOptions fields.
await fetch(`https://api.agora.io/${region}/v1/projects/${appId}/rtmp-converters`, {
method: 'POST', headers,
body: JSON.stringify({
converter: {
name: 'youtube',
transcodeOptions: { rtcChannel: channelName /* audioOptions, videoOptions */ },
rtmpUrl: `rtmp://a.rtmp.youtube.com/live2/${youtubeKey}`,
},
}),
});// Restream belongs to live streams. The stream owns its own room.
const stream = await livqeno.liveStreams.create({ title: 'Launch', hostIdentity: 'host-1' });
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',
});
// Pause, resume, remove
await livqeno.liveStreams.restreams.update(stream.id, destination.id, { enabled: false });
await livqeno.liveStreams.restreams.delete(stream.id, destination.id);import os
from livqeno import CreateLiveStreamParams, AddHostParams, CreateRestreamParams
stream = livqeno.live_streams.create(CreateLiveStreamParams(title="Launch", host_identity="host-1"))
host_grant = livqeno.live_streams.add_host(stream["id"], AddHostParams(identity="host-1"))
livqeno.live_streams.start(stream["id"])
destination = 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. Media Push's custom layouts
(transcodeOptions) have no equivalent: Livqeno's output is a fixed grid at
1280×720. See Restream.
Media Gateway (encoder in)
Agora's Media Gateway gives an encoder a streaming key for a channel. Livqeno's ingest does the same for a live stream, over WHIP or RTMP(S):
const ingress = await livqeno.liveStreams.ingress.create(stream.id, { protocol: 'RTMP' });
// Shown only now (and after reset()). Paste into OBS.
console.log(ingress.rtmpsUrl, ingress.streamKey);H.264 only, with B-frames turned off, up to 1080p30 and 8 Mbps. The video is passed through without transcoding, so viewers get exactly what the encoder sends.
Webhooks: NCS → Livqeno
const crypto = require('node:crypto');
// NCS signs the raw body with your NCS secret (check their docs for headers).
const expected = crypto.createHmac('sha256', process.env.AGORA_NCS_SECRET).update(rawBody).digest('hex');
if (expected === req.header('Agora-Signature-V2')) {
const { eventType, payload } = JSON.parse(rawBody);
// e.g. 103 / 104: a user joined / left the channel
}import { createHmac, timingSafeEqual } from 'node:crypto';
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); // { id, type, data, ... }; dedupe on event.id
}event, err := livqeno.VerifyWebhookRequest(r, os.Getenv("LIVQENO_WEBHOOK_SECRET"))
if err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
// event.ID, event.Type, event.Dataimport com.livqeno.server.webhooks.WebhookVerifier;
byte[] raw = request.getInputStream().readAllBytes();
String signature = request.getHeader(WebhookVerifier.SIGNATURE_HEADER);
if (!WebhookVerifier.verify(raw, signature, System.getenv("LIVQENO_WEBHOOK_SECRET"))) {
response.setStatus(400);
return;
}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;
}| Agora NCS event (check their docs for your channel profile) | Livqeno webhook |
|---|---|
| Channel created / destroyed | room.started / room.ended |
| Broadcaster or user joins / leaves | room.participant_joined / room.participant_left |
| Audience joins / leaves (live profile) | live_stream.viewer_joined / live_stream.viewer_left for live streams |
| Client role change | live_stream.stage_promoted / live_stream.stage_demoted |
| Cloud Recording events | live_stream.recording_ready for broadcast-mode streams only |
| Media Push converter events | live_stream.restream_active / live_stream.restream_failed |
| Media Gateway stream events | live_stream.ingress_connected / _disconnected / _failed |
The full list is the Event catalogue.
Feature gaps
What Agora has that Livqeno doesn't, as of today:
| Agora feature | On Livqeno |
|---|---|
| Cloud Recording (mixed, individual, web page) | Not available for calls. Broadcast-mode live streams get an HLS replay. |
| Recording to your own storage bucket | Not available. |
| Media Push custom layouts / transcoding options | Fixed grid at 1280×720 only. Up to 5 destinations. |
| Media Push or Media Gateway for an ordinary channel | Restream and ingest work on live streams only, not plain rooms. |
| Ingest codecs other than H.264 (no B-frames), SRT input | Not available. |
| Channel media relay (cross-channel) | Not available. |
| RTM storage (channel / user metadata) and locks | Not available. Chat has message metadata, not a key-value store. |
| Spatial audio, raw audio/video frame APIs, extensions marketplace | Not available. See Effects removed. |
| Real-time transcription | Not available. |
| Very large interactive channels | 50 participants per room by default; live streams for bigger audiences (Limits). |
| Game-engine SDKs (Unity, Unreal) | Not available. Livqeno has Web, React, React Native, Flutter (mobile and desktop), iOS and Android (SDKs). |
| E2EE on React Native | Not yet. |
Migration checklist
- Inventory. List every use of
agora-rtc-sdk-ng,agora-rtm/ Agora Chat, the token builders, the Cloud Recording, Media Push and Media Gateway REST calls, and your NCS handler. - Identities. Decide how numeric
uids become string identities (usuallyString(uid)), and make sure they're unique per room. - Credentials. Create a Livqeno project and an API key per environment (API credentials).
- Token service. Add a Livqeno branch next to
RtcTokenBuilder:rooms.create({ name: channelName, getOrCreate: true }), thentokens.create(). Return the whole grant. Map Agora roles to permission sets. AddrefreshTokenon the client. - Client. Wrap join, mute, screen share, dual stream and data in one interface. Pick the provider per channel, never per user inside one channel.
- Messaging. Move RTM channels or Agora Chat groups to conversations; replace the RTM token with a chat token.
- Live and RTMP. Move channels that use Media Push or Media Gateway to live streams.
- Webhooks. Register a Livqeno endpoint, verify
Swalvo-Signature, deduplicate onLivqeno-Event-Id, map NCS event types with the table above. - Recording. Keep Cloud Recording channels on Agora until you have a plan for them.
- Dual-run, then cut over. Ramp new channels to Livqeno while watching observability. Remove Agora once no channel has used it for a full token lifetime.
Pricing
Livqeno's rates are on Usage. Compare them with Agora's current pricing pages; this guide doesn't quote them.