In this guide you'll build a two-person video call from scratch, with every file shown in full. The Quickstart shows the same flow in small pieces; this guide puts the pieces together into an app you can run, so you can see how the backend and the browser talk to each other.
Where this fits: project → API key → your backend makes tokens → your app joins. This guide builds both of those last two parts.
What you'll build
Two browser tabs that can see and hear each other. Think of it as the core of any 1:1 call: a doctor and a patient, a support agent and a customer, two friends.
What you need
- Node 20+
- A Livqeno account (see Create a project)
- The Livqeno CLI (
npm install -g @livqeno/cli), for Step 2 - About 20 minutes
You don't run any media servers. Your small Express server talks to Livqeno's hosted API, and Livqeno carries the video.
The files
your-app/
├── server.js Express: holds the API key, mints tokens
└── public/
└── index.html the call UI: never sees the API key
Why two parts? The API key works for your whole project. Anything you ship to a browser can be read by whoever opens it. If the key reached the browser, anyone could copy it and make a token as any user. So the server makes tokens, and the browser only receives them.
A token is a short-lived pass for one user in one room. That's all the browser ever holds.
Step 1: Set up the project
On your computer, create a folder for the app and install Express (the small web server that will hold your API key):
mkdir livqeno-call && cd livqeno-call
npm init -y
npm install expressInstall the Livqeno SDKs:
pnpm add @livqeno/server @livqeno/rtcnpm install @livqeno/server @livqeno/rtcyarn add @livqeno/server @livqeno/rtcbun add @livqeno/server @livqeno/rtc@livqeno/serverruns on your backend. It makes rooms and tokens.@livqeno/rtcruns in the browser. It joins the call and handles the camera, mic, and video.
Step 2: Get an API key
Your server needs a key to ask Livqeno for tokens. On your computer, log in, create a project, and create a development key (development keys can't touch production data, so they're safe to experiment with):
livqeno config set apiUrl https://api.livqeno.com # once: the CLI defaults to localhost
livqeno login
livqeno projects create my-first-call # prints the project ID
livqeno projects use <project-id> # the ID it just printed
livqeno keys create --name local-dev --environment developmentYou'll see the key only once:
rvk_dev_8Kd2nQxwYtLm.aG9wZXlvdWFyZWhhdmluZ2Fsb3ZlbHlkYXk
Set it in the terminal you'll run the server from. Don't paste it into a file you might commit, because anyone with the file could use your project:
export LIVQENO_API_KEY="rvk_dev_8Kd2nQxwYtLm.aG9wZXlvdWFyZWhhdmluZ2Fsb3ZlbHlkYXk"
export LIVQENO_API_URL="https://api.livqeno.com"Step 3: Write the server
This is your backend. Create server.js. It does two things:
- Creates the room when the server starts. A room is where a call happens. Two people are in the same call when their tokens are for the same room.
- Gives out a token at
POST /api/token. The browser calls this when the user clicks Join.
import express from 'express';
import { Livqeno } from '@livqeno/server';
const app = express();
app.use(express.json());
app.use(express.static('public'));
const livqeno = new Livqeno({
apiKey: process.env.LIVQENO_API_KEY,
baseUrl: process.env.LIVQENO_API_URL, // https://api.livqeno.com
});
// Create the room once at startup. In a real app you'd create rooms in
// response to something — a meeting being scheduled, a support chat
// opening — and store the id alongside whatever that thing is.
let roomId;
app.listen(3000, async () => {
const room = await livqeno.rooms.create({ name: 'tutorial-room', getOrCreate: true });
roomId = room.id;
console.log('Ready on http://localhost:3000');
});
app.post('/api/token', async (req, res) => {
// In a real app, `identity` comes from YOUR session — req.user.id, a
// decoded JWT, a database lookup. Never from the request body, which
// is exactly what the client controls. Taking it from the body here
// is the tutorial's one shortcut, and it is not safe in production.
const identity = req.body.identity;
if (!identity) {
return res.status(400).json({ error: 'identity is required' });
}
const token = await livqeno.tokens.create({
room: roomId,
identity,
permissions: { join: true, publish: true, subscribe: true },
expiresIn: 3600,
});
// Forward the whole response — token, endpoint, and iceServers are
// all needed by the browser, and iceServers especially must never be
// hand-constructed.
res.json(token);
});Add "type": "module" to your package.json so import works.
getOrCreate: true means "create the room, or return it if it already
exists". Without it, restarting the server would fail with RAVEN_CONFLICT
(409), because the room name is already taken.
Step 4: Write the page
Now the browser side. Create public/index.html. It's a simple page
with a name box, two buttons, and two areas for video. It loads
/bundle.js, which you'll build in Step 5:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Livqeno call</title>
<style>
body { font-family: system-ui; max-width: 800px; margin: 2rem auto; }
video { width: 320px; background: #000; border-radius: 8px; margin: 0.5rem; }
button { padding: 0.5rem 1rem; margin-right: 0.5rem; }
</style>
</head>
<body>
<h1>Livqeno call</h1>
<p>
<input id="name" placeholder="Your name" />
<button id="join">Join</button>
<button id="leave" disabled>Leave</button>
</p>
<p>Status: <strong id="status">not joined</strong></p>
<div>
<h2>You</h2>
<div id="local"></div>
<h2>Others</h2>
<div id="remote"></div>
</div>
<script type="module" src="/bundle.js"></script>
</body>
</html>Step 5: Write the call logic
Create public/call.js. This runs in the browser. Here's what it does,
in order:
- Asks your server for a token. (Not Livqeno. The browser never talks to Livqeno's API directly.)
- Joins the room with that token.
- Listens for other people's video and shows it.
- Shows people who were already in the room before you joined.
- Turns on your camera and mic. Others start seeing you right away.
Two words you'll see in the code:
- A track is one stream of media: a camera, a microphone, or a screen.
trackSubscribedfires when someone else's track has arrived and is ready to play. That's the moment to put it on screen.
import { createRTCClient } from '@livqeno/rtc';
const statusEl = document.getElementById('status');
const localEl = document.getElementById('local');
const remoteEl = document.getElementById('remote');
let client;
let room;
document.getElementById('join').onclick = async () => {
const identity = document.getElementById('name').value.trim();
if (!identity) return alert('Enter a name first');
// 1. Ask YOUR server for a token. The browser never talks to Livqeno's
// control plane directly, and never holds an API key.
const res = await fetch('/api/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity }),
});
const auth = await res.json();
// 2. Build a client from exactly what the server returned.
client = createRTCClient({
token: auth.token,
endpoint: auth.endpoint,
iceServers: auth.iceServers,
});
// 3. Join. Events live on the room, not the client — wire them up
// immediately after, before doing anything else.
room = await client.join(auth.roomName);
room.on('connectionStateChanged', (state) => {
statusEl.textContent = state;
});
room.on('error', (error) => {
console.error(error.code, error.message);
});
// Someone else's track arrived — attach it to a <video>.
room.on('trackSubscribed', (track, participant) => {
const el = track.attach();
el.id = `track-${participant.identity}-${track.kind}`;
remoteEl.appendChild(el);
});
room.on('trackUnsubscribed', (track) => {
track.detach().forEach((el) => el.remove());
});
room.on('participantLeft', (participant) => {
// Clean up anything still on screen for them.
remoteEl
.querySelectorAll(`[id^="track-${participant.identity}-"]`)
.forEach((el) => el.remove());
});
// 4. Anyone already in the room when you joined won't fire
// trackSubscribed — they're available synchronously instead.
for (const participant of room.remoteParticipants) {
for (const track of participant.tracks) {
remoteEl.appendChild(track.attach());
}
}
// 5. Publish your own camera and mic.
const camera = await room.enableCamera();
await room.enableMicrophone();
if (camera) {
const el = camera.attach();
el.muted = true; // never play your own audio back at yourself
localEl.appendChild(el);
}
document.getElementById('join').disabled = true;
document.getElementById('leave').disabled = false;
};
document.getElementById('leave').onclick = async () => {
await room?.leave();
localEl.innerHTML = '';
remoteEl.innerHTML = '';
statusEl.textContent = 'not joined';
document.getElementById('join').disabled = false;
document.getElementById('leave').disabled = true;
};The browser can't load @livqeno/rtc by its npm name on its own. A
bundler copies the SDK code into one file the browser can load. On your
computer, bundle call.js into public/bundle.js:
npx esbuild public/call.js --bundle --format=esm --outfile=public/bundle.jsRun this again whenever you change call.js.
Step 6: Run it
node server.jsYou should see Ready on http://localhost:3000. That means the server
reached Livqeno with your key and the room exists.
- Open two browser tabs at
http://localhost:3000. - Type a different name in each, like
aliceandbob. - Click Join in both, and allow camera and mic access when the browser asks.
You should now see your own camera under "You" in each tab, and the other
tab's camera under "Others". The status shows connected.
You don't need to set up Allowed Origins yet: localhost is allowed on any
port by default.
What just happened
POST /api/tokentokens.create()- Each tab asked your server for a token.
- Your server used its API key to ask Livqeno for one. The key never left your server.
- Each tab got back a grant: the token plus the connection details
(
endpoint, andiceServersfor relays). The token is for one user, one room, and a fixed set of permissions, and it expires in an hour. - Each tab joined with its token. Livqeno's media server (an SFU) receives each tab's camera and mic and forwards them to the other tab.
The same four steps happen in every Livqeno call, whatever the app.
Common problems
| What you see | Why | Fix |
|---|---|---|
| "Cannot find module '@livqeno/rtc'" | The package isn't installed where the bundler runs. | Run npm install @livqeno/server @livqeno/rtc again from the project folder. |
| A tab drops out or keeps reconnecting when the other joins | Both tabs used the same name. A room allows one connection per identity, so each new join replaces the older one (close code 4002). | Use a different name in each tab. |
| Black video, no error | The browser blocked the camera. | Check the permission icon in the address bar and allow it. |
RAVEN_CONFLICT when the server starts | The room exists and getOrCreate is missing. | Keep getOrCreate: true. |
| Works locally, fails on another network | iceServers was dropped or changed. Without the TURN relay it lists, many real networks block the call. | Forward the whole token response, as server.js does. |
Next
You have a working call. Most apps go on to:
- Fix the identity shortcut. Take
identityfrom your login session. Authentication explains why and how. - Add more people: Build a group call.
- Survive bad networks: Handle reconnection.
- Add chat next to the call: Chat. Same idea, with a chat token.
- Use React: React SDK does the same with hooks and less code.
- Measure call quality: Diagnostics.