Skip to main content

WebRTC Video Call

Build a multi-party video call with Metered Realtime Messaging + Metered TURN, using MeteredPeer. The SDK handles SDP exchange, ICE trickle, perfect negotiation, peer discovery via presence, and MediaStream fan-out. You write maybe 80 lines.

For comparison, the raw-WebSocket version of this guide is ~250 lines. The SDK replaces all of the SDP-exchange + ICE-routing plumbing with a single peer.join(channel).

What you'll build

Two browser tabs (or two users on different devices) join the same channel. Each gets the other's camera + microphone, rendered into a <video> element. Add more tabs → automatic mesh, every peer sees every other peer.

Prerequisites

  • A Metered account with Realtime Messaging enabled.
  • An sk_live_… secret key from Dashboard → Realtime Messaging → Keys (for minting JWTs).
  • A TURN credential pair from Dashboard → TURN → Credentials. We'll embed it in the JWT.
  • A backend that can sign HS256 JWTs (any framework).

pk_live_ keys work for this guide too — see WebRTC No Backend for a backend-less variant.

Step 1 — Mint a JWT with embedded TURN creds

Your backend signs an HS256 JWT carrying the user's identity + channel scope + TURN credentials.

/api/mint-call-token
const jwt = require("jsonwebtoken");

const KEY_ID = process.env.SIGNALLING_KEY_ID; // sk_id_…
const SECRET = process.env.SIGNALLING_KEY_SECRET; // sk_secret_…

app.get("/api/mint-call-token", requireAuth, async (req, res) => {
const turnCreds = await fetchTurnCredentialsForUser(req.user.id);
// turnCreds shape: [
// { urls: "stun:stun.relay.metered.ca:80" },
// { urls: "turn:standard.relay.metered.ca:80", username: "...", credential: "..." },
// { urls: "turns:standard.relay.metered.ca:443", username: "...", credential: "..." },
// ]

const token = jwt.sign(
{
sub: req.user.id,
channels: [`app_${req.appId}/call-*`],
permissions: ["publish", "subscribe", "presence", "send"],
metadata: {
iceServers: turnCreds,
},
peerMetadata: {
username: req.user.name,
avatarUrl: req.user.avatar,
},
exp: Math.floor(Date.now() / 1000) + 3600,
},
SECRET,
{ algorithm: "HS256", header: { alg: "HS256", kid: KEY_ID } },
);

res.json({ token });
});

If you'd rather not host the JWT-signing yourself, call the REST API POST /v1/tokens instead.

Step 2 — Browser code

call.html
<!DOCTYPE html>
<html>
<head><title>Metered Video Call</title></head>
<body>
<video id="local" autoplay playsinline muted></video>
<div id="remotes"></div>
<script type="module" src="./call.js"></script>
</body>
</html>
call.js
import { MeteredPeer } from "@metered-ca/peer";

const peer = new MeteredPeer({
tokenProvider: async () => {
const r = await fetch("/api/mint-call-token");
if (!r.ok) throw new Error("token mint failed");
const { token } = await r.json();
return token;
},
});

// Get the local camera + mic.
const localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
document.querySelector("#local").srcObject = localStream;

// Attach to the SDK so every peer (current + future) receives it.
// Metadata labels the stream so receivers can lay it out correctly
// (vs simultaneous screen-share streams added below).
peer.addStream(localStream, { role: "camera", label: "front cam" });

// Wire UI per remote peer.
const tiles = new Map(); // peerId → <video> element

peer.on("peer-joined", ({ peer: remote }) => {
remote.on("stream-added", ({ stream, metadata }) => {
const tile = document.createElement("video");
tile.autoplay = true;
tile.playsInline = true;
tile.dataset.peerId = remote.id;
tile.dataset.streamId = stream.id;
if (metadata?.role === "screen") tile.classList.add("screen-share");
document.querySelector("#remotes").appendChild(tile);
tile.srcObject = stream;
tiles.set(`${remote.id}:${stream.id}`, tile);
});

remote.on("stream-removed", ({ stream }) => {
const tile = tiles.get(`${remote.id}:${stream.id}`);
tile?.remove();
tiles.delete(`${remote.id}:${stream.id}`);
});

remote.on("state-change", ({ to }) => {
for (const [key, tile] of tiles) {
if (key.startsWith(`${remote.id}:`)) {
tile.style.opacity = to === "connected" ? 1 : 0.3;
}
}
});
});

peer.on("peer-left", ({ peer: remote }) => {
for (const [key, tile] of tiles) {
if (key.startsWith(`${remote.id}:`)) {
tile.remove();
tiles.delete(key);
}
}
});

// Optional: reconnect banner.
peer.on("state-change", ({ to }) => {
document.body.dataset.state = to;
});

