Skip to navigation

Build video calls

View as MarkdownOpen in Claude

A Video Room is a Resource that several people join at once, each with a camera and microphone. Start by creating a room and joining it from two browser tabs, then add controls for the microphone and camera, camera choice, screen sharing, the layout, and the participant list, and finally let phone callers join the same room.

Pick the right product for video calls

The Browser SDK carries the video. The REST API, usually through the Server SDKs, creates the room and issues the token the browser signs in with. SWML and Relay bring phone callers in by audio.

FunctionSWMLWebSocket (Relay)RESTBrowser SDK
Create a Video Room and get its address
Issue a token that a web page joins the room with
Join the room with your camera and microphone
Mute and unmute your microphone and camera
Switch cameras during the call
Share your screen
Change the layout during the call
Set the layout a room uses by default
See who is in the room
Bring a phone caller into the room
  • SWML is a document of call instructions that SignalWire runs. Its join_room method puts the caller in a room.
  • WebSocket (Relay) is a Relay client in the Server SDKs. It holds a WebSocket open from your server and can move a live call into a room from your code.
  • The REST API creates and configures Video Rooms, lists their addresses, and issues tokens. The Server SDKs wrap each of these requests.
  • The Browser SDK puts a person in the room from a web page, with their camera, microphone, screen, and in-call controls.

Prepare for video calls

Have these values ready:

  • Your Space URL, such as <YOUR_SPACE>.signalwire.com.
  • Your Project ID and API token from the Dashboard’s API credentials page. Enable the token’s Video permission, and Voice if you bring in phone callers over Relay.
  • A web page you can serve over HTTPS or localhost, and a browser with a camera and microphone.
  • To bring in phone callers, a voice-capable phone number in your Space and a phone to call it from.
Browsers only share the camera and microphone on HTTPS or localhost

A page served over plain HTTP from any other host can’t use the camera or microphone, so it can’t join the room with them. Serve the page over HTTPS, or open it from localhost while you develop.

For audio calls between two people rather than a shared room, see Make and receive calls. If you have a page built on Browser SDK v3 and room tokens, Migrate from v3 maps it to the flow on this page.

Replace these values in the samples you run:

ValueReplace with
<YOUR_SPACE>Your Space’s subdomain in <YOUR_SPACE>.signalwire.com
<YOUR_PROJECT_ID>Your Project ID
<YOUR_API_TOKEN>Your API token, used only in server code
<YOUR_ROOM_NAME>A name for the Video Room, using letters, numbers, underscores, and dashes, such as team-standup
<YOUR_RESOURCE_ID>The Video Room’s id from Create a Video Room
<YOUR_ADDRESS_ID>The room address’s id, when you call the REST API directly
<YOUR_BASIC_AUTH_PASSWORD>A password you choose for your SWML server, when you bring in phone callers

Join your first video call

Three things put a browser in a room. The Video Room is the Resource, and SignalWire gives it an address when you create it. Your server issues a guest token for that address. The page signs in with the token and dials the address. SignalWire composes every participant’s video into one stream and sends the same stream to everyone, so the page shows the whole room in a single <video> element.

1

Create a Video Room

Run this program once on your server. It creates the room and prints its id, the Resource ID you use in the next step. Room names are unique in a project, so a second run with the same name fails.

# Install: python -m pip install signalwire-sdk
# Save as create_video_room.py and run: python create_video_room.py
from signalwire.rest import RestClient
client = RestClient(
project="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
host="<YOUR_SPACE>.signalwire.com",
)
room = client.fabric.conference_rooms.create(
name="<YOUR_ROOM_NAME>",
enable_room_previews=False,
)
print(room["id"])

The cURL request calls Create conference room. The REST API reference calls a Video Room a conference room; it’s the same Resource.

To create the room in the Dashboard instead, open Resources, select Add, and choose Video Room. Enter a Room Name, choose Build From Scratch so the room has no prebuilt interface, and select Create. Then find the room’s id with List conference rooms.

