Skip to content

Livestreaming & Voice ​

Two real-time media systems. Livestreams come in two kinds that share one LiveStream table (kind: "mux" | "livekit"), one viewer screen, and one realtime room for chat, hearts and viewer count.

OBS livestreamPhone livestream ("Go live")Voice/video rooms
ProviderMuxLiveKit (self-hosted)LiveKit (self-hosted)
TopologyOne-to-many broadcastOne-to-many broadcastMany-to-many conference
ProtocolRTMP in, HLS outWebRTC in and outWebRTC
LatencySecondsSub-secondSub-second
LiveStream rowsOne per user, reused (kind: "mux")One per broadcast (kind: "livekit")—
Used forCreators streaming from OBSInstagram-Live-style streams from the app cameraChannel rooms and 1-on-1 sessions

Livestreaming from OBS (Mux) ​

Credentials are provisioned early ​

Every user gets a LiveStream record with a Mux stream key — it is created during registration (registerUser calls createLiveStream). Streaming is a matter of retrieving credentials rather than provisioning them at broadcast time:

http
GET  /api/v1/livestreams/credentials              → { streamKey, muxPlaybackId, status }
POST /api/v1/livestreams/credentials/regenerate   → new stream key

Going live ​

Rendering diagram…

The User.isLive and User.activeLiveStreamId denormalised columns are what let the feed and discovery surfaces show a live badge without joining to LiveStream.

Webhook events handled ​

POST /api/v1/webhooks/mux (apps/api/src/router/webhookRoutes.ts):

EventEffect
video.asset.readyMedia marked ready, dimensions and duration stored, post marked ready, realtime update emitted
video.live_stream.activeStream active, User.isLive = true, live:status sent to followers, followers notified (push + in-app, via the live-notify queue)
video.live_stream.idle / .disconnectedStream idle; User.isLive cleared only if activeLiveStreamId still points at this Mux row (the user may be live from their phone by now)
.connected, .recording, .updated, .enabled, .disabled, .warningAcknowledged, no action

The Mux webhook is unauthenticated

MUX_WEBHOOK_SECRET is present in the environment and the deployment manifests, but the handler does not verify the signature. Anyone who can reach api.playpalz.gg/api/v1/webhooks/mux can post a crafted video.live_stream.active and mark an arbitrary creator live, or mark arbitrary media ready.

Fix by verifying Mux-Signature with mux.webhooks.verifySignature(rawBody, headers, secret) before the switch — which also requires capturing the raw body for that route, since express.json() has already consumed it.

Livestreaming from the phone (LiveKit) ​

Home + → Go live opens a full-screen camera preview (app/(app)/(modals)/live/go-live.tsx), then a 3-2-1 countdown, then the live view. The host publishes camera + mic to a LiveKit room named live_<livestreamId>; viewers join the same room hidden and subscribe-only.

Rendering diagram…
StatusMeaning
startingCreated on "Go Live"; publishing during the countdown, visible to nobody
liveOn air; followers notified
endedHost ended it, or the host dropped (webhook / sweeper)
cancelledAborted during the countdown
abandonedNever started (sweeper, after 3 minutes)

Follower notifications (queue) ​

"X started a live video" goes out through the BullMQ queue live-notify (@playpals/queue), never on the request path — for phone lives (startPhoneLive) and for OBS streams (the Mux video.live_stream.active webhook). notifyFollowersLive checks a 15-minute per-host cooldown (which also absorbs OBS reconnects) and enqueues one job per broadcast session: a phone live's session is its row id; an OBS session is <muxRowId>-<activation time>, because the Mux row is reused and dedupe keyed on it would swallow the next broadcast's notifications. the worker (apps/api/src/jobs/liveNotify.ts) does the rest:

JobDoes
fanoutReads one page of 1,000 followers, enqueues deliver batches of 50, and enqueues the next page as a new fanout job
deliverCalls notify() for each follower in the batch (which applies blocks and the liveStreams preference)

Both jobs stop if the stream is no longer live, so a queue backlog never sends notifications for a stream that has already ended. Retries are safe: batch job ids are derived from their contents, and each recipient is claimed in the Redis set live:notify:done:<id> before being notified (released again if the push fails), so a retried batch skips anyone already reached.

The worker runs inside every API replica by default (BullMQ spreads jobs across them). To move it to a dedicated process, set LIVE_NOTIFY_WORKER=false on the API and call startLiveNotifyWorker() from the new process's entrypoint.

Ending is idempotent (conditional updateMany on status in (starting, live)), because three things race to end a stream: the host's End button, the LiveKit webhook and the sweeper.

http
POST /api/v1/livestreams/live                 → { livestream, token, livekitHost }
POST /api/v1/livestreams/:id/start            → owner only; starting → live
POST /api/v1/livestreams/:id/end              → owner only; → LiveSummary
GET  /api/v1/livestreams/:id/token            → viewer token (410 once ended, 403 if blocked)
GET  /api/v1/livestreams/following-live       → followed users live now (Home "Live now" rail)

Ending a stream nobody ended ​

  • LiveKit webhook — POST /api/v1/webhooks/livekit, verified with WebhookReceiver over the raw application/webhook+json body. Only live_* rooms are handled: the host's participant_left or room_finished ends the stream.
  • Sweeper — apps/api/src/jobs/liveSweeper.ts, every 60s on every replica behind a Redis lock: a live stream whose host is missing from the LiveKit room on two sweeps in a row is ended; a starting stream older than 3 minutes is abandoned. This is also what cleans up in local dev, where LiveKit can't reach the webhook without a tunnel.
  • Host backgrounding — the app mutes the camera (viewers see "will be right back"). Android ends the stream after 60s in the background; iOS suspends the app, so the webhook/sweeper ends it.

