Skip to main content

GraphQL

Lumio provides a GraphQL endpoint for flexible data querying.

Endpoint

POST /v1/gql

Base URLs

EnvironmentURL
Productionhttps://api.lumio.vision/v1/gql
Production Previewhttps://lumio.api.prod.zaflun.dev/v1/gql
Staginghttps://lumio.api.staging.zaflun.dev/v1/gql

Interactive Explorer

GraphiQL is available at /v1/graphiql when the API is running (gated behind graphql.playground = true in config). Use it to explore the schema, build queries, and test mutations.

Schema Download

Download the GraphQL SDL schema:

curl -o schema.graphql https://api.lumio.vision/v1/schema

Example Query

query {
overlays {
id
name
key
width
height
}
}

Feature Flags & Status

Queries

me.featureStatuses: [FeatureStatus!]!

Returns merged feature-flag statuses for the current user. Includes account-scope flags (all non-system:* categories) and the user-scope system:account_creation flag. Available on the Me type returned by me { ... }.

accountFeatures { enabledFeatures featureStatuses }

Returns feature flags scoped to the caller's active account. Requires authentication (no account:read permission needed — works with popout-token auth).

  • enabledFeatures: [String!]! — list of flag keys that are enabled for this account (excludes system:* flags).
  • featureStatuses: [FeatureStatus!]! — full status list with key, enabled, and reason for each flag (excludes system:* flags).
query {
accountFeatures {
enabledFeatures
featureStatuses {
key
enabled
reason
}
}
}

me { ownedAccountCount } and me { maxAccounts } — multi-account limits

The Me type exposes two fields for multi-account management:

  • ownedAccountCount: Int! — the number of Lumio accounts the authenticated user owns (i.e. where they hold the Owner role).
  • maxAccounts: Int! — the maximum number of accounts this user may own, derived from the user's plan and any admin overrides.

These fields are used by the dashboard onboarding flow and account-creation gate to surface upgrade prompts when the user is at their account limit.

me { loginConnections } / account { channelConnections } / account { botConnections } — platform filtering

Login connections, channel connections, and bot connections filter by the corresponding platform flag (platform:{x}:login, platform:{x}:channel, platform:{x}:bot). A platform whose flag is disabled globally is excluded from these lists. This filtering also applies to the REST equivalents (GET /users/me/login-connections, GET /connections/channel, GET /bot-connections).

me { activeAccountId } — stale-membership filtering

If the JWT carries an accountId the user is no longer a member of (e.g. the account owner removed them after the token was issued), the resolver returns activeAccountId: null rather than the stale claim. Permissions are computed against the corrected scope. The dashboard shell reads this signal and routes the user to onboarding instead of rendering an account context they can no longer access.

The REST DELETE /v1/accounts/{id}/members/{membership_id} (admin kick) and POST /v1/accounts/{id}/leave (self-leave) endpoints invalidate the Redis permission cache for the removed user, matching the existing GraphQL removeMember mutation behaviour — two-protocol parity for cache invalidation.

Mutations

adminUpdateUserAccountCreationOverride(userId: UUID!, override: AccountCreationOverride!): Boolean!

Set or clear the per-user system:account_creation override. Requires users:edit admin permission. Invalidates the cached override for the user immediately. Returns true on success.

Types

FeatureStatus

FieldTypeDescription
keyString!Feature flag key (e.g., feature:bots)
enabledBoolean!Whether the feature is enabled for this caller
reasonFeatureDisabledReasonWhy the feature is disabled; null when enabled

FeatureDisabledReason (enum)

ValueMeaning
GLOBAL_OFFThe flag is turned off globally by an admin kill-switch
PLAN_LOCKEDThe account's plan does not include this feature
ACCOUNT_OVERRIDEAn explicit per-account override blocks this feature
USER_OVERRIDEAn explicit per-user override blocks this feature (system:account_creation only)

AccountCreationOverride (enum)

ValueDescription
DEFAULTInherit the global system:account_creation flag setting
ALLOWUser is always allowed to create accounts (overrides global OFF)
DENYUser is always blocked from creating accounts (overrides global ON)

AdminUser.accountCreationOverride: AccountCreationOverride!

Available on the AdminUser type returned by adminUser(id) and the user-list query. Reflects the stored per-user override value.

