Live Streaming (VP Live) API Documentation
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 <token>unless noted. - There is no "go live" endpoint. A host asks for an ingest key and
publishes; the stream turns
LIVEwhen SRS reports media arriving. A client never sets state — the media server is the authority. playbackUrlis 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
409with acode(404forNOT_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=<ingestKey>",
"rtmpUrl": "rtmp://127.0.0.1/live/<ingestKey>"
}
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.