Chat, hearts and viewer count (both kinds) ​

Realtime lives in the esu socket room livestream:<id>, joined by the host and every viewer.

EventDirectionNotes
livestream:join / :leaveclient → esuAuthorised by GET /livestream/:id/authz (host always; others while on air and not blocked). Ack carries the current viewer count.
livestream:heart { count }client → esuBatched taps (≤10 per batch, ≤1 batch / 250ms per socket). Relayed to the room and INCRBY in Redis.
livestream:message:newesu → roomEmitted by the API after a message is stored, minus users blocked with the sender
livestream:viewers { count }esu → roomUnique users in the room minus the host, debounced 1s, via fetchSockets() (all replicas)
livestream:hearts { count }esu → roomAnonymous
livestream:started / :endedesu → room
live:statusesu → followers' user roomsRefreshes the Home "Live now" rail

esu writes the current and peak viewer counts and the heart total to Redis (livestreamRedisKeys in @playpals/types); the API reads them into peakViewers / heartCount when the stream ends.

Chat is stored, not ephemeral — LiveStreamMessage rows:

http
GET  /api/v1/livestreams/:id/messages   → latest 50, oldest first, blocked authors excluded
POST /api/v1/livestreams/:id/messages   → { body (1–300), clientId? }; only while on air; 1 per 700ms per user

The sender's clientId is echoed on the response and the socket event so the optimistic row is replaced in place (same pattern as DMs and room chat).

Live tips ​

Viewers tip the host with POST /tips { targetType: "livestream", targetId: <stream id> } while the stream is on air (live, starting or active). The tip and a LiveStreamMessage with kind: "tip" and tipId are written in one transaction; the line is then broadcast through the normal livestream:message:new path, so block exclusions and history work as for chat. The LiveMessage carries kind: "tip" and tip: { id, coins }; body is the tip's message (may be empty). Clients show it as a gold row and a "Riya tipped 500" banner.

The host can hide a tip's message (POST /tips/:id/hide-message). The API re-broadcasts the line with body: "" and tip.messageHidden: true, and clients replace the row they already show. Anyone watching except the sender can report it. The viewer screen is a full-screen modal, so it mounts its own tip sheet. See Monetization for the coin rules.

Voice rooms (LiveKit) ​

Why self-hosted ​

LiveKit runs on a dedicated DigitalOcean droplet rather than in the Kubernetes cluster. WebRTC needs a wide UDP port range and direct host networking, which is awkward to express through an ingress controller. The droplet is provisioned by Ansible — see Ansible & the LiveKit Node for provisioning, upgrades, and credentials.

Locally, LiveKit runs in Docker Compose with the dev key pair devkey / secret:

bash
docker compose up -d livekit
PortPurpose
7880HTTP API
7881RTC over TCP
7882/udpRTC over UDP

Joining a room ​

Rendering diagram…

The API is the only issuer of LiveKit tokens, so room access rules live in one place. Tokens are short-lived (1 hour) and scoped to a single room with explicit canPublish / canSubscribe grants. Empty rooms are torn down after emptyTimeout: 300 seconds.

Room access rules ​

apps/api/src/lib/roomAccess.ts — canAccessRoom(userId, roomId):

VisibilityWho may enter
publicAnyone
members (default)The channel owner, or a ChannelMember
vipThe channel owner, or a channel member with an active CreatorSubscription to the channel owner
privateOnly the two parties of the Session attached to the room (the PlayPal and the purchaser)

vip is the paywall: membership alone is not enough, the fan must hold a live subscription whose expiresAt is null or in the future.

private is how 1-on-1 booked sessions work — the room is bound to a Session row, and only the playpalId and purchaserId can get a token.

The same rule gates a room's text chat: GET /rooms/:roomId, GET /rooms/:roomId/messages and POST /rooms/:roomId/messages answer 403 when canAccessRoom is false, so a channel member without a subscription can't read or post in a vip room.

Client side ​

useLiveKitCall (apps/mobile/hooks/useLiveKitCall.ts) wraps @livekit/react-native. The native WebRTC module means voice rooms cannot be tested in Expo Go — you need a development build.

Configuration ​

VariableServiceNotes
MUX_ACCESS_TOKEN, MUX_SECRET_KEYapi, ogunMux API credentials
MUX_WEBHOOK_SECRETapiPresent but unused — see the warning above
LIVEKIT_HOSTapiHTTP API base, e.g. https://ws.playpalz.gg
LIVEKIT_WS_URLapiClient WebSocket URL, e.g. wss://ws.playpalz.gg
LIVEKIT_API_KEY, LIVEKIT_API_SECRETapiToken signing and LiveKit webhook verification
LIVE_NOTIFY_WORKERapifalse stops this replica consuming the live-notify queue (default: consumes)

The LiveKit server must be configured to send webhooks to https://<api>/api/v1/webhooks/livekit (the webhook.urls block in livekit.yaml, signed with the same API key). Without it, the sweeper still ends abandoned phone lives, just more slowly.

In production these come from the mux-secrets and livekit-secrets sealed secrets. See Secrets Management.

Internal documentation — PlayPalz platform