Skip to content

Livestreaming & Voice

Two different real-time media systems, used for two different things.

LivestreamingVoice rooms
ProviderMuxLiveKit (self-hosted)
TopologyOne-to-many broadcastMany-to-many conference
ProtocolRTMP in, HLS outWebRTC
LatencySecondsSub-second
Where it runsMux's infrastructureA dedicated DigitalOcean droplet
Used forCreator broadcasts with chat and coin giftingChannel rooms and 1-on-1 sessions

Livestreaming (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
video.live_stream.idle / .disconnectedStream idle, User.isLive = false, activeLiveStreamId cleared
.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.

Livestream chat

Chat is stored, not ephemeral — LiveStreamMessage rows via:

http
GET  /api/v1/livestreams/:id/messages
POST /api/v1/livestreams/:id/messages

Coin gifting during a stream is what feeds the creator earnings calculation in Monetization.

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 (infra/ansible/playbooks/livekit.yml) and configured by livekit.yaml.

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.tscanAccessRoom(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.

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://livekit.playpalz.gg
LIVEKIT_WS_URLapiClient WebSocket URL, e.g. wss://livekit.playpalz.gg
LIVEKIT_API_KEY, LIVEKIT_API_SECRETapiToken signing

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

Internal documentation — PlayPalz platform