2

Issue a guest token for the room

Run this program on your server once for each browser tab you want in the room. It looks up the room’s address and issues a guest token for it, then prints both. Paste the address in place of <YOUR_ROOM_ADDRESS> in the page in the next step, and keep the tokens for the tabs.

# Install: python -m pip install signalwire-sdk
# Save as video_room_token.py and run: python video_room_token.py
from signalwire.rest import RestClient
client = RestClient(
project="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
host="<YOUR_SPACE>.signalwire.com",
)
# A new Video Room has one address.
address = client.fabric.resources.list_addresses("<YOUR_RESOURCE_ID>")["data"][0]
guest = client.fabric.tokens.create_guest_token(allowed_addresses=[address["id"]])
print("Room address:", address["channels"]["video"])
print("Guest token:", guest["token"])

The cURL requests call List Resource Addresses, then Create Subscriber guest token.

The room address looks like /public/team-standup?channel=video. Copy it from the output rather than building it from the room name: SignalWire names the address after the room’s display name, and adds a suffix when that name is already taken.

A guest token is issued for the addresses you list in allowed_addresses, up to 10, and lasts two hours by default. It places calls only, which is all a room page needs. To sign people in as Subscribers instead, or to issue tokens from your backend each time a visitor opens the page, follow the authentication guide.

3

Run the room page

Load this script on a page served over HTTPS or localhost, with the elements listed in its comments. The page signs in with the token you paste, dials the room address, and shows your camera beside the room.

JavaScript — Browser SDK
// Install: npm install @signalwire/js@4.0.0-rc.5 rxjs@7.8.2
// Load as a module script on an HTTPS or localhost page with these elements:
// <input id="token" type="password" placeholder="Guest token">
// <button id="join" type="button">Join</button>
// <button id="leave" type="button" disabled>Leave</button>
// <video id="local-video" autoplay playsinline muted></video>
// <video id="room-video" autoplay playsinline></video>
// Paste a guest token issued by your server for this room.
import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
const tokenInput = document.querySelector("#token");
const joinButton = document.querySelector("#join");
const leaveButton = document.querySelector("#leave");
const localVideo = document.querySelector("#local-video");
const roomVideo = document.querySelector("#room-video");
let client;
joinButton.onclick = async () => {
joinButton.disabled = true;
try {
if (!client) {
client = new SignalWire(new StaticCredentialProvider({
token: tokenInput.value.trim(),
}));
}
const call = await client.dial("<YOUR_ROOM_ADDRESS>", {
audio: true,
video: true,
receiveVideo: true,
});
call.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
call.remoteStream$.subscribe((stream) => (roomVideo.srcObject = stream));
leaveButton.disabled = false;
leaveButton.onclick = () => {
void call.hangup().catch(console.error);
};
call.status$.subscribe((status) => {
if (status === "disconnected" || status === "failed" || status === "destroyed") {
localVideo.srcObject = null;
roomVideo.srcObject = null;
leaveButton.disabled = true;
joinButton.disabled = false;
}
});
} catch (error) {
joinButton.disabled = false;
console.error(error);
}
};

dial() sends your camera and microphone with video: true and audio: true, and receiveVideo: true asks for the room’s video in return. The channels.video address ends in ?channel=video, which turns on all three by itself. Keeping the options explicit means the page still receives the room’s video if it dials the address without that suffix, where receiving video is off by default. DialOptions lists every media option.

The local preview is muted so you don’t hear yourself. #room-video isn’t, because it also carries the room’s mixed audio.

4

Join from two tabs

Paste a guest token, select Join, and allow camera and microphone access. Your camera appears in #local-video, and #room-video shows the room with you in it. Open the page in a second tab, paste the second token, and select Join. A tone plays as the second participant enters, and both tabs show both participants in the room video. Select Leave in each tab when you finish.

Two tabs on one computer play each other’s audio back into the microphones, so mute one tab or wear headphones while you test.

