Skip to main content

WebSocket

Real-time communication via WebSocket using a channel-based pub/sub protocol.

Connection

EnvironmentURL
Productionwss://api.lumio.vision/v1/ws
Production Previewwss://lumio.api.prod.zaflun.dev/v1/ws
Stagingwss://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 typeAuthorization: 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 prefixAccount accessRBAC permission
overlay:\{key\}not required (public)none
events:\{account_id\}requiredevents:read
spotify:\{account_id\}requiredspotify:read
chat:\{account_id\}requiredchat:read
automations:\{account_id\}requiredautomations: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 prefixRequired feature flag
automations:\{account_id\}feature:automation
all othersno 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.

typeFieldsPurpose
subscribechannel: stringSubscribe to a channel. Server responds with subscribed or error.
unsubscribechannel: stringUnsubscribe. Server responds with unsubscribed.
broadcastchannel: string, payload: stringPublish 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.

typeFieldsMeaning
welcomesession_id: string, server_version: stringSent once immediately after the socket opens.
subscribedchannel: stringSubscription accepted.
unsubscribedchannel: stringUnsubscription accepted.
pong--Reply to ping.
errormessage: string, code: stringError. See codes below.
eventdata: objectEvent payload relayed from a subscribed channel. See "Relay envelope" below.
spotify:now-playingdata: objectSpotify playback-state update relayed from spotify:\{account_id\}.
chat:messagedata: objectChat event relayed from chat:\{account_id\} (new messages, deletions, moderation logs, etc.).
automationdata: objectAutomation-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 channelWebSocket envelope typePublished to sessions subscribed on
lumio:events:\{account_id\}eventevents:\{account_id\}
lumio:spotify:\{account_id\}spotify:now-playingspotify:\{account_id\}
lumio:chat:\{account_id\}chat:messagechat:\{account_id\}
lumio:automations:\{account_id\}automationautomations:\{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.typeDirectionPayload shape
absent / regular ChatMessage fieldsWorker ➔ clientsFull chat-message row (id, username, message, emotes, badges, reply fields, …)
chat:deleteWorker ➔ clients{type, platform_message_id, deleted_by} — single message moderation-deleted; clients grey out that row
chat:clear_userWorker ➔ 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_logWorker ➔ clientsAudit-log entry for moderation feed widgets
chat:user_treatment_updateWorker ➔ clientsPer-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

codeMeaning
UNAUTHORIZEDCaller lacks access to the requested channel (missing account access or resource:action permission).
FEATURE_DISABLEDCaller is authorized but the channel's feature flag is disabled for the account (e.g. feature:automation off).
SUBSCRIBE_FAILEDServer-side failure while registering the subscription.
INVALID_FORMATMessage was not valid JSON or did not match any known client-message shape.
MESSAGE_TOO_LARGEText 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.