Calls
Calls puts people in a live audio and video call with each other. Cloudflare’s Realtime SFU carries the media; your app never touches it. That’s what a consultation, a tutoring session, a standup, a support call or a watch party is built out of.
It is not Video. That one stores a recording and plays it back later. This one is people talking to each other right now, and nothing is kept.
What your app writes
Section titled “What your app writes”One import, behind a button:
import { joinCall, leaveCall } from "call";
const call = await joinCall("lobby", { onTrack(stream, who) { const el = document.createElement("video"); el.srcObject = stream; el.autoplay = true; document.querySelector("#peers").append(el); }, onLeave(who) { /* remove their video */ },});
document.querySelector("#mine").srcObject = call.localStream;joinCall publishes the local camera and microphone, subscribes to everyone else’s, and calls
onTrack once per remote person. leaveCall() ends it and stops the camera.
The browser will refuse the camera unless the page is on https and the request follows a
click. The preview and the deployed app are both https, so the only rule that matters is: put
joinCall() behind a button, never on load.
Who is in the call
Section titled “Who is in the call”The SFU moves media between sessions and knows nothing about rooms — it cannot tell you who
joined, who left, or which track belongs to whom. Realtime does that, so
Calls needs Live on. joinCall("lobby") opens the Live room named lobby and announces its
track ids there, which is how the other browsers know what to pull.
A call room and a Live room with the same name are the same room, so the naming rule is the one
you already know: public/… is open to anyone, and any other name is members-only once sign-in
is on.
The API
Section titled “The API”You do not normally call these — joinCall() does. They exist because the app secret must never
reach the browser: every one of them is your Worker talking to Cloudflare on the visitor’s behalf,
signed with a secret only the Worker holds.
| Method & path | Body | Returns |
|---|---|---|
POST /api/_call/session |
{"sessionDescription":{"type":"offer","sdp":"…"}} |
{"sessionId","sessionDescription"} — joinCall() calls this for you |
POST /api/_call/session/:id/tracks |
{"tracks":[…],"sessionDescription?":{…}} |
{"tracks","sessionDescription?","requiresImmediateRenegotiation?"} |
PUT /api/_call/session/:id/renegotiate |
{"sessionDescription":{"type":"answer","sdp":"…"}} |
{"ok":true} |
PUT /api/_call/session/:id/tracks/close |
{"tracks":[{"mid":"0"}],"sessionDescription":{…},"force?":false} |
{"tracks","sessionDescription"} |
GET /api/_call/session/:id |
— | {"tracks":[…]} — what this session is currently sending and receiving |
Never fetch rtc.live.cloudflare.com from the browser, never ask for a key, and never put one in
the app’s source.
What Cloudflare needs
Section titled “What Cloudflare needs”Turning Calls on creates a Realtime app in your own Cloudflare account. There is nothing to paste and nothing to configure.
Realtime is a separate subscription on the account — free to add, with 1,000 GB a month included and $0.05/GB after that. If turning Calls on is refused, add it in the Cloudflare dashboard under Realtime, then try again. Cloudflare answers a missing subscription and a missing permission with the same refusal and no way to tell them apart, so the error names both.
Cloudflare shows a Realtime app’s secret once, when it is created, and serves no way to fetch or rotate it. If the stored secret is ever lost, turning Calls on again replaces the app rather than trying to recover it. Nothing is lost with it: a session dies with its call, so a Realtime app holds no state.
Limits
Section titled “Limits”Sessions are capped at 120/min per caller (429), and a session carries at most 32 tracks.
Everyone in a call pulls everyone else’s tracks, so the work grows with the square of the room. Past roughly a dozen people, build it as one speaker and many listeners rather than everyone publishing.