If it doesn’t work, check the symptom:

  • The browser never asks for the camera. Serve the page over HTTPS or localhost, and check that the site isn’t blocked from the camera in the browser’s settings.
  • dial() rejects with CallCreateError. The token doesn’t reach the address. Check that the page dials the channels.video address printed with the token, and that the token is for this room.
  • Your camera shows, but the room video stays black. Check that the page dials the channels.video address, or keeps receiveVideo: true in the dial() options.
  • Joining worked earlier and now fails. The guest token expired after two hours. Issue a new one and reload the page.

The Browser SDK troubleshooting guide covers permissions and media problems in more depth.

Mute and unmute your microphone and camera

Muting is a control on your own participant, call.self, not on the call. A muted participant stays in the room, and the other participants see their state change.

Mute and unmute from a browser

Add two buttons to the page from Join your first video call, then add the lookups and the handlers below. toggleMute() mutes or unmutes your microphone, and toggleMuteVideo() turns your camera off or on in the room.

JavaScript — Browser SDK
// <button id="mute" type="button" disabled>Mute</button>
// <button id="camera" type="button" disabled>Stop video</button>
import { switchMap } from "rxjs";
const muteButton = document.querySelector("#mute");
const cameraButton = document.querySelector("#camera");
// Inside joinButton.onclick, after dial() resolves:
muteButton.onclick = () => void call.self?.toggleMute();
cameraButton.onclick = () => void call.self?.toggleMuteVideo();
call.self$.pipe(switchMap((self) => self.audioMuted$)).subscribe((muted) => {
muteButton.disabled = false;
muteButton.textContent = muted ? "Unmute" : "Mute";
});
call.self$.pipe(switchMap((self) => self.videoMuted$)).subscribe((muted) => {
cameraButton.disabled = false;
cameraButton.textContent = muted ? "Start video" : "Stop video";
});

call.self$ emits once you’ve joined the room, and again each time your participant changes, so switchMap keeps exactly one subscription to each state. Disable both buttons again in the page’s status$ handler when the call ends. The Browser SDK call controls guide adds deafening, hand raising, and keypad tones.

Switch cameras during a video call

The browser can list every camera it can use, and the page can switch between them without leaving the room.

Pick a camera from a browser

Add a picker to the page, then add these lines inside the if (!client) block, right after the client is created. videoInputDevices$ emits the camera list, and selectVideoInputDevice() switches to the one you pick, including on a call already in progress.

JavaScript — Browser SDK
// <select id="cameras"></select>
const cameraPicker = document.querySelector("#cameras");
// Inside the if (!client) block, after the client is created:
client.videoInputDevices$.subscribe((cameras) => {
cameraPicker.replaceChildren(
...cameras.map((camera) => new Option(camera.label || "Camera", camera.deviceId)),
);
});
cameraPicker.onchange = () => {
const camera = client?.videoInputDevices.find((device) => device.deviceId === cameraPicker.value);
if (camera) client?.selectVideoInputDevice(camera);
};

Browsers hide camera names until the page has camera access, so the labels fill in after the first Join. The Browser SDK device management guide adds microphones, speakers, and devices plugged in mid-call.

Share your screen in a video call

A screen share joins the room as its own participant, and SignalWire composes it into the room video beside the cameras.

Share your screen from a browser

Add a button to the page, then add the lookup and the handlers below. startScreenShare() opens the browser’s screen picker, and screenShareStatus$ reports none, starting, started, or stopping.

JavaScript — Browser SDK
// <button id="share" type="button" disabled>Share screen</button>
import { switchMap } from "rxjs";
const shareButton = document.querySelector("#share");
// Inside joinButton.onclick, after dial() resolves:
shareButton.onclick = async () => {
const self = call.self;
if (!self) return;
try {
if (self.screenShareStatus === "started") await self.stopScreenShare();
else if (self.screenShareStatus === "none") await self.startScreenShare();
} catch (error) {
// Closing the screen picker rejects with NotAllowedError. The call keeps running.
if (!(error instanceof DOMException && error.name === "NotAllowedError")) {
console.error(error);
}
}
};
call.self$.pipe(switchMap((self) => self.screenShareStatus$)).subscribe((status) => {
shareButton.disabled = status === "starting" || status === "stopping";
shareButton.textContent = status === "started" ? "Stop sharing" : "Share screen";
});

