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 → API Keys → Create key, type
publishable. - 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—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. - There is no allowed-origins setting: the key works from any website, so treat it as public and keep the channels narrow.
- Channel access: choose Specific channels only and enter the 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.
-
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.
-
Same key used in source + deployed app + open-source repos. A
pk_live_is meant to be public, and itschannelsandactionsare its only access control. If you hardcode it in a repo, anyone can run the SDK against your key and connect. Scopechannelsandactionstightly, and revoke the key (Dashboard → Realtime Messaging → API Keys → Revoke) if it's being misused. -
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.