Streaming CDN

Go live from OBS to the browser in under a second with WHIP

Updated 2026-11-03

WHIP (WebRTC-HTTP Ingestion Protocol) lets an encoder publish over WebRTC with a single HTTP request. OBS Studio supports it natively, so a stream can go from OBS to viewers' browsers over WebRTC end to end, in the low hundreds of milliseconds, instead of the several seconds that segment-based delivery such as HLS adds.

1. The publish URL

OBS publishes to your ingest domain, a publish point and a stream name:

https://<ingest-hostname>/<publish-point>/<stream-name>

# example
https://push.example.com/live/camera-01

2. Before you publish

  1. The ingest domain has active DNS and TLS.
  2. If source IP allowlisting is on, add the publisher's public egress IP (a private LAN address is not enough).
  3. If Stream Key authentication is on, create a key for this exact ingest domain. The secret is shown once.
  4. Use a current OBS Studio release that includes WHIP.

3. OBS settings

Open Settings → Stream and enter:

OBS fieldValue
ServiceWHIP
ServerThe full WHIP URL, including publish point and stream name
Bearer TokenThe ingest domain's Stream Key (empty only when Stream Key authentication is off)

OBS sends the Bearer Token as Authorization: Bearer <stream-key>. Treat Stream Keys as passwords: never put them in public URLs, screenshots, repositories or shared OBS profiles.

4. Recommended output for low latency

SettingValueWhy
Video encoderH.264 (hardware or software)Broad browser support
Rate controlCBRPredictable bandwidth
720p bitrate1.5–2.5 MbpsAdjust for motion and source quality
1080p bitrate3–6 MbpsAdjust for motion and source quality
Keyframe interval1 secondFaster first frame and recovery
H.264 profileMain or BaselineAvoids unsupported decoder features
B-frames0Lower latency
AudioEnabled, not mutedWHIP negotiates the audio track

5. Check that it works

  1. OBS changes from Connecting to Live.
  2. The console's Live streams list shows the stream name with active tracks.
  3. Open https://<playback-hostname>/<publish-point>/<stream-name> and check video and audio.

6. When it does not work

SymptomLikely causeWhat to do
401 invalid_stream_keyMissing, expired, revoked or wrong-domain keyCreate a key for the active ingest domain and paste it into Bearer Token
403 publisher_ip_deniedThe publisher's public IP is not allowedAdd the real public egress IP, or turn the allowlist off while testing
429 Too Many RequestsA duplicate publisher, or a concurrency limitStop the old publisher or use a different stream name
OBS stays on ConnectingFirewall, outbound UDP/DTLS, DNS or TLSCheck DNS/TLS, allow outbound UDP, read the OBS log
Live with no pictureHidden or muted source, incompatible outputCheck the OBS preview and mixer; use H.264 and a one-second keyframe interval

Publishing from a web page instead

A browser page can publish too, for example a screen share or a camera page for a presenter, with no encoder installed. It is the same WHIP request OBS makes: fetch() posts an SDP offer from a send-only RTCPeerConnection, the answer comes back in a 201, and a DELETE on the Location URL stops the stream.

  1. In the console, open the ingest domain and list the page's origin (for example https://studio.example.com) under browser publishing origins (CORS). Empty, the default, refuses every browser page; encoders such as OBS never ask for CORS and are unaffected.
  2. Keep the Stream Key off public pages: have your backend hand it to a signed-in operator. The origin list is not access control; Stream Keys and publisher IP allowlists still decide who may publish.
  3. Prefer H.264 for the video track. The platform relays video exactly as it is published and never re-encodes it, so pick a codec your viewers' browsers decode.
const whipUrl = "https://push.example.com/live/screen-01";
const streamKey = await getStreamKeyFromYourBackend(); // never hard-code it in a public page

const media = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
// or: navigator.mediaDevices.getUserMedia({ video: true, audio: true })

const pc = new RTCPeerConnection();
for (const track of media.getTracks()) {
  const transceiver = pc.addTransceiver(track, { direction: "sendonly" });
  if (track.kind === "video") {
    const h264 = RTCRtpSender.getCapabilities("video").codecs.filter((c) => c.mimeType === "video/H264");
    transceiver.setCodecPreferences(h264); // video is relayed as published, never re-encoded
  }
}

await pc.setLocalDescription(await pc.createOffer());
await new Promise((resolve) => {
  if (pc.iceGatheringState === "complete") return resolve();
  pc.addEventListener("icegatheringstatechange", () => pc.iceGatheringState === "complete" && resolve());
});

const response = await fetch(whipUrl, {
  method: "POST",
  headers: { "Content-Type": "application/sdp", Authorization: `Bearer ${streamKey}` },
  body: pc.localDescription.sdp
});
if (response.status !== 201) throw new Error(`publish refused: ${response.status} ${await response.text()}`);
const sessionUrl = new URL(response.headers.get("Location"), whipUrl);
await pc.setRemoteDescription({ type: "answer", sdp: await response.text() });

// to stop publishing:
async function stop() {
  await fetch(sessionUrl, { method: "DELETE", headers: { Authorization: `Bearer ${streamKey}` } });
  pc.close();
  media.getTracks().forEach((track) => track.stop());
}

Refusals arrive as readable responses rather than CORS errors, so the page can show 401 invalid_stream_key or 403 publisher_ip_denied to the operator. Stream names can be up to 80 characters; a page that publishes a new name every time should get its own ingest domain, because each domain keeps only its 64 most recent names.

Interactive live streaming

Zero-latency WebRTC live streaming for streamers, live commerce and events: real-time chat and votes over data channels, picture-in-picture, adaptive quality, recording and playback protection.

Learn more · Contact us