If the share fails, your camera and microphone stay in the room. The Browser SDK screen sharing guide covers sharing audio with the screen and checking whether a participant may share.

Change the video room layout

The layout decides how SignalWire arranges participants in the room video. Every participant gets the same composed stream, so a layout change applies to everyone in the room.

Change the layout from a browser

Add a picker to the page, then add the lookup and the handlers below. layouts$ emits the names of the layouts the room offers, layout$ emits the current one, and setLayout() switches to another.

JavaScript — Browser SDK
// <select id="layouts"></select>
const layoutPicker = document.querySelector("#layouts");
// Inside joinButton.onclick, after dial() resolves:
call.layouts$.subscribe((names) => {
layoutPicker.replaceChildren(...names.map((name) => new Option(name, name)));
layoutPicker.value = call.layout ?? "";
});
call.layout$.subscribe((name) => (layoutPicker.value = name));
layoutPicker.onchange = () => {
void call.setLayout(layoutPicker.value, {}).catch(console.error);
};

The second argument to setLayout() can also move specific participants into positions in the layout. An empty {} changes only the layout. The Browser SDK layouts guide covers participant positions and drawing overlays on the room video.

Set the default layout via REST

Set layout on the Video Room to choose the layout participants see when they join, before anyone changes it. A new room uses grid-responsive. The other values are grid-responsive-mobile, highlight-1-responsive, 1x1, 2x1, 2x2, 5up, 3x3, 4x4, 5x5, 6x6, 8x8, and 10x10.

# Install: python -m pip install signalwire-sdk
# Save as set_room_layout.py and run: python set_room_layout.py
from signalwire.rest import RestClient
client = RestClient(
project="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
host="<YOUR_SPACE>.signalwire.com",
)
room = client.fabric.conference_rooms.update(
"<YOUR_RESOURCE_ID>",
layout="highlight-1-responsive",
)
print(room["conference_room"]["layout"])

The cURL request calls Update conference room, which also changes the room’s video quality, participant limit, and most of the other settings. A room’s name can’t be changed after you create it.

Show who is in a video call

The page can list everyone in the room, including you and any screen share, and keep the list current as people join, leave, and mute.

List participants in a browser

Add a list to the page, then add the lookup and the subscription below. participants$ emits the whole list each time someone joins, leaves, or changes state, so the page redraws it from scratch.

JavaScript — Browser SDK
// <ul id="participants"></ul>
const participantList = document.querySelector("#participants");
// Inside joinButton.onclick, after dial() resolves:
call.participants$.subscribe((participants) => {
participantList.replaceChildren(
...participants.map((participant) => {
const item = document.createElement("li");
const muted = participant.audioMuted ? " (muted)" : "";
const videoOff = participant.videoMuted ? " (video off)" : "";
item.textContent = `${participant.name ?? "Guest"}${muted}${videoOff}`;
return item;
}),
);
});

Clear the list in the page’s status$ handler when the call ends.

Track and leave a video call

The call’s status tells the page when you’re in the room and when you’ve left it, whether you selected Leave or the call failed.

Track the call and leave from a browser

hangup() takes you out of the room and leaves everyone else in it. The call then moves to disconnecting and on to destroyed. A call that fails moves to failed. With disconnected, failed, and destroyed treated as final, the page’s one status$ subscription resets it for every outcome. To show the status, add <p id="status">Idle</p> to the page, then add the lookup below and replace the page’s status$ subscription:

JavaScript — Browser SDK
const statusLine = document.querySelector("#status");
// Inside joinButton.onclick, after dial() resolves:
call.status$.subscribe((status) => {
statusLine.textContent = `Call: ${status}`;
if (status === "disconnected" || status === "failed" || status === "destroyed") {
localVideo.srcObject = null;
roomVideo.srcObject = null;
leaveButton.disabled = true;
joinButton.disabled = false;
}
});

The status shows connected while you’re in the room. After Leave, select Join again to rejoin with the same token.

Let phone callers join a video room

A phone caller joins the same room by audio. Browser participants hear the caller, and the caller hears the room. To put a caller in the room, the call handler on your phone number runs join_room with the Video Room’s name: the name you created it with, such as team-standup, not its address. For a room created in the Dashboard, read conference_room.name from List conference rooms.

join_room creates a new room when the name doesn't match

If no room has the name you pass, join_room creates one, and the caller waits alone in it. Copy the name exactly as you created the Video Room.

Let phone callers join via SWML

Run either server below, or host the YAML document as a SWML Script. Then give it a public URL and assign the script to your phone number, as Answer a call via SWML shows. join_room answers the call itself and sets join_room_result to joined or failed.

# Install: python -m pip install signalwire-sdk
# Save as video_room_swml_server.py and run: python video_room_swml_server.py
from signalwire import SWMLBuilder, SWMLService
service = SWMLService(
name="video-room-swml-server",
route="/swml",
port=3000,
basic_auth=("signalwire", "<YOUR_BASIC_AUTH_PASSWORD>"),
)
SWMLBuilder(service).join_room(name="<YOUR_ROOM_NAME>")
# Serves the document at /swml. Put the credentials in the URL you give
# SignalWire: https://signalwire:<YOUR_BASIC_AUTH_PASSWORD>@<YOUR_PUBLIC_HOST>/swml
service.serve()

With a browser tab in the room, call your number from your phone. The tab plays the entry tone, the caller appears in the participant list, and you hear each other. If the tab never hears the caller, check that the number’s Inbound Call Settings point at this script and that the name matches the Video Room’s. In a trial project, call from a verified number, as Prepare for your first call describes.

Let phone callers join via WebSocket (Relay)

Run either handler below and leave it running. It subscribes to the video-room topic, answers each call, and moves it into the room. Assign a Relay Application for that topic to your phone number, as Answer a call via WebSocket (Relay) shows.

# Install: python -m pip install signalwire-sdk
# Save as video_room_relay.py and run: python video_room_relay.py
from signalwire.relay import RelayClient
client = RelayClient(
project="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
contexts=["video-room"],
)
@client.on_call
async def handle_call(call):
await call.answer()
await call.join_room("<YOUR_ROOM_NAME>")
await call.wait_for_ended()
client.run()

Test it the same way as the SWML version. To take the caller out of the room while keeping the call, have the handler leave the room with the Server SDKs Relay client instead of hanging up.

Examples

Build a room page with every control

This page combines the first run with every browser task above: mute buttons, a camera picker, screen sharing, a layout picker, a participant list, and a status line.

Build the room page in a browser