Protocol parity: All queries and mutations above have matching REST endpoints — see REST API.

Admin Role Management

The following queries and mutations are admin-scope: they check the caller's admin permissions (not account permissions) and return errors for unauthenticated or unauthorized requests.

Queries

adminRoles(): [AdminRole!]!

List all admin roles with their permissions and member counts. Requires admin-roles:read.

adminRole(id: UUID!): AdminRole!

Get a single admin role by ID. Requires admin-roles:read. Returns "Admin role not found" if the ID does not exist.

adminRoleMembers(roleId: UUID!): [AdminRoleMember!]!

List all users assigned to an admin role. Requires admin-roles:read.

allAdminPermissions(): [AdminPermissionInfo!]!

Return the full catalog of admin-scope permissions with their category labels. Requires admin-roles:read. Source: lo_auth::rbac::all_admin_permissions(). Used by the permission picker UI.

Mutations

adminCreateRole(input: CreateAdminRoleInput!): AdminRole!

Create a new admin role. Requires admin-roles:create.

  • Auto-injects admin:access into the permissions list if not present
  • Validates all permissions against all_admin_permissions() — rejects unknown permissions with "Invalid permission: <perm>"
  • Returns "Role name already in use" on duplicate name

adminUpdateRole(id: UUID!, input: UpdateAdminRoleInput!): AdminRole!

Update an existing admin role. Requires admin-roles:edit.

  • Fields are optional: omit to leave unchanged
  • description: null clears the description; omitting it leaves it unchanged
  • Permissions are applied as a diff against known permissions (unknown/legacy permissions are preserved)
  • Same validation as create; is_system = true roles are fully editable (not protected from edits)

adminDeleteRole(id: UUID!): Boolean!

Delete an admin role. Requires admin-roles:delete.

  • Returns true if deleted
  • Rejects with "Cannot delete system admin role" if is_system = true
  • Cascades to admin_role_permissions and user_admin_roles

adminAssignUserRole(userId: UUID!, roleId: UUID!): Boolean!

Assign a user to an admin role. Requires admin-roles:edit. Idempotent — returns true if a new assignment was created, false if already assigned.

adminUnassignUserRole(userId: UUID!, roleId: UUID!): Boolean!

Remove a user's admin role assignment. Requires admin-roles:edit. Returns true if removed.

Types

AdminRole

FieldTypeDescription
idUUID!Role ID
nameString!Display name
descriptionStringOptional description
isSystemBoolean!System roles cannot be deleted
permissions[String!]!Sorted permission strings
memberCountInt!Number of assigned users
createdAtString!ISO-8601 timestamp
updatedAtString!ISO-8601 timestamp

AdminRoleMember

FieldTypeDescription
userIdUUID!User ID
displayNameString!User display name
emailStringUser email
avatarUrlStringUser avatar URL
assignedAtString!ISO-8601 timestamp

AdminPermissionInfo

FieldTypeDescription
permissionString!Permission string (e.g., admin-roles:read)
categoryString!Category label (e.g., Admin Role Management)

CreateAdminRoleInput

FieldTypeRequired
nameString!Yes
descriptionStringNo
permissions[String!]!Yes (may be empty)

UpdateAdminRoleInput

FieldTypeNotes
nameStringOptional; trimmed
descriptionStringnull = clear, omit = leave unchanged
permissions[String!]Optional; replaces via diff

Protocol parity: All queries and mutations above have matching REST endpoints under /v1/admin/admin-roles and /v1/admin/admin-permissions — see REST API.

Plan Management

Admin-scope queries and mutations for managing subscription plans. All check plans:* admin permissions.

Queries

adminPlans: [AdminPlan!]!

List all plans with their feature assignments and account counts. Requires plans:read.

Mutations

adminCreatePlan(input: AdminCreatePlanInput!): AdminPlan!

Create a new plan. Requires plans:create.

  • Validates slug against regex ^[a-z0-9]+(?:-[a-z0-9]+)*$, length 2–40 chars. Returns "Invalid slug format" on failure.
  • Rejects duplicate slugs with "Plan slug already in use".
  • Rejects negative prices / limits with "Price cannot be negative" / "Limit cannot be negative".
  • currency defaults to "USD" if omitted.
  • Stripe IDs are optional free-text strings. Lumio does not call Stripe — paste IDs from the Stripe dashboard.

