Live Streaming (VP Live) API Documentation Author : Josh S. Sakweli, Backend Lead Team Last Updated : 2026-08-20 Version : v1.0 Base URL : http://localhost:8765/api/v1 (local) — server.port=8765 Short Description : VP Live is one-to-many broadcasting: a host publishes, an audience watches over HLS through the CDN. Four surfaces sit here — Streams (lifecycle and ingest), Comments (the live overlay), Attachments (the commerce shelf) and Spaces (many speakers, an HLS audience). Media goes host → SRS → CDN → viewers; the backend never carries a frame. Hints : Every endpoint needs Authorization: Bearer unless noted. There is no "go live" endpoint. A host asks for an ingest key and publishes; the stream turns LIVE when SRS reports media arriving . A client never sets state — the media server is the authority. playbackUrl is null until media actually flows . Handing it out earlier gives a player a URL that 404s, which reads as broken rather than as not-started-yet. The comment read endpoint is CDN-cached and returns a bare array , not the platform envelope. It is the one endpoint served at volume. Refusals are 409 with a code ( 404 for NOT_FOUND ). Standard Response Format { "success": true, "httpStatus": "OK", "message": "Stream created", "action_time": "2026-08-20T10:30:45", "data": { } } Refusals 409 CONFLICT (or 404 when the code is NOT_FOUND ). For streams, attachments and comments the envelope message carries the code : { "success": true, "httpStatus": "CONFLICT", "message": "NOT_HOST", "data": { "code": "NOT_HOST", "reason": "This is not your stream" } } Spaces are shaped the other way round — message is the prose and data holds only { "code": … } . Read data.code in both cases and you are safe. Part 1 — Streams api/v1/live/streams Why there is no "go live" button HOST BACKEND SRS VIEWERS | | | | |-- POST /live/streams -------->| DRAFT | | | {title, mode} | | | | | | | | [ device-check screen; host is actually ready ] | | |-- POST /{id}/ingest-key ----->| mint key | | |<-- {ingestKey, whipUrl, rtmpUrl, expiresAt} | | | | | | |======== publish (WHIP or RTMP) ====================>| | | |<-- hooks /publish --| | | | state STARTING | (nothing to play)| | | | | | |<-- hooks /hls ------| first segment closed | | state LIVE | | | | announce + notify ====================> | | | | playbackUrl now set | | | | |-- POST /{id}/end ------------>| ENDED | | | |<-- hooks /dvr ------| recording ready | | | PROCESSING -> REPLAY_READY | The point to make in a demo: STARTING exists so that announcing a stream never sends followers to an empty player. RTMP takes ~8 seconds to produce a first segment, and WHIP can take a minute. LIVE is the first moment the word is true for anybody except the host. Stream state machine DRAFT ─────┐ ├──> STARTING ──> LIVE ──> ENDED ──> PROCESSING ──> REPLAY_READY SCHEDULED ─┘ └──> FAILED └──(abandoned ~1 day, silently)──> ENDED State Meaning DRAFT Being set up. Nothing is live and nothing costs anything SCHEDULED Booked for later. Announced on entry and again when it opens STARTING Publisher reached SRS, but no segment has closed — deliberately not live LIVE A segment exists ENDED The broadcaster stopped. Also where an abandoned schedule lands, silently — a stream that announces its own abandonment is worse than one that quietly started late PROCESSING Recording handed to File Thunder REPLAY_READY Replayable; replayMediaId populated FAILED Setup failed, or the recording could not be processed REPLAY_READY rather than VOD_READY on purpose: "video on demand" is wrong for AUDIO_RADIO , which has no video, and a state named after a format the mode does not produce quietly teaches the next reader something false. Modes StreamMode Shape SRS app LIVE_VIDEO One broadcaster, video and audio (default) live AUDIO_RADIO One broadcaster, audio only radio Radio publishes into its own SRS app , which is what scopes the 32 kbps transcode to the streams that actually need it. Mode is chosen at creation and never changed — switching mid-session renegotiates every participant's media and resets every HLS player. (Spaces used to be a third mode. They now have their own entity and their own endpoints — see Part 4.) POST /api/v1/live/streams — Create Setup only. Nothing is live and nothing costs anything — the host may fill it in, leave, and come back. Request ( CreateStreamRequest ): Field Type Required Description title string yes Unlike a call, a stream is something people find and choose description string no coverMediaId UUID no scheduledFor ISO-8601 no Absent → DRAFT (about to start). Future → SCHEDULED , which announces itself and reminds people mode enum no LIVE_VIDEO if unset Returns StreamResponse . POST /api/v1/live/streams/{streamId}/ingest-key — Get broadcast credentials Called from the device-check screen, once the host is actually ready. This is the only place an ingest key is ever returned. Host only. Response: { "streamId": "…", "ingestKey": "…", "expiresAt": "2026-08-20T…", "whipUrl": "http://127.0.0.1:1985/rtc/v1/whip/?app=live&stream=", "rtmpUrl": "rtmp://127.0.0.1/live/" } The stream name a publisher uses is the key itself — that is what lets SRS hand it to us for checking without any other channel. POST /api/v1/live/streams/{streamId}/end — End the broadcast Host only. Returns StreamResponse with state: ENDED . PUT /api/v1/live/streams/{streamId}/reminder — Remind me Param In Default wanted query true A PUT with wanted=false rather than a DELETE route, because "remind me" is one toggle in the UI. GET /api/v1/live/streams/{streamId} — Read a stream StreamResponse Field Type Notes id UUID mode enum state enum hostUserId UUID title , description string coverMediaId UUID scheduledFor ISO-8601 playbackUrl string null unless LIVE or REPLAY_READY audioOnly boolean startedAt , endedAt ISO-8601 peakViewers int replayMediaId UUID Note what is absent: ingestKey . It is a broadcast credential, returned only from the endpoint the host calls to get one, and never carried on an object that viewers also read. playbackUrl is gated on state , not on replayMediaId being set — that id is stored the moment the recording is handed to File Thunder, so during PROCESSING it exists but points at nothing playable yet. Stream refusal codes Code HTTP When NOT_FOUND 404 no such stream TITLE_REQUIRED 409 no title NOT_HOST 409 host-only verb NOT_SCHEDULED 409 the stream is not in a schedulable state STREAM_FINISHED 409 acting on a stream that is over NO_ACCOUNT 409 no account behind the caller ACCOUNT_TOO_NEW 409 anti-abuse: account age gate on going live AGE_RESTRICTED 409 Part 2 — Live comments api/v1/live/streams/{streamId}/comments A write endpoint and a read endpoint, and the difference between them is the whole design. viewer ──POST──> origin (per viewer, rate limited, moderated) 10 000 viewers ──GET──> [ CDN, 2s cache ] ──one read──> origin The write is per viewer and rate limited. The read is the same response for everybody , cached at the CDN for two seconds — one origin read serving thousands. That is why it takes no parameters: a since cursor would give every viewer a unique URL and turn the cache off. The client drops what it has already rendered using the per-stream monotonic seq . POST …/comments — Post a comment Request: { "body": "…" } Refuses unless the stream is LIVE . Then moderation runs: Verdict / code Message NOT_LIVE This stream is not live MUTED You cannot comment on this stream TOO_FAST Slow down SLOW_MODE Slow mode is on FILTERED That comment was not posted TOO_LONG That comment is too long EMPTY Say something On success returns the posted LiveComment to the poster , even though the read window may sample it away for everyone else. A viewer always sees their own comment — it is what makes the feature feel alive when a comment reached nobody. GET …/comments — Read the overlay No parameters. Identical for every viewer. Cache-Control: public, max-age=2 . Deliberately not wrapped in the platform envelope — the payload is the comments: [ { "seq": 412, "userId": "…", "name": "Mama Yoyo", "body": "bei gani?", "at": 1755680000000, "system": false, "event": null, "refId": null } ] Field Type Notes seq long Per-stream, monotonic. The client drops what it already rendered userId , name string The name is carried inline because a cached read cannot resolve names per viewer body string at long epoch millis system boolean System events share the stream — someone joined, the host pinned something event string A key, not a sentence. The server writing English would ship untranslatable text into a Swahili UI refId string What the event refers to A comment is deliberately not shaped like a chat message: no conversation, no recipient, no delivery guarantee, no id the sender can retry against. Host tools Method Path Params PUT …/comments/mute/{userId} muted (default true ) PUT …/comments/slow-mode seconds (0 turns it off) Both host-only; a non-host gets 409 NOT_HOST . Part 3 — Attachments (the commerce shelf) api/v1/live/streams/{streamId}/attachments Host verbs are attach, pin, unpin and remove; the only viewer verbs are reading the shelf and tapping through. Pinning is a live act — most attachments arrive while the host is talking, not at setup. AttachmentState Meaning SHELF Attached and browsable, not on screen. Unbounded PINNED On screen right now. Exactly one per stream — that is what tells a viewer arriving mid-stream which thing is being talked about now REMOVED Taken down. Kept rather than deleted so a replay still shows what was on screen at the time AttachmentType PRODUCT something to buy SHOP a duka to visit — "this is where I get these" EVENT TICKET also bought, so attribution is not product-only POST an earlier post, for context LINK All types are equal — attach any number, of any type, from any shop, at any point in the stream's life. Method Path Who Description POST …/attachments host Body { "type": "PRODUCT", "targetId": "…" } GET …/attachments anyone Everything attached and not removed, in the order it was added PUT …/attachments/{attachmentId}/pin host Puts it on screen DELETE …/attachments/pin host Clears the pin DELETE …/attachments/{attachmentId} host Removes it POST …/attachments/{attachmentId}/tap viewer Records a tap-through AttachmentResponse Field Notes id , type , targetId , state streamOffsetMs The client applies a pin at this playback position rather than on arrival — the whole point of recording it pinnedAt taps Counted apart from orders — interest is not money, and the post-stream figures keep them separate so the number a seller reads means something Part 4 — Spaces api/v1/live/spaces — many speakers, audio, an HLS audience. The cost model, which is the whole design HOST + COHOSTs + SPEAKERs ──WebRTC──> [ LiveKit SFU ] | Egress mixes the stage | RTMP ──> [ SRS ] ──> HLS ──> [ CDN ] | LISTENERS ────────────────────────┘ Note which endpoints return a token and which do not. Opening the stage and being promoted return one; joining as a listener never does. That asymmetry IS the cost model — a token is what would put the audience on the SFU. An audience of ten thousand on LiveKit would need ten thousand SFU downstreams; on HLS it is one CDN origin. SpaceRole On the SFU? HOST yes COHOST yes — may also change the stage SPEAKER yes LISTENER no — HLS through the CDN like any other viewer Stage cap: 10 by default ( app.live.space-stage-limit ). A product limit, not an infrastructure one — Egress was measured carrying 10+ concurrent Spaces. A conversation with twenty people talking is not a conversation. SpaceState mirrors the stream states: DRAFT , SCHEDULED , STARTING , LIVE , ENDED , PROCESSING , REPLAY_READY , FAILED . Same rule — LIVE happens because SRS reported the mixed audio arrived , not because the host's app said so. Endpoints Method Path Returns a token? Description POST /live/spaces no Body { title, description?, scheduledFor? } GET /live/spaces/{spaceId} no playbackUrl is null until LIVE POST /live/spaces/{spaceId}/stage yes Host opens the stage. Does not make the Space LIVE — SRS does that POST /live/spaces/{spaceId}/join no, on purpose Join as a listener; play the HLS URL PUT /live/spaces/{spaceId}/hand no `raised=true PUT /live/spaces/{spaceId}/speakers/{userId} yes Promote to speaker DELETE /live/spaces/{spaceId}/speakers/{userId} no Move back to the audience DELETE /live/spaces/{spaceId}/me no Leave GET /live/spaces/{spaceId}/participants no userId , role , handRaised , joinedAt POST /live/spaces/{spaceId}/end no The riskiest thing in this feature: the token returned by promote is a permission, not a connection . Crossing from HLS playback to a LiveKit publish without a gap in audio is the client's problem. Space refusal codes Code When NOT_FOUND no such Space NOT_OPEN the Space is not accepting people NOT_IN_SPACE you are not in it NOT_HOST host-only verb NOT_STAGE_CONTROL only the host or a co-host can change the stage STAGE_FULL at the stage limit ALREADY_SPEAKING already on stage CANNOT_DEMOTE_HOST Part 5 — SRS hooks (internal) api/v1/live/hooks — SRS → backend. Not for clients. These are what actually drive stream state: Hook Effect POST /live/hooks/publish Validates the ingest key; STARTING . A stream that has ended must not accept a publisher reconnecting with an old key POST /live/hooks/hls First segment closed → LIVE , announce, notify POST /live/hooks/unpublish Publisher gone → ENDED POST /live/hooks/dvr Recording ready → PROCESSING → REPLAY_READY Configuration Property Default Meaning app.live.space-stage-limit 10 Concurrent speakers in a Space app.live.rtmp-ingest-url rtmp://srs:1935 Where Egress publishes the mixed stage SRS treats Egress as an ordinary broadcaster, so the ingest key is the credential exactly as it is for a phone publishing a stream.