Go live from OBS to the browser in under a second with WHIP
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
- The publish point defaults to
/live; the value configured in the console is authoritative. - The stream name is the last path segment and is case-sensitive. Streams published at the same time on one ingest domain need different names.
- Viewers watch through a playback domain bound to the ingest domain the stream was published to.
2. Before you publish
- The ingest domain has active DNS and TLS.
- If source IP allowlisting is on, add the publisher's public egress IP (a private LAN address is not enough).
- If Stream Key authentication is on, create a key for this exact ingest domain. The secret is shown once.
- Use a current OBS Studio release that includes WHIP.
3. OBS settings
Open Settings → Stream and enter:
| OBS field | Value |
|---|---|
| Service | WHIP |
| Server | The full WHIP URL, including publish point and stream name |
| Bearer Token | The 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
| Setting | Value | Why |
|---|---|---|
| Video encoder | H.264 (hardware or software) | Broad browser support |
| Rate control | CBR | Predictable bandwidth |
| 720p bitrate | 1.5–2.5 Mbps | Adjust for motion and source quality |
| 1080p bitrate | 3–6 Mbps | Adjust for motion and source quality |
| Keyframe interval | 1 second | Faster first frame and recovery |
| H.264 profile | Main or Baseline | Avoids unsupported decoder features |
| B-frames | 0 | Lower latency |
| Audio | Enabled, not muted | WHIP negotiates the audio track |
5. Check that it works
- OBS changes from Connecting to Live.
- The console's Live streams list shows the stream name with active tracks.
- Open
https://<playback-hostname>/<publish-point>/<stream-name>and check video and audio.
6. When it does not work
| Symptom | Likely cause | What to do |
|---|---|---|
401 invalid_stream_key | Missing, expired, revoked or wrong-domain key | Create a key for the active ingest domain and paste it into Bearer Token |
403 publisher_ip_denied | The publisher's public IP is not allowed | Add the real public egress IP, or turn the allowlist off while testing |
429 Too Many Requests | A duplicate publisher, or a concurrency limit | Stop the old publisher or use a different stream name |
| OBS stays on Connecting | Firewall, outbound UDP/DTLS, DNS or TLS | Check DNS/TLS, allow outbound UDP, read the OBS log |
| Live with no picture | Hidden or muted source, incompatible output | Check 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.
- 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. - 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.
- 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.