adminUpdatePlan(id: UUID!, input: AdminUpdatePlanInput!): AdminPlan!

Update an existing plan. Requires plans:edit.

  • slug is immutable and is not part of AdminUpdatePlanInput. To change a slug, create a new plan, migrate accounts, then delete the old one.
  • All input fields rewrite the plan's editable state.
  • On success, invalidates the feature cache for every account currently on the plan.
  • Returns "Plan not found" if no plan has the given ID.

adminDeletePlan(id: UUID!): Boolean!

Delete a plan. Requires plans:delete. Returns true on success.

  • Returns "Plan not found" if no plan has the given ID.
  • Returns "Cannot delete plan: N account(s) still reference it. Migrate them to a different plan first." if any accounts are still on the plan.
  • Cascades to plan_features via the FK constraint.

Types

AdminPlan

type AdminPlan {
id: UUID!
slug: String!
name: String!
description: String
priceMonthly: Int!
priceYearly: Int!
currency: String!
isPublic: Boolean!
sortOrder: Int!
maxOverlays: Int!
maxStorageBytes: Int!
maxUploadSizeBytes: Int!
maxIntegrations: Int!
chatRetentionDays: Int!
stripeProductId: String
stripeMonthlyPriceId: String
stripeYearlyPriceId: String
features: [AdminPlanFeature!]!
accountsUsing: Int!
}

AdminPlanFeature

type AdminPlanFeature {
featureId: UUID!
featureKey: String!
label: String!
enabled: Boolean!
}

AdminCreatePlanInput

input AdminCreatePlanInput {
slug: String!
name: String!
description: String
priceMonthly: Int!
priceYearly: Int!
currency: String
isPublic: Boolean!
sortOrder: Int!
maxOverlays: Int!
maxStorageBytes: Int!
maxUploadSizeBytes: Int!
maxIntegrations: Int!
chatRetentionDays: Int!
stripeProductId: String
stripeMonthlyPriceId: String
stripeYearlyPriceId: String
}

AdminUpdatePlanInput

input AdminUpdatePlanInput {
name: String!
description: String
priceMonthly: Int!
priceYearly: Int!
currency: String!
isPublic: Boolean!
sortOrder: Int!
maxOverlays: Int!
maxStorageBytes: Int!
maxUploadSizeBytes: Int!
maxIntegrations: Int!
chatRetentionDays: Int!
stripeProductId: String
stripeMonthlyPriceId: String
stripeYearlyPriceId: String
}

AdminUpdatePlanInput intentionally has no slug field — slugs are immutable after creation.

Protocol parity: All queries and mutations above have matching REST endpoints under /v1/admin/plans — see REST API.

Public Pricing

Public, auth-optional queries used by the marketing pricing surfaces (/pricing, landing page, dashboard onboarding, /account/subscription).

plans

Returns every plan where is_public = true, ordered by sort_order. Admin-only is_public = false plans are filtered out.

type BillingPlan {
id: UUID!
slug: String!
name: String!
description: String
priceMonthly: String!
priceYearly: String!
currency: String!
isPublic: Boolean!
sortOrder: Int!
maxOverlays: Int!
maxStorageBytes: String!
maxUploadSizeBytes: String!
maxIntegrations: Int!
chatRetentionDays: Int!
features: [PlanFeature!]!
}

type PlanFeature {
featureId: UUID!
featureKey: String!
label: String!
enabled: Boolean!
"True when the underlying feature_flag kill-switch is on. Effective availability = enabled && globallyEnabled."
globallyEnabled: Boolean!
}

features[] semantics: Only flags whose category is in ('feature', 'widget', 'integration', 'bot_module') are returned — platform:*, system:*, automation:*, copyright_provider:*, and event:* are admin/infrastructure concerns and stay off the pricing card. The query uses a LEFT JOIN on plan_features with COALESCE(pf.enabled, false) — a flag without an explicit plan_features row renders as struck-through on every plan card (fail-closed). To include a flag in a plan, add a matching plan_features row via migration (see apps/api/migrations/20260415000008_backfill_plan_features_matrix.up.sql for the canonical pattern).

enabledPlatforms

extend type Query {
enabledPlatforms: [String!]!
}

Returns the list of streaming platform slugs whose kill-switch is globally enabled AND which have an enabled :login or :bot sub-flag. Used by the "Supported Platforms" badge row on every pricing card.

