Skip to content

Webhooks

This content is for the 1.0 version. Switch to the latest version for up-to-date documentation.

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.

Set callbacks.url (and, strongly recommended, callbacks.secret) on an app’s config through the global apps endpoint:

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

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.

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

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 route
import 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();
});
  • 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 2xx to 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} with resultdelivered | 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.

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.

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.

{
"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" }
}

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"
}
}

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: ..." }
}
{
"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" }
}

How a recording/start call becomes a finished VOD and a vod_ready callback:

  1. POST /apps/{app}/recording/start starts a LiveKit egress (room-composite or participant, per the app config) writing to a local file apps/{app}/recordings/<stream>-<ts>.mp4; a VOD row is inserted with status=recording.
  2. LiveKit’s egress_updated / egress_ended webhooks flow into the internal handler, which flips the VOD to status=uploading and enqueues the upload job.
  3. The job uploads the local file to the app’s S3 bucket, derives a publicUrl (presigned or public depending on s3.public_url), deletes the local file (if recording.delete_local_after_upload is set), generates + uploads a snapshot frame, sets status=ready, persists metatags (room, app, duration, resolution, codec), and fires vod_ready.
  4. On upload failure: status=failed, the local file is kept, and recording_failed fires instead.

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.