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…) | ✓ | ✓ | 600/min |
User API Key (lm_usr_…) | ✓ | ✗ | 1200/min |
System Key (lm_sys_…) | ✓ | ✗ | unlimited |
Popout Token (lm_pop_…) | ✗ | ✓ | 600/min |
Overlay Token (lm_overlay_…) | ✓ | ✓ | 600/min |
Shared Overlay Token (lm_share_…) | ✓ | ✓ | 600/min |
Extension Token (lm_ext_…) | ✓ | ✓ | 600/min |
Widget Token (lm_widget_…) | ✓ | ✓ | 600/min |
| Anonymous | (no token sent) | (no token sent) | 120/min |
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).
Rate-limit tiers are fixed per auth context, not derived from the account's plan.
Presented-but-invalid credentials
The distinction the middleware draws is "credential presented and rejected" vs. "no credential" — not "anonymous allowed, yes or no":
- No token (neither header nor
?token=) → the connection isAnonymous. Public, overlay and widget channel paths are unaffected. - A token is presented but fails validation — an expired or bad-signature JWT, a
refresh/legacy JWT on the request path, or a JWT whose backing session was revoked (logout) — the WebSocket upgrade is rejected with HTTP401before the101switch, and no session is started. The 401 body is the standard error envelope`{ "error": "TOKEN_EXPIRED" | "UNAUTHORIZED", "message": "…" }`—TOKEN_EXPIREDwhen the JWT is expired,UNAUTHORIZEDfor an invalid signature, a wrong token type, or a revoked session.
Previously a presented-but-invalid token was silently downgraded to Anonymous: the upgrade returned 101, then the first chat:* / account-scoped subscribe failed with UNAUTHORIZED while the socket stayed open — indistinguishable from a healthy connection. Clients should treat the 401 as "re-mint the token and reconnect" (Lumio's own clients do; see Client reconnection).
On REST and GraphQL routes a presented-but-invalid credential still resolves to Anonymous (unchanged), so public routes with a stale cookie or header keep working; only the /v1/ws upgrade fails closed today. Either way the rejection is logged distinguishably from a genuinely anonymous request (auth_type: rejected, no token material).
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.
Dashboard/popout pages must not place the raw session cookie JWT in the ?token= URL (that value is also the API/session credential and would be exposed to JS, browser history and proxy logs, defeating the cookie's httpOnly protection). Instead the SSR page mints a WebSocket-scoped JWT and passes that as the query-param token. A first-party dashboard page calls the issueWsToken GraphQL mutation (REST twin POST /v1/auth/ws-token); a popout page calls issuePopoutWsToken (REST twin POST /v1/auth/popout/ws-token) — a separate mint because issueWsToken is first-party gated (ZAF-469) and rejects a popout, and because the popout token must carry the popout's own permission subset, never the account owner's. Either token carries no session_id and a WebSocket-only use tag, so the API accepts it only on the /v1/ws upgrade path — a leak of the WS URL cannot be replayed against a REST/GraphQL mutation or a session-management endpoint. It is short-lived (auth.ws_token_expiration, ~15 min); the client re-mints on reconnect. Raw JWTs passed via ?token= on any non-WebSocket route are ignored.
A JWT-authenticated session's access is bounded by the token's expiry for the whole connection, not just at the handshake: the server checks the token exp on every heartbeat tick (5s) and disconnects an already-open session with TOKEN_EXPIRED once it passes (the same in-memory expiry enforcement applied to shared overlay tokens). The client re-mints a fresh WS token and reconnects (see Client reconnection), so an open socket cannot outlive its token.
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. crates/lo-websocket/src/gate.rs::channel_gate_for maps each channel type to one of these gate kinds:
| Gate | Meaning |
|---|---|
Public | Anyone, including anonymous callers |
PublicWhenFeature(flag) | Anyone while the named system flag is on; rejected with FEATURE_DISABLED when off |
Authenticated | Any non-anonymous identity |
Permission(perm) | Caller can access the account ID in the channel name and holds perm on it |
AccountScoped | Caller can access the account ID in the channel name; no specific permission |
UserPermission(perm) | Caller holds perm in their user-scoped permissions; the channel ID is not an account UUID |
OverlayToken / WidgetToken | Requires that bound token type, with a matching overlay_id / widget_id |
ExtensionOwner | Caller is a developer who owns the extension in the channel ID |
Unknown | Unrecognised channel type — always rejected |
The complete channel map:
| Channel | Gate |
|---|---|
overlay:\{key\} | OverlayToken — an lm_overlay_* or lm_share_* token bound to this specific overlay |
widget:\{widget_instance_id\} | WidgetToken — an lm_widget_* token bound to this widget instance |
events:\{account_id\} | Permission("events:read") |
spotify:\{account_id\} | Permission("spotify:read") |
chat:\{account_id\} | Permission("chat:read") |
automations:\{account_id\} | Permission("automations:read") |
history:\{account_id\} | Permission("history:read") |
ext-storage:\{install_id\} | Permission("extension-store:read") |
ext-install-logs:\{account_id\} | Permission("bot-modules:read") |
sounds:\{account_id\} | AccountScoped — Widget/Overlay tokens carry no account RBAC but must still stay inside their own account |
presence:\{resource_channel\} | PresenceScoped — bound to the account that owns the wrapped resource |
ext-logs:\{extension_id\} | ExtensionOwner |
ideas:list, ideas:\{idea_id\} | PublicWhenFeature("system:ideas_hub_public") |
public-stats:\{platform\}:\{broadcast_id\} | PublicWhenFeature("system:public_stats") |
login-assignments:\{id\} | Public |
presence:* channels track who is connected to an editor session. Subscribe to presence:widget:\{id\} to receive presence:join, presence:leave, and bootstrap events with the current connections list — which include each editor's display name, avatar, user_id and account_id. Because that bootstrap leaks member identities, presence is account-scoped: the server resolves the wrapped resource (e.g. widget:\{id\}) to its owning account and rejects the subscription unless the caller can access that account. A subscriber from another account (or an anonymous one) is rejected with UNAUTHORIZED; the subscribe also fails closed if the resource cannot be resolved to an account.
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 |
|---|---|
overlay:\{key\} | feature:overlays |
chat:\{account_id\} | feature:multichat |
automations:\{account_id\} | feature:automation |
events:\{account_id\} | feature:events |
spotify:\{account_id\} | feature:music |
sounds:\{account_id\} | feature:sounds |
history:\{account_id\} | feature:stream_history |
widget:\{widget_instance_id\} | feature:widgets |
ext-storage:\{install_id\} | feature:extensions |
ext-logs:\{extension_id\} | feature:extension_development |
ext-install-logs:\{account_id\} | feature:bot_module_extensions |
ideas:* | system:ideas_hub |
public-stats:* | system:public_stats |
all others (presence, login-assignments) | no feature gate |
For overlay:\{key\} the channel suffix is the opaque overlay key, not an account UUID, so the entitlement is checked against the account carried by the lm_overlay_* / lm_share_* token — not by parsing the channel id. This mirrors the feature:overlays FeatureGuard on every GraphQL overlay path, so an account that loses the feature (e.g. a downgrade) stops streaming over an already-issued overlay token.
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. Idempotent: subscribing again to a channel this session already holds re-acks with subscribed but does not open a second stream — a channel is delivered exactly once per session regardless of repeat subscribes. |
unsubscribe | channel: string | Unsubscribe. Server responds with unsubscribed and delivery for that channel stops immediately. |
broadcast | channel: string, payload: string | Publish a JSON-encoded payload to every session subscribed to channel. Requires the channel's write permission — distinct from the subscribe :read permission. Only three channel types accept a client broadcast: events (events:create), chat (chat:write), and automations (automations:execute). Every other channel type, including overlay:*, spotify:*, and history:*, rejects client broadcasts outright; server-side code is the only publisher. 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.
Messages generated by the session loop itself use the ServerMessage shapes:
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 | channel: string, event_type: string, data: object | Event payload for a subscribed channel. |
Relayed traffic reuses event for most families but keeps three legacy per-family discriminators:
type | Fields | Meaning |
|---|---|---|
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 payload to every subscribed WebSocket session, wrapped in an envelope. The envelope depends on the Redis channel prefix. Only the prefixes listed below are relayed — a publish on any other Redis channel never reaches a WebSocket client.
| Redis channel | WebSocket envelope | Published to sessions subscribed on |
|---|---|---|
lumio:events:\{account_id\} | \{"type":"event","data":…\} | events:\{account_id\} |
lumio:spotify:\{account_id\} | \{"type":"spotify:now-playing","data":…\} | spotify:\{account_id\} |
lumio:chat:\{account_id\} | \{"type":"chat:message","data":…\} | chat:\{account_id\} |
lumio:automations:\{account_id\} | \{"type":"automation","data":…\} | automations:\{account_id\} |
lumio:overlay:\{key\} | \{"type":"event","channel":…,"event_type":…,"data":…\} — event_type from the payload, default overlay:update | overlay:\{key\} |
lumio:widget:\{widget_instance_id\} | \{"type":"event","channel":…,"event_type":…,"data":…\} — event_type from the payload's type, default widget:update | widget:\{widget_instance_id\} |
lumio:ext-storage:\{install_id\} | \{"type":"event","channel":…,"event_type":"storage:update","data":…\} | ext-storage:\{install_id\} |
lumio:sounds:\{account_id\} | \{"type":"event","channel":…,"event_type":"sound:play"|"sound:stop","data":…\} | sounds:\{account_id\} |
lumio:history:\{account_id\} | \{"type":"event","channel":…,"event_type":"history:session_started"|…,"data":…\} | history:\{account_id\} |
The relay is one-way (Redis to WebSocket). For the four legacy families (events, spotify, chat, automations) data is the exact JSON the publisher sent, including publisher-specific fields such as type, event_type, and payload. For the other families the relay lifts event_type out of the payload and forwards the payload's data member.
Channel-status transitions do not have their own WebSocket channel: the channel-status relay republishes them onto lumio:events:\{account_id\} as channel:online / channel:offline, so they arrive on events:\{account_id\}.
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, …). For a Twitch Shared Chat message originating in a different channel's room, also carries is_shared_chat: true and source_channel: {platform_channel_id, login, display_name, avatar_url}; both are absent/null for normal and host-origin messages (byte-for-byte unchanged). |
chat:delete | Worker ➔ clients | {type, platform_message_id, deleted_by} — a single message was moderation-deleted, either issued through Lumio or detected natively (Twitch EventSub channel.chat.message_delete, YouTube moderator-delete / author-retract, InnerTube). The row is only ever soft-deleted — never hard-deleted — and the removal is recorded in the affected user's moderation log; 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 / Kick webhook moderation.banned); 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 | {type, data: {action, platform, target_user_id, target_username, moderator_name, …}} — audit-log entry for moderation feed widgets |
chat:user_treatment_update | Worker ➔ clients | {type, platform, platform_user_id, user_treatment} — per-user treatment changed (user_treatment ∈ active_monitoring, restricted, none); REST, GraphQL, and Twitch EventSub all publish this identical flat shape |
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 session's token has expired mid-connection — a shared overlay token whose duration elapsed, or a JWT session whose token exp passed. Re-mint the token and reconnect (Lumio's clients do this automatically); for a shared link, create a new one to regain access. |
SUBSCRIBE_FAILED | Server-side failure while registering the subscription. |
CHANNEL_DROPPED | The client fell too far behind on one channel and the server dropped it from that channel (its outbound buffer stayed full). The session stays connected and other channels are unaffected; resubscribe to resume. Surfaced instead of the channel silently disappearing. |
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 disconnects any session that has been silent for 30 seconds (client_timeout, 6× the 5-second heartbeat interval — a margin wide enough that throttled background tabs are not dropped). The server does not send WebSocket Ping frames, so the client is responsible for keeping the connection alive: send a {"type":"ping"} every few seconds, or maintain any other inbound traffic. A hidden browser tab has its setInterval clamped to at most once per minute, so the wide client_timeout margin is what keeps backgrounded overlay and Multichat tabs connected — do not shrink it toward the heartbeat interval. Standard WebSocket Ping control frames sent by the client are auto-answered with Pong. (The 5-second heartbeat tick is an internal timer used for overlay/shared-token revalidation and JWT-session expiry enforcement, not a ping to the client.)
The same client_timeout also bounds every outbound send: if a client stops draining its socket (a sleeping phone, a half-open TCP connection), the server closes and reaps the session within that window instead of buffering its broadcasts without limit. A per-session outbound queue caps at 256 messages; a session that stays behind past that point is disconnected rather than allowed to grow unbounded.
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.
JWT session expiry
For sessions authenticated with a JWT (a logged-in User, including the WebSocket-scoped token minted by issueWsToken), the server enforces the token's exp for the entire connection. On each heartbeat tick it compares the token expiry (carried in from the validated claims — no per-tick database round-trip) against the current time and, once it has passed, sends an error with code: "TOKEN_EXPIRED" and closes the WebSocket. This bounds an already-open socket to its token's lifetime: the handshake rejection alone (see Authentication) only stops an expired token from starting a session, whereas this closes a session whose token expires while connected. Lumio's clients re-mint a fresh WS token and reconnect automatically.
Client reconnection
Lumio's own browser clients (dashboard chat/events/history feeds, popout windows, overlay browser sources) share one resilient reconnection strategy:
- Fresh credential per connect. The dashboard mints its WS token (
issueWsToken) from a short-lived route on every connect attempt (never a value frozen at page load), and popout windows mint their own WS-scoped token (issuePopoutWsToken) the same way. A tab left open past the token TTL therefore re-mints and recovers on its next reconnect without a page reload. welcome-gated subscribe. The client subscribes only after thewelcomeframe and reports itself "connected" only once the server acknowledges a subscription withsubscribed. A rejected subscribe surfaces theerrorcodein the UI rather than a false "connected" state.- Bounded auth recovery. An
errorwithUNAUTHORIZED/FORBIDDEN/TOKEN_EXPIRED/TOKEN_REVOKEDtriggers exactly one fresh-token reconnect; a second consecutive auth error stops the loop (no reconnect storm).FEATURE_DISABLEDstops immediately;CHANNEL_DROPPEDreconnects to re-subscribe. - Jittered backoff. Reconnect delay is exponential (1s → 30s cap) with equal jitter so a fleet of tabs does not stampede the API in lockstep after a restart, and the backoff counter resets only after a connection has held stably — a socket that drops right after upgrading keeps escalating instead of hammering at the 1s floor.
Observability
The WebSocket server exposes two live gauges on the public health surface (GET /v1/health, data.websocket): sessions (active WebSocket sessions) and channels (active broadcast channels). Both are aggregate counters with no per-user or per-channel detail. They are maintained on every connect/disconnect and subscribe/unsubscribe and read lock-free, so scraping the health endpoint never contends with the broadcast hot path. Watching sessions trend flat rather than climbing is the production signal that the connection-reaping path is healthy.
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).history:\{account_id\}-- Stream-history session lifecycle (started / updated / ended). Requiresfeature:stream_historyandhistory:read.sounds:\{account_id\}-- Sound playback commands (sound:play,sound:stop) consumed by browser sources to trigger in-overlay audio. Requiresfeature:soundsand access to the account in the channel name.overlay:\{key\}-- Per-overlay updates for browser-source popouts. Requires anlm_overlay_*orlm_share_*token bound to the specific overlay identified bykey.widget:\{widget_instance_id\}-- Per-widget config/state updates for browser sources. Requires anlm_widget_*token bound to that instance andfeature:widgets.ext-storage:\{install_id\}-- Live key/value updates for an extension installation's storage. Requiresextension-store:readandfeature:extensions.ext-logs:\{extension_id\}-- Build and runtime log stream for an extension, restricted to its owning developer. Requiresfeature:extension_development.ext-install-logs:\{account_id\}-- Runtime logs for the account's installed extensions. Requiresbot-modules:readandfeature:bot_module_extensions.presence:widget:\{id\}-- Editor presence (presence:join,presence:leave, bootstrap list). Account-scoped: the server resolves the wrapped resource (widget:\{id\}) to its owning account and rejects subscribers who cannot access that account (and anonymous callers). Resource types without an account resolver fail closed.ideas:list/ideas:\{idea_id\}-- Ideas Hub list and per-idea updates. Public whilesystem:ideas_hub_publicandsystem:ideas_hubare on.public-stats:\{platform\}:\{broadcast_id\}-- Public Stats live read for one stream. On subscribe the server pushes abootstrapevent with the field-gated, opt-out-respecting stream snapshot (the same DTO as the GraphQLpublicStreamStats/ RESTGET /v1/public-stats/streams/...). Unauthenticated and carries no per-user data; gated globally onsystem:public_stats. An opted-out channel (or an unknown stream) yields no bootstrap. Clients never publish — server-side only. The cross-channel browse/search surface (GET /v1/public-stats/\{browse,games\}+ GraphQLpublicChannelBrowse/publicGames) has no WebSocket channel — a browse/search directory has no live-push consumer (a justified single-protocol omission, like the emote directory).login-assignments:\{id\}-- Login-assignment updates. Public.