Integration-only platforms (currently Spotify — channel-OAuth only, no login and no chat bot) are intentionally excluded so they don't appear as streaming destinations on pricing cards, even though their kill-switch is on.

The companion resolver enabledProviders(connectionType: "login" | "channel" | "bot") returns the full per-subtype list and is what the ID app's login page and the /account/profile login-connections section use.

Login Assignments

Login assignments link a user's login connection to a specific Lumio account, enabling multi-account ownership from a single user identity.

Own assignments are always allowed without permissions. The login-assignments:* permissions only apply when managing assignments on behalf of another user.

Queries

accountLoginAssignments(userId: UUID): [LoginAssignment!]!

List all login assignments for the caller's active account. When userId is supplied, returns the assignments for that specific user (requires login-assignments:read). When omitted, returns the caller's own assignments.

query {
accountLoginAssignments {
provider
loginConnectionId
userId
assignedAt
}
}

Mutations

assignLoginConnection(loginConnectionId: UUID!, provider: String!, userId: UUID): LoginAssignment!

Assign a login connection to the caller's active account. userId defaults to the authenticated user when omitted. Requires login-assignments:create to assign on behalf of another user; own assignments are always allowed.

removeLoginAssignment(provider: String!, userId: UUID): Boolean!

Remove the login assignment for the given provider from the active account. userId defaults to the authenticated user. Requires login-assignments:delete to remove another user's assignment; own assignments are always allowed. Returns true on success.

disconnectLoginConnection(loginConnectionId: UUID!): Boolean!

Delete a login connection by UUID. The connection must belong to the authenticated user. No special permission required beyond ownership. Returns true on success.

Note: This mutation supersedes the previous provider-based disconnect operation. The old form accepted a provider string; the new form accepts a loginConnectionId UUID to uniquely identify the connection even when a user has multiple connections to the same platform.

Types

LoginAssignment

FieldTypeDescription
providerString!Platform slug (e.g. "twitch", "google")
loginConnectionIdUUID!ID of the linked login connection
userIdUUID!ID of the owning user
assignedAtString!ISO-8601 timestamp

Protocol parity: All operations above have matching REST endpoints — see REST API.

Notifications

User-scoped notifications with read/unread tracking and actionable items. See Notifications for the full feature documentation, queries, and mutations.