JavaScript — Browser SDK
// Install: npm install @signalwire/js@4.0.0-rc.5 rxjs@7.8.2
// Load as a module script on an HTTPS or localhost page with these elements:
// <input id="token" type="password" placeholder="Guest token">
// <button id="join" type="button">Join</button>
// <button id="leave" type="button" disabled>Leave</button>
// <button id="mute" type="button" disabled>Mute</button>
// <button id="camera" type="button" disabled>Stop video</button>
// <button id="share" type="button" disabled>Share screen</button>
// <select id="cameras"></select>
// <select id="layouts"></select>
// <p id="status">Idle</p>
// <video id="local-video" autoplay playsinline muted></video>
// <video id="room-video" autoplay playsinline></video>
// <ul id="participants"></ul>
// Paste a guest token issued by your server for this room.
import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
import { switchMap } from "rxjs";
const tokenInput = document.querySelector("#token");
const joinButton = document.querySelector("#join");
const leaveButton = document.querySelector("#leave");
const muteButton = document.querySelector("#mute");
const cameraButton = document.querySelector("#camera");
const shareButton = document.querySelector("#share");
const cameraPicker = document.querySelector("#cameras");
const layoutPicker = document.querySelector("#layouts");
const statusLine = document.querySelector("#status");
const localVideo = document.querySelector("#local-video");
const roomVideo = document.querySelector("#room-video");
const participantList = document.querySelector("#participants");
const callButtons = [leaveButton, muteButton, cameraButton, shareButton];
let client;
joinButton.onclick = async () => {
joinButton.disabled = true;
try {
if (!client) {
client = new SignalWire(new StaticCredentialProvider({
token: tokenInput.value.trim(),
}));
client.videoInputDevices$.subscribe((cameras) => {
cameraPicker.replaceChildren(
...cameras.map((camera) => new Option(camera.label || "Camera", camera.deviceId)),
);
});
cameraPicker.onchange = () => {
const camera = client?.videoInputDevices.find((device) => device.deviceId === cameraPicker.value);
if (camera) client?.selectVideoInputDevice(camera);
};
}
const call = await client.dial("<YOUR_ROOM_ADDRESS>", {
audio: true,
video: true,
receiveVideo: true,
});
call.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
call.remoteStream$.subscribe((stream) => (roomVideo.srcObject = stream));
leaveButton.disabled = false;
leaveButton.onclick = () => {
void call.hangup().catch(console.error);
};
// Microphone and camera
muteButton.onclick = () => void call.self?.toggleMute();
cameraButton.onclick = () => void call.self?.toggleMuteVideo();
call.self$.pipe(switchMap((self) => self.audioMuted$)).subscribe((muted) => {
muteButton.disabled = false;
muteButton.textContent = muted ? "Unmute" : "Mute";
});
call.self$.pipe(switchMap((self) => self.videoMuted$)).subscribe((muted) => {
cameraButton.disabled = false;
cameraButton.textContent = muted ? "Start video" : "Stop video";
});
// Screen sharing
shareButton.onclick = async () => {
const self = call.self;
if (!self) return;
try {
if (self.screenShareStatus === "started") await self.stopScreenShare();
else if (self.screenShareStatus === "none") await self.startScreenShare();
} catch (error) {
// Closing the screen picker rejects with NotAllowedError. The call keeps running.
if (!(error instanceof DOMException && error.name === "NotAllowedError")) {
console.error(error);
}
}
};
call.self$.pipe(switchMap((self) => self.screenShareStatus$)).subscribe((status) => {
shareButton.disabled = status === "starting" || status === "stopping";
shareButton.textContent = status === "started" ? "Stop sharing" : "Share screen";
});
// Layout
call.layouts$.subscribe((names) => {
layoutPicker.replaceChildren(...names.map((name) => new Option(name, name)));
layoutPicker.value = call.layout ?? "";
});
call.layout$.subscribe((name) => (layoutPicker.value = name));
layoutPicker.onchange = () => {
void call.setLayout(layoutPicker.value, {}).catch(console.error);
};
// Participants
call.participants$.subscribe((participants) => {
participantList.replaceChildren(
...participants.map((participant) => {
const item = document.createElement("li");
const muted = participant.audioMuted ? " (muted)" : "";
const videoOff = participant.videoMuted ? " (video off)" : "";
item.textContent = `${participant.name ?? "Guest"}${muted}${videoOff}`;
return item;
}),
);
});
// Status and cleanup
call.status$.subscribe((status) => {
statusLine.textContent = `Call: ${status}`;
if (status === "disconnected" || status === "failed" || status === "destroyed") {
localVideo.srcObject = null;
roomVideo.srcObject = null;
participantList.replaceChildren();
for (const button of callButtons) button.disabled = true;
joinButton.disabled = false;
}
});
} catch (error) {
joinButton.disabled = false;
console.error(error);
}
};

Next steps