Skip to main content

WebRTC Signalling (with Metered TURN)

The marquee end-to-end pattern: your backend mints a Metered TURN credential + a Realtime Messaging JWT in one step, embeds the ICE-server array inside the JWT, and the browser gets everything it needs for a complete WebRTC call on a single WebSocket connect — no separate TURN-credential request from the client.

No backend? Use the pk_-only path

If your app is a static site, SPA, or prototype with no backend that can mint JWTs, see the WebRTC Signalling — No Backend guide for a pure-browser path using a pk_live_… publishable key.

┌──────────────┐    1. mint TURN credential                  ┌──────────────┐
│ │ ──────────────────────────────────────────→ │ │
│ │ │ Metered TURN │
│ │ ←── { username, password, ttl, urls } ───── │ REST API │
│ │ └──────────────┘
│ │
│ YOUR │ 2. mint realtime-messaging JWT with
│ BACKEND │ iceServers in metadata claim ┌──────────────┐
│ │ ──────────────────────────────────────────→ │ │
│ │ │ Metered │
│ │ ←── { token, expiresAt } ───────────────── │ Realtime │
│ │ │ Messaging │
└──────────────┘ └──────────────┘

│ 3. hand the JWT to your client (e.g. via REST response)

┌──────────────┐ 4. connect with ?token=<jwt>
│ │ ──────────────────────────────────────────→ ┌──────────────┐
│ │ │ │
│ BROWSER │ ←── welcome { metadata.iceServers: [...] } │ Metered │
│ │ │ Realtime │
│ │ 5. exchange SDP / ICE via `send` and │ Messaging │
│ │ `subscribe` to the call channel │ │
└──────────────┘ ──────────────────────────────────────────→ └──────────────┘

│ 6. media flows over Metered TURN — relayed but never inspected

┌──────────────┐
│ REMOTE │
│ PEER │
└──────────────┘

The browser doesn't need a Metered TURN credential of its own. The credential lives only in the JWT's metadata.iceServers claim and gets handed to the browser inside welcome.metadata.iceServers — never exposed in client-side code.


Prerequisites


Step 1 — Backend mints TURN credentials + JWT

backend/sessions.js
const fetch = require("node-fetch");

const METERED_APP_NAME = "your-app"; // dashboard home page
const METERED_TURN_API_KEY = process.env.METERED_TURN_API_KEY;
const METERED_REALTIME_SK = process.env.METERED_REALTIME_SK; // sk_live_…