The invite notification type carries data.inviteId (UUID of the account_invites row) and exposes two actions: accept_invite (adds the invitee as a member with the invite's role) and decline_invite (deletes the invite row).

Notification preferences

OperationArgsReturnsPermission
notificationPreferences[NotificationPreference!]!Auth only
updateNotificationPreference(notificationType: String!, channel: String!)NotificationPreference!Auth only

channel accepts "off", "in_app", or "in_app_email". Updating a locked type (e.g. invite) returns an error.

Idea participant autocomplete

QueryArgsReturnsPermission
ideaParticipants(ideaId: UUID!, search: String)[IdeaParticipant!]!Auth only

Returns the union of the idea author, voters, and commenters, filtered by the optional search string. Used to populate the @mention autocomplete dropdown in idea comments.

Protocol parity: Both operations have matching REST endpoints — GET /v1/notifications/preferences, PATCH /v1/notifications/preferences/{type}, and GET /v1/ideas/{id}/participants — see REST API.

YouTube Member Badges

Account-scoped read query and a system-admin-scoped erasure mutation. See Member Badges for the underlying architecture.

Queries

youtubeMembershipTiers(): [YoutubeMembershipTier!]!

List YouTube member-tier badges observed for the caller's account, sorted by first-observation order. Returns [] when the cache has not been populated yet (e.g. before the first member message of the very first stream). Requires chat:read.

Fields on YoutubeMembershipTier: tooltip (raw English InnerTube tooltip — uniquely identifies the badge artwork at this loyalty milestone), tierName, badgeUrl, durationValue, durationUnit (MONTH/MONTHS/YEAR/YEARS), memberMonthsMin, sortOrder, firstSeenAt, lastSeenAt.

Mutations

eraseYoutubeMemberData(input: EraseYoutubeMemberDataInput!): EraseYoutubeMemberResult!

GDPR Art. 17 — erase all cached references to one YouTube member channel across the entire Lumio Redis namespace. Audit-logged (one global youtube_member_erasure row plus one per affected account). Requires admin:privacy-erase.

Result fields: erasedKeyCount, affectedAccountIds. The mutation deletes all lumio:yt:member:*:{memberChannelId} cache rows, the matching lumio:yt:refresh_lock:*:{memberChannelId} debounce locks, and the channel-emote cache lumio:yt:channel_emotes:{memberChannelId} (a no-op for pure viewers; only populated when the data subject is a broadcaster who hosts custom emotes). PII inside historical platform_chat_messages.badges is not addressed by this mutation — the limitation is disclosed in the Auskunftsbescheid; backfill is tracked as a follow-up.

Protocol parity: mirror at GET /v1/youtube/memberships/tiers and DELETE /v1/admin/privacy/youtube/member/{id} — see REST API.

Chat Moderation

Account-scoped moderation across all four chat platforms. Queries for chat history / user info live in the chat module — see Chat for the full surface.

moderateChat(input: ModerationInput!): GqlModerationResult!

Perform a moderation action on twitch, youtube, kick, or trovo. Permission depends on the action: chat:ban for BAN, chat:timeout for TIMEOUT, chat:delete for DELETE. The mutation never returns a partial success — failures bubble up via result.success = false and result.details = "<message>" so the frontend can show them in the Failed-Sends banner.

ModerationInput fields:

  • action: ModerationActionGql!BAN, TIMEOUT, or DELETE
  • platform: String!"twitch", "youtube", "kick", or "trovo"
  • userId: String — platform user ID (required for BAN / TIMEOUT)
  • messageId: String — required for DELETE
  • durationSecs: Int — timeout length (defaults to 300 when omitted; YouTube accepts 186400)
  • reason: String — optional moderator reason (logged to moderation_log)
  • liveChatId: String — only used by YouTube. Optional: when omitted the server resolves the active broadcast's liveChatId from the polling worker's Redis cache (lumio:youtube:active_streams:{account_id}). Pass it explicitly when the broadcaster runs multiple concurrent broadcasts and you want to target a specific one.

Per-platform behaviour:

PlatformSupported actionsNotes
twitchBAN, TIMEOUT, DELETECalls Helix; needs moderator:manage:banned_users / moderator:manage:chat_messages scope on the moderator's login token
youtubeBAN, TIMEOUT, DELETEBAN/TIMEOUT use liveChatBans.insert (type=permanent vs type=temporary + banDurationSeconds); DELETE uses liveChatMessages.delete. Returns 403 if the target is the broadcaster or another moderator — Lumios UI hides the buttons for those targets to surface the limitation as missing UI rather than a failed request.
kickDELETE onlyKick's public mod API only exposes message deletion; ban/timeout return BadRequest.
trovoBAN onlyTrovo's public mod API has no timeout/delete endpoints.

After a successful BAN or TIMEOUT the server soft-deletes every message from the affected user and broadcasts a chat:clear_user event to the chat WebSocket channel — see the WebSocket reference for the payload.

Protocol parity: mirror at POST /v1/chat/moderate — see REST API.

refreshPlatformUserProfile(platform: String!, platformUserId: String!): GqlUnifiedProfile!

Force a fresh enrichment of a platform user's profile, bypassing the normal 24h/14-day staleness interval. Returns the same GqlUnifiedProfile type as the platformUserProfile query.

Permission: chat:refresh_user

Rate limit: One refresh per (account, platform, user) triple every 10 minutes. When the cooldown is active the mutation returns an error with extension { "code": "REFRESH_COOLDOWN", "retry_after_seconds": N }.

Platforms: "twitch", "youtube", "kick", "trovo".

Protocol parity: mirror at POST /v1/chat/users/\{platform\}/\{platform_user_id\}/refresh — see REST API.

Ideas Hub

Community idea board with voting, comments, moderation, categories, and tags. All GET queries use OptionalAuth — they are public and return data for unauthenticated callers too. Mutations require the system:ideas_hub feature flag to be enabled on the account.

Queries

QueryArgumentsReturnsPermission
ideasfilter: IdeaFilterInput, sort: IdeaSortInput, limit: Int, offset: IntIdeaConnectionPublic
ideaid: UUID!IdeaPublic
ideaCommentsideaId: UUID![IdeaComment]Public
ideaCategories[IdeaCategory]Public
ideaTagssearch: String[IdeaTag]Public
ideaVotersideaId: UUID![IdeaVoter]Public
ideaTimelineideaId: UUID![IdeaTimelineEntry]Public
ideaParticipantsideaId: UUID!, search: String[IdeaParticipant]Auth only

ideaParticipants returns the union of the idea author, voters, and commenters filtered by the optional search string. Used to populate the @mention autocomplete dropdown in idea comments.

Mutations

MutationArgumentsReturnsPermission
createIdeainput: CreateIdeaInput!Ideaideas:create
updateIdeaid: UUID!, input: UpdateIdeaInput!Ideaideas:edit or ideas:moderate_edit
deleteIdeaid: UUID!Booleanideas:delete or ideas:moderate_delete
voteIdeaid: UUID!, voteType: String!Ideaideas:vote
removeVoteid: UUID!Ideaideas:vote
createIdeaCommentinput: CreateIdeaCommentInput!IdeaCommentideas:comment_create
updateIdeaCommentid: UUID!, body: String!IdeaCommentideas:comment_edit
deleteIdeaCommentid: UUID!Booleanideas:comment_delete or ideas:moderate_comment
updateIdeaStatusid: UUID!, status: String!Ideaideas:moderate_status
createIdeaCategoryinput: CreateIdeaCategoryInput!IdeaCategoryAdmin ideas:edit
updateIdeaCategoryid: UUID!, input: UpdateIdeaCategoryInput!IdeaCategoryAdmin ideas:edit
deleteIdeaCategoryid: UUID!BooleanAdmin ideas:delete
createIdeaTagname: String!IdeaTagideas:create
deleteIdeaTagid: UUID!BooleanAdmin ideas:delete

Types

Idea

FieldTypeDescription
idUUID!Idea ID
authorIdeaAuthor!Author info
categoryIdeaCategoryCategory (nullable)
tags[IdeaTag!]!Assigned tags
titleString!Idea title
descriptionString!Idea description (plain text or HTML)
statusString!Status slug (e.g. open, in_progress, done, declined)
voteCountUpInt!Number of upvotes
voteCountDownInt!Number of downvotes
commentCountInt!Total comment count
myVoteStringAuthenticated caller's vote ("up", "down", or null)
createdAtString!ISO-8601 timestamp
updatedAtString!ISO-8601 timestamp

IdeaComment

FieldTypeDescription
idUUID!Comment ID
ideaIdUUID!Parent idea ID
authorIdeaAuthor!Comment author
parentIdUUIDParent comment ID for nested replies
bodyString!Sanitized HTML from rich text editor
createdAtString!ISO-8601 timestamp
updatedAtString!ISO-8601 timestamp
replies[IdeaComment!]!Nested replies (one level deep)

Comment bodies contain sanitized HTML. @mentions appear as <span data-mention-id="UUID" class="mention">@Name</span>.

IdeaCategory

FieldTypeDescription
idUUID!Category ID
nameString!Machine-readable slug
labelString!Display label
colorString!Hex color for UI display
sortOrderInt!Sort position

IdeaTag

FieldTypeDescription
idUUID!Tag ID
nameString!Tag name

IdeaTimelineEntry

FieldTypeDescription
idUUID!Entry ID
actorIdeaAuthor!Who performed the action
actionString!Action type (e.g. status_changed, edited)
oldValueStringPrevious value
newValueStringNew value
createdAtString!ISO-8601 timestamp

IdeaParticipant

FieldTypeDescription
idUUID!User ID
displayNameString!Display name
avatarUrlStringAvatar URL

IdeaFilterInput

FieldTypeDescription
statusStringFilter by status slug
categoryIdUUIDFilter by category
tagIds[UUID!]Filter by one or more tags
authorIdUUIDFilter by author
searchStringFull-text search on title and description

IdeaSortInput (enum)

ValueDescription
NEWESTMost recently created first
MOST_VOTEDHighest net vote count first
MOST_COMMENTEDMost comments first
RECENTLY_UPDATEDMost recently updated first

Protocol parity: All queries and mutations above have matching REST endpoints under /v1/ideas — see REST API.

Real-time Updates

Lumio does not expose a GraphQL Subscription type. For real-time updates (events, chat, overlay changes), use the channel-based WebSocket at /v1/ws — see WebSocket.