// Join the call.
await peer.join("app_xyz/call-room-42");

That's it. Two browser tabs open this page → mutual video call. Three tabs → three-way mesh. Each peer connects directly to each other peer; TURN relays only when direct connect fails.

Step 3 — Add chat / control messages

Same channel, different message type. peer.send is the broadcast.

peer.on("data", ({ senderPeerId, data }) => {
if (data.type === "chat") {
appendChatMessage(senderPeerId, data.text);
}
});

document.querySelector("#send-chat").onclick = async () => {
const text = document.querySelector("#chat-input").value;
await peer.send({ type: "chat", text });
};

peer.send is server-routed, not P2P. For chat, that's ideal — it works before ICE completes, doesn't depend on the peer connection succeeding, and you don't manage DataChannels. See routing trade-offs on the MeteredPeer reference.

Step 4 — Mute / camera off / screen share

Toggle audio

function setAudioEnabled(enabled) {
localStream.getAudioTracks().forEach((t) => (t.enabled = enabled));
peer.send({ type: "audio-state", enabled }); // tell other peers
}

The track.enabled = false keeps the SDP sender wired up — peers still see the track but it's silent. Cheaper than removing and re-adding.

Switch camera ↔ screen share — two patterns

Pattern A: simultaneous camera + screen share (preferred for modern UIs)

Add the screen as a SECOND stream alongside the camera. Receivers see both and lay them out side-by-side, picture-in-picture, or however your UI decides:

let screenStream = null;

async function startScreenShare() {
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
peer.addStream(screenStream, { role: "screen", label: "shared screen" });

// User clicks "stop sharing" in the browser UI
screenStream.getVideoTracks()[0].onended = () => stopScreenShare();
}

function stopScreenShare() {
if (!screenStream) return;
peer.removeStream(screenStream);
screenStream.getTracks().forEach((t) => t.stop());
screenStream = null;
}

Receivers branch on metadata.role:

remote.on("stream-added", ({ stream, metadata }) => {
if (metadata?.role === "camera") attachToFaceTile(stream);
if (metadata?.role === "screen") attachToScreenTile(stream);
});

Pattern B: in-place swap (replaces camera with screen)

If your UI only ever shows one video tile per peer, swap the camera track for the screen track without renegotiating:

async function switchToScreen() {
const screen = await navigator.mediaDevices.getDisplayMedia({ video: true });
const oldVideoTrack = localStream.getVideoTracks()[0];
const newVideoTrack = screen.getVideoTracks()[0];

try {
await peer.replaceTrack(oldVideoTrack, newVideoTrack);
} catch (e) {
if (e instanceof MeteredPeerReplaceTrackError) {
for (const { peerId, err } of e.failed) {
console.warn(`screen share failed for ${peerId}:`, err);
}
} else throw e;
}

oldVideoTrack.stop();
newVideoTrack.onended = () => switchToCamera(); // user clicked "stop sharing"
}

replaceTrack swaps the sender without renegotiation — much faster than removeStream + addStream. Even partial failure leaves the call working — e.failed lists peers that didn't get the swap so you can retry only those.

Which to use? Most modern calling UIs use Pattern A — Zoom, Meet, Teams all show camera + screen as separate panels. Pattern B is better when the UI is strictly one-tile-per-peer (mobile, older designs) or when you want to save bandwidth (one fewer encoder running).

Pitfalls

  1. Not handling peer-left. If a peer leaves and your code doesn't remove their <video> element, it sits there frozen. Always clean up in peer-left.

  2. Attaching localStream after join() without expecting renegotiation. Adding a stream after join triggers an SDP renegotiation cycle (~200–600 ms) per peer. For best call setup latency, get getUserMedia first, then addStream, THEN join. The example in Step 2 does it that way.

  3. Reading data.from instead of senderPeerId. senderPeerId is server-stamped; data.from is whatever the sender put. A spoofed sender bypasses your access checks if you trust data.from.

  4. Not testing reconnect. Real users WILL lose Wi-Fi mid-call. Read Reconnect Best Practices and verify your "reconnecting" UI before you ship.

  5. Letting getUserMedia fail silently. If the user denies camera access, your addStream never happens and peers see no video from you. Wrap the getUserMedia call in try/catch and show an "allow camera" UI.

  6. Forgetting to stop localStream on close. peer.close() doesn't stop the underlying tracks — the camera light stays on. Call localStream.getTracks().forEach(t => t.stop()) in your cleanup.

  7. Not embedding TURN creds. Calls between users on restrictive networks (cellular, corporate firewalls) need TURN. If you skip iceServers, ~30% of calls in production will fail to connect. Always embed creds in the JWT.

See also