App endpoints
Endpoints on this page are scoped to a single app, under
/api/v1/apps/{app}/.... Platform-wide endpoints (apps registry, tokens,
cluster) are on the Global endpoints page.
$BASE = https://<your-domain>/api/v1. {app} is the app name (slug),
e.g. live. Every route below requires Authorization: Bearer <token>
unless marked public.
Room namespacing: a room you pass in is namespaced under the app’s room
prefix. Omit it and it defaults to the app prefix; pass one that isn’t
already prefixed and it becomes <prefix>-<room>. So app live (prefix
live) + room=demo → LiveKit room live-demo.
Tokens
Section titled “Tokens”POST /apps/{app}/tokens mints a LiveKit join token — the credential a
browser/OBS-like client actually uses to publish/subscribe to a room — plus
ready-made player/embed URLs. This is different from the sk_ API tokens and
dashboard JWTs covered in Authentication: those
authenticate against the StreamHub REST API; this one authenticates
against the LiveKit media plane.
| Method | Path | Permission | Description |
|---|---|---|---|
| POST | /apps/{app}/tokens |
stream:write |
Mint a join token (+ playUrl/embedUrl/iframe) |
| GET | /apps/{app}/play-token/{room} |
public | Anonymous subscribe-only viewer token — see Authentication |
| GET | /apps/{app}/radio/{room}/listen-token |
public | Anonymous subscribe-only, audio-only token |
POST /apps/{app}/tokens body — all fields optional:
| Field | Type | Default | Notes |
|---|---|---|---|
room |
string | app prefix | ≤ 120, [a-zA-Z0-9._-] |
identity |
string | random anon-<uuid> |
≤ 120 |
name |
string | — | display name, ≤ 120 |
canPublish |
boolean | true |
|
canSubscribe |
boolean | true |
|
ttl |
string | "6h" |
e.g. "10m", "1h" |
metadata |
string | — | opaque participant metadata, ≤ 2000 |
hidden |
boolean | false |
QC/recorder grant: subscribes to everything but is invisible and not counted as a viewer (needs the app’s hiddenQc feature) |
recorder |
boolean | false |
roomRecord grant; pairs with hidden, subscribe-only by default |
audioOnly |
boolean | false |
restricts publishing to the microphone only |
Minting a publisher token (canPublish !== false) counts against the
tenant’s max_concurrent_streams quota; subscribe-only tokens don’t.
# viewer (subscribe-only) tokencurl -s -X POST $BASE/apps/live/tokens \ -H "Authorization: Bearer $STREAMHUB_TOKEN" -H "Content-Type: application/json" \ -d '{"room":"demo","identity":"viewer-1","canPublish":false,"ttl":"10m"}'
# hidden QC / recorder tokencurl -s -X POST $BASE/apps/live/tokens \ -H "Authorization: Bearer $STREAMHUB_TOKEN" -H "Content-Type: application/json" \ -d '{"room":"demo","identity":"qc-1","hidden":true,"recorder":true,"canPublish":false}'{ "data": { "token": "<livekit-jwt>", "app": "live", "room": "live-demo", "identity": "viewer-1", "wsUrl": "wss://media.example.com", "playUrl": "https://your-domain.example.com/play/live/live-demo", "embedUrl": "https://your-domain.example.com/embed/live/live-demo", "iframe": "<iframe src=\"...\" width=\"640\" height=\"360\" allow=\"autoplay; fullscreen; camera; microphone\" allowfullscreen></iframe>" }}Ingress (RTMP / WHIP / RTSP-relay)
Section titled “Ingress (RTMP / WHIP / RTSP-relay)”An ingress feeds an external stream (RTMP push, WHIP, or a pulled RTSP/HLS URL) into a LiveKit room as if it were a WebRTC publisher.
| Method | Path | Permission | Description |
|---|---|---|---|
| POST | /apps/{app}/ingress |
ingress:create |
Create an RTMP/WHIP/URL ingress |
| GET | /apps/{app}/ingress?limit&offset&room&q |
ingress:read |
Paginated list ({ data, total, limit, offset }, limit 1..500, default 50) — every row carries live state (status, bitrate, dimensions, viewers) plus the revealable ingest credentials |
| GET | /apps/{app}/ingress/{id} |
ingress:read |
Get one ingress |
| DELETE | /apps/{app}/ingress/{id} |
ingress:delete |
Delete an ingress |
| POST | /apps/{app}/ingress/{id}/validate |
ingress:write |
Validate an RTMP stream password, marking the connection authorized |
POST /apps/{app}/ingress body:
| Field | Type | Required | Notes |
|---|---|---|---|
inputType |
rtmp | whip | url |
yes | url covers RTSP pull/relay (e.g. rtsp://camera.local/stream) |
room |
string | no | destination room; defaults to app prefix |
participantIdentity |
string | no | defaults to ingress-<app> |
participantName |
string | no | display name |
url |
string | when inputType=url |
remote source to pull |
enableTranscoding |
boolean | no | multi-layer transcoding; defaults from the app’s transcoding.enabled and rtmp.transcode (off = passthrough on new apps) |
curl -s -X POST $BASE/apps/live/ingress \ -H "Authorization: Bearer $STREAMHUB_TOKEN" -H "Content-Type: application/json" \ -d '{"inputType":"rtmp","room":"demo","enableTranscoding":true}'{ "data": { "ingressId": "IN_abc123", "url": "rtmp://media.example.com:1935/live", "rtmp_url": "rtmp://media.example.com:1935/live/sk-9f3c...", "streamKey": "sk-9f3c...", "stream_key": "sk-9f3c...", "roomName": "live-demo", "requires_password": false, "adaptive": true, "player_url": "https://your-domain.example.com/play/live/live-demo", "embed_iframe": "<iframe ...></iframe>" }}Push with ffmpeg: ffmpeg -re -i input.mp4 -c:v libx264 -c:a aac -f flv "rtmp://media.example.com:1935/live/<streamKey>".
When an app enables the rtmpPassword feature, RTMP ingresses also return a
stream_password that must accompany the push and gets checked via
POST /apps/{app}/ingress/{id}/validate.
WS ingest (ESP32 / MJPEG direct)
Section titled “WS ingest (ESP32 / MJPEG direct)”A low-power camera path (ESP32-CAM and similar) that pushes raw MJPEG frames
over a plain WebSocket instead of RTMP/WebRTC — no transcode, no LiveKit
publisher. See streamhub-docs/integrations/ESP32-WS-INGEST.md for the
device-side protocol.
| Method | Path | Permission | Description |
|---|---|---|---|
| POST | /apps/{app}/ws-ingest |
ingress:create |
Mint a wsk_ camera key for a room |
| GET | /apps/{app}/ws-ingest |
ingress:read |
List keys + live state |
| DELETE | /apps/{app}/ws-ingest/{id} |
ingress:delete |
Revoke a key (closes a live connection immediately) |
| GET | /apps/{app}/ws-ingest/live/{room} |
public | Whether a camera is live in the room |
POST /apps/{app}/ws-ingest body:
| Field | Type | Required | Notes |
|---|---|---|---|
room |
string | yes | slug, ≤ 64 chars |
identity |
string | no | slug, ≤ 64 chars |
Subject to the same max_concurrent_streams quota as any other publisher.
curl -s -X POST $BASE/apps/live/ws-ingest \ -H "Authorization: Bearer $STREAMHUB_TOKEN" -H "Content-Type: application/json" \ -d '{"room":"cam1"}'{ "data": { "id": "wsi_...", "streamKey": "wsk_...", "room": "live-cam1", "identity": "cam1", "wsUrl": "wss://your-domain.example.com/ingest/ws?app=live&room=live-cam1", "mjpegUrl": "https://your-domain.example.com/live/live/live-cam1/mjpeg", "frameUrl": "https://your-domain.example.com/live/live/live-cam1/frame.jpg", "playerUrl": "https://your-domain.example.com/play/live/live-cam1", "embedUrl": "https://your-domain.example.com/embed/live/live-cam1" }}The streamKey (wsk_...) is plaintext once. The device authenticates
on the WebSocket with Authorization: Bearer wsk_... (or ?key=), sending
one binary message per JPEG frame. Playback without any transcode step is
available at GET /live/{app}/{room}/mjpeg (multipart, works in an <img>
tag) and GET /live/{app}/{room}/frame.jpg (last frame) — both public unless
the app’s publicPlayback feature is off, in which case they require a
?token= play-token. These playback routes sit outside /api/v1.
curl -s $BASE/apps/live/ws-ingest/live/cam1# { "data": { "active": true, "type": "ws-mjpeg", "room": "live-cam1", "mjpegUrl": "...", "frameUrl": "..." } }Recording
Section titled “Recording”The recording mode (room-composite | participant) and layout come
from the app’s config.yaml, not the request body — see
Global → App config.
| Method | Path | Permission | Description |
|---|---|---|---|
| POST | /apps/{app}/recording/start |
recording:start |
Start an egress recording for a room |
| POST | /apps/{app}/recording/{id}/stop |
recording:stop |
Stop it ({id} = VOD id or egress id) |
| POST | /apps/{app}/streams/{id}/record/start |
recording:start |
Record a specific live stream |
| POST | /apps/{app}/streams/{id}/record/stop |
recording:stop |
Stop it |
POST /apps/{app}/recording/start body:
| Field | Type | Required | Notes |
|---|---|---|---|
roomName |
string | yes | LiveKit room to record, ≤ 200 |
streamId |
string | no | logical stream id; in participant mode also used as the egress target’s participant identity |
curl -s -X POST $BASE/apps/live/recording/start \ -H "Authorization: Bearer $STREAMHUB_TOKEN" -H "Content-Type: application/json" \ -d '{"roomName":"live-demo"}'# { "data": { "vodId": 12, "egressId": "EG_xyz789", "status": "recording" }, "error": null }
curl -s -X POST $BASE/apps/live/recording/12/stop -H "Authorization: Bearer $STREAMHUB_TOKEN"# { "data": { "vodId": 12, "egressId": "EG_xyz789", "status": "uploading" }, "error": null }A VOD row is inserted immediately with status=recording; how it becomes
ready (upload → snapshot → vod_ready callback) is covered on
Webhooks.
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /apps/{app}/vods?... |
vod:read |
List VODs — filters, ordering, paging, total count |
| GET | /apps/{app}/vods/{id} |
vod:read |
Detail with a fresh presigned URL + adaptive HLS variants |
| GET | /apps/{app}/vods/{id}/download |
vod:read |
Attachment download URL (presigned S3 or local /raw) |
| GET | /apps/{app}/vods/{id}/raw |
vod:read |
Stream a local VOD file as an attachment |
| POST | /apps/{app}/vods/{id}/probe |
vod:write |
ffprobe backfill of duration/dimensions/format for legacy rows |
| DELETE | /apps/{app}/vods/{id} |
vod:delete |
Delete (DB row + S3 object + snapshot + local file) |
GET /apps/{app}/vods query (all optional):
| Param | Type | Default | Notes |
|---|---|---|---|
room |
string | — | exact match |
status |
enum | — | recording | uploading | ready | failed |
since / until |
ISO-8601 | — | started_at bounds, inclusive |
order |
enum | id |
started_at | size_bytes | id |
dir |
enum | desc |
asc | desc |
all |
1 |
— | return every matching row, ignoring limit/offset |
limit / offset |
int | 200 / 0 | limit clamped 1..1000 |
curl -s "$BASE/apps/live/vods?status=ready&room=live-demo&order=size_bytes&dir=desc&limit=50" \ -H "Authorization: Bearer $STREAMHUB_TOKEN"{ "data": [ { "id": 12, "room": "live-demo", "status": "ready", "sizeBytes": 10485760, "durationS": 120, "width": 1280, "height": 720, "format": "mp4", "...": "..." } ], "total": 137, "limit": 50, "offset": 0, "error": null}GET /apps/{app}/vods/{id} adds a freshly presigned url/presignedUrl/
publicUrl plus, when the app has adaptive VOD transcoding on, an adaptive
block (masterUrl — point an HLS player at it) and a variants[] array of
renditions/alternates. HLS URLs require the app’s s3.public_url to be set
(segments are fetched relative to the playlist — a presigned playlist alone
can’t play).
curl -s $BASE/apps/live/vods/12/download -H "Authorization: Bearer $STREAMHUB_TOKEN"# { "data": { "url": "https://...&response-content-disposition=attachment...", "filename": "clip-12.mp4", "expiresInSeconds": 3600 }, "error": null }/download returns 409 when the VOD isn’t ready yet, 404 when it’s
ready but has neither an S3 object nor a local file. A local-only VOD (not
yet uploaded) gets a url pointing at /raw instead, with
expiresInSeconds: null.
Streams
Section titled “Streams”A stream is an active publisher in a room — a WebRTC participant, or an RTMP/RTSP/WHIP ingress. Rows are upserted by the LiveKit webhook handlers.
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /apps/{app}/streams |
stream:read |
List active streams |
| GET | /apps/{app}/streams/{id} |
stream:read |
Detail, with a best-effort live viewer count |
| DELETE | /apps/{app}/streams/{id} |
stream:stop |
Stop it → 204 |
| POST | /apps/{app}/snapshots |
stream:write |
On-demand frame capture (uploaded to S3 if configured) |
| POST | /apps/{app}/streams/{id}/data |
stream:write |
Send a server-side chat/reaction data message |
curl -s $BASE/apps/live/streams -H "Authorization: Bearer $STREAMHUB_TOKEN"[ { "id": 1, "streamId": "live-demo/camera-1", "type": "webrtc", "room": "live-demo", "participant": "camera-1", "status": "active", "startedAt": "2026-06-30T12:00:00.000Z", "lastStatsJson": "{\"live\":true,\"participants\":2,\"publishers\":1}" } ]type ∈ webrtc | rtmp | rtsp | whip; status ∈ active | ended. Stopping
a stream disconnects the participant / removes the ingress and ends the room
if nothing else is publishing — LiveKit cleanup is best-effort but the row is
always marked ended.
Chat and reactions ride on LiveKit data channels, not a dedicated REST
endpoint — a client with a join token can publish/subscribe to the chat/
reaction topics directly. POST /apps/{app}/streams/{id}/data is the
server-side path (used by integrations without a live LiveKit connection) and
also fires the matching chat_message/reaction outbound callback. Both
topics are gated by the app’s chat/reactions feature flags.
Config & transcoding
Section titled “Config & transcoding”The adaptive/transcoding slice of the app config (no secrets). Broader config
(display name, callbacks, feature flags) goes through the global
PATCH /apps/{name} — see Global endpoints.
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /apps/{app}/config |
config:read |
Get the adaptive/transcoding config |
| PATCH | /apps/{app}/config |
config:write |
Patch it (partial; a provided layers array replaces the whole ladder) |
| GET | /apps/{app}/transcoding/layers |
config:read |
Effective WebRTC rendition ladder (defaults to 720/480/240) |
curl -s -X PATCH $BASE/apps/live/config \ -H "Authorization: Bearer $STREAMHUB_TOKEN" -H "Content-Type: application/json" \ -d '{"transcodingEnabled":true,"encoding":"h264+vp8","vodAdaptive":true,"vodRenditions":[{"height":720,"bitrateKbps":2800},{"height":480,"bitrateKbps":1400}]}'transcoding.enabled is the server-side master switch — false on new
apps (pure passthrough).
Logs & stats
Section titled “Logs & stats”| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /apps/{app}/stats |
app:read |
Live/VOD/storage/ingress snapshot, cached 5s in memory |
| GET | /apps/{app}/logs?... |
usage:read |
Same filters/shape as the global log viewer, scoped to this app |
Plugins
Section titled “Plugins”The plugin framework (streamhub-core/src/modules/plugins/) manages
optional per-app tools/processors/panels — e.g. the yolo object-detection
worker. A plugin declares its own configSchema (fields with type
string|number|boolean|select|secret, every field carrying a default) and,
if needsWorker is set, a worker.spawn(ctx) the framework uses to own the
process lifecycle.
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /apps/{app}/plugins |
config:read |
Marketplace: every known plugin + its install/enabled state for this app |
| GET | /apps/{app}/plugins/public |
public | Sanitized list of enabled player-overlay plugins, safe for anonymous /play pages |
| GET | /apps/{app}/plugins/{id} |
config:read |
One plugin’s manifest + state |
| POST | /apps/{app}/plugins/{id}/install |
config:write |
Install (idempotent) |
| PATCH | /apps/{app}/plugins/{id} |
config:write |
{ enabled?, config? } — enabling starts its worker (if any), disabling stops it |
| DELETE | /apps/{app}/plugins/{id} |
config:write |
Uninstall (stops the worker first) → { removed: true } |
| POST | /apps/{app}/plugins/{id}/worker/start |
config:write |
Start the worker process (needsWorker plugins only) |
| POST | /apps/{app}/plugins/{id}/worker/stop |
config:write |
Stop it (no-op if already idle) |
| GET | /apps/{app}/plugins/{id}/worker/status |
config:read |
Worker process status |
| GET | /apps/{app}/plugins/{id}/logs?limit |
config:read |
Recent worker output, limit clamped 1..1000 (default 200) |
curl -s -X POST $BASE/apps/live/plugins/yolo/install -H "Authorization: Bearer $STREAMHUB_TOKEN"
curl -s -X PATCH $BASE/apps/live/plugins/yolo \ -H "Authorization: Bearer $STREAMHUB_TOKEN" -H "Content-Type: application/json" \ -d '{"enabled":true,"config":{"confidence":0.5}}'Restream (multi-destination)
Section titled “Restream (multi-destination)”Forward a live stream to external RTMP destinations (YouTube, Twitch, Facebook, or a custom RTMP URL) simultaneously with local playback.
| Method | Path | Permission | Description |
|---|---|---|---|
| POST | /apps/{app}/streams/{id}/restream |
broadcast:start |
Start forwarding to one destination |
| GET | /apps/{app}/streams/{id}/restream |
broadcast:read |
List destinations currently starting/active/failed (stopped ones are omitted) |
| DELETE | /apps/{app}/streams/{id}/restream/{egressId} |
broadcast:stop |
Stop one destination |
POST .../restream body:
| Field | Type | Required | Notes |
|---|---|---|---|
platform |
youtube | twitch | facebook | custom |
no | preset RTMP endpoint per platform |
url |
string | when platform=custom |
rtmp(s)://..., ≤ 2000 |
key |
string | when using a preset platform | stream key, ≤ 500 |
name |
string | no | ≤ 120, label for this destination |
layout |
string | no | ≤ 100 |
curl -s -X POST $BASE/apps/live/streams/live-demo%2Fcamera-1/restream \ -H "Authorization: Bearer $STREAMHUB_TOKEN" -H "Content-Type: application/json" \ -d '{"platform":"youtube","key":"xxxx-xxxx-xxxx-xxxx"}'Also counts against the tenant’s egress quota. Responses always mask the
destination URL/key — the raw stream key is never echoed back. Each
destination fires its own restream_started/restream_stopped/
restream_failed callback — see Webhooks.