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
- Dashboard → Realtime Messaging → Keys → Create key, type
publishable. - Set:
- Channels: wildcards your demo will use, e.g.
demo-room-* - Actions:
subscribe,publish,presence,send—sendis 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 theirRTCPeerConnection. - Allowed origins: the URL of your static site, e.g.
https://demo.example.com
- Channels: wildcards your demo will use, e.g.
- 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
<!DOCTYPE html>
<html>
<body>
<video id="local" autoplay playsinline muted></video>
<div id="remotes"></div>
<script type="module" src="./call.js"></script>
</body>
</html>
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
No
peerMetadata— peers don't know each other's names. Use a broadcastintroducemessage onjoined(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 forpeer-joinedand re-send.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.
allowedOriginsblocking the WS. Server compares the WS'sOriginheader to the key'sallowedOrigins. If you forget to add your deploy URL, you get 4001 on every connect. Check the dashboard.Same key used in source + deployed app + open-source repos. A
pk_live_is meant to be public, but itsallowedOriginsis the only access control. If you hardcode it in a repo and forget to scopeallowedOrigins, anyone running the SDK against your key fromlocalhostcould connect. Scope tightly.Trusting
data.from(or anything indata) for identity. Since there's nopeerMetadata, you might be tempted to put afrom: "alice"field insidedataand trust it. Don't — anyone in the channel can lie about that. Use the envelope-levelsenderPeerIdas the canonical sender; the SDK won't surfacedata.fromseparately.
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
peerIdacross 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.