In this guide you'll build a simple video call for two people. Then you'll add chat.
Time: about 10 minutes. Result: two browser tabs (or phones) that can see and hear each other.
The plan:
- Your backend makes a token for the logged-in user.
- Your app uses the token to join a room.
- The app turns on the camera and mic, and shows the other person.
POST /join-roomWhy the backend step? Your app can't be trusted with your secret API key, so your backend uses the key on the app's behalf and hands back a short-lived token instead. Think of a hotel: the API key is the master key that never leaves the front desk (your backend), and the token is a guest keycard for one room that expires. New to these terms? See What is Livqeno? or the Glossary.
What you need
- A Livqeno account. Sign up in the dashboard. There are no servers to set up.
- A backend (TypeScript, Python, Go, Java or PHP here) that knows who is logged in. Any login system works. Livqeno only needs a user id from it.
- An app: a web page, React, React Native, or Flutter.
Create a project and an API key
A project holds everything for your app. The API key is your
backend's secret for asking Livqeno to make tokens. Use a development key
while you build.
- Create a project in the dashboard. It comes with a development environment ready to use.
- Create an API key. Use the dashboard, or the CLI
(
npm install -g @livqeno/cli):
livqeno config set apiUrl https://api.livqeno.com # once: the CLI defaults to localhost
livqeno login
livqeno projects list # copy your project's ID
livqeno projects use <project-id> # select it for the next command
livqeno keys create --name backend --environment developmentYou'll see the key only once. Copy it now:
rvk_dev_8Kd2nQxwYtLm.aG9wZXlvdWFyZWhhdmluZ2Fsb3ZlbHlkYXkSet two environment variables where your backend runs:
LIVQENO_API_KEY=rvk_dev_... # the key you just copied
LIVQENO_API_URL=https://api.livqeno.comThat's all the setup you need.
Install the SDKs
You need one package for your backend (it uses the API key) and one for your app (it uses tokens). Run each command in the matching project:
In your backend project (it holds the API key):
pnpm add @livqeno/servernpm install @livqeno/serveryarn add @livqeno/serverbun add @livqeno/serverIn your browser app (it holds only a token):
pnpm add @livqeno/rtcnpm install @livqeno/rtcyarn add @livqeno/rtcbun add @livqeno/rtcIn your backend project:
pnpm add @livqeno/servernpm install @livqeno/serveryarn add @livqeno/serverbun add @livqeno/serverIn your React app:
pnpm add @livqeno/rtc @livqeno/reactnpm install @livqeno/rtc @livqeno/reactyarn add @livqeno/rtc @livqeno/reactbun add @livqeno/rtc @livqeno/reactpnpm add @livqeno/react-native @livqeno/rtc react-native-webrtc react-native-incall-managernpm install @livqeno/react-native @livqeno/rtc react-native-webrtc react-native-incall-manageryarn add @livqeno/react-native @livqeno/rtc react-native-webrtc react-native-incall-managerbun add @livqeno/react-native @livqeno/rtc react-native-webrtc react-native-incall-managercd ios && pod install # iOS onlyYou must install react-native-webrtc, but you never import it.
Camera and mic setup: Permissions.
dependencies:
livqeno_rtc: ^0.2.2Mint a token on your backend
This code runs on your backend. It's the only place your API key is
used. Add an endpoint (here /join-room) that your app calls when a user
clicks Join. It does two things:
- Gets or creates a room. Both
aliceandbobcall the same endpoint.getOrCreate: truemakes the first call createdemo-roomand the second call get the same room back, instead of failing. - Asks Livqeno for a token for the logged-in user, for that room, with the permissions they should have. Here: join, see others, and send camera and mic.
import { Livqeno } from '@livqeno/server';
const livqeno = new Livqeno({
apiKey: process.env.LIVQENO_API_KEY!,
baseUrl: process.env.LIVQENO_API_URL!, // https://api.livqeno.com
});
app.post('/join-room', async (req, res) => {
// Every participant calls this. getOrCreate returns the existing room
// to the second caller instead of a 409.
const room = await livqeno.rooms.create({ name: 'demo-room', getOrCreate: true });
const grant = await livqeno.tokens.create({
room: room.id,
identity: req.user.id, // from your session, never from req.body
permissions: { join: true, subscribe: true, publish: true },
expiresIn: 600,
});
res.json(grant); // forward the whole object
});import os
from livqeno import Livqeno, CreateTokenParams, TokenPermissions
livqeno = Livqeno(
api_key=os.environ["LIVQENO_API_KEY"],
base_url=os.environ["LIVQENO_API_URL"], # https://api.livqeno.com
)
@app.post("/join-room")
def join_room(request):
# get_or_create needs livqeno 0.2.0 (see the Changelog).
room = livqeno.rooms.create(name="demo-room", get_or_create=True)
grant = livqeno.tokens.create(
CreateTokenParams(
room=room["id"],
identity=request.user.id, # from your session, never the body
permissions=TokenPermissions(join=True, subscribe=True, publish=True),
expires_in=600,
)
)
return grant # forward the whole objectclient := livqeno.New(
os.Getenv("LIVQENO_API_KEY"),
livqeno.WithBaseURL(os.Getenv("LIVQENO_API_URL")), // https://api.livqeno.com
)
http.HandleFunc("POST /join-room", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Every participant calls this. GetOrCreate returns the existing room
// to the second caller instead of a 409.
room, err := client.Rooms.Create(ctx, livqeno.CreateRoomParams{Name: "demo-room", GetOrCreate: true})
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
grant, err := client.Tokens.Create(ctx, livqeno.CreateTokenParams{
Room: room.ID,
Identity: currentUser(r).ID, // from your session, never the body
Permissions: &livqeno.TokenPermissions{
Join: livqeno.Bool(true), Subscribe: livqeno.Bool(true), Publish: livqeno.Bool(true),
},
ExpiresIn: 600,
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(grant) // forward the whole object
})import com.livqeno.server.Livqeno;
import com.livqeno.server.model.*;
Livqeno livqeno = Livqeno.builder()
.apiKey(System.getenv("LIVQENO_API_KEY"))
.baseUrl(System.getenv("LIVQENO_API_URL")) // https://api.livqeno.com
.build();
@PostMapping("/join-room")
IssuedToken joinRoom(@AuthenticationPrincipal User user) {
// Every participant calls this. getOrCreate returns the existing room
// to the second caller instead of a 409.
Room room = livqeno.rooms().create(CreateRoomParams.builder().name("demo-room").getOrCreate(true).build());
return livqeno.tokens().create(CreateTokenParams.builder()
.room(room.id())
.identity(user.getId()) // from your session, never the body
.permissions(TokenPermissions.builder().join(true).subscribe(true).publish(true).build())
.expiresIn(600)
.build()); // forward the whole object
}$client = new \Livqeno\Client(
apiKey: getenv('LIVQENO_API_KEY'),
baseUrl: getenv('LIVQENO_API_URL'), // https://api.livqeno.com
);
// Every participant calls this. getOrCreate returns the existing room
// to the second caller instead of a 409.
$room = $client->rooms->create(['name' => 'demo-room', 'getOrCreate' => true]);
$grant = $client->tokens->create([
'room' => $room->id,
'identity' => $currentUser->id, // from your session, never the body
'permissions' => ['join' => true, 'subscribe' => true, 'publish' => true],
'expiresIn' => 600,
]);
header('Content-Type: application/json');
echo json_encode($grant); // forward the whole objectLivqeno sends back an object like this (we call it the grant):
{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"endpoint": "wss://api.livqeno.com/v1/rtc",
"iceServers": [{ "urls": "stun:..." }, { "urls": "turn:...", "username": "...", "credential": "..." }],
"telemetryUrl": "https://api.livqeno.com",
"roomId": "8d86361a-7c01-4969-98cb-d0748360b803",
"roomName": "demo-room",
"participantIdentity": "user-42",
"permissions": { "join": true, "subscribe": true, "publish": true, "publishAudio": true, "publishVideo": true, "publishData": false },
"expiresAt": "2026-09-08T21:10:00.000Z"
}Send the whole object to your app. Don't change it.
The iceServers field lets calls work on mobile networks. If you remove or
replace it, calls often work on Wi-Fi but fail on cellular.
More detail: Access tokens and RTC authentication.
Prefer plain HTTP? Here's the same request with curl:
curl -X POST "$LIVQENO_API_URL/v1/rooms/$ROOM_ID/rtc-tokens" \
-H "Authorization: Bearer $LIVQENO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "participantIdentity": "user-42", "permissions": { "join": true, "subscribe": true, "publish": true }, "ttlSeconds": 600 }'Join the room and publish
This code runs in your app (browser or phone). It only uses the grant your backend returned, never the API key.
- Get the grant from your backend endpoint.
- Join the room.
- Turn on the camera and mic. Others see you right away. There's no
separate
publish()step.
import { createRTCClient } from '@livqeno/rtc';
const grant = await fetch('/join-room', { method: 'POST' }).then((r) => r.json());
// createRTCClient reads token, endpoint, iceServers and telemetryUrl from the grant.
const client = createRTCClient(grant);
const room = await client.join(grant.roomName);
await room.enableCamera();
await room.enableMicrophone();'use client';
import { LivqenoRoom, useCamera, useConnectionState, useMicrophone } from '@livqeno/react';
export function CallPage({ grant }) {
// <LivqenoRoom> joins on mount and leaves on unmount.
return (
<LivqenoRoom
token={grant.token}
endpoint={grant.endpoint}
iceServers={grant.iceServers}
telemetryUrl={grant.telemetryUrl}
room={grant.roomName}
fallback={<p>Connecting…</p>}
>
<Controls />
<RemoteGrid />
</LivqenoRoom>
);
}
function Controls() {
const state = useConnectionState(); // 'connecting' | 'connected' | 'reconnecting' | ...
const camera = useCamera();
const microphone = useMicrophone();
return (
<>
<p>Status: {state}</p>
<button onClick={() => (camera.enabled ? camera.disable() : camera.enable())}>
Camera: {camera.enabled ? 'on' : 'off'}
</button>
<button onClick={() => (microphone.enabled ? microphone.disable() : microphone.enable())}>
Mic: {microphone.enabled ? 'on' : 'off'}
</button>
</>
);
}import { Livqeno } from '@livqeno/react-native';
const grant = await fetch('https://your-backend.example/join-room', { method: 'POST' })
.then((r) => r.json());
// Registers the WebRTC globals and asks for camera/mic permission on join.
const livqeno = new Livqeno(grant);
const room = await livqeno.join(grant.roomName);
await room.enableCamera();
await room.enableMicrophone();import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:livqeno_rtc/livqeno_rtc.dart';
final res = await http.post(Uri.parse('https://your-backend.example/join-room'));
final grant = jsonDecode(res.body) as Map<String, dynamic>;
final livqeno = Livqeno(
token: grant['token'] as String,
endpoint: grant['endpoint'] as String,
// Without iceServers there is no TURN relay, and most NATed devices fail.
iceServers: (grant['iceServers'] as List)
.map((s) => LivqenoIceServer.fromJson(s as Map<String, dynamic>))
.toList(),
);
final room = await livqeno.join(grant['roomName'] as String);
await room.enableCamera();
await room.enableMicrophone();Listen for participants
This code also runs in your app. Joining only connects you. To see others, your app must show each person's video (their tracks) as it arrives.
- People already in the room are ready as soon as
joinfinishes. - People who arrive later fire an event.
const grid = document.getElementById('grid')!;
for (const participant of room.remoteParticipants) {
for (const track of participant.tracks) grid.append(track.attach());
}
room.on('participantJoined', (participant) => console.log(`${participant.identity} joined`));
// Fires when a remote track is actually playable — the one to render on.
room.on('trackSubscribed', (track, participant) => grid.append(track.attach()));
room.on('participantLeft', (participant) => console.log(`${participant.identity} left`));import { ParticipantView, useRemoteParticipants } from '@livqeno/react';
function RemoteGrid() {
// Re-renders on participant and track events: no listeners to wire up.
const remote = useRemoteParticipants();
return remote.map((p) => <ParticipantView key={p.identity} participant={p} />);
}import { LivqenoVideoView, useRemoteParticipants } from '@livqeno/react-native';
function RemoteGrid({ room }) {
const remote = useRemoteParticipants(room);
return remote.map((p) => (
<LivqenoVideoView key={p.identity} participant={p} room={room} style={{ flex: 1 }} />
));
}// In a widget: one LivqenoVideoView per remote participant.
for (final participant in room.remoteParticipants) {
LivqenoVideoView(participant: participant, room: room);
}
room.participantChanges.listen((participants) {
// The full current roster: rebuild your grid with setState.
});All room events are listed in Events.
Leave
await room.leave(); // stops local tracks, closes the connection, tells the roomWhen <LivqenoRoom> unmounts, it leaves for you. For a "Leave" button, use useLivqeno():
const { leave } = useLivqeno();
<button onClick={() => leave()}>Leave</button>;await livqeno.leave(); // leaves the room
await livqeno.dispose(); // tears down everything, including chatawait livqeno.leave();Call leave() when the user closes the page or the screen. Then others see
participantLeft right away.
Done! Open the page in a second tab. You now have a working video call.
What just happened behind the scenes:
- Each tab called your backend, which made a token for that user.
- Each tab used its token to connect to Livqeno's media server.
- Turning on the camera published a video track. The other tab
subscribed to it and fired
trackSubscribed, so you could show it.
Add chat
Most call apps also want text chat: to share a link, or to talk when the mic is off. Chat in Livqeno is a separate product with its own token. Your backend makes it with the same API key. An RTC token doesn't work for chat, and a chat token doesn't work for calls.
Here we name the conversation demo-room too, so the call and its chat
line up. Unlike a call, chat only lets in members, so your backend adds
the user first.
On your backend:
- Get or create the conversation.
- Add the user as a member. (Safe to call every time.)
- Make a chat token for the user.
app.post('/chat-token', async (req, res) => {
// Create the conversation once (later callers get the same one back),
// then make this user a member. A chat token only reaches conversations
// its user belongs to; addMember is idempotent, so call it every time.
await livqeno.chat.createConversation({ name: 'demo-room', getOrCreate: true });
await livqeno.chat.addMember('demo-room', { userId: req.user.id });
const chat = await livqeno.chat.createToken({
userId: req.user.id,
conversations: ['demo-room'],
});
res.json(chat); // { token, chatUrl, apiUrl, scopes, ... }
});Then in your app, connect and send a message:
import { createChatClient } from '@livqeno/chat';
const grant = await fetch('/chat-token', { method: 'POST' }).then((r) => r.json());
const chat = createChatClient(grant);
// Join as you connect: a client subscribed to no conversation receives
// nothing, not even its own echo.
await chat.connect({ room: 'demo-room' });
chat.on('message', (message) => console.log(message.text));
await chat.sendMessage({ room: 'demo-room', text: 'hello from the quickstart' });Full walkthrough: Chat quickstart.
Common errors
If something doesn't work, find what you see here first:
| Error | Cause | Fix |
|---|---|---|
RAVEN_CONFLICT (409) on the second join | rooms.create or createConversation without getOrCreate. | Pass getOrCreate: true (Python get_or_create=True). |
ROOM_NOT_FOUND on join() | The room passed to join() isn't the one the token was minted for. | Pass grant.roomName or grant.roomId. |
ROOM_NOT_FOUND on the first chat send | The user isn't a member of the conversation. | addMember(room, { userId }) before createToken. |
ORIGIN_NOT_ALLOWED | The page's origin isn't on the project's allowed list. | Add it under Settings → Security → Allowed Origins. |
TOKEN_EXPIRED | Tokens are short-lived. | Mint a fresh one; a token's lifetime cannot be extended. |
CAMERA_PERMISSION_DENIED / MICROPHONE_PERMISSION_DENIED | The OS or browser refused device access. | See Permissions. |
| Works on Wi-Fi, fails on cellular | iceServers was dropped or replaced. | Forward the grant's iceServers untouched. |
More in RTC troubleshooting.