Skip to main content

WebRTC Signalling — No Backend (pk_ key only)

For single-page apps, static sites, prototypes, and pure-browser demos where you don't run a backend that can mint JWTs.

JavaScript / TypeScript?

This guide uses raw WebSockets so it works from any stack. If you're on JS or TS, the SDK version of this guide is significantly shorter — peer.join(channel) handles the SDP / ICE exchange.

┌──────────────┐ ┌──────────────┐
│ │ 1. GET TURN ICE servers │ │
│ BROWSER │ ───────────────────────────────→ │ Metered TURN │
│ │ ←── { stun/turn URLs + creds } ─ │ REST API │
│ │ └──────────────┘
│ │
│ │ 2. WS connect ?key=pk_live_... ┌──────────────┐
│ │ ───────────────────────────────→ │ Metered │
│ │ ←── welcome (random peerId) ──── │ Realtime │
│ │ │ Messaging │
│ │ 3. subscribe to call channel + │ │
│ │ exchange SDP/ICE via `send` │ │
└──────────────┘ ───────────────────────────────→ └──────────────┘
▲
│ 4. RTCPeerConnection uses ICE servers from step 1
│ Media flows over Metered TURN
▼
┌──────────────┐
│ REMOTE │
│ PEER │
└──────────────┘

No JWT, no backend, no server-side mint step. Two browser-safe credentials do everything.


When this is the right path​

Use this no-backend path if…Use the backend path if…
Static / Jamstack site, single-page app with no serverYou have a backend that authenticates users (login system, sessions)
Pure-browser prototype, demo, or learning projectYou want stable user identities (named participants in the call)
Anonymous or pseudonymous WebRTC use caseYou want peerMetadata stamping (server-side identity on presence + direct)
Every user gets the same scope (same channelPatterns, same actions)Different users get different scopes (admins vs guests, room-A vs room-B)
You're OK with peerIds randomly assigned by the serverYou need to pick the peerId yourself (matches your userId)

If you're not sure, the backend path is more flexible — start there if you have a backend at all. This page is for the case where you genuinely don't.


Constraints you trade for the simplicity​

Pure pk_ connections are intentionally low-trust. The trade-offs:

ConstraintWhat it meansWorkaround
Server-assigned random peerIdThe pk_ key is too low-trust to let the client pick a peerId. Server generates one at connect time (e.g. pk-conn-a1b2c3d4) and returns it in welcome.peerId.Send identity inside your data payloads instead of relying on from (see "Carrying identity" below)
No peerMetadata stampingWithout a JWT, there's no peerMetadata claim — presence events and direct messages won't carry the sender's username/avatarEmbed identity in the message body: { from: "Alice", avatar: "…", sdp: "…" }. The client renders from that, not fromMetadata.
No metadata in welcomeThe connection-private welcome bag is a JWT-claim pass-through; pk_ connections have no JWT to source fromFetch TURN credentials yourself via the Metered TURN REST API (next section) — they don't need to ride in the JWT
Fixed scope from dashboard configchannelPatterns and actions are set on the key at create time. You can't narrow scope per-user (every connection inherits the full set).Use a backend if per-user scoping matters. Otherwise pick the narrowest patterns that work for the whole app.

Prerequisites​

You need TWO browser-safe credentials, both created in the Metered dashboard:

  1. A Realtime Messaging pk_live_… key — Dashboard → Realtime Messaging → API Keys → Create key, type Publishable. Set:
    • channelPatterns to whatever channels your app uses (e.g. ["call-*"])
    • actions to ["publish", "subscribe", "presence", "send"]
    • There is no allowed-origins setting, so the key works from any domain — anyone who copies it from your page can use it with the scope above. Keep channelPatterns as narrow as your app allows. See Authentication → Publishable keys.
  2. A Metered TURN credential's apiKey — Dashboard → TURN Server → Credentials, then Get credential → Show API Key on the credential's row (classic dashboard: the Show API Key button on the TURN Server page). The credential apiKey is browser-safe (distinct from your account secretKey); it's designed for direct frontend use with the Get Credential API. See Metered TURN → Get Credential.

Both can live in your frontend bundle. Treat them like any other public configuration constant.


Step 1 — Fetch TURN ICE servers from the browser​