/**
* Mint a connect-token for `userId` to join `callId`.
* Returns { token, expiresAt } — pass to the client.
*/
async function mintCallToken(userId, callId, displayName, profilePic) {
// 1. Get fresh, time-bounded TURN credentials. Each call to this
// endpoint returns a credential valid for ~24h; cache for ~1h to
// avoid hammering the TURN API.
const turnResp = await fetch(
`https://${METERED_APP_NAME}.metered.live/api/v1/turn/credentials?apiKey=${METERED_TURN_API_KEY}`,
);
if (!turnResp.ok) throw new Error(`TURN credentials fetch failed: ${turnResp.status}`);
const iceServers = await turnResp.json();

// 2. Mint the realtime-messaging JWT carrying:
// - channels: the call's channel for signalling fan-out
// - permissions: full (publish/subscribe/send/presence) for active participants
// - metadata.iceServers: TURN config delivered to this peer only via welcome
// - peerMetadata: user identity stamped onto presence + direct messages so
// every other peer in the call sees who joined without a backend lookup
const mintResp = await fetch("https://rms.metered.ca/v1/tokens", {
method: "POST",
headers: {
Authorization: `Bearer ${METERED_REALTIME_SK}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
peerId: userId,
channels: [`call-${callId}`],
permissions: ["publish", "subscribe", "presence", "send"],
expiresInSec: 3600,
metadata: { iceServers },
peerMetadata: {
userId,
username: displayName,
profilePic,
},
}),
});
if (!mintResp.ok) throw new Error(`token mint failed: ${mintResp.status}`);
return mintResp.json();
}

Why TWO API keys? Different blast radii:

  • The TURN apiKey is per-credential — losing it lets someone consume TURN bandwidth on your account, scoped to that one credential.
  • The Realtime Messaging sk_ is per-app — losing it lets someone mint tokens for any peer. Treat it like a database password.

Both stay on your backend. The browser sees only the minted JWT.


Step 2 — Client connects and reads TURN config from welcome

client/call.js
// Get the token from your backend.
const { token } = await fetch("/api/calls/abc/join").then(r => r.json());

const ws = new WebSocket(`wss://rms.metered.ca/v1?token=${encodeURIComponent(token)}`);

let pc; // RTCPeerConnection — created after we receive welcome
let localPeerId;

ws.onmessage = (evt) => {
const msg = JSON.parse(evt.data);

if (msg.type === "welcome") {
localPeerId = msg.peerId;

// 🎯 Create RTCPeerConnection with TURN servers from the welcome
pc = new RTCPeerConnection({
iceServers: msg.metadata?.iceServers ?? [],
});
wireUpRTCPeerConnection(pc);

// Subscribe to the call channel for signalling
ws.send(JSON.stringify({
type: "subscribe",
channel: `call-abc`,
requestId: "sub-1",
}));
}

if (msg.type === "presence" && msg.channel === "call-abc") {
// A new peer joined; if it's not us, initiate an offer to them.
for (const peer of msg.joined) {
if (peer.peerId !== localPeerId) {
console.log(`peer joined: ${peer.metadata?.username ?? peer.peerId}`);
initiateOfferTo(peer.peerId);
}
}
}

if (msg.type === "direct") {
// SDP or ICE candidate from another peer
const { kind, sdp, candidate } = msg.data;
if (kind === "offer") handleOffer(msg.from, sdp);
if (kind === "answer") handleAnswer(msg.from, sdp);
if (kind === "ice") handleIceCandidate(msg.from, candidate);
}
};

async function initiateOfferTo(remotePeerId) {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({
type: "send",
to: remotePeerId,
data: { kind: "offer", sdp: offer.sdp },
requestId: `offer-${remotePeerId}`,
}));
}

async function handleOffer(remotePeerId, sdp) {
await pc.setRemoteDescription({ type: "offer", sdp });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
ws.send(JSON.stringify({
type: "send",
to: remotePeerId,
data: { kind: "answer", sdp: answer.sdp },
requestId: `answer-${remotePeerId}`,
}));
}

async function handleAnswer(_remotePeerId, sdp) {
await pc.setRemoteDescription({ type: "answer", sdp });
}

async function handleIceCandidate(_remotePeerId, candidate) {
await pc.addIceCandidate(candidate);
}

function wireUpRTCPeerConnection(pc) {
pc.onicecandidate = (evt) => {
if (!evt.candidate) return;
// Broadcast the candidate to all other peers in the call.
// For 1:1 calls you'd use `send` to a specific peerId; for groups
// you can broadcast on the call channel.
ws.send(JSON.stringify({
type: "publish",
channel: "call-abc",
data: { kind: "ice", candidate: evt.candidate },
requestId: `ice-${Date.now()}`,
}));
};
pc.ontrack = (evt) => {
document.getElementById("remoteVideo").srcObject = evt.streams[0];
};
// Add local tracks
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => stream.getTracks().forEach(t => pc.addTrack(t, stream)));
}

Why this pattern works well

One round-trip for everything

The browser doesn't make a separate request to your TURN backend for credentials, then another to your signalling backend for a JWT, then a third to open the WebSocket. It opens one WS with one URL parameter and gets the TURN config in the first inbound frame. Faster TTFB, fewer failure points.

TURN credentials never touch the client storage layer

There's no localStorage.setItem("turnCreds", ...) or Cookie: turn=… for an attacker to lift. The credentials are scoped to the lifetime of the JWT (≤24h) and only exist in-memory on the active WebSocket connection.

Rotation is automatic on reconnect

JWT expires → client mints a new one from your backend → backend mints a new TURN credential. No "rotation job" in client code.

peerMetadata gives you participant UX for free

The joined array in the one-shot presence event the server sends on subscribe carries every existing participant's identity. You don't need a "who's in this call?" REST endpoint — presence.joined is it.


Group calls — full-mesh vs SFU

The pattern above is full-mesh: every peer establishes an RTCPeerConnection with every other peer. Fine up to ~6 participants; bandwidth scales as N×(N-1).

For larger groups, use Metered Global Cloud SFU as the media plane. The signalling pattern is the same — peers subscribe to the call channel, exchange SDP/ICE — but instead of N-to-N peer connections, each peer maintains one connection to the SFU.


Production checklist

  • Cache TURN credentials per user for ~1h. The credential is good for ~24h; minting it on every JWT mint hammers the TURN API needlessly.
  • Use peerMetadata for display fields, not for things the user shouldn't see (don't put email, billing tier, etc.).
  • Don't broadcast ICE candidates if you're 1:1. Use send to the specific peerId — less wire traffic, no leak to extra subscribers.
  • Handle expiresAt from welcome. Mint a fresh token a minute before exp so the user doesn't drop mid-call.
  • Pair with Metered TURN's region parameter for geo-pinned media if you're handling regulated traffic.

See also