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 |
|---|---|---|
JWT (lm_eyJ…) | ✓ | ✓ |
User API Key (lm_usr_…) | ✓ | ✗ |
System Key (lm_sys_…) | ✓ | ✗ |
Popout Token (lm_pop_…) | ✗ | ✓ |
| 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) and Popout tokens are revocable, 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.
Anonymous connections are allowed but can only subscribe to public overlay:* channels.
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 (public) | none |
events:\{account_id\} | required | events:read |
spotify:\{account_id\} | required | spotify:read |
chat:\{account_id\} | required | chat:read |
automations:\{account_id\} | required | automations:read |
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 |
| 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\} |
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). |
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.
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).overlay:\{key\}-- Per-overlay updates for browser-source popouts. Public channel keyed by the overlay's non-enumerable key.