call.js
const METERED_APP_NAME = "your-app"; // your Metered dashboard app name
const METERED_TURN_API_KEY = "<credential-apiKey>"; // browser-safe; from the TURN credential
const METERED_REALTIME_PK = "pk_live_xxxxx..."; // browser-safe; from a Realtime Messaging publishable key

async function fetchIceServers() {
const resp = await fetch(
`https://${METERED_APP_NAME}.metered.live/api/v1/turn/credentials?apiKey=${METERED_TURN_API_KEY}`,
);
if (!resp.ok) throw new Error(`TURN credentials fetch failed: ${resp.status}`);
return resp.json();
}

The response is an array of ICE-server entries — STUN endpoints, TURN endpoints over UDP/TCP/TLS, with embedded username/credential strings — ready to feed straight into RTCPeerConnection.

Caching TURN credentials

A credential is good for ~24h. Cache the array in sessionStorage so reloads don't re-fetch:

async function fetchIceServersCached() {
const cached = sessionStorage.getItem("iceServers");
if (cached) return JSON.parse(cached);
const fresh = await fetchIceServers();
sessionStorage.setItem("iceServers", JSON.stringify(fresh));
return fresh;
}

Step 2 — Connect the WebSocket with the pk_ key​

call.js (continued)
const iceServers = await fetchIceServersCached();

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

let pc; // RTCPeerConnection — created after welcome
let localPeerId; // assigned by the server

// Local display identity — chosen by the user, embedded in every message
// payload so other peers know who you are (no peerMetadata available
// on pk_ connections).
const localDisplayName = prompt("Your name?") || "Anonymous";

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

if (msg.type === "welcome") {
localPeerId = msg.peerId;
console.log(`connected as ${localPeerId} (display: ${localDisplayName})`);

// Build the RTCPeerConnection with TURN servers from step 1
pc = new RTCPeerConnection({ iceServers });
wireUpRTCPeerConnection(pc);

// Join the call channel
ws.send(JSON.stringify({
type: "subscribe",
channel: "call-demo-room",
requestId: "sub-1",
}));
}

if (msg.type === "presence" && msg.channel === "call-demo-room") {
// A new peer joined. presence.joined entries carry NO metadata
// (no peerMetadata on pk_ connections), only peerIds.
for (const peer of msg.joined) {
if (peer.peerId !== localPeerId) {
console.log(`peer joined: ${peer.peerId}`);
// Initiate an SDP offer to them — embed our display name in
// the offer payload so they know who we are without backend.
initiateOfferTo(peer.peerId);
}
}
for (const peer of msg.left) {
console.log(`peer left: ${peer.peerId}`);
// Tear down the RTCPeerConnection for this peer
}
}

if (msg.type === "direct") {
// SDP / ICE from another peer. `msg.from` is their random peerId.
// `msg.data.fromName` is their self-reported display name (we put
// it in the payload because the protocol can't stamp it for us).
const { kind, sdp, candidate, fromName } = msg.data;
if (kind === "offer") handleOffer(msg.from, fromName, sdp);
if (kind === "answer") handleAnswer(msg.from, sdp);
if (kind === "ice") handleIceCandidate(msg.from, candidate);
}
};

ws.onclose = (evt) => {
console.log("ws closed", evt.code, evt.reason);
// 4001/4002 → fix key, reload
// 4010/4011/4012 → see protocol/close-codes.md
};

Step 3 — Exchange SDP and ICE candidates​

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,
fromName: localDisplayName, // ← our identity, embedded in the payload
},
requestId: `offer-${remotePeerId}`,
}));
}

async function handleOffer(remotePeerId, fromName, sdp) {
console.log(`offer from ${fromName} (${remotePeerId})`);
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,
fromName: localDisplayName,
},
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;
// For a 1:1 call, send candidates directly. For groups, broadcast
// on the call channel and the recipient filters by candidate.from.
ws.send(JSON.stringify({
type: "publish",
channel: "call-demo-room",
data: {
kind: "ice",
candidate: evt.candidate,
fromName: localDisplayName,
},
}));
};
pc.ontrack = (evt) => {
document.getElementById("remoteVideo").srcObject = evt.streams[0];
};
navigator.mediaDevices
.getUserMedia({ video: true, audio: true })
.then((stream) => stream.getTracks().forEach((t) => pc.addTrack(t, stream)));
}

That's a working 1:1 WebRTC call, entirely from the browser.


Carrying identity without peerMetadata​

