This tutorial shows how to broadcast ultra-low latency live video from a browser to Cloudflare Stream using WHIP ↗ and play it back in a browser using WHEP ↗. Both the broadcaster and the player use the browser's built-in WebRTC APIs — there are no libraries to install and no external applications.
By the end, you will have a basic HTML page that captures your camera and microphone, streams it to a live input, and plays the same stream back with sub-second latency. You should be able to complete this walkthrough in less than 15 minutes.
WHIP and WHEP are simple HTTP-based signaling protocols for WebRTC. In both cases, this code creates an RTCPeerConnection ↗, generates a local session description (SDP offer), sends that offer to a Cloudflare URL with a single HTTP POST, and applies the SDP answer that Cloudflare returns. Because the whole exchange is one request and response, you do not need a signaling server of your own.
To follow this tutorial, you will need:
- Any of the following, so you can create a live input:
- A paid Stream subscription.
- A Pro or Business zone plan — these include 100 minutes of video storage and 10,000 minutes of video delivery.
- An enterprise contract with Stream enabled.
- A modern browser with a camera and microphone.
- To serve your page over
httpsor fromlocalhost.- Why? Browsers only allow
getUserMedia()↗ in a secure context ↗. Opening an HTML file directly with afile://URL will not work. - Deploy to Cloudflare Workers or Pages to get started quickly, for free.
- Why? Browsers only allow
Every broadcast targets a live input. Create one using either option:
-
Use the Live inputs page of the Cloudflare dashboard, then look under the Broadcast and Playback tabs to get the WebRTC URLs.
Go to Live inputs ↗ -
Make a
POSTrequest to the/live_inputsAPI endpoint.
The response includes two URLs you will use in this tutorial:
{
"uid": "1a553f11a88915d093d45eda660d2f8c",
...
"webRTC": {
"url": "https://customer-<CODE>.cloudflarestream.com/<SECRET>/webRTC/publish"
},
"webRTCPlayback": {
"url": "https://customer-<CODE>.cloudflarestream.com/<INPUT_UID>/webRTC/play"
},
...
}webRTC.urlis the WHIP endpoint you broadcast to. The broadcast secret is part of this URL, so treat it like a stream key and share it only with the person broadcasting.webRTCPlayback.urlis the WHEP endpoint viewers play from, unless you have enabled signed URLs on the input (not covered here).
Copy both URLs. You will paste them into the code below.
This broadcast script captures local media, adds it to an RTCPeerConnection as send-only tracks, and posts the resulting SDP offer to the WHIP URL.
Starting with a basic HTML page, add a <video> element to preview the local camera:
<video id="broadcast-preview" autoplay muted playsinline></video>Then add this script to broadcast:
// Paste the webRTC.url value from your live input.
const WHIP_URL = "<WHIP_URL_FROM_YOUR_LIVE_INPUT>";
async function startBroadcast() {
// 1. Capture the camera and microphone.
const media = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
document.getElementById("broadcast-preview").srcObject = media;
// 2. Create the peer connection and add each track as send-only.
const pc = new RTCPeerConnection();
media.getTracks().forEach((track) => {
pc.addTransceiver(track, { direction: "sendonly" });
});
// 3. Create the SDP offer and set it as the local description.
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 4. POST the offer to the WHIP endpoint.
const response = await fetch(WHIP_URL, {
method: "POST",
headers: { "Content-Type": "application/sdp" },
body: offer.sdp,
});
if (!response.ok) {
throw new Error(`WHIP request failed: ${response.status}`);
}
// 5. Apply the SDP answer returned by Cloudflare.
const answer = await response.text();
await pc.setRemoteDescription({ type: "answer", sdp: answer });
// The Location header identifies this session, used to stop it later.
const sessionUrl = new URL(
response.headers.get("Location"),
WHIP_URL,
).toString();
return { pc, sessionUrl };
}
startBroadcast().catch(console.error);Once you call startBroadcast() and grant camera and microphone permission, the browser negotiates a connection and begins sending live video and audio to Cloudflare over WebRTC. You do not need to select a codec — the browser will negotiate a supported codec automatically.
This script does not cover selecting between multiple camera or audio sources and will use the default provided by the browser.
The player script is the reverse of the broadcaster. Instead of adding local tracks, it adds receive-only transceivers, posts an offer to the WHEP URL, and attaches the incoming media to a <video> element.
Starting with a basic HTML page, add a <video> element for playback:
<video id="playback-video" autoplay playsinline controls></video>Then add this script to play:
// Paste the webRTCPlayback.url value from your live input.
const WHEP_URL = "<WHEP_URL_FROM_YOUR_LIVE_INPUT>";
async function startPlayback() {
const pc = new RTCPeerConnection();
// 1. Ask to receive one audio track and one video track.
pc.addTransceiver("video", { direction: "recvonly" });
pc.addTransceiver("audio", { direction: "recvonly" });
// 2. Attach incoming media to the video element as it arrives.
const stream = new MediaStream();
document.getElementById("playback-video").srcObject = stream;
pc.ontrack = (event) => stream.addTrack(event.track);
// 3. Create the SDP offer and set it as the local description.
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 4. POST the offer to the WHEP endpoint.
const response = await fetch(WHEP_URL, {
method: "POST",
headers: { "Content-Type": "application/sdp" },
body: offer.sdp,
});
if (!response.ok) {
throw new Error(`WHEP request failed: ${response.status}`);
}
// 5. Apply the SDP answer returned by Cloudflare.
const answer = await response.text();
await pc.setRemoteDescription({ type: "answer", sdp: answer });
const sessionUrl = new URL(
response.headers.get("Location"),
WHEP_URL,
).toString();
return { pc, sessionUrl };
}
startPlayback().catch(console.error);While the broadcaster is live, the player connects and shows the stream with less than 500 milliseconds of latency.
WebRTC sessions end automatically when the page closes or the connection drops, but you should end them explicitly when the user is done. Send an HTTP DELETE to the session URL from the Location header, then close the peer connection:
async function stop({ pc, sessionUrl }) {
if (sessionUrl) {
await fetch(sessionUrl, { method: "DELETE" });
}
pc.close();
}This applies to both WHIP and WHEP sessions — pass the object returned by startBroadcast() or startPlayback().
The following single file combines everything above. Replace the two placeholder URLs with the webRTC.url and webRTCPlayback.url values from your live input. Then serve the file over https (with Workers or Pages) or localhost and open it in a browser.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Cloudflare Stream WHIP/WHEP example</title>
</head>
<body>
<h2>Broadcast (WHIP)</h2>
<video id="broadcast-preview" autoplay muted playsinline></video>
<button id="broadcast-btn">Start broadcasting</button>
<h2>Playback (WHEP)</h2>
<video id="playback-video" autoplay playsinline controls></video>
<button id="playback-btn">Start playback</button>
<script type="module">
const WHIP_URL = "<WHIP_URL_FROM_YOUR_LIVE_INPUT>";
const WHEP_URL = "<WHEP_URL_FROM_YOUR_LIVE_INPUT>";
async function negotiate(pc, url) {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/sdp" },
body: offer.sdp,
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const answer = await response.text();
await pc.setRemoteDescription({ type: "answer", sdp: answer });
return new URL(response.headers.get("Location"), url).toString();
}
document
.getElementById("broadcast-btn")
.addEventListener("click", async () => {
const media = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
document.getElementById("broadcast-preview").srcObject = media;
const pc = new RTCPeerConnection();
media
.getTracks()
.forEach((track) =>
pc.addTransceiver(track, { direction: "sendonly" }),
);
await negotiate(pc, WHIP_URL);
});
document
.getElementById("playback-btn")
.addEventListener("click", async () => {
const pc = new RTCPeerConnection();
pc.addTransceiver("video", { direction: "recvonly" });
pc.addTransceiver("audio", { direction: "recvonly" });
const stream = new MediaStream();
document.getElementById("playback-video").srcObject = stream;
pc.ontrack = (event) => stream.addTrack(event.track);
await negotiate(pc, WHEP_URL);
});
</script>
</body>
</html>If a broadcast or playback session does not connect, your browser's built-in WebRTC tools show the SDP exchange and ICE connection state:
- Chrome: Navigate to
chrome://webrtc-internalsto view detailed logs and graphs. - Firefox: Navigate to
about:webrtcto view information about WebRTC sessions. - Safari: From the inspector, open the settings tab (cogwheel icon), and set WebRTC logging to "Verbose" in the dropdown menu.
Common issues:
getUserMediathrows an error or returns nothing — confirm the page is served securely and that you granted camera and microphone permission.- The
POSTfails — confirm you pasted the correct URL. UsewebRTC.urlfor broadcasting andwebRTCPlayback.urlfor playback. - Playback stays black — confirm a broadcaster is actively live on the same input and that signed URLs are not enabled.
- Review the WebRTC reference for supported codecs, protocol conformance, and limitations.
- Broadcast from other software with the OBS and FFmpeg instructions.
- Use a maintained WHIP or WHEP client library instead of writing signaling yourself.