Skip to main content

WebRTC Video Call — No Backend

Same WebRTC call as the main guide, but without a server to mint JWTs. Suitable for:

  • Static sites, single-page demos, prototypes
  • Internal tools where every user shares the same scope
  • Examples + tutorials

What you trade away by going backend-less:

  • No per-user peerId — server assigns a UUID each connect. If your app needs stable per-user IDs, mint JWTs.
  • No peerMetadata — presence events don't carry usernames or avatars. You'd have to broadcast them yourself after connecting (peer.send({ type: "introduce", username, avatar })).
  • All peers share the key's permissions — no per-user scoping.

If those trade-offs are OK, this path saves you from running any server-side code.

Get a pk_live_ key​

  1. Dashboard → Realtime Messaging → API Keys → Create key, type publishable.
  2. Set:
    • Channel access: choose Specific channels only and enter the wildcards your demo will use, e.g. demo-room-*
    • What can this key do? — subscribe, publish, presence, send — send is off by default for publishable keys but is required for this no-backend WebRTC path because the SDK uses it to deliver SDP and ICE between peers. Without it, peers join the channel but never negotiate their RTCPeerConnection.
    • There is no allowed-origins setting: the key works from any website, so treat it as public and keep the channels narrow.
  3. Copy the pk_live_… value.

Metered TURN — auto-injected (default)​

For a real WebRTC connection between users on cellular / corporate networks, you'll need TURN. When you create the pk_live_ key with "Auto-inject TURN" enabled (default), the Realtime Messaging service fetches your Metered TURN credentials and injects them into the welcome message. The SDK applies them to every RTCPeerConnection automatically — no client-side TURN fetch needed.

Requirement: your app needs an active TURN service (any tier, including free).

Browser code​

call.html
<!DOCTYPE html>
<html>
<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/realtime";

const PK_KEY = "pk_live_…";

const peer = new MeteredPeer({
apiKey: PK_KEY,
// TURN credentials are auto-injected via the welcome message — no
// rtcPeerConnectionFactory override needed.
});

const localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
document.querySelector("#local").srcObject = localStream;
peer.addStream(localStream);

const tiles = new Map();

peer.on("peer-joined", ({ peer: remote }) => {
const tile = document.createElement("video");
tile.autoplay = true;
tile.playsInline = true;
document.querySelector("#remotes").appendChild(tile);
tiles.set(remote.id, tile);

remote.on("track", ({ streams }) => {
tile.srcObject = streams[0];
});
});

peer.on("peer-left", ({ peer: remote }) => {
tiles.get(remote.id)?.remove();
tiles.delete(remote.id);
});

// Identify yourself via a broadcast (since there's no peerMetadata).
peer.on("joined", () => {
peer.send({ type: "introduce", username: "Alice" });
});

const usernames = new Map(); // peerId → username
peer.on("data", ({ senderPeerId, data }) => {
if (data.type === "introduce") {
usernames.set(senderPeerId, data.username);
refreshTileLabel(senderPeerId);
}
});

async function fetchIceServers() {
const r = await fetch(
`https://YOUR_TURN_APP.metered.live/api/v1/turn/credentials?apiKey=${TURN_APP_KEY}`,
);
return r.json();
}

await peer.join("demo-room-42");

That's the complete app. Drop it on Netlify, share the URL, two visitors get a working call.

What about pure presence (no TURN)?​

If you don't need WebRTC — just chat, telemetry, pub/sub — none of the TURN setup matters. peer.send(data) doesn't require peer-to-peer connectivity (it's server-routed). For pure-pub/sub apps you can either leave TURN service unconfigured on your account, or flip "Auto-inject TURN" off on the pk_ key from the dashboard.

Pitfalls​

  1. No peerMetadata — peers don't know each other's names. Use a broadcast introduce message on joined (as above). Every peer learns every other peer's name once they introduce. New peers joining later won't know existing peers until you re-broadcast — listen for peer-joined and re-send.

  2. Auto-injection requires a TURN service. If you haven't added TURN to your app, the Realtime Messaging service injects nothing and the SDK falls back to STUN-only / host candidates. That's fine for same-LAN or permissive-NAT scenarios but fails behind symmetric NAT. Add any TURN tier (including free) to your app, or supply your own iceServers via the JWT path.

  3. Expecting the key to be locked to your domain. It isn't — publishable keys have no allowed-origins setting, so the server accepts the key from any website. Anyone who copies it from your page can connect with the same permissions.

  4. Same key used in source + deployed app + open-source repos. A pk_live_ is meant to be public, and its channels and actions are its only access control. If you hardcode it in a repo, anyone can run the SDK against your key and connect. Scope channels and actions tightly, and revoke the key (Dashboard → Realtime Messaging → API Keys → Revoke) if it's being misused.

  5. Trusting data.from (or anything in data) for identity. Since there's no peerMetadata, you might be tempted to put a from: "alice" field inside data and trust it. Don't — anyone in the channel can lie about that. Use the envelope-level senderPeerId as the canonical sender; the SDK won't surface data.from separately.

When to graduate to the JWT path​

If your app reaches any of:

  • Multiple users with different permissions (admin vs viewer)
  • Need for stable per-user peerId across sessions
  • Per-user peerMetadata (usernames, avatars in presence events) instead of broadcast-introduce
  • Production with paying users

…switch to tokenProvider. The browser code barely changes; you just add a backend route that mints the JWT. See the main WebRTC guide and Authentication.

See also​