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 → Keys → Create key, type publishable.
  2. Set:
    • Channels: wildcards your demo will use, e.g. demo-room-*
    • Actions: subscribe, publish, presence, sendsend 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.
    • Allowed origins: the URL of your static site, e.g. https://demo.example.com
  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. allowedOrigins blocking the WS. Server compares the WS's Origin header to the key's allowedOrigins. If you forget to add your deploy URL, you get 4001 on every connect. Check the dashboard.

  4. Same key used in source + deployed app + open-source repos. A pk_live_ is meant to be public, but its allowedOrigins is the only access control. If you hardcode it in a repo and forget to scope allowedOrigins, anyone running the SDK against your key from localhost could connect. Scope tightly.

  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