Webhooks
GetStream events HowlCast acts on, plus their payloads.
Single webhook URL: /api/webhooks/getstream on the api worker. Used by both Video and Chat. Events dispatched in apps/server/src/index.ts.
Trust model
| Source | Verification |
|---|---|
| GetStream Video | HMAC SHA-256 against STREAM_API_SECRET via X-SIGNATURE header |
| GetStream Chat | (unverified — currently URL secrecy as trust boundary; TODO) |
Anything not in the explicit allowlist below returns 400 — keeps the endpoint from becoming an open ping.
Acted-on events
call.live_started (or call.session_started)
Fired by GetStream when the call goes live. Different configs send different names — both treated identically.
Action:
- Update
channel_config.live_started_at, clearlive_ended_at - Insert row into
stream_sessionswith new UUID +started_at = now - Set
analytics:current_session_idin KV - Zero
analytics:viewers:{sessionId}in KV - Fire Discord webhooks (public + private if subscribed to live-start)
call.session_ended / call.ended
Fired when streaming ends.
Action:
- Update
channel_config.live_ended_at = now - Find most recent open session row, set
ended_at+ computetotal_minutes - Delete
analytics:viewers:{sessionId}+analytics:current_session_idfrom KV - Fire Discord webhooks (subscribed to stream-end)
call.session_participant_joined
Fired when someone joins the call. Skipped if event.participant.user.id === channel_config.owner_id (broadcaster).
Action:
- Read current viewer count from KV, increment, write back
- Insert row into
stream_viewer_snapshots - If new count > current
peak_viewerson the session row, update
call.session_participant_left
Same as joined but decrement (clamped to 0). Snapshot row written.
message.new (Chat)
Sent by GetStream Chat. Currently HMAC unverified.
Action:
- Read
analytics:current_session_idfrom KV. No-op if not live. UPDATE stream_sessions SET chat_message_count = chat_message_count + 1 WHERE id = ?- UPSERT row into
stream_chat_minuteskeyed on(session_id, minute_bucket_ms)where bucket =floor(now / 60000) * 60000
Ack-only events
Returned 200 OK with no DB writes:
Video: call.member_added, call.member_removed, call.member_updated, call.updated, call.permission_request, call.recording_started, call.recording_stopped
Chat: message.updated, message.deleted, user.banned, user.unbanned
These are subscribed in case GetStream auto-includes them; we just don't act on them.
Configuring in GetStream dashboard
See Configure → GetStream. Same URL for both products. Ensure participant events + message.new are subscribed or analytics stays at 0.
Failure handling
- HMAC fail → 401, no DB write
- Unknown event type → 400
- JSON parse fail → 400
- DB write fail → 500 (GetStream retries automatically)
Discord fanout is fire-and-forget. Errors logged, not propagated. The DB write happens before the Discord call so live state is correct even if Discord is down.
Next: Design decisions.