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.

sequenceDiagram participant P as Huddle host (WHIP client) participant S as Media server participant V as Viewer (WHEP player) P->>S: POST /huddle-stage/whip (SDP offer) S-->>P: 201 Created, Location, SDP answer P->>S: PATCH session (trickle ICE, optional) P-->>S: ICE, DTLS, SRTP media (sendonly) V->>S: POST /huddle-stage/whep (SDP offer) S-->>V: 201 Created, Location, SDP answer S-->>V: ICE, DTLS, SRTP media (recvonly) V->>S: DELETE session P->>S: DELETE 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 bundled m= section carries a=rtcp-mux-only.

  • There is a single MediaStream: every m= section has the same msid stream id, with at most one audio and one video track. A server that cannot accept the track layout rejects the whole POST with 422 or 400 rather than answering partially.

  • The client sends a=setup:actpass, and may send active if 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 answer HEAD with 200, Content-Type: application/sdp and Link: <urn:ietf:params:whep>; rel=profile (a profile relation 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 Acceptable and its own SDP offer in the body, with Location pointing at the pending session. The player then answers with PATCH and Content-Type: application/sdp, and the server returns 204. A 406 without 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 Conflict with Retry-After, and the player retries after that many seconds. (MediaMTX 1.21.2 instead answered 404 with a JSON error naming the missing stream when nobody was publishing.)

  • Error handling. A POST with a content type other than application/sdp gets 415 in the draft, which is more specific than WHIP’s generic 4xx. MediaMTX 1.21.2 answered 400 for it, so a player must treat codes by class.

  • Extensions. Optional features are announced in Link headers whose rel is a registered urn: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.

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 CreateIngress API, 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-server Node.js library (npm package janus-whip-server) implements the RFC 9725 endpoint on top of Janus through Janode, and a sibling simple-whep-server does 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.

Lifecycle, pitfalls and common errors

  • Wrong content type. The POST must be application/sdp. fetch() with a string body defaults to text/plain, and the server answers 4xx (MediaMTX: 400 invalid Content-Type).

  • Malformed SDP. MediaMTX answers 400 with the position of the syntax error. A server may also reject an offer that breaks the constraints above, such as several video tracks or a recvonly section, with 400 or 422.

  • No stream yet. A WHEP POST before the publisher connects returns 404 on MediaMTX, or 409 with Retry-After on 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 preflight OPTIONS (the endpoints advertise Accept-Post: application/sdp) and allow the page’s origin. MediaMTX’s webrtcAllowOrigins defaults to all origins; restrict it as in the Compose file. Do not expect an Authorization header 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 DELETE leaves a session until ICE consent expires, a matter of seconds. Send DELETE on pagehide using fetch() with keepalive: true for 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 with DELETE and 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://localhost setup above is for development only.

  • Guessable session URLs. Anyone who knows a session URL can DELETE it. Servers must generate unguessable identifiers (the UUID in the capture above) and rate-limit POST, PATCH and DELETE.

  • Resource exhaustion. Each POST allocates 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, DELETE flow 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.