Video and voice calls
A call is a channel that everyone joins with the host role, so every participant may publish audio and video. This guide covers a one-to-one call, a small group call, and the controls users expect once they are in.
The shape of a call#
There is no separate “create call” step. A channel exists as soon as the first participant joins it and disappears when the last one leaves. Two people who join the channel lobby are in a call together; a third who joins the same name makes it a group call.
Channel names must match [a-zA-Z0-9_-]{1,64}. Names are scoped to your project, so your lobbyand another customer's lobby are different channels.
Choosing identities#
Your server sets identity when it calls POST /v1/token. The identity is how every other participant refers to that person, it is the uid on remote user objects, and it is the key used in gifting and session records. It must be between 1 and 128 characters.
- Use your own stable user ID (
user-42,u_9f3c…), not a display name. - Two participants must never share an identity. The second connection replaces the first.
- For anonymous or guest users, generate an ID on your server and store it with the session.
Display names belong in metadata, which is embedded in the token and delivered to every participant. It must be a JSON object under 2048 bytes. If you pass metadata.name, it also becomes the participant's name.
POST https://api.applooma.dev/v1/tokenx-applooma-key: ak_your_keyx-applooma-secret: as_your_secretContent-Type: application/json{"roomName": "lobby","identity": "user-42","role": "host","metadata": { "name": "Rina", "avatar": "https://…/rina.png" }}→ 200{"token": "…","wsUrl": "wss://…","roomName": "…","expiresAt": "2026-01-01T12:00:00.000Z"}
Tokens last six hours
A token is only checked when the participant joins. A call that is already connected is not interrupted when the token expires. Issue a fresh token for every join.
Joining#
Create the engine once, attach your event handlers, then join with the token your server returned. Attach handlers before joining: participants who were already in the channel are reported immediately after the connection is established.
import AppEngine from '@applooma/rtc-web';const engine = AppEngine.create({ appId: 'YOUR_APP_ID' });engine.on('user-joined', (user) => addTile(user.uid, user.displayName));engine.on('user-left', (user) => removeTile(user.uid));engine.on('track-subscribed', (track, user) => {const el = track.attach(); // <video> or <audio>document.getElementById(`tile-${user.uid}`).append(el);});engine.on('track-unsubscribed', (track) => track.detach());engine.on('connection-state-changed', (state) => {// 'connecting' | 'connected' | 'reconnecting' | 'disconnected'setBanner(state);});const { token, wsUrl } = await (await fetch('/api/rtc-token', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ channel: 'lobby' }),})).json();await engine.joinChannel(token, wsUrl, {role: 'host',camera: true, // camera off by defaultmicrophone: true, // on by default; pass false for a muted join});engine.attachLocalVideo(document.getElementById('local'));
Mute, camera and screen share#
Local media is controlled with three symmetrical calls. Each takes a boolean, so one handler can toggle.
| Call | Effect |
|---|---|
enableMicrophone(on) | Publishes or unpublishes the microphone track. This is your mute button. |
enableCamera(on) | Publishes or unpublishes the camera track. Other participants see the tile go dark. |
enableScreenShare(on) | Publishes the screen as an additional video track. |
switchCamera() | Flips between the front and back camera. Web SDK. |
let muted = false;document.getElementById('mute').onclick = async () => {muted = !muted;await engine.enableMicrophone(!muted);};let cameraOn = true;document.getElementById('camera').onclick = async () => {cameraOn = !cameraOn;await engine.enableCamera(cameraOn);};// Front / back camera on mobile browsersdocument.getElementById('flip').onclick = () => engine.switchCamera();// Screen share. The browser shows its own picker; the user can cancel it,// which surfaces as an 'error' event rather than a rejected promise.document.getElementById('share').onclick = () => engine.enableScreenShare(true);
Screen share is not available everywhere
Desktop browsers support it. On mobile browsers it is generally unavailable, and on native mobile it needs platform screen-capture permissions to be configured in your app. Feature-detect and hide the button rather than letting the call fail.
Reading remote state
Each remote user exposes what your UI needs to draw a tile: uid, displayName, metadata, isSpeaking, audioEnabled and videoEnabled. Redraw on track-subscribed, track-unsubscribed and active-speakers-changed.
function renderTile(user) {tile(user.uid).classList.toggle('is-muted', !user.audioEnabled);tile(user.uid).classList.toggle('camera-off', !user.videoEnabled);}engine.on('active-speakers-changed', (identities) => {document.querySelectorAll('.tile').forEach((el) => {el.classList.toggle('speaking', identities.includes(el.dataset.uid));});});
Laying out the grid#
A call is a set of tiles, and the only thing that changes with group size is how you arrange them. Some practical rules:
- One-to-one. Give the remote participant the full frame and float your own preview in a corner. Do not split the screen evenly.
- Three to six. A square-ish grid works: two columns up to four people, three columns above that. Keep every tile the same size so nobody looks demoted.
- Seven and up. Switch to a speaker view driven by
active-speakers-changed, with the rest in a filmstrip. Twelve equal tiles on a phone is unreadable. - Screen share. When any participant publishes a screen track, give it the main area and push camera tiles to the strip.
- Sort stably. Order tiles by join order or identity, not by array position, so faces do not jump around when someone leaves.
The SDK adapts video quality to the size at which you render each tile, so small tiles receive a smaller stream automatically. Give elements their real size in CSS rather than scaling a large video down.
How many people can publish
A channel supports up to 200 publishing participants. That is a platform limit, not a UI recommendation — beyond roughly a dozen cameras, use the live streaming or audio room pattern, where most people join as audience and do not publish at all.
Leaving cleanly#
Call leaveChannel() when the user hangs up. It disconnects, stops the local camera and microphone, and clears the roster. Remote participants receive a user-left event, and your server receives a session.ended webhook.
On the web, also call destroy() when the component unmounts or the page is being torn down. Without it the underlying connection and its listeners stay alive for the lifetime of the page. An engine that has been destroyed cannot be reused — create a new one for the next call.
document.getElementById('hangup').onclick = async () => {await engine.leaveChannel();};// On teardown (SPA route change, page unload)window.addEventListener('pagehide', () => { void engine.destroy(); });
Do not rely on the browser closing for you
If you never leave, the participant stays in the channel until the connection times out. Until then other participants see a frozen tile and the session keeps counting.