Skip to content

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.

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.

Terminal window
# viewer (subscribe-only) token
curl -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 token
curl -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>"
}
}

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)
Terminal window
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.

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.

Terminal window
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.

Terminal window
curl -s $BASE/apps/live/ws-ingest/live/cam1
# { "data": { "active": true, "type": "ws-mjpeg", "room": "live-cam1", "mjpegUrl": "...", "frameUrl": "..." } }

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
Terminal window
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
Terminal window
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).

Terminal window
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.

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
Terminal window
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}" } ]

typewebrtc | rtmp | rtsp | whip; statusactive | 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.

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)
Terminal window
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).

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

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)
Terminal window
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}}'

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
Terminal window
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.