livqenoDocs

GuidesBuild a video call

Tutorial: build a video call

A complete two-participant video call, from empty folder to working call, with nothing hidden.

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):

Terminal
mkdir livqeno-call && cd livqeno-call
npm init -y
npm install express

Install the Livqeno SDKs:

pnpm add @livqeno/server @livqeno/rtc
  • @livqeno/server runs on your backend. It makes rooms and tokens.
  • @livqeno/rtc runs 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):

Terminal
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 development

You'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:

Terminal
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:

  1. 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.
  2. 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:

  1. Asks your server for a token. (Not Livqeno. The browser never talks to Livqeno's API directly.)
  2. Joins the room with that token.
  3. Listens for other people's video and shows it.
  4. Shows people who were already in the room before you joined.
  5. 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.
  • trackSubscribed fires 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:

Terminal
npx esbuild public/call.js --bundle --format=esm --outfile=public/bundle.js

Run this again whenever you change call.js.

Step 6: Run it

Terminal
node server.js

You should see Ready on http://localhost:3000. That means the server reached Livqeno with your key and the room exists.

  1. Open two browser tabs at http://localhost:3000.
  2. Type a different name in each, like alice and bob.
  3. 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

Tab A
Your server
Livqeno
Tab B
POST /api/token
tokens.create()
token
token
join(room): token only, never the API key
joins the same way
media flows between the tabs
Two tabs, one server, one room.
  1. Each tab asked your server for a token.
  2. Your server used its API key to ask Livqeno for one. The key never left your server.
  3. Each tab got back a grant: the token plus the connection details (endpoint, and iceServers for relays). The token is for one user, one room, and a fixed set of permissions, and it expires in an hour.
  4. 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 seeWhyFix
"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 joinsBoth 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 errorThe browser blocked the camera.Check the permission icon in the address bar and allow it.
RAVEN_CONFLICT when the server startsThe room exists and getOrCreate is missing.Keep getOrCreate: true.
Works locally, fails on another networkiceServers 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: