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 paramRate 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 is Anonymous. 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 HTTP 401 before the 101 switch, and no session is started. The 401 body is the standard error envelope `{ "error": "TOKEN_EXPIRED" | "UNAUTHORIZED", "message": "…" }` - TOKEN_EXPIRED when the JWT is expired, UNAUTHORIZED for 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).

Scope

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 once, distinguishably from a genuinely anonymous request (auth_type: rejected, no token material): at debug for an expired JWT, which the client renews on TOKEN_EXPIRED, and at warn for an invalid signature, a wrong token type or a revoked session.

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). This covers both a logged-in user's JWT and a popout session JWT (the issuePopoutWsToken token or the exchanged popout session). 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. There are a few exceptions for extension content: an overlay or widget token also reaches its own account's events: / chat: stream when it renders an extension that declares events:read / chat:read (see Extension hosts). It also reaches the storage and realtime channels of the installs it renders.

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. Revocation is caught by the 30-second credential re-validation.

Channels being dissolved​

While a channel is scheduled for dissolution, the /v1/ws upgrade is refused with HTTP 403 and { "error": "CHANNEL_DISSOLVING", … } for any credential of that channel, a member's session JWT included. Sockets that are already open are closed with the error code CHANNEL_DISSOLVING on the next 30-second credential re-validation. Every feature flag also resolves off for the account, so the feature-flag gate refuses its channels. After the owner cancels the dissolution, clients connect again normally. See REST API → Channels being dissolved.

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:

GateMeaning
PublicAnyone, including anonymous callers
PublicWhenFeature(flag)Anyone while the named system flag is on; rejected with FEATURE_DISABLED when off
AuthenticatedAny non-anonymous identity
Permission(perm)Caller can access the account ID in the channel name and holds perm on it
PermissionOrExtensionHost(perm)Same as Permission(perm), or an lm_overlay_* / lm_widget_* token of that account whose overlay or widget renders a runnable install whose manifest declares perm (see Extension hosts)
AccountScopedCaller 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 / WidgetTokenRequires that bound token type, with a matching overlay_id / widget_id
ExtensionOwnerCaller is a developer who owns the extension in the channel ID
UnknownUnrecognised channel type - always rejected

The complete channel map:

ChannelGate
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\}PermissionOrExtensionHost("events:read")
spotify:\{account_id\}Permission("spotify:read")
chat:\{account_id\}PermissionOrExtensionHost("chat:read")
automod:\{account_id\}Permission("chat:automod-read")
automations:\{account_id\}Permission("automations:read")
history:\{account_id\}Permission("history:read")
chatters:\{account_id\}Permission("chat:read")
ext-storage:\{install_id\}ExtensionStorage - the readers of the install's storage: its own extension token, a member session, user API key or service key of the owning account with extension-store:read, or an overlay / widget token whose overlay or widget renders the (runnable) install; shared overlay, popout and System keys are refused
ext-install-logs:\{install_id\}InstallLogs - the install's owning account plus its category's read permission: bot-modules:read (bot module) or automations:read (automation node)
extension_install:\{install_id\}ExtensionInstall - the callers of a query server function on the install: its own extension token, an overlay token whose overlay renders it, a widget token whose widget renders it, or a member session or user API key of the owning account with extension-data:read; a System key is refused (the feature:extensions check needs an account of its own)
sounds:\{account_id\}AccountScoped - Widget/Overlay tokens carry no account RBAC but must still stay inside their own account; a System key needs bots:internal
presence:\{resource_channel\}PresenceScoped - signed-in members of the account that owns the wrapped resource; a widget token only for its own widget
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 bound to both the owning account and the caller's identity class. The server parses the wrapped resource (e.g. widget:\{id\}), resolves it to its owning account, and accepts only:

  • a signed-in member (JWT session) of that account;
  • a widget token (lm_widget_*) whose widget is the wrapped resource - presence:widget:\{id\} for its own widget id, and no other widget of the same account;
  • a System key carrying bots:internal (a System key without it is rejected like any other credential).

Every other credential is rejected with UNAUTHORIZED, even inside the owning account: popout, overlay, shared overlay, extension, user API key, service key and developer key tokens, and anonymous callers. The subscribe also fails closed if the resource type is unknown, the id is malformed, or the resource cannot be resolved to an account.

"Can access the account" means the account in the caller's own auth context, or a System key. On Permission channels a System key also needs the channel's permission; on sounds:* and presence:*, which carry no channel permission, it needs bots:internal - never the key type alone. No other identity has a cross-tenant reach: a platform operator holding admin:access is gated exactly like any other member of their active account. admin:access is only the admin-dashboard entry gate, so it never opens another tenant's events, chat, presence, or other account-scoped channel.

Subscribing without the matching permission yields an error message with code: "UNAUTHORIZED" and the channel is not registered.

Extension hosts read events and chat​

An OBS browser source renders extensions with an overlay token (lm_overlay_*, overlay page) or a widget token (lm_widget_*, widget page). Those tokens carry no account RBAC, so the extension's own manifest is the grant. An extension declares events:read and/or chat:read in permissions of its lumio.config.json. Such a token may subscribe events:\{account_id\} / chat:\{account_id\} when all of these hold:

  • the channel's account is the token's own account;
  • its overlay has an ext:\{slug\} layer for the extension, or its widget renders the install (widget_instances.install_id);
  • the install is runnable: live, enabled, extension and run version not suspended, account not in its dissolution grace period;
  • the manifest of the version the host renders (extension_versions.manifest of the install's version_id, the version the bootstrap sends) declares that same permission. events:read never opens chat: and vice versa.

The check runs once per subscribe. Anything else is rejected with UNAUTHORIZED: another account's channel, an install without the declaration, a disabled or uninstalled install, an overlay without the extension's layer. Shared overlay links (lm_share_*), popout tokens without the grant and System keys follow the plain Permission rule. The feature gates still apply (feature:events, feature:multichat on the channel's account). Frames are unchanged: events: carries \{"type":"event","data":StreamEvent\} and chat: carries \{"type":"chat:message","data":…\}. The host forwards each event only to extensions that declared the matching permission: chat types (chat:message, twitch:chat, youtube:chat, kick:chat, twitch:ban, twitch:unban) need chat:read, every other event type needs events:read. The hosts learn the declared permissions from extension_permissions in the overlay and widget bootstraps, and the dashboard widget editor learns them from WidgetInstance.extension.permissions.

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
overlay:\{key\}feature:overlays
chat:\{account_id\}feature:multichat
automod:\{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
chatters:\{account_id\}feature:chat-viewer-list
widget:\{widget_instance_id\}feature:widgets
ext-storage:\{install_id\}feature:extensions (checked on the subscriber's account)
ext-logs:\{extension_id\}feature:extension_development
ext-install-logs:\{install_id\}feature:bot_module_extensions (checked on the subscriber's active account)
extension_install:\{install_id\}feature:extensions (checked on the subscriber's account)
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. widget:\{widget_instance_id\} works the same way. Its suffix is the widget id, so feature:widgets is checked against the account that owns the widget, which the lm_widget_* token carries, never against the widget id.

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. 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.
unsubscribechannel: stringUnsubscribe. Server responds with unsubscribed and delivery for that channel stops immediately.
ping--Heartbeat. Server replies with pong.

The socket is read-only for clients. No frame lets a client publish into a channel. Every channel's publisher is server-side code, such as the event pipeline, chat ingest, workers, and REST/GraphQL mutations. Any other type, including a broadcast frame, is answered with code: "INVALID_FORMAT" and has no effect.

Example:

{"type":"subscribe","channel":"events:550e8400-e29b-41d4-a716-446655440000"}

Messages larger than 64 KiB are rejected with MESSAGE_TOO_LARGE.

Subscribe rate limit​

Each session has a token bucket for subscribe and unsubscribe frames. Both frame kinds draw from the same bucket, on every channel type. A fresh session may send 120 of them back-to-back (subscribe_burst), then 5 per second (subscribe_refill_per_second). The burst is never below the session channel cap (max_channels_per_session, default 100): a lower configured value is raised to the cap at startup, so a client can always subscribe a full channel set at connect. A frame sent with the bucket empty gets an error with code: "RATE_LIMITED" and is dropped: the subscription is not changed, and no authorization or bootstrap work runs for it. The message names the channel and how many milliseconds until the next frame is accepted. The session stays open, and channels already subscribed keep delivering. Repeat the dropped frame after that delay. ping frames do not count against the bucket.

The limit exists because every subscribe runs the channel's authorization lookups and its bootstrap read. For public-stats:* that bootstrap is a database read, available to anonymous callers. A normal client subscribes to a few channels after welcome and stays far below the burst. A reconnect opens a new session with a full bucket. The /ws upgrade itself is covered by the HTTP rate limiter, so one caller cannot reset its bucket faster than it is allowed to open connections.

Server Messages​

All server messages are JSON with a type discriminator.

Messages generated by the session loop itself use the ServerMessage shapes:

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.
eventchannel: string, event_type: string, data: objectEvent payload for a subscribed channel.

Relayed traffic reuses event for most families but keeps three legacy per-family discriminators:

typeFieldsMeaning
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 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 channelWebSocket envelopePublished to sessions subscribed on
lumio:events:\{account_id\}\{"type":"event","data":…\} (data is the full event row; raw is forwarded unchanged, incl. the Twitch Shared Chat is_shared_chat / source_channel / shared_chat_notice_type partner-event fields - see Events)events:\{account_id\}
lumio:spotify:\{account_id\}\{"type":"spotify:now-playing","data":…\}spotify:\{account_id\}
lumio:chat:\{account_id\}\{"type":"chat:message","data":…\} (including data.type: "chat:pins" and data.type: "shared_chat_session")chat:\{account_id\}
lumio:automod:*event with event_type and dataautomod:\{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:updateoverlay:\{key\}
lumio:widget:\{widget_instance_id\}\{"type":"event","channel":…,"event_type":…,"data":…\} - event_type from the payload's type, default widget:updatewidget:\{widget_instance_id\}
lumio:ext-storage:\{install_id\}\{"type":"event","channel":…,"event_type":"storage:update","data":…\}ext-storage:\{install_id\}
lumio:extension_install:\{install_id\}\{"type":"event","channel":…,"event_type":"extension:realtime","data":\{"topic":…,"payload":…\}\}extension_install:\{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"|"history:session_sampled"|"history:session_updated"|…,"data":…\}history:\{account_id\}
lumio:chatters:\{account_id\}\{"type":"event","channel":…,"event_type":"chatters:delta","data":…\}chatters:\{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.

A chat:message envelope whose data.type is shared_chat_session contains the full REST-shaped session state, not a delta: status, revision, session_id, host and own broadcaster IDs, times, participants and total_viewers. It uses the existing chat:\{account_id\} subscription, including for delegated popout tokens with chat:read; there is no new channel or Redis pub/sub key. Clients drop an older revision and keep the last state on unknown. Every persisted transition also sends history:session_updated on the existing history channel, with session_id, reason: "shared_chat" and revision, so open reports can reload at once.

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\}.

After each successful history sampler tick, history:\{account_id\} receives history:session_sampled with data: { "session_id": "<uuid>", "platforms": ["twitch", "kick"], "sampled_at": "<RFC3339 minute>" }. The channel requires history:read and feature:stream_history. The event tells the dashboard to refetch channelHistoryOverview; it does not contain the sampled counters. The dashboard reuses its existing socket for this subscription.

Relay liveness​

One relay task holds one Redis pub/sub subscription and feeds every channel in the table above, for every account. Its health is therefore all-or-nothing: if that subscription dies, every WebSocket client goes silent at once while their sockets stay open and their pings keep getting answered.

A pub/sub subscriber never writes after subscribing, so a peer that disappears without a clean TCP close leaves a socket that looks perfectly healthy and simply never delivers again. lo_cache::RedisSubscriber therefore probes its own connection: after 30 s of silence it sends a Redis PING, and if that goes unanswered for 5 s the subscription is declared dead so the relay's reconnect loop takes over. TCP keepalive and TCP_USER_TIMEOUT are enabled on the same socket as a kernel-level backstop (redis leaves both off by default). Every RedisSubscriber user inherits this - the API relays and workers as well as all five chat bots.

Watch ws_relay_connected, ws_relay_reconnects_total and ws_relay_messages_total (see Metrics).

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, …). An announcement has message_type: "announcement" and announcement_color (primary, blue, green, orange, purple); an ordinary message has no announcement colour. A reply carries the parent author as both reply_parent_username (display name) and reply_parent_login (login/handle twin; null where unavailable). 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.
chat:pinsPin worker ➔ clients{type: "chat:pins", twitch, kick} replaces the full current pin state for both platforms. Each pin has sent_at (the original message time or null) beside starts_at, ends_at and updated_at. Subscribe to the existing chat:\{account_id\} channel; there is no separate pins subscription.
chat:deleteWorker ➔ 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, Kick MessageDeletedEvent from the unofficial chat socket - deleted_by is "moderator" or "Kick AutoMod"). 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_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 / 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_logWorker ➔ clients{type, data: {action, platform, target_user_id, target_username, target_display_name, moderator_name, moderator_login, reason, duration_secs, ends_at, target_message_id, created_at}} - moderation-log entry for the user card and moderation feed widgets. It is published for every stored entry: native actions (Twitch EventSub ban/unban, Kick webhook moderation.banned, Kick delete/unban from the unofficial chat socket) and actions issued through Lumio (REST/GraphQL moderate, all platforms), all built from the same fields as the stored row. duration_secs / ends_at are set for a timeout and null for a ban, delete or unban. target_username / moderator_login carry the login half, target_display_name / moderator_name the display half; either half may be null when a platform exposes only one name (e.g. YouTube) or a system placeholder acted.
chat:user_treatment_updateWorker ➔ 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.

The events:\{account_id\} relay also carries the five pin events (twitch:pin_added, twitch:pin_updated, twitch:pin_removed, kick:pin_added, kick:pin_removed) and the four Twitch role events (twitch:mod_added, twitch:mod_removed, twitch:vip_added, twitch:vip_removed) as ordinary event frames. Pin raw includes the sender and moderator person shapes described in Events; raw.sent_at on a pin-added event is the original message time or null.

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).
TOKEN_REVOKEDThe credential the session was opened with is no longer valid: it was revoked, rotated, deleted or has expired, or (popout tokens, personal API keys) the member it is bound to left the account. See Credential re-validation. Reconnect with a fresh credential - for a browser source, a fresh URL from the dashboard.
AUTH_UNAVAILABLEThe server could not re-validate the session's credential twice in a row (database timeout or error), so it closed the session instead of keeping it open unchecked. Transient - reconnect with backoff.
TOKEN_EXPIREDThe 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.
CHANNEL_DISSOLVINGThe channel the session belongs to is scheduled for dissolution and suspended. Sent on the 30-second credential re-validation tick, then the socket is closed. Reconnecting is refused until the owner cancels the dissolution.
SUBSCRIBE_FAILEDServer-side failure while registering the subscription.
CHANNEL_DROPPEDThe 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_FORMATMessage was not valid JSON or did not match any known client-message shape.
MESSAGE_TOO_LARGEText frame exceeded the 64 KiB limit.
RATE_LIMITEDA subscribe or unsubscribe frame arrived with the session's subscribe rate limit used up. The frame was dropped; the session stays open. Retry the frame after the delay named in the message.

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 credential re-validation and JWT / shared-token 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.

Credential re-validation​

Every session opened with a revocable credential has that credential re-validated against the database every 30 seconds (throttled inside the 5-second heartbeat tick), not only at the handshake. This covers every token kind:

CredentialInvalid on the next re-check when
Popout token lm_pop_*, and popout session JWTs minted from onethe origin token is revoked, expired or deleted, or the member it is assigned to left the account
Personal API key lm_usr_*the key is rotated, expired or deleted, or its owner left the account
Service key lm_svc_*the key is rotated, expired or deleted
Developer key lm_dev_*the key is revoked, rotated, expired or deleted
Overlay token lm_overlay_*the token is rotated, revoked, expired or deleted
Shared overlay token lm_share_*the link is revoked or has expired
Extension token lm_ext_*the token has expired or was deleted
Widget token lm_widget_*the token is rotated, revoked, expired or deleted
JWT carrying a session_idthe session was logged out or has expired

The check reads the stored token hash, so a rotated secret stops working on an open socket even though the token row (and its id) survives the rotation. Nothing about validity is cached: a revocation is visible on the very next re-check.

When the credential is no longer valid, the server sends an error 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.

The re-check fails closed. Each check has a 500 ms budget; a timeout or database error keeps the session's last result (it was valid, or the session would already be closed) for one check only. A second consecutive failure closes the session with code: "AUTH_UNAVAILABLE"; clients reconnect with backoff, and the reconnect handshake validates the credential afresh. A single successful check resets the count, so an isolated database blip never disconnects anyone.

A JWT without a session_id (the WS token minted by issueWsToken / issuePopoutWsToken) has nothing to look up; its lifetime is bounded by its exp instead (see JWT session expiry). Anonymous sessions and system keys are not re-validated.

JWT session expiry​

For sessions authenticated with a JWT (a logged-in User, including the WebSocket-scoped token minted by issueWsToken, and a popout session JWT, including the token minted by issuePopoutWsToken), 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 the welcome frame and reports itself "connected" only once the server acknowledges a subscription with subscribed. A rejected subscribe surfaces the error code in the UI rather than a false "connected" state.
  • Bounded auth recovery. An error with UNAUTHORIZED / FORBIDDEN / TOKEN_EXPIRED / TOKEN_REVOKED triggers exactly one fresh-token reconnect; a second consecutive auth error stops the loop (no reconnect storm). FEATURE_DISABLED stops immediately; CHANNEL_DROPPED reconnects to re-subscribe; AUTH_UNAVAILABLE is transient and reconnects with the normal backoff.
  • 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.
  • Subscribe only to channels the credential can read. Because auth recovery is triggered by an UNAUTHORIZED error frame, a client that runs several channels over one socket must not subscribe to a channel its own credential cannot read - the per-channel rejection would enter the reconnect path and churn the whole socket, dropping the channels that were working. So a surface asks only for what its token's permission subset allows, resolved before it opens the socket. Concretely, a delegated/shared chat pop-out (a popout session from a shared lm_pop_* link) always subscribes to chat:\{account_id\}, and additionally subscribes to events:\{account_id\} only when its token was granted events:read and the account holds feature:events - the same conditions the subscribe-time RBAC gate enforces server-side. The common delegated subset (chat:read, no events:read) therefore never attempts the events: subscribe, so it shows live chat but no inline stream-event lines; a pop-out whose creator explicitly delegated events:read gets both. See Multichat → Inline stream events.

Chatters channel​

chatters:\{account_id\} carries the live chatter list. Same guard as GraphQL chatters and REST GET /v1/chat/chatters: chat:read + feature:chat-viewer-list; popout tokens (ws-token) with chat:read are accepted. Clients never publish. Its supported_platforms contains Twitch and, when the account has a usable Kick channel connection, Kick.

  • Subscribe → one bootstrap event whose data is the full ChattersSnapshot (supported_platforms, platforms, chatters — the REST shape without the data/links wrapper). Holding the channel open keeps the server polling the platform. Every subscription also renews the account's 7-day wanted marker, so the server keeps polling while that platform's stream is live even with no subscriber (see Chatter List → Polling). An account that never subscribes causes no platform calls.
  • Then deltas only, event_type: "chatters:delta":
data.opPayloadClient action
join\{op, platform, entries: [Chatter]\}Add each entry. A join for a key the client already holds is applied as an update (dedupe, e.g. after a reconnect)
update\{op, platform, entries: [Chatter]\}Replace the entry with the same (platform, user_id). This includes leaving the chat (present: false, frozen last_seen_at) and coming back (present: true, new first_seen_at)
leave\{op, platform, entries: [Chatter]\}Drop the row with the same (platform, user_id); unknown keys are ignored. Sent when the recently-left retention is over, and for every entry when the stream goes offline
status\{op, platform, entries: [], status: ChatterPlatformStatus\}Replace that platform's status (available, reason, total, truncated, updated_at)

status.reason can be missing_scope, not_connected, disabled, or source_unavailable. The last value means that the platform’s list source failed (for example, Kick’s undocumented endpoint): available is false, total is null, truncated is false, and recent writers remain in the list. After two failed polls, snapshot-only entries receive update with present: false. Other platforms continue independently.

Entries are keyed by (platform, user_id), never by name. One frame carries at most 500 entries. A new bootstrap (re-subscribe) replaces the whole list.

Presence lifecycle of one key (ZAF-1767):

  1. join (present: true, first_seen_at set) — the person appears.
  2. updates while they stay: role, avatar, color, name. first_seen_at does not move; a present row's moving last_seen_at is not re-sent per poll.
  3. update with present: false — they left. last_seen_at is now final. Show them as recently left; never count them.
  4. Either update with present: true and a new first_seen_at (they came back within 30 min), or leave once last_seen_at is older than the retention (30 min) — remove the row.

When the stream goes offline the server sends leave for every entry, so the next stream starts empty. A client that missed frames re-subscribes and gets a fresh bootstrap.

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.

AutoMod events​

automod:\{account_id\} carries automod:held, automod:resolved and automod:moderation_log as event envelopes. It is separate from chat:\{account_id\}: a held message never reaches ordinary chat, overlays or widgets. The channel requires chat:automod-read and feature:multichat; a popout token with that permission can subscribe. The client also fetches heldChatMessages / GET /v1/chat/automod/held to restore open holds after reconnect.

The automod:held payload has the same fields as a heldChatMessages entry: emotes (Twitch emotes and cheermotes in the chat-message emotes shape, derived from the fragments when the message leaves the server; absent or null when there are none or the fragments were dropped for size) and the sender's avatar_url, color and badges (a snapshot of Lumio's chatter record at hold time; absent for a chatter Lumio has not seen). Resolved holds stay readable after a reload as automod items of the merged chat history (mergedChatHistory).

Example Channels​

  • events:\{account_id\} -- Stream events (follows, subs, cheers, raids, redemptions, tips, channel online/offline, bot-command updates, spotify:track). Requires feature:events. Readers are members with events:read and extension hosts whose extension declares events:read.
  • 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. Requires feature:multichat. Readers are members with chat:read and extension hosts whose extension declares chat:read.
  • automod:\{account_id\} -- Twitch AutoMod holds and decisions; requires feature:multichat and chat:automod-read.
  • automations:\{account_id\} -- Automation-engine lifecycle updates (triggers, execution progress).
  • history:\{account_id\} -- Stream-history session lifecycle (started / updated / ended) and sampler ticks (history:session_sampled). Requires feature:stream_history and history:read.
  • chatters:\{account_id\} -- Live chatter list: snapshot as bootstrap, then chatters:delta (join / leave / update / status). Requires feature:chat-viewer-list and chat:read. See Chatters channel.
  • sounds:\{account_id\} -- Sound playback commands (sound:play, sound:stop) consumed by browser sources to trigger in-overlay audio. Requires feature:sounds and access to the account in the channel name.
  • overlay:\{key\} -- Per-overlay updates for browser-source popouts. Requires an lm_overlay_* or lm_share_* token bound to the specific overlay identified by key. On subscribe the server pushes a bootstrap event with account_id and the overlay's layers (id, overlay_id, type, name, config, visible, sort_order, created_at, updated_at), plus obs, spotify and chat state when the overlay has such layers. An extension layer (type: "ext:\{slug\}") also carries extension_id, extension_install_id, extension_slug extension_version and extension_permissions (the permissions array of that version's manifest, [] when it declares none) when the overlay's account has a live, enabled install of that extension and the extension is not suspended; the bundle is served at /extension-bundles/\{extension_id\}/\{extension_version\}/layer.js. Without such an install these keys are absent and the overlay mounts nothing for the layer. A layers:updated event (sent when the overlay is saved in the editor) carries layers in the same shape, so a live overlay keeps its extension layers mounted. The overlay bootstrap exists only on WebSocket; GraphQL overlayLayers is the dashboard editor read and returns the raw rows.
  • widget:\{widget_instance_id\} -- Per-widget config/state updates for browser sources. Requires an lm_widget_* token bound to that instance and feature:widgets. On subscribe the server pushes a bootstrap event with account_id, widget_type, name, config, width, height, extension_id and install_id. An extension widget also gets version and extension_permissions: version is the bundle version, served at /extension-bundles/\{extension_id\}/\{version\}/layer.js. extension_permissions is the permissions array of that version's manifest. Both are sent only while the install is live and enabled and the extension is not suspended. Otherwise version is null and extension_permissions is [].
  • ext-storage:\{install_id\} -- Live key/value updates for an extension installation's storage: every write through GraphQL setExtensionStorage / REST POST /v1/extension-installs/\{install_id\}/storage arrives as a storage:update event with data: \{ "key", "value", "install_id" \}. The id is an install id; the server resolves it to the install and admits exactly the callers that may read that storage on GraphQL extensionStorage / REST GET /v1/extension-installs/\{install_id\}/storage - the install's own extension token, a member session, user API key or service key of the owning account holding extension-store:read, or an overlay token whose overlay renders the install in an extension layer (ext:{slug}) or a widget token whose widget renders it, as long as the install is runnable. Overlay or widget tokens that do not render the install, shared overlay, popout, developer and System keys, anonymous callers, a member of another account, an unknown install, an install waiting for its data deletion and a malformed id are rejected with UNAUTHORIZED. Also requires feature:extensions on the caller's account. Updates are not stored; read the current values through extensionStorage. The web extension host subscribes to this channel for every install it renders and hands each update to the extension as storage.update, so useExtensionStorage() re-renders on every surface.
  • ext-logs:\{extension_id\} -- Build and runtime log stream for an extension, restricted to its owning developer. Requires feature:extension_development.
  • ext-install-logs:\{install_id\} -- Runtime logs of one bot-module or automation-node install. The server resolves the install to its owning account and extension category; the caller needs access to that account and the category's read permission: bot-modules:read for a bot module, automations:read for an automation node. This is the same rule as GraphQL extensionInstallLogs and REST GET /v1/extension-installs/\{id\}/logs. An unknown or uninstalled install, an install of any other category, and a malformed id are rejected with UNAUTHORIZED. Also requires feature:bot_module_extensions on the caller's account.
  • extension_install:\{install_id\} -- Realtime messages of one extension install: every ctx.realtime.push(topic, payload) of its server-function and bot-module handlers arrives as an extension:realtime event with data: \{ "topic", "payload" \}. The extension host in the dashboard, overlay and widget pages subscribes with its own credential and hands each message to the extension (server.realtime, SDK useRealtime). Admitted are exactly the callers of a query server function on the install - the install's extension token, an overlay token whose overlay has a layer of the extension, a widget token whose widget renders the install, and a member session or user API key of the owning account holding extension-data:read. System keys (even with bots:internal, since the feature:extensions check runs on the subscriber's own account), popout, shared overlay, service key, developer key and anonymous credentials, an unknown or uninstalled install, an install whose code may not run (extension or version suspended, install disabled, account in its dissolution grace period) and a malformed id are rejected with UNAUTHORIZED. Also requires feature:extensions on the caller's account. Messages are not stored; a client that is not subscribed when a push happens never receives it. There is no GraphQL subscription twin: the API has no GraphQL subscription transport, and browsers never call GraphQL directly.
  • presence:widget:\{id\} -- Editor presence (presence:join, presence:leave, bootstrap list). Open to signed-in members of the account that owns the widget, to that widget's own widget token, and to a System key carrying bots:internal; every other credential is rejected. Resource types without an account resolver fail closed.
  • ideas:list / ideas:\{idea_id\} -- Ideas Hub list and per-idea updates. Public while system:ideas_hub_public and system:ideas_hub are on.
  • public-stats:\{platform\}:\{broadcast_id\} -- Public Stats live read for one stream. On subscribe the server pushes a bootstrap event with the field-gated, opt-out-respecting stream snapshot (the same DTO as the GraphQL publicStreamStats / REST GET /v1/public-stats/streams/...). Unauthenticated and carries no per-user data; gated globally on system: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\} + GraphQL publicChannelBrowse / 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.