Webhooks
StreamHub can POST a signed JSON payload to your app on every event of a room’s lifecycle — stream start/stop, recordings finishing, chat/reactions, restream state, and more (AntMedia-style callbacks). This is a purely outbound mechanism: there is no inbound “post a chat message” endpoint — see App endpoints → Streams for why.
Configure a callback URL
Section titled “Configure a callback URL”Set callbacks.url (and, strongly recommended, callbacks.secret) on an
app’s config through the global apps endpoint:
curl -s -X PATCH $BASE/apps/live \ -H "Authorization: Bearer $STREAMHUB_TOKEN" -H "Content-Type: application/json" \ -d '{"callbackUrl":"https://hooks.example.com/streamhub","callbackSecret":"a-long-random-secret"}'Without a callbacks.url, nothing is dispatched. Without callbacks.secret,
deliveries are sent unsigned (no X-StreamHub-Signature header) — set one in
production.
Envelope
Section titled “Envelope”Every delivery is a POST with Content-Type: application/json:
{ "id": "f2b1c8e4-...", "event": "vod_ready", "app": "live", "room": "live-demo", "ts": "2026-06-30T12:00:00.000Z", "timestamp": "2026-06-30T12:00:00.000Z", "data": { "...": "event-specific payload" }}timestamp is an alias of ts kept for backward compatibility. data is
flat and JSON-safe; depending on the event it may include participant,
track, ingress, egress, plus business fields like streamId, vodId,
message, reaction.
Headers
Section titled “Headers”| Header | Value |
|---|---|
X-StreamHub-Event |
the event name, e.g. vod_ready |
X-StreamHub-Delivery |
unique delivery id (UUID, matches id in the body) |
X-StreamHub-Timestamp |
ISO-8601 emission time |
X-StreamHub-Signature |
sha256=<hex> — HMAC-SHA256 of the raw request body, keyed with callbacks.secret. Only present when a secret is configured. |
User-Agent |
streamhub-core/callbacks |
Verify the signature
Section titled “Verify the signature”Compute HMAC-SHA256 over the exact bytes received (don’t re-serialize the
parsed JSON — whitespace/key order would break the match), hex-encode, and
compare to the value after sha256= using a constant-time comparison.
// Node / Express — mount with a RAW body parser for this routeimport crypto from 'node:crypto';app.post('/streamhub', express.raw({ type: '*/*' }), (req, res) => { const sig = req.get('X-StreamHub-Signature') || ''; const expected = 'sha256=' + crypto .createHmac('sha256', process.env.STREAMHUB_CALLBACK_SECRET) .update(req.body) // raw bytes, not JSON.stringify(parsed) .digest('hex'); const ok = sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); if (!ok) return res.status(401).end();
const evt = JSON.parse(req.body.toString('utf8')); switch (evt.event) { case 'vod_ready': /* ... */ break; case 'stream_started': /* ... */ break; // ... } res.status(200).end();});Retry & delivery semantics
Section titled “Retry & delivery semantics”- Timeout: 10s per attempt.
- Retries: up to 3 attempts total, exponential backoff (500ms, then 1s).
- Retried:
5xx,408,429, and network errors. - Not retried: any other
4xx— treated as a permanent rejection. - Respond
2xxto acknowledge. A downstream failure never propagates back into the triggering StreamHub request — callbacks are always best-effort and fire-and-forget from the caller’s point of view. - Delivery results are metered to Prometheus as
streamhub_callbacks_total{event,result}withresult∈delivered | failed | dropped. - Callbacks are only fired once an event can be resolved to a known app; an event that can’t be mapped to any app fires nothing.
Events
Section titled “Events”Room / participants (forwarded from the LiveKit webhook sink):
room_started, room_finished, participant_joined, participant_left,
track_published, track_unpublished. Hidden QC/recorder participants
never generate these.
Ingress / egress (forwarded from LiveKit): ingress_started,
ingress_ended, egress_started, egress_updated, egress_ended.
StreamHub business events (fired by the Recording/Streams/HLS services —
these are the ones with a stable, documented data shape below):
stream_started, stream_ended, recording_started, recording_part_ready,
recording_ready, recording_failed, snapshot_taken, vod_ready,
vod_variants_ready, hls_started, hls_stopped, restream_started,
restream_stopped, restream_failed (destination URLs always masked).
Chat / reactions (fired when a data message is observed on the matching
LiveKit data-channel topic; enabled per app via features.chat /
features.reactions): chat_message, reaction.
Plugin worker lifecycle (fired by the plugins framework’s worker manager
for any needsWorker plugin, e.g. yolo, deface): plugin_worker_started,
plugin_worker_stopped, plugin_worker_error.
Stream latency alerts (fired by the per-app latency monitor
when latency_alert.enabled is set): stream.latency_high,
stream.latency_recovered.
stream_started
Section titled “stream_started”A publisher or ingress went live.
{ "id": "…", "event": "stream_started", "app": "live", "timestamp": "2026-06-30T12:00:00.000Z", "data": { "streamId": "live-demo/camera-1", "room": "live-demo", "type": "webrtc", "participant": "camera-1" }}For ingress-driven streams, type is rtmp | whip | rtsp and
streamId is the ingress id; participant may be absent.
stream_ended
Section titled “stream_ended”{ "id": "…", "event": "stream_ended", "app": "live", "timestamp": "2026-06-30T12:01:30.000Z", "data": { "streamId": "live-demo/camera-1", "room": "live-demo", "participant": "camera-1" }}vod_ready
Section titled “vod_ready”A recording finished uploading and is playable.
{ "id": "…", "event": "vod_ready", "app": "live", "timestamp": "2026-06-30T12:02:30.000Z", "data": { "vodId": 12, "app": "live", "room": "live-demo", "streamId": "cam-42", "fileKey": "streamhub/live/live-demo-2026-06-30.mp4", "s3Url": "https://s3.us-east-1.wasabisys.com/bucket/streamhub/live/live-demo-2026-06-30.mp4", "publicUrl": "https://.../presigned-or-public", "snapshotKey": "streamhub/live/snapshots/live-demo-2026-06-30.jpg", "sizeBytes": 10485760, "durationS": 120, "width": 1280, "height": 720, "format": "mp4" }}recording_failed
Section titled “recording_failed”The upload/finalize step failed; the local file is kept on disk (not deleted) so it can be retried or recovered manually.
{ "id": "…", "event": "recording_failed", "app": "live", "timestamp": "2026-06-30T12:02:30.000Z", "data": { "vodId": 12, "app": "live", "room": "live-demo", "streamId": "cam-42", "reason": "s3 upload failed", "detail": "AccessDenied: ..." }}chat_message / reaction
Section titled “chat_message / reaction”{ "id": "…", "event": "chat_message", "app": "live", "timestamp": "2026-06-30T12:03:00.000Z", "data": { "room": "live-demo", "from": "user-123", "message": "hello 👋", "ts": "2026-06-30T12:03:00.000Z" }}{ "id": "…", "event": "reaction", "app": "live", "timestamp": "2026-06-30T12:03:05.000Z", "data": { "room": "live-demo", "from": "user-123", "reaction": "heart" }}plugin_worker_started / plugin_worker_stopped / plugin_worker_error
Section titled “plugin_worker_started / plugin_worker_stopped / plugin_worker_error”Fired around the lifecycle of any needsWorker plugin’s process — enabling
the plugin (re)starts it, disabling stops it, and a non-zero/unsignalled exit
or a spawn failure counts as an error (deduped, so a crash-looping worker
fires _error only once per run).
{ "id": "…", "event": "plugin_worker_started", "app": "live", "timestamp": "2026-07-03T12:00:00.000Z", "data": { "plugin": "deface", "pid": 4821 }}{ "id": "…", "event": "plugin_worker_error", "app": "live", "timestamp": "2026-07-03T12:05:00.000Z", "data": { "plugin": "deface", "exitCode": 1, "signal": null }}stream.latency_high / stream.latency_recovered
Section titled “stream.latency_high / stream.latency_recovered”Fired by the app’s latency monitor
when a room’s probe RTT crosses latency_alert.threshold_ms — latched, so
stream.latency_high fires once per breach and stream.latency_recovered
once it clears.
{ "id": "…", "event": "stream.latency_high", "app": "live", "timestamp": "2026-07-03T12:10:00.000Z", "data": { "room": "live-demo", "rttMs": 1340, "thresholdMs": 1000, "metric": "livekit_room_probe_rtt_ms", "participants": 12, "publishers": 1 }}Recording flow
Section titled “Recording flow”How a recording/start call becomes a finished VOD and a vod_ready
callback:
POST /apps/{app}/recording/startstarts a LiveKit egress (room-compositeorparticipant, per the app config) writing to a local fileapps/{app}/recordings/<stream>-<ts>.mp4; a VOD row is inserted withstatus=recording.- LiveKit’s
egress_updated/egress_endedwebhooks flow into the internal handler, which flips the VOD tostatus=uploadingand enqueues the upload job. - The job uploads the local file to the app’s S3 bucket, derives a
publicUrl(presigned or public depending ons3.public_url), deletes the local file (ifrecording.delete_local_after_uploadis set), generates + uploads a snapshot frame, setsstatus=ready, persists metatags (room, app, duration, resolution, codec), and firesvod_ready. - On upload failure:
status=failed, the local file is kept, andrecording_failedfires instead.
Incoming: the LiveKit webhook sink
Section titled “Incoming: the LiveKit webhook sink”For completeness — this is StreamHub’s own inbound receiver, not something
your integration calls. POST /webhooks/livekit is the sink LiveKit’s server
posts its own room/egress/ingress/participant events to. It’s public (no
Bearer token) but authenticity is verified via the LiveKit-issued signature
in the Authorization header, checked against the raw request body. It
always acknowledges 200 once the signature is valid — downstream handler
errors are logged, never surfaced as a non-200 — to avoid LiveKit retry
storms. A bad/missing signature gets 401. This is what drives most of the
forwarded “Room / participants” and “Ingress / egress” events listed above.