The pattern above is the answer to "how does the no-backend path do user identity?" — embed it in the message payload instead of relying on the protocol's fromMetadata field.

Every outbound message that needs to render with an identity carries the fields the recipient will use:

ws.send(JSON.stringify({
type: "publish",
channel: "call-demo-room",
data: {
kind: "chat",
text: "hello",
fromName: localDisplayName, // user-supplied
fromAvatar: localAvatarUrl, // optional, e.g. gravatar
fromColor: localCursorColor, // optional, randomly assigned
},
}));

The recipient ignores msg.from for display purposes (it's just a random peerId) and renders from msg.data.fromName / fromAvatar instead.

Why this is OK at this trust level: the identity is self-asserted. Anyone with the same pk_ key could publish a message claiming to be "Alice." With a backend + JWT, the server stamps identity from the validated JWT claim, so "Alice" is verified. Pure pk_ trades that verification for backend-free simplicity. For prototypes, demos, or genuinely-anonymous use cases, that's fine. For anything where someone could impersonate another user maliciously, use the backend path.


Security checklist for pk_ keys in the browser​

Before shipping a pk_ key to production:

  • You accept that the key is public. pk_ keys can't be restricted to your domain (there is no allowed-origins setting), so anyone who finds the key in your JS bundle can use it from their own site with the same scope. The items below are what limit the damage.
  • channelPatterns is as narrow as practical — e.g. ["call-*"] if your app only uses call channels, not ["**"].
  • actions only includes what the frontend genuinely needs — typically publish / subscribe / presence / send. Don't grant a capability you don't use.
  • You're on a paid plan or have rate-limiting acceptable for your traffic — pk_ keys hit your account's connection / message quotas; a malicious user spamming connections still costs you (caps protect you, but you'll see traffic).
  • Remember the TURN credential apiKey is public too — the dashboard has no origin allowlist for it either. Anyone who copies it can fetch TURN credentials for that credential, so watch its usage and delete and recreate the credential if it's abused.

The dashboard's Create key form shows the channel and action settings. Send is off by default for publishable keys, but Channel access defaults to All channels in this app — switch it to Specific channels only.


What happens if the pk_ key leaks​

A leaked pk_ key works from any domain — publishable keys can't be restricted by origin. Whoever has it can connect with the key's full scope (its channel patterns and actions) until you revoke it. If Auto-inject TURN is on (the default), whoever has the key also receives Metered TURN credentials on every connect, so misuse can spend your TURN bandwidth too — turn it off for keys that don't need WebRTC. Revoking the key also expires its linked TURN credential.

If you suspect a key has leaked:

  1. Revoke the key immediately — Dashboard → Realtime Messaging → API Keys → revoke. Live connections close within seconds.
  2. Create a new key with a different keyId — old key's keyId no longer works.
  3. Update your frontend to ship the new key.
  4. Keep the new key's scope as narrow as possible — and if the abuse continues, move to the JWT (tokenProvider) path, where your backend decides who gets a token.

The revocation is propagated via the auth-invalidate pub/sub event — see Authentication → Key rotation.


Adding a "have a backend now" upgrade later​

The no-backend path is a starting point. If your product later adds user accounts and you want stable peerIds + verified identity, the migration is small:

  1. Create a secret key (sk_id_… + sk_secret_…) on the dashboard (separate from the pk_)
  2. On your new backend, mint JWTs via POST /v1/tokens carrying the user's real ID + peerMetadata
  3. The browser swaps ?key=pk_live_… for ?token=<jwt> in the connect URL
  4. The browser keeps reading TURN config — either by fetching from the TURN REST API as before, OR (recommended) by reading welcome.metadata.iceServers so the backend ships ICE config inside the JWT (no extra round-trip — see the backend WebRTC guide)

Nothing about the WebRTC layer or the wire protocol changes — the SDP/ICE flow is identical. Only the auth path migrates.


Group calls​

The example above shows a 1:1 call. For groups, you'd:

  • Subscribe to a shared call channel as before
  • On each presence.joined, create an RTCPeerConnection per remote peer and exchange SDP/ICE via send (1:1) to that specific peerId
  • Track a Map<peerId, RTCPeerConnection> on the local side

Full-mesh works up to ~6 peers. For larger groups, Metered Global Cloud SFU is the media-plane primitive that lets every peer keep one connection to the SFU instead of N-to-N peer-to-peer.


See also​