WHIP, WHEP and Live Streaming
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
WHIP and WHEP are two tiny HTTP protocols that remove the one part of WebRTC that was never standardised: how a
publisher or a viewer tells a media server about itself. WHIP (WebRTC-HTTP Ingestion Protocol) sends a stream
into a server, WHEP (WebRTC-HTTP Egress Protocol) pulls a stream out of it. Each is one HTTP POST carrying an SDP
offer, one 201 Created carrying the SDP answer, and one DELETE to say goodbye. After that, media flows over the
ordinary ICE, DTLS and SRTP stack described in Peer Connections and SDP.
This page was written against WHIP as published in RFC 9725 (March 2025, Standards Track) and WHEP as the Internet-Draft draft-ietf-wish-whep-05 of 2026-10-11, which expires on 2027-04-15 and has not been published as an RFC. The examples were run on MediaMTX 1.21.2 with Chrome and Node.js 24. All facts were checked on 2026-10-24. WHEP is therefore labelled a draft everywhere on this page: its details may still change, while WHIP is stable.
What it is and who uses it
Of the three applications in this section, WHIP and WHEP matter most to Huddle, the videoconference, and only at its edges. Inside a call, participants talk to an SFU through the server’s own signalling (see mediasoup and LiveKit). WHIP and WHEP appear when Huddle goes beyond the call:
-
A host streams the camera of a "town hall" to thousands of passive viewers. The host publishes with WHIP, and the audience plays with WHEP or, when the audience is very large, with HLS.
-
A producer pushes a mixed programme from OBS Studio into Huddle’s media server with WHIP, instead of RTMP.
-
A recording or compositing worker plays a room with WHEP and writes a file (see Recording and Media Processing).
Chatterbox (chat) does not need either protocol. Pixel Arena (game) could stream a match to spectators with WHEP, but the game state itself travels over WebSockets or data channels, not over video.
The problem WHIP and WHEP solve is old. Before them, every WebRTC service invented its own signalling: a WebSocket protocol, a REST call, a proprietary SDK. A broadcaster that wanted to send a stream to a service had to use RTMP, whose latency is measured in seconds and whose codecs are limited, because RTMP was the only protocol every encoder and every server understood. WHIP gives encoders a standard way to speak WebRTC, so OBS Studio, hardware encoders, browsers and servers can interoperate without a custom SDK on either side.
The two protocols in one picture
Both protocols have the same shape. The client creates an RTCPeerConnection, makes an offer, and sends it with POST to a fixed
endpoint URL. The server creates a session (a resource), answers, and returns the URL of the session in a Location
header. The URL of the session is then used to trickle candidates and to end the session.
The signalling channel is plain HTTP and has no state of its own. That is the main design point: a WHIP or WHEP endpoint can
sit behind a normal HTTP load balancer, be authenticated with ordinary HTTP mechanisms, and be called with curl or
fetch(). The media path is unchanged from any other WebRTC connection, which also means the same NAT problems and the
same TURN requirements described in ICE, STUN and TURN.
WHIP in detail (RFC 9725)
RFC 9725 is titled "WebRTC-HTTP Ingestion Protocol (WHIP)". It updates RFC 8840 (trickle ICE SDP fragments) and RFC 8842 (DTLS in SDP). Its rules, grouped by phase:
Session setup. The client generates an initial offer by the JSEP rules of RFC 9429 and sends it with POST to the endpoint, using
Content-Type: application/sdp. The server answers 201 Created with Content-Type: application/sdp, the SDP answer
in the body and a Location header naming the new session. A wrong content type or malformed SDP gets a 4xx. The client
should offer sendonly (or sendrecv), never recvonly or inactive, and the server answers recvonly.
Constraints on WebRTC. To keep servers simple the RFC restricts what a publisher may do:
-
Everything is bundled (RFC 9143) with
max-bundle, RTP and RTCP are multiplexed, and each bundledm=section carriesa=rtcp-mux-only. -
There is a single
MediaStream: everym=section has the samemsidstream id, with at most one audio and one video track. A server that cannot accept the track layout rejects the wholePOSTwith422or400rather than answering partially. -
The client sends
a=setup:actpass, and may sendactiveif it only implements the DTLS client role. -
The client must implement full ICE; a server on a public address may use ICE lite. Trickle ICE and ICE restarts are optional but recommended.
-
Simulcast (RFC 8853) is allowed and negotiated in the SDP; scalable video coding is left to encoder and decoder agreement. See Codecs, Simulcast and SVC.
Trickle ICE and ICE restarts. The client sends PATCH to the session URL with
Content-Type: application/trickle-ice-sdpfrag (an RFC 8840 fragment holding a=ice-ufrag, a=ice-pwd, a=mid and
a=candidate lines, or a=end-of-candidates). The server returns 204. A session that supports one feature but not the
other answers 422. Because overlapping PATCH requests may arrive out of order, a server that supports ICE restarts
returns a strong ETag for the current ICE session and the client sends it back; a missing tag gives 428 and a stale
one 412.
Termination. The client sends DELETE to the session URL. A server must not depend on it, because browsers crash and
networks drop: it releases the session when ICE consent freshness (RFC 7675) expires or the DTLS session closes.
ICE servers. The 201 Created response may carry one Link header per STUN or TURN server, with the relation type ice-server and, for TURN, username and credential attributes:
Link: <stun:stun.example.net>; rel="ice-server"
Link: <turn:turn.example.net?transport=udp>; rel="ice-server"; username="user"; credential="example-secret"
Authentication. Every WHIP entity must support HTTP authentication, and bearer tokens (RFC 6750) are the mandatory
baseline. The token is the WHIP equivalent of an RTMP stream key and travels in an Authorization: Bearer <token> header on every
request except the CORS preflight. A server may instead encode a token in the endpoint or session URL.
Errors. A client must handle any status by its class (4xx, 5xx) and must not rely on a specific code meaning a
specific cause. A server may add an RFC 9457 problem-details JSON body. A GET to an endpoint or session returns 2xx with no content.
WHEP in detail (draft-ietf-wish-whep)
The current draft is titled "WebRTC-HTTP Egress Protocol (WHEP)", revision 05, from the same IETF WISH working group.
It mirrors WHIP with the directions reversed: the player offers recvonly and the server answers sendonly. The parts
that differ from WHIP are worth knowing, because they are the ones most likely to change before publication:
-
Discoverability. A generic player can recognise a WHEP URL the way it recognises
.m3u8. The draft lets the server answerHEADwith200,Content-Type: application/sdpandLink: <urn:ietf:params:whep>; rel=profile(aprofilerelation to the WHEP URN). Players must not identify a URL from the content type alone. -
Server counter-offer. A server may reject the player’s offer with
406 Not Acceptableand its own SDP offer in the body, withLocationpointing at the pending session. The player then answers withPATCHandContent-Type: application/sdp, and the server returns204. A406without a valid SDP offer is an ordinary error. -
Partial acceptance. The server may accept some
m=sections and reject others by setting their port to 0, for example to refuse a data channel, but at least one audio or video section must stay. -
Live gating. If a stream must already be live, the server answers
409 ConflictwithRetry-After, and the player retries after that many seconds. (MediaMTX 1.21.2 instead answered404with a JSON error naming the missing stream when nobody was publishing.) -
Error handling. A
POSTwith a content type other thanapplication/sdpgets415in the draft, which is more specific than WHIP’s generic4xx. MediaMTX 1.21.2 answered400for it, so a player must treat codes by class. -
Extensions. Optional features are announced in
Linkheaders whoserelis a registeredurn:ietf:params:whep:ext:<name>value.
Trickle ICE, ETag handling, bearer tokens, DELETE and consent-freshness teardown are the same as in WHIP.
A minimal exchange on the wire
The following exchange was captured from MediaMTX 1.21.2 while Chrome published to /huddle-stage/whip (the SDP bodies
are shortened here). It shows the CORS preflight answer, the session creation and the trickle PATCH accepted by the server.
OPTIONS /huddle-stage/whip HTTP/1.1
Origin: http://localhost:41610
HTTP/1.1 204 No Content
Accept-Post: application/sdp
POST /huddle-stage/whip HTTP/1.1
Content-Type: application/sdp
v=0
o=- 8942905374630766124 2 IN IP4 127.0.0.1
...
a=sendonly
...
HTTP/1.1 201 Created
Content-Type: application/sdp
Location: /huddle-stage/whip/77cc24a7-da78-4a74-ab30-af70b085006a
Accept-Patch: application/trickle-ice-sdpfrag
ETag: *
v=0
...
a=recvonly
...
PATCH /huddle-stage/whip/77cc24a7-da78-4a74-ab30-af70b085006a HTTP/1.1
Content-Type: application/trickle-ice-sdpfrag
a=ice-ufrag:jBtI
a=ice-pwd:t1NlcAYOyhzR900zQlIw26tG
m=video 9 RTP/AVP 0
a=mid:0
a=end-of-candidates
HTTP/1.1 204 No Content
Two details deserve attention. The Location value is relative, so a client resolves it against the endpoint URL
(new URL(location, endpoint)) instead of concatenating strings. And this server returned ETag: * and no Link: ice-server headers, so
a deployment that needs TURN must configure the client’s iceServers itself.
Running the examples
The examples use MediaMTX, an open-source media server written in Go that exposes WHIP, WHEP, HLS, RTSP, RTMP and SRT from
one binary. Its WebRTC listener serves POST /<path>/whip and POST /<path>/whep. The Compose file below pins the image,
turns off the protocols the page does not use, and publishes the HTTP port (41600), the WebRTC media port (41601, UDP and TCP) and the
HLS port (41602). The webrtcAdditionalHosts value tells MediaMTX to advertise 127.0.0.1 as an ICE candidate, which a
browser on the same machine needs when the server runs in a container.
name: g4-whip-whep
services:
media:
image: bluenviron/mediamtx:1.21.2
container_name: g4-whip-media
environment:
MTX_RTSP: "no"
MTX_RTMP: "no"
MTX_SRT: "no"
MTX_API: "no"
MTX_WEBRTCADDRESS: ":8889"
MTX_WEBRTCLOCALUDPADDRESS: ":41601"
MTX_WEBRTCLOCALTCPADDRESS: ":41601"
MTX_WEBRTCADDITIONALHOSTS: "127.0.0.1"
MTX_WEBRTCALLOWORIGINS: "http://localhost:41610"
ports:
- "41600:8889"
- "41601:41601/udp"
- "41601:41601/tcp"
- "41602:8888"
The default MediaMTX configuration lets anyone publish or read any path, so this stack is for a development machine. In production, configure its authentication (internal users, an HTTP hook or JWT) before exposing the port. In a real deployment the WebRTC port must also be reachable by clients: the container advertises candidates, and the host firewall and any NAT must forward the same UDP and TCP port (see Scaling WebRTC and TURN).
Example: Huddle host publishes with WHIP
This module is Huddle’s "go live" button. It captures the camera and microphone, adds each track as a sendonly
transceiver (so the offer has the direction WHIP requires), waits for ICE gathering to finish so the single POST
carries every candidate, and keeps the session URL for the later DELETE. Waiting for gathering is the simplest variant
and works with every WHIP server, at the cost of a slower start, since the offer is not sent until the STUN lookup finishes.
// publisher.js: Huddle "go live" button, publishing the camera to a WHIP endpoint (RFC 9725).
const WHIP_URL = 'http://localhost:41600/huddle-stage/whip';
const TOKEN = 'change-me'; // placeholder; use a short-lived token issued by your backend
let pc;
let resource; // absolute URL of the WHIP session returned in Location
function waitForGathering(peer) {
if (peer.iceGatheringState === 'complete') return Promise.resolve();
return new Promise((resolve) => {
peer.addEventListener('icegatheringstatechange', () => {
if (peer.iceGatheringState === 'complete') resolve();
});
});
}
export async function goLive(videoElement) {
const media = await navigator.mediaDevices.getUserMedia({
video: { width: 1280, height: 720, frameRate: 30 },
audio: true,
});
videoElement.srcObject = media;
pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
for (const track of media.getTracks()) {
pc.addTransceiver(track, { direction: 'sendonly', streams: [media] });
}
await pc.setLocalDescription(await pc.createOffer());
await waitForGathering(pc); // non-trickle: the offer carries every candidate
const response = await fetch(WHIP_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp', Authorization: `Bearer ${TOKEN}` },
body: pc.localDescription.sdp,
});
if (response.status !== 201) {
throw new Error(`WHIP endpoint answered ${response.status}: ${await response.text()}`);
}
resource = new URL(response.headers.get('Location'), WHIP_URL).href;
await pc.setRemoteDescription({ type: 'answer', sdp: await response.text() });
return resource;
}
export async function stopLive() {
if (resource) {
await fetch(resource, { method: 'DELETE', headers: { Authorization: `Bearer ${TOKEN}` } });
resource = undefined;
}
pc?.close();
pc = undefined;
}
A page that loads it needs a secure context for getUserMedia, so http://localhost is fine and a LAN address is not:
<!doctype html>
<meta charset="utf-8">
<title>Huddle: go live</title>
<video id="local" autoplay muted playsinline></video>
<script type="module">
import { goLive, stopLive } from './publisher.js';
window.goLive = () => goLive(document.getElementById('local'));
window.stopLive = stopLive;
</script>
Trickle ICE variant
With trickle ICE the POST goes out immediately after setLocalDescription(), and candidates follow as PATCH requests.
Candidates found before the 201 arrives must be held back, because the session URL does not exist yet. The helper below builds the
RFC 8840 fragment from the ICE username fragment and password of the local description.
// publisher-trickle.js: the same publisher, with trickle ICE over HTTP PATCH.
export async function goLiveTrickle(whipUrl) {
const media = await navigator.mediaDevices.getUserMedia({ video: true });
const pc = new RTCPeerConnection();
media.getTracks().forEach((track) => pc.addTransceiver(track, { direction: 'sendonly' }));
let resource;
const pending = [];
const credentials = () => ({
ufrag: /a=ice-ufrag:(\S+)/.exec(pc.localDescription.sdp)[1],
pwd: /a=ice-pwd:(\S+)/.exec(pc.localDescription.sdp)[1],
});
const sendCandidate = async (candidate) => {
const { ufrag, pwd } = credentials();
const mid = candidate ? candidate.sdpMid : '0';
const line = candidate ? `a=${candidate.candidate}` : 'a=end-of-candidates';
const fragment = `a=ice-ufrag:${ufrag}\r\na=ice-pwd:${pwd}\r\nm=video 9 RTP/AVP 0\r\na=mid:${mid}\r\n${line}\r\n`;
await fetch(resource, {
method: 'PATCH',
headers: { 'Content-Type': 'application/trickle-ice-sdpfrag' },
body: fragment,
});
};
pc.onicecandidate = (event) => {
if (resource) sendCandidate(event.candidate);
else pending.push(event.candidate);
};
await pc.setLocalDescription(await pc.createOffer());
const response = await fetch(whipUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp,
});
resource = new URL(response.headers.get('Location'), whipUrl).href;
await pc.setRemoteDescription({ type: 'answer', sdp: await response.text() });
for (const candidate of pending) await sendCandidate(candidate);
return { pc, resource };
}
Example: a Huddle viewer plays with WHEP
The player mirrors the publisher. It creates recvonly transceivers for audio and video, so that the offer asks the
server to send both. A WHEP offer has no tracks of its own, so the transceivers are what make the m= sections exist. It attaches arriving tracks to one
MediaStream for the <video> element. The element is muted so that autoplay is allowed without a user gesture.
// player.js: Huddle viewer playing a stream through WHEP (draft-ietf-wish-whep).
const WHEP_URL = 'http://localhost:41600/huddle-stage/whep';
let pc;
let resource;
function waitForGathering(peer) {
if (peer.iceGatheringState === 'complete') return Promise.resolve();
return new Promise((resolve) => {
peer.addEventListener('icegatheringstatechange', () => {
if (peer.iceGatheringState === 'complete') resolve();
});
});
}
export async function watch(videoElement) {
pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
const remote = new MediaStream();
pc.ontrack = (event) => {
remote.addTrack(event.track);
videoElement.srcObject = remote;
};
await pc.setLocalDescription(await pc.createOffer());
await waitForGathering(pc);
const response = await fetch(WHEP_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp,
});
if (response.status !== 201) {
throw new Error(`WHEP endpoint answered ${response.status}: ${await response.text()}`);
}
resource = new URL(response.headers.get('Location'), WHEP_URL).href;
await pc.setRemoteDescription({ type: 'answer', sdp: await response.text() });
return resource;
}
export async function stopWatching() {
if (resource) await fetch(resource, { method: 'DELETE' });
resource = undefined;
pc?.close();
pc = undefined;
}
The matching page is the publisher page with the import changed:
<!doctype html>
<meta charset="utf-8">
<title>Huddle: watch</title>
<video id="remote" autoplay muted playsinline></video>
<script type="module">
import { watch, stopWatching } from './player.js';
window.watch = () => watch(document.getElementById('remote'));
window.stopWatching = stopWatching;
</script>
Example: verify publish then play from a script
Both pages need a camera and a microphone. A headless Chrome supplies fake devices, which makes the whole scenario
repeatable in a CI job. The script below serves the two pages and their modules from a tiny Node.js HTTP server on
localhost:41610, opens the publisher in one tab, starts the player in another, and reads the size of the received
video. The package.json pins the one dependency:
{
"type": "module",
"dependencies": { "puppeteer-core": "24.31.0" }
}
// server.js: serves ./site on http://localhost:41610 (a secure context for getUserMedia).
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
const types = { '.html': 'text/html', '.js': 'text/javascript' };
createServer(async (req, res) => {
try {
const file = join('site', req.url === '/' ? 'publisher.html' : req.url);
const body = await readFile(file);
res.writeHead(200, { 'Content-Type': types[extname(file)] ?? 'text/plain' });
res.end(body);
} catch {
res.writeHead(404).end();
}
}).listen(41610);
// drive.mjs: publish with WHIP in one tab, play with WHEP in another, check that video arrives.
import puppeteer from 'puppeteer-core';
const browser = await puppeteer.launch({
executablePath: process.env.CHROME_PATH, // path to a Chrome or Chromium binary
headless: 'new',
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
});
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const publisher = await browser.newPage();
await publisher.goto('http://localhost:41610/publisher.html');
console.log('WHIP session', await publisher.evaluate(() => window.goLive()));
await sleep(2000);
const player = await browser.newPage();
await player.goto('http://localhost:41610/player.html');
console.log('WHEP session', await player.evaluate(() => window.watch()));
await sleep(4000);
const received = await player.evaluate(() => {
const video = document.getElementById('remote');
return { width: video.videoWidth, height: video.videoHeight, seconds: video.currentTime };
});
console.log(received); // expected: 1280 x 720 and a positive currentTime
await player.evaluate(() => window.stopWatching());
await publisher.evaluate(() => window.stopLive());
await browser.close();
docker compose -p g4-whip-whep up -d
node server.js &
CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" node drive.mjs
docker compose -p g4-whip-whep down -v
The run printed a WHIP and a WHEP session URL and a received video of 1280 by 720 pixels with a growing currentTime,
which proves the media path (ICE, DTLS and RTP through the server) and not only the HTTP exchange. Two console lines about a
404 are the browser asking for favicon.ico.
Pushing from OBS Studio and other encoders
OBS Studio can publish with WHIP: in Settings, Stream, choose the service "WHIP", paste the endpoint URL into the Server
field and the bearer token into the Bearer Token field. OBS calls the token the WHIP name for a stream key. For the
MediaMTX stack above the endpoint is http://localhost:41600/huddle-stage/whip. OBS’s guide lists the codec flexibility of WebRTC
(AV1, H.265 and Opus) among the reasons to prefer it to RTMP, and it also lets the encoder bypass any server-side
transcoding. For an HLS output, publish H.264; see the comparison section below.
WHIP and WHEP versus HLS and DASH
WebRTC and segmented HTTP streaming solve different halves of the same problem, and Huddle uses both.
| WebRTC (WHIP in, WHEP out) | HLS and DASH | |
|---|---|---|
Transport |
SRTP over UDP (TCP and TURN as fallbacks), one connection per viewer |
Files over HTTP, any CDN, any proxy |
Typical latency |
Well under a second, because frames are sent as soon as encoded |
Classic HLS and DASH: several segment durations, commonly 6 to 30 seconds. Low-Latency HLS and low-latency DASH: roughly 2 to 5 seconds |
Scale |
The server keeps state per viewer and sends each viewer a separate stream, so capacity grows with servers |
A CDN caches each segment once, so a million viewers cost no more origin load than one |
Adaptive bitrate |
Per-viewer congestion control and simulcast layers chosen by the server |
The player picks a rendition from the manifest |
Interactivity |
Viewers can become participants (publish back) |
One-way only |
Codecs |
Those the browsers negotiate: VP8, VP9, H.264, AV1, Opus |
H.264, HEVC, VP9, AV1 with AAC, Opus, depending on the player |
Failure mode |
ICE failures behind strict firewalls need TURN |
Almost none; HTTP always works |
The rule of thumb for Huddle follows from the table. A call, a panel with questions from the audience or an auction needs sub-second latency, so it stays on WebRTC. A broadcast to a large audience that only watches is cheaper and more robust on HLS from a CDN. Many products combine them: WHIP or WebRTC ingest from the host, then a media server repackages the stream into HLS for the large audience and keeps WebRTC for the first few hundred viewers and the interactive guests.
The same MediaMTX instance shows the repackaging. While Chrome published over WHIP, GET /huddle-stage/index.m3u8 on the HLS port
returned a playlist (version 10, low-latency variant). Its only rendition was Opus audio: MediaMTX’s HLS muxer carries
H.264, VP9 and AV1 but not the VP8 that Chrome’s addTransceiver() negotiated first, so the video was left out. To
feed HLS from a browser, the publisher must prefer H.264, for example with setCodecPreferences() on the transceiver (see
Codecs, Simulcast and SVC).
For how to package and serve segmented streams, see Video streaming: HLS, DASH and CMAF and Video encoding fundamentals. For the player side (hls.js, dash.js, Shaka Player, MSE) see Video Playback and Streaming in the Browser. The comparison with other low-latency transports is on WebTransport and Media over QUIC.
Source: RFC 8216: HTTP Live Streaming
Into an SFU: LiveKit, Janus and mediasoup
A WHIP endpoint is just an HTTP front for something that terminates WebRTC, so every SFU can sit behind one. How much work that is differs by server:
-
LiveKit ships it. LiveKit Ingress is a separate service that exposes RTMP and WHIP endpoints. The application creates an ingress with the
CreateIngressAPI, which returns a URL and stream key for the encoder. The service validates the request, transcodes the input (or forwards it unchanged when transcoding is disabled) and joins the LiveKit room as a participant that publishes the media, so room clients such as Huddle’s receive it like any other track. See LiveKit. -
Janus has no built-in HTTP endpoint but has a companion: the Meetecho
simple-whip-serverNode.js library (npm packagejanus-whip-server) implements the RFC 9725 endpoint on top of Janus through Janode, and a siblingsimple-whep-serverdoes the same for WHEP. See Janus and Jitsi. -
mediasoup is a library, and it leaves signalling to the application by design. A WHIP endpoint is application code: an HTTP route that would translate the SDP offer into a transport and one producer per track, and build the answer from the transport’s ICE and DTLS parameters. mediasoup’s own documentation says it is up to the application to carry that exchange over WebSocket, HTTP or any other means. See mediasoup.
-
Standalone media servers such as MediaMTX, used above, are the shortest path when the goal is only ingest and playback with no rooms.
An SFU that accepts WHIP sees the publisher as one more producer, so everything else on Topologies: Mesh, SFU and MCU applies: the server forwards simulcast layers, and a viewer pulls them either with the SFU’s client SDK or, where offered, with WHEP.
Source: LiveKit docs: Ingress overview
Source: Meetecho: Simple WHIP Server
Lifecycle, pitfalls and common errors
-
Wrong content type. The
POSTmust beapplication/sdp.fetch()with a string body defaults totext/plain, and the server answers4xx(MediaMTX:400 invalid Content-Type). -
Malformed SDP. MediaMTX answers
400with the position of the syntax error. A server may also reject an offer that breaks the constraints above, such as several video tracks or arecvonlysection, with400or422. -
No stream yet. A WHEP
POSTbefore the publisher connects returns404on MediaMTX, or409withRetry-Afteron servers following the draft. A player should retry with a delay rather than fail. -
CORS. A browser publisher or player sends a cross-origin request with the non-simple content type
application/sdp, so the server must answer the preflightOPTIONS(the endpoints advertiseAccept-Post: application/sdp) and allow the page’s origin. MediaMTX’swebrtcAllowOriginsdefaults to all origins; restrict it as in the Compose file. Do not expect anAuthorizationheader on the preflight. -
Relative Location. Resolve it against the endpoint URL. Some servers return an absolute URL on another host, which is how a load balancer redirects the session to a specific node.
-
Container networking. The server advertises the container’s addresses. A browser on the host connects only if the server advertises a reachable address (
webrtcAdditionalHosts) and the UDP port is published with the same number inside and outside the container. -
Stuck sessions. A tab that closes without
DELETEleaves a session until ICE consent expires, a matter of seconds. SendDELETEonpagehideusingfetch()withkeepalive: truefor a faster cleanup. -
Reconnecting. A WHIP session cannot renegotiate its tracks. When the network changes, the client either performs an ICE restart through
PATCH(if the server supports it) or ends the session withDELETEand publishes again. -
HLS shows only audio. The codec is one the packager does not support; see the previous section.
Security
-
Tokens. Treat a WHIP bearer token like a stream key: whoever has it can publish to that path. Issue short-lived tokens from your backend, scope them to one path, and never embed a long-lived one in a public page. Keep tokens out of URLs that get logged, even though the RFC allows a token inside the URL.
-
HTTPS. Both specifications require HTTPS, because the SDP contains the DTLS fingerprint that authenticates the media. An attacker who can rewrite the SDP can sit in the middle of the media path. The local
http://localhostsetup above is for development only. -
Guessable session URLs. Anyone who knows a session URL can
DELETEit. Servers must generate unguessable identifiers (the UUID in the capture above) and rate-limitPOST,PATCHandDELETE. -
Resource exhaustion. Each
POSTallocates ICE and DTLS state that lives until a timeout, so a valid token holder can flood the endpoint. Rate-limit per token. -
Open servers. A default MediaMTX or Janus install allows anonymous publishing. Anything exposed to the internet needs its authentication enabled first.
Version and specification notes
-
WHIP was developed in the IETF WISH working group and became RFC 9725 in March 2025, authored by Sergio Garcia Murillo and Alexandre Gouaillard.
-
WHEP comes from the same working group and is still an Internet-Draft, now at revision 05 (October 2026). Treat any WHEP behaviour beyond the basic
POST,201,DELETEflow as possibly changing, and prefer a server and a player from the same vendor if you rely on the optional features. -
RFC 9725 relies on JSEP in RFC 9429 (which obsoletes RFC 8829) and on BUNDLE in RFC 9143 (which obsoletes RFC 8843).
See also WebRTC Architecture for where signalling sits in the stack, Signaling and Perfect Negotiation for the WebSocket alternative, and Building a Videoconference App for the complete Huddle application.