WebSocket
Real-time communication via WebSocket using a channel-based pub/sub protocol.
Connection
| Environment | URL |
|---|---|
| Production | wss://api.lumio.vision/v1/ws |
| Production Preview | wss://lumio.api.prod.zaflun.dev/v1/ws |
| Staging | wss://lumio.api.staging.zaflun.dev/v1/ws |
The endpoint is an HTTP GET /v1/ws request that is upgraded to a WebSocket connection. All fields in both directions use snake_case JSON.
Protocol
Lumio uses a custom JSON-message channel-subscription protocol. Clients subscribe to named channels (e.g. events:\{account_id\}, overlay:\{key\}) and receive messages pushed by the server whenever workers publish to the matching Redis pub/sub channel.
Lumio does not use graphql-ws and does not expose GraphQL subscriptions (EmptySubscription). All real-time updates flow through this WebSocket.
Authentication
Auth is resolved by the same middleware that guards REST and GraphQL. Two transport mechanisms are supported, and what they accept differs:
| Token type | Authorization: Bearer … header | ?token=… query param | Rate limit |
|---|---|---|---|
JWT (lm_eyJ…) | ✓ | ✓ | per-plan |
User API Key (lm_usr_…) | ✓ | ✗ | per-plan |
System Key (lm_sys_…) | ✓ | ✗ | unlimited |
Popout Token (lm_pop_…) | ✗ | ✓ | 600/min |
Overlay Token (lm_overlay_…) | ✓ | ✓ | 600/min |
Shared Overlay Token (lm_share_…) | ✓ | ✓ | 600/min |
| Anonymous | (no token sent) | (no token sent) | — |
API keys and System keys go only through the header. Query strings end up in server access logs, browser history, and Referer headers — long-lived credentials must not be passed there. JWTs are short-lived (~15 min), Popout tokens are revocable, and Overlay tokens are revocable/rotatable, so they're acceptable as query-param transport for browser WebSocket clients (which can't set custom headers natively).
Browser apps that want to use an API key for the WebSocket connection must proxy through their own SSR layer (Next.js Route Handler → API with the header attached) — the browser's WebSocket API can't send headers directly.
Overlay tokens (lm_overlay_*) are per-overlay access tokens designed for browser sources. Each token is bound 1:1 to a specific overlay and can only subscribe to the overlay:\{key\} channel matching that overlay. They carry no RBAC permissions and cannot access REST or GraphQL endpoints.
Shared overlay tokens (lm_share_*) are temporary, time-limited tokens for sharing overlay access with collaborators. Like overlay tokens, they are bound to a specific overlay and can only subscribe to the matching overlay:\{key\} channel. The server checks expiry every heartbeat tick (5s) and disconnects with TOKEN_EXPIRED when the token's duration elapses. DB revocation is checked every 30 seconds.
RBAC gate (subscribe-time)
Channel access is enforced at subscribe time. Each channel type maps to a single resource:action permission via crates/lo-websocket/src/gate.rs::channel_gate_for:
| Channel prefix | Account access | RBAC permission |
|---|---|---|
overlay:\{key\} | not required | Requires lm_overlay_* or lm_share_* token bound to this specific overlay |
events:\{account_id\} | required | events:read |
spotify:\{account_id\} | required | spotify:read |
chat:\{account_id\} | required | chat:read |
automations:\{account_id\} | required | automations:read |
presence:\{resource_channel\} | not required | Any authenticated identity (no RBAC — Authenticated gate) |
sounds:\{account_id\} | required | sounds:read |
presence:* channels track who is connected to an editor session. Subscribe to presence:widget:\{id\} or presence:overlay:\{key\} to receive presence:join, presence:leave, and bootstrap events with the current connections list. The gate is Authenticated (not a specific permission) because browser sources (WidgetToken/OverlayToken) lack account-scoped RBAC.
Subscribing without the matching permission yields an error message with code: "UNAUTHORIZED" and the channel is not registered.
Feature-flag gate (subscribe-time)
After the RBAC gate passes, the WebSocket layer also checks the feature-flag layer (same source as REST/GraphQL — account_features overrides on top of plan defaults). Channels mapped in crates/lo-websocket/src/gate.rs::channel_feature_for reject the subscribe with code: "FEATURE_DISABLED" when the flag is off for the account:
| Channel prefix | Required feature flag |
|---|---|
automations:\{account_id\} | feature:automation |
sounds:\{account_id\} | feature:sounds |
| all others | no feature gate |
Plan-bypass via the WebSocket protocol is therefore impossible for gated channels — disabling feature:automation for a Free account stops both the REST endpoints and the live automations:* stream. Add new mappings to channel_feature_for when introducing paid-tier streams.
Client Messages
All client messages are JSON with a type discriminator.
type | Fields | Purpose |
|---|---|---|
subscribe | channel: string | Subscribe to a channel. Server responds with subscribed or error. |
unsubscribe | channel: string | Unsubscribe. Server responds with unsubscribed. |
broadcast | channel: string, payload: string | Publish a JSON-encoded payload to every session subscribed to channel. Requires the channel's write permission (events:create, chat:write, automations:execute) — distinct from the subscribe :read permission. overlay:* and spotify:* channels never accept client broadcasts. Payload MUST be valid JSON; server rejects malformed payloads with INVALID_FORMAT. Wrapped server-side as \{"type":"event","data":<payload>\} before forwarding. |
ping | -- | Heartbeat. Server replies with pong. |
Example:
{"type":"subscribe","channel":"events:550e8400-e29b-41d4-a716-446655440000"}
Messages larger than 64 KiB are rejected with MESSAGE_TOO_LARGE.
Server Messages
All server messages are JSON with a type discriminator.
type | Fields | Meaning |
|---|---|---|
welcome | session_id: string, server_version: string | Sent once immediately after the socket opens. |
subscribed | channel: string | Subscription accepted. |
unsubscribed | channel: string | Unsubscription accepted. |
pong | -- | Reply to ping. |
error | message: string, code: string | Error. See codes below. |
event | data: object | Event payload relayed from a subscribed channel. See "Relay envelope" below. |
spotify:now-playing | data: object | Spotify playback-state update relayed from spotify:\{account_id\}. |
chat:message | data: object | Chat event relayed from chat:\{account_id\} (new messages, deletions, moderation logs, etc.). |
automation | data: object | Automation-engine update relayed from automations:\{account_id\}. |
Relay envelope
When a worker publishes to a Redis pub/sub channel, the API's event-relay worker forwards the raw payload to every subscribed WebSocket session, wrapped in a typed envelope. The type of the envelope depends on the Redis channel prefix:
| Redis channel | WebSocket envelope type | Published to sessions subscribed on |
|---|---|---|
lumio:events:\{account_id\} | event | events:\{account_id\} |
lumio:spotify:\{account_id\} | spotify:now-playing | spotify:\{account_id\} |
lumio:chat:\{account_id\} | chat:message | chat:\{account_id\} |
lumio:automations:\{account_id\} | automation | automations:\{account_id\} |
lumio:sounds:\{account_id\} | sound:play or sound:stop | sounds:\{account_id\} |
The relay is one-way (Redis to WebSocket) and message-preserving -- data contains the exact JSON the publisher sent (including fields like type, event_type, payload, etc. that are specific to the event family).
Example relayed follower event on events:\{account_id\}:
{"type":"event","data":{"type":"twitch:follower","payload":{"username":"viewer123"}}}
Chat sub-types
Inside the chat:message envelope, the data.type field disambiguates the underlying chat event:
data.type | Direction | Payload shape |
|---|---|---|
absent / regular ChatMessage fields | Worker ➔ clients | Full chat-message row (id, username, message, emotes, badges, reply fields, …) |
chat:delete | Worker ➔ clients | {type, platform_message_id, deleted_by} — single message moderation-deleted; clients grey out that row |
chat:clear_user | Worker ➔ clients | {type, platform, platform_user_id, deleted_by, action, duration_secs} — fired after a successful ban/timeout (issued through Lumio or detected via Twitch EventSub channel.ban); clients grey out every message from (platform, platform_user_id) and append a localised "Banned/Hidden/Timed-out by X" suffix. action is "ban" or "timeout"; duration_secs is null for permanent bans. |
chat:moderation_log | Worker ➔ clients | Audit-log entry for moderation feed widgets |
chat:user_treatment_update | Worker ➔ clients | Per-user treatment changed (active_monitoring, restricted, none) |
Subscribe to chat:\{account_id\} once and dispatch on data.type — the chat-shell, multichat hook, and OBS browser-source clients all use the same demultiplexer.
Error codes
code | Meaning |
|---|---|
UNAUTHORIZED | Caller lacks access to the requested channel (missing account access or resource:action permission). |
FEATURE_DISABLED | Caller is authorized but the channel's feature flag is disabled for the account (e.g. feature:automation off). |
TOKEN_REVOKED | The overlay or shared overlay access token has been rotated or revoked. Reconnect with a fresh URL from the dashboard. |
TOKEN_EXPIRED | The shared overlay token has expired. Create a new shared link to regain access. |
SUBSCRIBE_FAILED | Server-side failure while registering the subscription. |
INVALID_FORMAT | Message was not valid JSON or did not match any known client-message shape. |
MESSAGE_TOO_LARGE | Text frame exceeded the 64 KiB limit. |
Heartbeats and timeouts
The server ticks a heartbeat every 5 seconds and disconnects any session that has been silent for 10 seconds. Clients should either respond to WebSocket-level pings, send a {"type":"ping"} every few seconds, or maintain any other traffic to keep the connection alive. Standard WebSocket Ping control frames are auto-answered with Pong.
Overlay token re-validation
For sessions authenticated with an lm_overlay_* token, the server re-validates the token against the database every 30 seconds. If the token has been rotated or revoked since the connection was established, the server sends an error message with code: "TOKEN_REVOKED" and closes the WebSocket. Browser sources automatically attempt to reconnect, at which point they need a fresh URL from the dashboard.
Example Channels
events:\{account_id\}-- Stream events (follows, subs, cheers, raids, redemptions, tips, channel online/offline, bot-command updates).spotify:\{account_id\}-- Spotify "now playing" state changes for the Spotify overlay widget.chat:\{account_id\}-- Live chat messages, deletions, moderation logs, user-treatment updates.automations:\{account_id\}-- Automation-engine lifecycle updates (triggers, execution progress).sounds:\{account_id\}-- Sound playback commands (sound:play,sound:stop) consumed by browser sources to trigger in-overlay audio. Requiresfeature:soundsandsounds:read.overlay:\{key\}-- Per-overlay updates for browser-source popouts. Requires anlm_overlay_*token bound to the specific overlay identified bykey.