VP Spaces API Documentation
Base URL: http://localhost:8765/api/v1 (local) — server.port=8765
Short Description: A Space is a live audio conversation with an audience. A small stage of speakers talks over WebRTC; everyone else listens over HLS through the CDN. It is the only feature in the platform that is two-way at the centre and one-way at the edge, and every design decision below falls out of that one fact.
Hints:
- Every endpoint needs
Authorization: Bearer <token>. - Which endpoints return a token is the cost model. Opening the stage and being promoted return one; joining as a listener never does.
- There is no "go live" call. SRS decides
LIVEwhen the mixed audio actually reaches it. A client never sets state. playbackUrlis null until media flows. Handing it out earlier gives a listener a playlist that 404s, which reads as broken rather than as not-started-yet.- Refusals are
409with a code indata.code(404shape forNOT_FOUNDis not used here — Spaces return 409 for everything, includingNOT_FOUND).
Standard Response Format
{
"success": true,
"httpStatus": "OK",
"message": "Space created",
"action_time": "2026-08-20T10:30:45",
"data": { }
}
Refusals — note the shape
409 CONFLICT. Spaces put the prose in message and the code in data —
the opposite of streams and calls, which put the code in message:
{
"success": true,
"httpStatus": "CONFLICT",
"message": "The stage is full (10)",
"action_time": "2026-08-20T10:30:45",
"data": { "code": "STAGE_FULL" }
}
Always read data.code. That is the only field consistent across all three
features. success stays true on a refusal — the envelope reports transport,
not business outcome.
SpaceDeniedException is its own type rather than borrowing VP Live's: a Space
is no longer a stream, and an exception crossing that line would quietly
re-create the coupling the entity split exists to remove.
Why a Space is not a stream
A Space started life as VP Live's third StreamMode, which made it share a
state machine, ingest keys and scheduling with Live and Radio. It now has its
own table and its own endpoints, because:
- it is a two-way conversation, so it belongs with the other two-way
features in
chat_system; - sharing the row meant
chat_systemimportinglive_mng— the boundary crossing the split removes.
What it keeps from that heritage is the shape, and deliberately so: the stage is WebRTC and the audience is HLS, so a Space still needs an ingest key (Egress publishes to SRS with it) and a playback URL (what listeners actually play). Those are not chat concepts; they are the price of an audience that does not cost per head.
The cost model — the one diagram to present
HOST ┐
COHOST ├── WebRTC ──> [ LiveKit SFU ] room: space_<spaceId>
SPEAKER ┘ │
│ Egress mixes the stage to one audio track
▼
RTMP rtmp://srs:1935/space/<ingestKey>
│
[ SRS ]
│ HLS
▼
[ CDN ]
│
LISTENER ×10 000 ────────┘ (no token, no SFU, no per-head cost)
An SFU costs linearly per subscriber. An audience of ten thousand on LiveKit would need ten thousand SFU downstreams; on HLS it is one origin read fanned out by the CDN. So listeners must never become LiveKit participants — and the enforcement is simply that they are never given a token.
SpaceRole |
On the SFU? | May change the stage? |
|---|---|---|
HOST |
yes | yes |
COHOST |
yes | yes |
SPEAKER |
yes | no |
LISTENER |
no — HLS via CDN | no |
Stage cap: 10 (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.
Lifecycle — and the trap in the middle of it
HOST BACKEND LIVEKIT SRS LISTENER
| | | | |
|-- POST /live/spaces ----->| DRAFT | | |
| | mints roomName + | | |
| | ingestKey (12h) NOW | | |
|<-- {id, state:DRAFT} -----| | | |
| | | | |
|-- POST /{id}/stage ------>| upsert HOST | | |
|<-- {token, roomName} -----| *** no egress yet *** | |
| | | | |
|==== connect with token ==>| room now EXISTS | | |
| |<-- webhook: joined --| | |
| |-- ensureEgress ----->| start mix | |
| | |==== RTMP ====>| |
| |<---- hooks /publish ---------------- | |
| | state STARTING | | |
| | | | |
| |<---- hooks /hls ------------------- | 1st segment
| | state LIVE | | |
| | playbackUrl set | | |
| | | | |
| |<-- POST /{id}/join ------------------------------ |
| |--- {spaceId} only, NO token --------------------> |
| | | | plays HLS |
| | | | |
|-- POST /{id}/end -------->| stop egress, then delete room | |
| | ENDED | | |
The trap, and it cost a debugging session: the mix is not started at
openStage. Egress cannot mix a room that does not exist, and a LiveKit room
does not come into being until the first participant connects — which has not
happened yet at openStage, because we are still minting the token they will
use to connect. Starting egress there returns 404 every time. So
ensureEgress runs off the LiveKit participant-joined webhook instead, and
is idempotent, so every later joiner is a no-op.
If egress refuses, the log says it plainly: "speakers will hear each other and nobody else will." That is the exact failure signature — a stage that works and an audience that gets nothing.
State machine
DRAFT ─────┐
├──> STARTING ──> LIVE ──> ENDED
SCHEDULED ─┘ │ ▲
└──────────────────┘ (dropped before a segment closed)
| State | Meaning | Set by |
|---|---|---|
DRAFT |
Being set up. The room does not exist yet and nothing costs anything | create |
SCHEDULED |
Booked for later | create with scheduledFor |
STARTING |
The mix is reaching SRS, but no segment has closed — a listener has nothing to play yet. Deliberately not LIVE |
SRS /publish |
LIVE |
A segment exists. The first moment a listener can actually hear it | SRS /hls |
ENDED |
Over | host end, or SRS /unpublish |
onUnpublish counts STARTING as well as LIVE: a stage that connected and
dropped before any segment closed still ended.
SpaceStatealso declaresPROCESSING,REPLAY_READYandFAILED. No code path sets them today — see Known gaps.
Endpoints
api/v1/live/spaces
| Method | Path | Token? | Who |
|---|---|---|---|
POST |
/live/spaces |
no | anyone |
GET |
/live/spaces/{spaceId} |
no | anyone |
POST |
/live/spaces/{spaceId}/stage |
yes | host |
POST |
/live/spaces/{spaceId}/join |
no, on purpose | anyone |
PUT |
/live/spaces/{spaceId}/hand |
no | participant |
PUT |
/live/spaces/{spaceId}/speakers/{userId} |
yes | host / co-host |
DELETE |
/live/spaces/{spaceId}/speakers/{userId} |
no | host / co-host |
DELETE |
/live/spaces/{spaceId}/me |
no | participant |
GET |
/live/spaces/{spaceId}/participants |
no | anyone |
POST |
/live/spaces/{spaceId}/end |
no | host |
POST /api/v1/live/spaces — Create
Request:
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | yes | |
description |
string | no | |
scheduledFor |
ISO-8601 | no | Present → SCHEDULED; absent → DRAFT |
curl -s -X POST $BASE/api/v1/live/spaces \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Friday pricing talk"}'
The host is always a person — a shop cannot hold a conversation.
roomName and ingestKey are minted up front, at creation: Egress needs
the key the moment the stage opens, and unlike a phone there is no device-check
screen to ask on. The key lives 12 hours and is single-purpose, so a leaked
key is not a standing right to publish into this Space.
GET /api/v1/live/spaces/{spaceId} — Read
Response (data):
| Field | Type | Notes |
|---|---|---|
id |
UUID | |
state |
enum | |
hostUserId |
UUID | |
title, description |
string | |
scheduledFor |
ISO-8601 | |
playbackUrl |
string | null unless LIVE |
startedAt, endedAt |
ISO-8601 |
Note what is absent: ingestKey, roomName and egressId. They are
broadcast internals and never appear on an object listeners read.
POST /api/v1/live/spaces/{spaceId}/stage — Open the stage
Host only. Registers the host as HOST and returns a LiveKit token.
Response:
{ "spaceId": "…", "roomName": "space_<spaceId>", "token": "…" }
This does not make the Space LIVE. SRS does that when the mixed audio
reaches it — a room with a host and no audio is not a broadcast, and marking it
live hands listeners a playlist that 404s.
POST /api/v1/live/spaces/{spaceId}/join — Join the audience
Returns no connection details, deliberately:
{ "spaceId": "…" }
The listener plays the playbackUrl from GET /live/spaces/{spaceId} like any
other HLS viewer. A token here is precisely what would put the audience on the
SFU.
PUT /api/v1/live/spaces/{spaceId}/hand — Raise / lower a hand
| Param | In | Required |
|---|---|---|
raised |
query | yes (true / false) |
Refuses with ALREADY_SPEAKING if you are already on stage, and
NOT_IN_SPACE if you never joined.
PUT /api/v1/live/spaces/{spaceId}/speakers/{userId} — Promote
Host or co-host. Sets the target to SPEAKER, clears their raised hand, and
returns their token:
{ "userId": "…", "token": "…" }
The riskiest interaction in the feature: the token is a permission, not a connection. Crossing from HLS playback to a LiveKit publish without a gap in audio is the client's problem.
DELETE /api/v1/live/spaces/{spaceId}/speakers/{userId} — Demote
Back to LISTENER. Refuses CANNOT_DEMOTE_HOST.
The token is not revoked, because a LiveKit token cannot be revoked — the same limit calls hit with the camera rule. What ends their turn is the client disconnecting; recording the demotion is what makes the next promotion valid rather than a duplicate.
DELETE /api/v1/live/spaces/{spaceId}/me — Leave
Sets leftAt and lowers the hand. The row is kept, not deleted — a Space
is a conversation, and who spoke in it is what moderation needs to answer
later; deleting the row deletes the answer.
Rejoining after a dropped connection must not demote a speaker back into the
audience, so the upsert only ever raises a LISTENER, never lowers a
speaker.
GET /api/v1/live/spaces/{spaceId}/participants — Who is here
Everyone who has not left. The envelope message is "<n> in this Space".
[ { "userId": "…", "role": "SPEAKER", "handRaised": false, "joinedAt": "…" } ]
POST /api/v1/live/spaces/{spaceId}/end — End it
Host only. Idempotent — ending an ENDED Space returns it unchanged.
Order matters: stop the mix first, then delete the room. Deleting the room drops the stage, and an egress still running against a room that no longer exists is a job nobody will ever stop.
The ingest key is deliberately not cleared on end: a recording callback
arrives after the Space ends and is named after the key SRS received. Nothing
is weakened, because a usable key requires acceptsPublish(), which an ENDED
Space fails.
Refusal codes
| Code | When |
|---|---|
NOT_FOUND |
no such Space |
NOT_HOST |
host-only verb (stage, end) |
NOT_OPEN |
the Space has ended; it will not accept a publisher |
NOT_IN_SPACE |
you (or the target) never joined |
NOT_STAGE_CONTROL |
only the host or a co-host can change the stage |
STAGE_FULL |
at the stage limit (10) |
ALREADY_SPEAKING |
raising a hand while already on stage |
CANNOT_DEMOTE_HOST |
the host holds the Space |
Server-to-server (internal)
Neither of these is for clients. They are what actually drive Space state.
LiveKit webhooks → SpaceRoomHandler
Routed by room-name prefix: calls own call_*, Spaces own space_*.
Without that split every Space event would reach CallWebhookService, which
resolves each one against a CallEntity and would find nothing — quietly, for
every participant of every Space.
| Event | Effect |
|---|---|
| participant joined | ensureEgress — the earliest moment Egress can be asked to mix. Idempotent |
| participant left | leave(user, space). Only stage members are LiveKit participants, so this never fires for a listener |
| room finished | Logged only. The Space's state still belongs to SRS |
| track published | A non-AUDIO track is logged as a client bug — Spaces are audio only |
SRS hooks → SpaceSrsHandler (app space)
The publisher here is never a person: it is LiveKit Egress pushing the
mixed stage in as RTMP. SRS cannot tell the difference and does not need to —
the ingest key is the credential either way, which is what lets a Space go
LIVE by the same rule as every broadcast: because media arrived, not
because an app said so.
| Hook | Effect |
|---|---|
/publish |
Validates the key; STARTING. Returns a refusal if the key authorises nothing |
/hls |
First segment closed → LIVE, playbackUrl set |
/unpublish |
The mix stopped → ENDED |
/dvr |
Logged, not published. Recording a Space is a consent question a broadcast does not raise, and is not built. The file exists on disk for moderation only |
Configuration
| Property | Default | Meaning |
|---|---|---|
app.live.space-stage-limit |
10 |
Concurrent speakers |
app.live.rtmp-ingest-url |
rtmp://srs:1935 |
Where Egress publishes the mix |
app.live.playback-base-url |
http://127.0.0.1:8080 |
HLS base; playlist is /space/<ingestKey>.m3u8 |
app.livekit.url |
ws://127.0.0.1:7880 |
SFU websocket |
Ingest key: 24 random bytes, URL-safe Base64, unpadded. Valid 12 hours.
Schema note
Enum columns are VARCHAR, not Postgres enums. Hibernate freezes a CHECK
constraint around an enum column when the table is created and never revisits
it, so a value added later is refused at INSERT — a trap that has already
cost this project twice.
Known gaps
Honest list, from reading the package — worth knowing before the demo so nobody asks about them from the floor.
| Gap | Detail |
|---|---|
COHOST cannot be assigned |
requireStageControl honours it, but no endpoint or code path ever sets the role. promote grants SPEAKER. Today, only the host can control the stage |
SCHEDULED never opens itself |
Nothing schedules or transitions it — no job, no announcer. The host simply opens the stage whenever they like. Unlike streams, which have StreamScheduleJob |
| No announcements or notifications | Nothing outside space_mng references Spaces at all. No follower announce, no reminders, no push |
PROCESSING / REPLAY_READY / FAILED are unreachable |
Declared on SpaceState, never set. Replay is not built — /dvr deliberately does not publish |
| No comments on a Space | Live comments are a stream feature (/live/streams/{id}/comments); Spaces have no equivalent overlay |
| Demotion cannot force a disconnect | LiveKit tokens are not revocable; a demoted speaker stops publishing only when their client cooperates |