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 }

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 MeResult 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: [FeatureStatusGql!]! — 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 MeResult 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 { isDeveloper } — developer status

isDeveloper: Boolean! is true when the authenticated user has a row in the developer_profiles table OR has the extension_dev_mode admin override enabled. This field drives access to the developer dashboard and extension management features in the web app. The REST equivalent is the is_developer field on GET /users/me.

me { extensionDevMode } — Extension Developer Mode

extensionDevMode: Boolean! — when true, extension bundle serving loads the latest draft/testing version instead of the published version for extensions the user develops. Toggle via updateMe(input: { extensionDevMode: true }). The REST equivalent is the extension_dev_mode field on GET/PATCH /users/me.

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).

connectionStatuses — health-aware connection overview

connectionStatuses (permission connections:read) returns one ConnectionStatus per supported channel platform — the overview feature surfaces read (Music control, Events, Multichat, incl. popouts). isConnected is health-aware: it is true only when a connection exists and is not flagged reconnectRequired (ZAF-754), so a dead-grant connection never appears usable. ConnectionStatus also carries reconnectRequired and expiresAt; a connection exists iff isConnected || reconnectRequired. The REST counterpart is GET /v1/connections/status (same fields, permission and feature:connections gate). Popout tokens carrying connections:read may read this query; the first-party-only flaggedConnections query (ZAF-469) is unaffected.

ConnectionStatus additionally carries the acting user's personal login-grant health per platform (ZAF-1045), reported separately from the channel signal because the Multichat sends with the login grant, not the channel connection: loginConnected (login grant exists and is healthy) and loginReconnectRequired (login grant exists but is flagged — the send will fail until reconnected). Both are false with no login grant, no user context (fail-closed), or a platform not sent-to via a login grant. The channel isConnected / reconnectRequired are unchanged — the login fields are additive.

sendChatToPlatform returns GqlSendResult { sent, messageId, error, errorCode, platform }. errorCode is a machine-readable code the client translates instead of parsing error: login_reconnect_required (the sender's login grant is dead — flagged reconnect_required, no crypto/DB internals in error), internal_error (scrubbed internal failure), or send_failed (the platform rejected the send; error carries its message). Absent on success. The REST twin POST /v1/chat/send returns the identical error_code + platform.

platformCredentialModes — where each connection's OAuth client comes from

platformCredentialModes (permission connections:read + feature:connections) returns one PlatformCredentialMode per (platform, kind) that has an OAuth mode. It is the backend truth the frontend reads instead of hardcoding which platforms Lumio operates with a global OAuth app. Fields:

  • platform — platform slug.
  • kindCHANNEL or BOT. Channel and bot can differ per platform (e.g. YouTube channel is ACCOUNT, YouTube bot is SYSTEM).
  • modeSYSTEM (global Lumio app) or ACCOUNT (per-account app_credentials). This is the configured intent, before fail-safe degradation.
  • systemConfigured — whether global OAuth keys are actually configured. Not derivable from mode: SYSTEM + systemConfigured=false means "should be Lumio-managed but is not set up" (it degrades to ACCOUNT at runtime).

Platforms without an OAuth mode for a kind are omitted (Spotify has no bot; the Discord bot uses a static admin token, not OAuth). The REST twin is GET /v1/connections/credential-modes — identical payload, kind included, same guard.

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!): AdminUser!

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

Types

FeatureStatusGql

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

FeatureDisabledReasonGql (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.

User API Keys

User API keys are personal lm_usr_ bearer credentials for API and CLI access. They are bound to the current user and active account, and the requested permissions must be a subset of the creator's current permissions. The full key is returned only from createUserApiKey.

First-party session required (ZAF-1017 / ZAF-469): personal API keys are a first-party surface — userApiKeys, createUserApiKey, updateUserApiKey and deleteUserApiKey all key off the acting user's own identity and require a logged-in user session or a user's own API key. A popout/overlay/widget/extension token — whose user_id is bound at token-create time and could be the account owner — is rejected with a FORBIDDEN error, so a permission-capped member can never list/mint/rename/revoke a key bound to the owner. Same gate and status behaviour as the REST /v1/api-keys twins.

Queries

userApiKeys: [UserApiKey!]!

List the current user's own API keys for the active account. Requires feature:apikeys and apikeys:read.

query {
userApiKeys {
id
userId
accountId
keyPrefix
label
permissions
createdAt
expiresAt
lastUsedAt
}
}

Mutations

createUserApiKey(input: CreateUserApiKeyInput!): CreateUserApiKeyResult!

Create a user API key for the current user and active account. Requires feature:apikeys and apikeys:create.

mutation {
createUserApiKey(
input: {
label: "CI/CD pipeline"
permissions: ["events:read", "overlays:read"]
expiresAt: "2026-09-01T00:00:00Z"
}
) {
key
apiKey {
id
keyPrefix
label
permissions
expiresAt
}
}
}

deleteUserApiKey(id: UUID!): DeleteTokenResult!

Revoke one of the current user's own API keys by hard-deleting it. Requires feature:apikeys and apikeys:delete.

Types

CreateUserApiKeyInput

FieldTypeDescription
labelString!Required display name
permissions[String!]!Permission subset granted to the key
expiresAtStringOptional RFC3339 expiry; null means no expiry

CreateUserApiKeyResult

FieldTypeDescription
keyString!Full lm_usr_ key, returned once
apiKeyUserApiKey!Stored key metadata

UserApiKey

FieldTypeDescription
idUUID!Key ID
userIdUUID!Owning user
accountIdUUID!Owning account
keyPrefixString!Display prefix for identification
labelString!Display name
permissions[String!]!Granted permission subset
createdAtString!Creation timestamp
expiresAtStringOptional expiry timestamp
lastUsedAtStringLast successful use, when known

Account Service Keys

Account service keys are lm_svc_ bearer credentials owned by the account — for CI/CD and long-lived integrations that must survive the departure of the member who created them. Unlike personal lm_usr_ keys they carry no member identity. They share the feature:apikeys flag and the apikeys:* permission family with personal keys, and create enforces the same permission-subset guard. The full key is returned only from createAccountServiceKey. Parity with REST /v1/service-keys.

Queries

accountServiceKeys: [AccountServiceKey!]!

List the account's service keys. Requires feature:apikeys and apikeys:read.

Mutations

createAccountServiceKey(input: CreateAccountServiceKeyInput!): CreateAccountServiceKeyResult!

Create a service key owned by the active account. Requires feature:apikeys and apikeys:create.

mutation {
createAccountServiceKey(
input: {
label: "CI/CD deploy"
permissions: ["events:read", "overlays:read"]
}
) {
key
serviceKey {
id
keyPrefix
label
permissions
expiresAt
}
}
}

updateAccountServiceKey(id: UUID!, label: String!): AccountServiceKey!

Rename a service key (label only; key material, permissions and expiry are immutable). Requires feature:apikeys and apikeys:edit.

deleteAccountServiceKey(id: UUID!): DeleteTokenResult!

Revoke a service key by hard-deleting it. Requires feature:apikeys and apikeys:delete.

Types

CreateAccountServiceKeyInput

FieldTypeDescription
labelString!Required display name
permissions[String!]!Permission subset granted to the key
expiresAtStringOptional RFC3339 expiry; null means no expiry

CreateAccountServiceKeyResult

FieldTypeDescription
keyString!Full lm_svc_ key, returned once
serviceKeyAccountServiceKey!Stored key metadata

AccountServiceKey

FieldTypeDescription
idUUID!Key ID
accountIdUUID!Owning account
createdByUUIDMember who minted the key; null once they leave the account
keyPrefixString!Display prefix for identification
labelString!Display name
permissions[String!]!Granted permission subset
createdAtString!Creation timestamp
updatedAtString!Last modification timestamp
expiresAtStringOptional expiry timestamp
lastUsedAtStringLast successful use, when known

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): [GqlLoginAssignment!]!

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): GqlLoginAssignment!

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!): LogoutResult!

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

First-party session required (ZAF-469): me, updateMe, disconnectLoginConnection, assignLoginConnection, removeLoginAssignment, setPrimaryLoginConnection, flaggedConnections, the session and notification operations, myDeveloperApplication / submitDeveloperApplication, developerVerificationStatus / submitDeveloperVerification, createAccount / dissolveAccount / leaveAccount, and the JWT-minting issueWsToken are all keyed on the acting user's own identity and require a logged-in user session or a user's own API key. A popout/overlay/widget/extension token — whose user_id is bound at token-create time and could be the account owner — is rejected with a FORBIDDEN error (so it can never obtain an owner-bound JWT). Same gate and status behaviour as the REST twins.

First-party session required — extension developer surface (ZAF-471): the same gate covers every resolver keyed on the acting user's developer identity: developerProfile, developerRevenue, developerPayoutSettings, developerPayouts, developerLimits, developerLimitRequests, developerLimitRequestEvents, developerExtensions and the ownership-scoped developerExtension* reads, updateDeveloperPayoutSettings, requestPayout, requestDeveloperLimitIncrease, createExtension / submitExtensionVersion / requestExtensionDeletion / inviteExtensionTester and their ownership-scoped siblings, the developerTeam* reads and DeveloperTeamMutation operations, and grantExtensionAccess / createExtensionAccessInvite. An unassigned popout token resolves its user_id to the account owner, so without this a permission-capped popout would act as the owner-developer (read revenue, redirect payouts). All require a logged-in user session or a user's own API key; a popout/overlay/widget/extension token is rejected with FORBIDDEN on both protocols. Account-scoped resolvers a popout is meant to drive — extension secrets and billing (createCheckout, createPortal, …), keyed on account_id — are unaffected.

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

GqlLoginAssignment

FieldTypeDescription
idUUID!Assignment ID
accountIdUUID!Account the login is assigned to
userIdUUID!ID of the owning user
loginConnectionIdUUID!ID of the linked login connection
providerString!Platform slug (e.g. "twitch", "google")
createdAtString!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", "email", or "in_app_email". Updating a locked type (e.g. invite) returns an error.

Idea participant autocomplete

QueryArgsReturnsPermission
ideaParticipants(ideaId: UUID!, search: String)[GqlIdeaAuthor!]!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: [GqlYoutubeMembershipTier!]!

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 GqlYoutubeMembershipTier: tooltip (raw English InnerTube tooltip — uniquely identifies the badge artwork at this loyalty milestone), tierName, badgeUrl, durationValue, durationUnit (GqlDurationUnit: MONTH/MONTHS/YEAR/YEARS), memberMonthsMin, sortOrder, firstSeenAt, lastSeenAt.

Mutations

eraseYoutubeMemberData(input: EraseYoutubeMemberDataInput!): GqlEraseYoutubeMemberResult!

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

Result fields: erasedKeyCount, deletedMessageCount, affectedAccountIds. The mutation deletes all lumio:yt:member:*:{memberChannelId} cache rows and the matching lumio:yt:refresh_lock:*:{memberChannelId} debounce locks. It does not touch the emote catalog (chat_emotes): an emote shortcode plus its public CDN URL is cross-channel branding reference data, not personal data of the erased member, and falls outside Art. 17 (founder ruling — ZAF-580). It also hard-deletes the member's historical platform_chat_messages rows — message text, identity fields, and the badge/emote JSONB — matched by (platform = 'youtube', user_id = memberChannelId); deletedMessageCount reports how many rows were removed (ZAF-237, previously deferred as a follow-up).

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

eraseChatSubjectData(input: EraseChatSubjectInput!): GqlChatErasureResult!

GDPR Art. 17 — hard-delete every platform_chat_messages row authored by a data subject across all accounts. Audit-logged as chat_pii_erasure (one global system-scoped row plus one account-scoped row per affected account — see audit scope). Requires admin:privacy-erase.

Input EraseChatSubjectInput identifies the subject by exactly one of: lumioUserId (a registered Lumio user), or both platform + userId (a platform chatter such as a YouTube UC… channel id). Supplying neither, both, or only one half of the platform pair is a validation error with the same message the REST endpoint returns.

Result fields: deletedMessageCount, affectedAccountIds.

Protocol parity: mirror at POST /v1/admin/privacy/chat/erase — see REST API. Note: account dissolution (dissolveAccount / POST /v1/accounts/{id}/dissolve) additionally erases the dissolved account's chat rows automatically.

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, including chatHistory's keyword search filter (ChatFilterInput.search, mirrored by the REST search query parameter).

moderateChat(input: ModerationInput!): GqlModerationResult!

Perform a moderation action on twitch, youtube, kick, or trovo. Requires the feature:multichat flag (same gate as the REST twin POST /v1/chat/moderate and the live chat:\{account_id\} WebSocket stream). 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 in seconds, required for TIMEOUT (validation rejects a TIMEOUT without it, identically on REST; 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.

searchPlatformUsers(query: String!, platforms: [String!], limit: Int): [GqlPlatformUserSearchResult!]!

Prefix-search the account's known chatters (the platform_users table) by username or displayName for the multichat filter name-completion. Same guard as platformUserProfilefeature:multichat + chat:userinfo — so it never surfaces a user the caller could not already inspect (and without chat:userinfo it returns nothing, so the search leaks no data).

  • query — case-insensitive prefix. Shorter than 2 characters (after trimming) returns an empty list, not an error (the UI queries per keystroke). %, _, and \ match literally.
  • platforms — optional platform filter; omit for all platforms.
  • limit — optional (default 10), hard-capped at 25 regardless of the requested value.
  • Ordered lastSeenAt DESC, messageCount DESC. Each row is the slim { platform, platformUserId, username, displayName, avatarUrl, lastSeenAt, messageCount }.

Protocol parity: mirror at GET /v1/chat/users/search?q=&platform=&limit= — same fields, validation, and empty-below-2-chars behaviour. See REST API and Chat.

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! = 20, offset: Int! = 0GqlIdeaConnection!Public
ideaid: UUID!GqlIdeaPublic
ideaCommentsideaId: UUID![GqlIdeaComment!]!Public
ideaCategories[GqlIdeaCategory!]!Public
ideaTagssearch: String[GqlIdeaTag!]!Public
ideaVotersideaId: UUID![GqlIdeaVoter!]!Public
ideaTimelineideaId: UUID![GqlIdeaTimelineEntry!]!Public
ideaParticipantsideaId: UUID!, search: String[GqlIdeaAuthor!]!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!GqlIdea!ideas:create
updateIdeaid: UUID!, input: UpdateIdeaInput!GqlIdea!ideas:edit or ideas:moderate_edit
deleteIdeaid: UUID!Boolean!ideas:delete or ideas:moderate_delete
voteIdeaid: UUID!, voteType: String!GqlIdea!ideas:vote
removeVoteid: UUID!GqlIdea!ideas:vote
createIdeaCommentinput: CreateIdeaCommentInput!GqlIdeaComment!ideas:comment_create
updateIdeaCommentid: UUID!, body: String!GqlIdeaComment!ideas:comment_edit
deleteIdeaCommentid: UUID!Boolean!ideas:comment_delete or ideas:moderate_comment
voteIdeaCommentid: UUID!, voteType: String!GqlIdeaComment!ideas:comment_vote
removeIdeaCommentVoteid: UUID!GqlIdeaComment!ideas:comment_vote
updateIdeaStatusid: UUID!, status: String!GqlIdea!ideas:moderate_status
createIdeaCategoryinput: CreateIdeaCategoryInput!GqlIdeaCategory!Admin ideas:edit
updateIdeaCategoryid: UUID!, input: UpdateIdeaCategoryInput!GqlIdeaCategory!Admin ideas:edit
deleteIdeaCategoryid: UUID!Boolean!Admin ideas:delete
createIdeaTagname: String!GqlIdeaTag!ideas:create
deleteIdeaTagid: UUID!Boolean!Admin ideas:delete

Types

GqlIdea

FieldTypeDescription
idUUID!Idea ID
authorGqlIdeaAuthor!Author info
categoryGqlIdeaCategory!Category
tags[GqlIdeaTag!]!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

GqlIdeaConnection

FieldTypeDescription
items[GqlIdea!]!Page of ideas
totalInt!Total matching the filter

GqlIdeaAuthor

Also the element type of ideaParticipants.

FieldTypeDescription
idUUID!User ID
displayNameString!Display name
avatarUrlStringAvatar URL

GqlIdeaComment

FieldTypeDescription
idUUID!Comment ID
ideaIdUUID!Parent idea ID
authorGqlIdeaAuthor!Comment author
parentIdUUIDParent comment ID for nested replies
bodyString!Sanitized HTML from rich text editor
createdAtString!ISO-8601 timestamp
updatedAtString!ISO-8601 timestamp
voteCountUpInt!Number of upvotes on the comment
voteCountDownInt!Number of downvotes on the comment
myVoteStringAuthenticated caller's vote on the comment ("up", "down", or null)
replies[GqlIdeaComment!]!Nested replies (one level deep)

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

GqlIdeaCategory

FieldTypeDescription
idUUID!Category ID
nameString!Machine-readable slug
labelString!Display label
colorStringHex color for UI display
sortOrderInt!Sort position
createdAtString!ISO-8601 timestamp

GqlIdeaTag

FieldTypeDescription
idUUID!Tag ID
nameString!Tag name
createdByUUIDCreating user
createdAtString!ISO-8601 timestamp

GqlIdeaTimelineEntry

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

GqlIdeaVoter

FieldTypeDescription
userIdUUID!Voting user
voteTypeString!"up" or "down"
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.

Developer Applications

User-scoped queries and mutations for the developer application flow. No account context is required.

Queries

myDeveloperApplication: DeveloperApplication

Returns the authenticated user's latest developer application. Returns null if no application exists.

Mutations

submitDeveloperApplication(input: SubmitDeveloperApplicationInput!): DeveloperApplication!

Submit a developer application. Validates the slug format and availability, checks that the user does not already have a developer profile or a pending application.

Errors:

  • "application_type must be 'solo' or 'team'" — invalid type
  • "Slug must be between 3 and 50 characters" / "Slug must start with a lowercase letter" / "Slug must contain only lowercase letters, digits, and hyphens" — slug format
  • "You already have a developer profile" — user is already a developer
  • "You already have a pending application" — previous application still pending
  • "Slug is already taken" — slug in use

Types

DeveloperApplication

FieldTypeDescription
idUUID!Application ID
userIdUUID!Applicant's user ID
applicationTypeString!"solo" or "team"
displayNameString!Developer/team display name
slugString!URL slug
descriptionString!Description
motivationString!Why the user wants developer access
whatToBuildString!Planned extensions
githubUrlStringGitHub profile URL
websiteUrlStringWebsite URL
experienceStringDevelopment experience
avatarKeyStringUpload key for avatar
statusString!"pending", "approved", or "rejected"
reviewNotesStringReviewer notes
reviewedByUUIDReviewer's user ID
reviewedAtDateTimeReview timestamp
createdAtDateTime!Submission timestamp
updatedAtDateTime!Last update timestamp

SubmitDeveloperApplicationInput

FieldTypeRequired
applicationTypeString!Yes
displayNameString!Yes
slugString!Yes
descriptionString!Yes
motivationString!Yes
whatToBuildString!Yes
githubUrlStringNo
websiteUrlStringNo
experienceStringNo

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

Developer Verification (self-service)

KYC verification an approved developer submits about themselves (individual or company) to unlock paid-extension publishing — the developer-facing counterpart to the admin review operations below. First-party principal required (ZAF-469): a popout/overlay/widget/extension token is rejected with a FORBIDDEN error. The caller must be an approved developer (have a developer_profiles row).

Deliberately not gated on feature:extension_development: that feature is granted by verification approval, so gating submission on it would be circular.

Queries

developerVerificationStatus: VerificationDetailGql

Returns the authenticated developer's own verification record, or null if none has been submitted (or the caller is not yet an approved developer).

Mutations

submitDeveloperVerification(input: SubmitVerificationInput!): VerificationDetailGql!

Submit (or resubmit) the developer's own KYC verification. Idempotent upsert — one record per developer; a resubmit re-enters pending and clears any prior review verdict. An already-verified record is terminal via self-service and cannot be resubmitted. The developer_type is inferred from whether companyName is provided. Emits the developer:verification_submitted audit event (user scope; no KYC/PII in metadata).

Errors:

  • "You must be an approved developer to submit verification" — caller has no developer profile
  • "Your developer verification is already approved" — the record is already verified
  • "Legal name is required" / "Address line 1 is required" / "Postal code is required" / "City is required" / "Country is required" / "Country must be a 2-letter ISO code" — validation

Types

SubmitVerificationInput

FieldTypeRequired
legalNameString!Yes
companyNameStringNo
addressLine1String!Yes
addressLine2StringNo
postalCodeString!Yes
cityString!Yes
countryString!Yes (2-letter ISO)
taxIdStringNo
tradeRegisterIdStringNo
documentKeyStringNo

VerificationDetailGql

FieldTypeDescription
idUUID!Verification ID
developerIdUUID!Developer profile ID
legalNameString!Legal name
companyNameStringCompany name (company type)
addressLine1String!Address line 1
addressLine2StringAddress line 2
postalCodeString!Postal code
cityString!City
countryString!ISO-3166-1 alpha-2 country code
taxIdStringTax ID
tradeRegisterIdStringTrade register ID
documentKeyStringUpload key for a supporting document
statusString!"pending", "verified", or "rejected"
reviewNotesStringReviewer notes (e.g. rejection reason)
reviewedByUUIDReviewer's user ID
reviewedAtStringReview timestamp (RFC 3339)
createdAtString!First submission timestamp (RFC 3339)
updatedAtString!Last update timestamp (RFC 3339)

Protocol parity: Both operations have matching REST endpoints under /v1/developer/verification — see REST API.

Admin Developer Applications

Admin-scope queries and mutations for reviewing developer applications. All require developer-verifications:* admin permissions.

Queries

adminDeveloperApplications(status: String): [AdminApplicationListItem!]!

List developer applications with optional status filter. Requires developer-verifications:read.

adminDeveloperApplication(id: UUID!): AdminApplicationDetail

Get full details of a specific application. Requires developer-verifications:read.

Mutations

approveDeveloperApplication(input: ApproveApplicationInput!): ApplicationActionResult!

Approve an application. Creates the developer profile, seeds default team roles (for team applications), and sends notifications. Requires developer-verifications:edit.

rejectDeveloperApplication(input: RejectApplicationInput!): ApplicationActionResult!

Reject an application. Sends rejection notifications. Requires developer-verifications:edit. The notes field is required to provide a rejection reason.

Types

AdminApplicationListItem

FieldTypeDescription
idUUID!Application ID
userIdUUID!Applicant's user ID
applicationTypeString!"solo" or "team"
displayNameString!Display name
slugString!URL slug
statusString!Application status
createdAtString!Submission timestamp
userDisplayNameStringApplicant's display name
userAvatarUrlStringApplicant's avatar URL

AdminApplicationDetail

Same fields as DeveloperApplication with string-formatted timestamps.

ApplicationActionResult

Returned by approveDeveloperApplication and rejectDeveloperApplication.

FieldTypeDescription
successBoolean!Whether the action succeeded
newStatusString!The new application status

ApproveApplicationInput / RejectApplicationInput

FieldTypeDescription
idUUID!Application ID
notesString on approve, String! on rejectReviewer notes

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

Developer Teams

Queries and mutations for developer team management. Team operations use team-scoped RBAC permissions (see Team RBAC).

Queries

QueryArgumentsReturnsPermission
developerTeams[DeveloperTeam!]!Auth (developer)
developerTeamteamId: UUID!DeveloperTeamteam-settings:read
developerTeamMembersteamId: UUID![DeveloperTeamMember!]!team-members:read
developerTeamInvitesteamId: UUID![DeveloperTeamInvite!]!team-members:invite
developerTeamInviteByCodecode: String!DeveloperTeamInviteDetailAuth
developerTeamRolesteamId: UUID![DeveloperTeamRole!]!team-members:read
developerTeamRoleteamId: UUID!, roleId: UUID!DeveloperTeamRoleDetail!team-members:read
availableTeamPermissions[TeamPermission!]!Auth

Mutations

MutationArgumentsReturnsPermission
createDeveloperTeamname: String!DeveloperTeam!Auth (developer)
updateDeveloperTeamteamId: UUID!, name: String!Boolean!team-settings:edit
deleteDeveloperTeamteamId: UUID!Boolean!Team owner only
updateDeveloperTeamMemberRoleteamId: UUID!, memberId: UUID!, roleId: UUID!Boolean!team-members:edit
removeDeveloperTeamMemberteamId: UUID!, memberId: UUID!Boolean!team-members:remove
createDeveloperTeamInviteteamId: UUID!, input: CreateTeamInviteInput!DeveloperTeamInvite!team-members:invite
deleteDeveloperTeamInviteinviteId: UUID!, teamId: UUID!Boolean!team-members:invite
acceptDeveloperTeamInvitecode: String!AcceptTeamInviteResult!Auth
createDeveloperTeamRoleteamId: UUID!, input: CreateTeamRoleInput!DeveloperTeamRoleDetail!team-settings:edit
updateDeveloperTeamRoleteamId: UUID!, roleId: UUID!, input: UpdateTeamRoleInput!DeveloperTeamRoleDetail!team-settings:edit
deleteDeveloperTeamRoleteamId: UUID!, roleId: UUID!Boolean!team-settings:edit

Types

DeveloperTeam

FieldTypeDescription
idUUID!Team ID
nameString!Team name
slugString!URL slug
ownerIdUUID!Owning user
createdAtString!Creation timestamp
updatedAtString!Update timestamp

DeveloperTeamMember

FieldTypeDescription
idUUID!Membership ID
teamIdUUID!Team ID
userIdUUID!Member's user ID
roleIdUUID!Assigned role ID
roleNameString!Role display name
roleSlugString!Role slug
roleColorStringRole colour
displayNameString!Display name
emailString!Email address
avatarUrlStringAvatar URL
createdAtString!When they joined

DeveloperTeamInvite

FieldTypeDescription
idUUID!Invite ID
teamIdUUID!Team ID
invitedByUUID!Inviting user
invitedByNameStringInviting user's display name
emailStringTarget email, when the invite is addressed
inviteCodeString!Invite code
roleIdUUID!Role assigned on accept
roleNameString!Role name
roleSlugString!Role slug
maxUsesInt!Maximum uses
useCountInt!Current use count
expiresAtStringExpiration timestamp
acceptedAtStringWhen it was accepted
acceptedByUUIDAccepting user
createdAtString!Creation timestamp

DeveloperTeamInviteDetail

Returned by developerTeamInviteByCode — the invite as seen by the recipient, without team-internal fields.

FieldTypeDescription
idUUID!Invite ID
teamIdUUID!Team ID
teamNameString!Team name
teamSlugString!Team slug
invitedByNameStringInviting user's display name
inviteCodeString!Invite code
roleIdUUID!Role assigned on accept
roleNameString!Role name
roleSlugString!Role slug
maxUsesInt!Maximum uses
useCountInt!Current use count
expiresAtStringExpiration timestamp
isExpiredBoolean!Whether the invite has expired
isFullBoolean!Whether useCount has reached maxUses

DeveloperTeamRole

List shape — carries counts rather than the permission strings.

FieldTypeDescription
idUUID!Role ID
teamIdUUID!Owning team ID
nameString!Role name
slugString!Role slug
descriptionStringRole description
isDefaultBoolean!Default role
isSystemBoolean!System role (cannot be deleted)
colorStringRole colour
sortOrderInt!Sort order
permissionCountInt!Number of permissions granted
memberCountInt!Number of members holding it
createdAtString!Creation timestamp
updatedAtString!Update timestamp

DeveloperTeamRoleDetail

Detail shape — same as DeveloperTeamRole but with permissions: [String!]! in place of permissionCount. Retains memberCount (Int!, number of members holding the role).

TeamPermission

FieldTypeDescription
keyString!Permission string (e.g. team-extensions:create)
categoryString!Grouping used by the role editor

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

Developer Payout Settings

Self-scoped read of the authenticated developer's saved payout configuration. This is a private, first-party resolver — do not confuse it with the public developerProfile(slug) store profile documented below (that returns the public DeveloperPublicProfile, which carries no payout fields).

Queries

developerPayoutSettings: DeveloperProfile

Returns the authenticated developer's saved payout settings — payoutMethod, stripeConnectId, paypalEmail, and SEPA bankIban / bankBic / bankName, plus revenueBalance / totalEarned — for prefilling the payouts form. Companion read to the updateDeveloperPayoutSettings mutation, which returns the same DeveloperProfile type. Errors with Developer profile not found if the caller has not applied as a developer.

Permission: extension-dev:payouts · Feature: feature:extension_development

Types

DeveloperProfile

FieldTypeDescription
idUUID!Developer profile ID
userIdUUID!Owning user ID
stripeConnectIdStringStripe Connect account id (Phase 9)
paypalEmailStringPayPal payout email
bankIbanStringSEPA IBAN
bankBicStringSEPA BIC
bankNameStringAccount holder name
payoutMethodString"stripe", "paypal", or "bank"
revenueBalanceInt!Current balance, minor units
totalEarnedInt!Lifetime earnings, minor units
createdAtDateTime!Profile creation date
updatedAtDateTime!Last update date

Protocol parity: matches REST GET /v1/developer/payout-settings — see REST API.

Developer Store Profiles

Public queries for developer and team profile pages displayed in the extension store. No authentication required.

Queries

developerProfile(slug: String!): DeveloperPublicProfile

Get a public developer profile by slug. Returns null if not found. Includes aggregate stats and a list of published extensions.

developerTeamProfile(slug: String!): DeveloperTeamProfile

Get a public team profile by slug. Returns null if not found. Includes aggregate stats, public member list, and published extensions.

inlineDeveloperPanel(developerId: UUID, teamId: UUID): InlineDeveloperPanel

Get a compact developer info panel for display on extension detail pages. Returns the developer/team name, slug, avatar, extension count, and whether the developer is a team.

Types

DeveloperPublicProfile

FieldTypeDescription
idUUID!Developer profile ID
displayNameString!Display name
slugString!URL slug
descriptionStringBio/description
githubUrlStringGitHub URL
websiteUrlStringWebsite URL
avatarKeyStringAvatar upload key
createdAtDateTime!Profile creation date
statsDeveloperStats!Aggregate statistics
extensions[ProfileExtension!]!Published extensions

DeveloperTeamProfile

FieldTypeDescription
idUUID!Team ID
nameString!Team name
slugString!URL slug
descriptionStringDescription
githubUrlStringGitHub URL
websiteUrlStringWebsite URL
avatarKeyStringAvatar upload key
createdAtDateTime!Creation date
statsDeveloperStats!Aggregate statistics
members[TeamMemberSummary!]!Public member list
extensions[ProfileExtension!]!Published extensions

DeveloperStats

FieldTypeDescription
extensionCountInt!Number of published extensions
totalInstallsInt!Total installs across all extensions
avgRatingFloat!Average rating across all extensions

ProfileExtension

FieldTypeDescription
idUUID!Extension ID
shortIdString!Short identifier
slugString!URL slug
nameString!Extension name
descriptionStringShort description
categoryString!Extension category
iconKeyStringIcon upload key
pricingTypeString!"free", "paid", etc.
pricingAmountIntPrice in minor units
pricingCurrencyString!ISO-4217 currency code
installCountInt!Number of installs
ratingAvgFloat!Average rating
ratingCountInt!Number of ratings
publishedAtDateTimePublish date

TeamMemberSummary

FieldTypeDescription
userIdUUID!Member's user ID
displayNameString!Display name
avatarUrlStringAvatar URL
roleString!Role name

InlineDeveloperPanel

FieldTypeDescription
developerIdUUID!Developer/team ID
displayNameString!Display name
slugStringURL slug
descriptionStringShort description
avatarKeyStringAvatar upload key
extensionCountInt!Number of extensions
isTeamBoolean!Whether this is a team profile

Protocol parity: developerProfile and developerTeamProfile have matching REST endpoints — see REST API.

Extension Limits

developerLimits: DeveloperLimits!

Returns the resolved limits for the authenticated developer, including per-category breakdowns with resolution sources and all extension-specific overrides.

Permission: extension-dev:read · Feature: feature:extension_development

developerLimitRequestEvents(requestId: UUID!): [LimitRequestEvent!]!

Returns the timeline events for a limit request owned by the authenticated developer.

Permission: extension-dev:read · Feature: feature:extension_development

extensionLimits(extensionId: UUID!): ExtensionLimits!

Returns the resolved sound limits for an extension. Requires the caller to be the extension owner or a member of the owning developer team.

Permission: extension-dev:read · Feature: feature:extensions

ExtensionLimits

FieldTypeDescription
maxSoundsInt!Resolved max bundled sounds
maxSoundsSourceString!Resolution source: default, developer_override, or extension_override
maxSoundFileSizeInt!Resolved max size of a single bundled sound, in bytes
maxSoundFileSizeSourceString!Resolution source for maxSoundFileSize
maxSoundStorageBytesInt!Resolved total sound storage budget, in bytes
maxSoundStorageBytesSourceString!Resolution source for maxSoundStorageBytes

Admin — Developer Limits

adminDeveloperLimits(developerId: UUID!): DeveloperLimitsGql!

Fetch limit overrides for a developer. Permission: developer-limits:read

adminSetDeveloperLimit(input: AdminSetDeveloperLimitInput!): AdminActionResultGql!

Set or remove a developer limit override. Pass null for limitValue to remove the override. Permission: developer-limits:edit

adminSetExtensionLimits(input: AdminSetExtensionLimitsInput!): AdminActionResultGql!

Set or clear per-extension limit overrides (maxSounds, maxSoundFileSize, maxSoundStorageBytes). Pass null for any field to revert to developer/platform default. Permission: developer-limits:edit

adminAllLimitRequests(status: String, scope: String, search: String): [LimitRequestGql!]!

List all developer limit requests with optional status filter. Permission: developer-limits:read

adminDeveloperLimitRequests(developerId: UUID!): [LimitRequestGql!]!

List limit requests for a specific developer. Permission: developer-limits:read

adminLimitRequestEvents(requestId: UUID!): [LimitRequestEventGql!]!

Get timeline events for a limit request. Permission: developer-limits:read

adminReviewLimitRequest(input: AdminReviewLimitRequestInput!): AdminActionResultGql!

Approve or reject a limit request. Approving auto-applies the requested limits. Rejecting requires review notes. Permission: developer-limits:edit

Automation Extension Nodes

Queries for managing extension-provided automation nodes.

Queries

installedAutomationNodes: [InstalledAutomationNode!]!

List all installed automation node extensions for the current account. Returns metadata for each node including schemas, trigger mode, icon, and color. Requires automations:read.

automationWebhookUrl(installId: UUID!, automationId: UUID!): AutomationWebhookUrl!

Get the webhook URL and secret for an installed trigger node within a specific automation. Requires automations:read.

Types

InstalledAutomationNode

FieldTypeDescription
installIdUUID!Install ID
extensionIdUUID!Extension ID
extensionNameString!Extension name
extensionSlugString!Extension slug
nodeTypeString!"trigger", "action", or "logic"
inputSchemaJSON!JSON Schema for node input
outputSchemaJSON!JSON Schema for node output
triggerModeString"webhook", "polling", or "both" (triggers only)
pollIntervalSecondsIntPoll interval for polling triggers
iconStringLucide icon name
colorStringHex color for canvas rendering

AutomationWebhookUrl

FieldTypeDescription
webhookUrlString!Full webhook URL for the external service
webhookSecretString!64-character hex secret for the X-Webhook-Secret header

Protocol parity: Both queries have matching REST endpoints -- see REST API.

Extension Version Files

Queries

extensionVersionFiles(extensionId: UUID!, version: String!): [ExtensionVersionFile!]!

List all files in an extension version's bundle. Published versions are publicly accessible; draft and testing versions are restricted to the extension's own developer/team members (or a platform operator) — an authenticated user from another account cannot enumerate them. The REST twin GET /v1/extensions/{id}/versions/{version}/files enforces the same ownership check.

query {
extensionVersionFiles(extensionId: "uuid", version: "1.2.0") {
id
versionId
filePath
contentType
sizeBytes
contentHash
createdAt
}
}

Types

ExtensionVersionFile

FieldTypeDescription
idUUID!File record ID
versionIdUUID!Parent version ID
filePathString!Path within the bundle (e.g. layer.js, assets/logo.png)
contentTypeString!MIME type (e.g. application/javascript, text/css)
sizeBytesInt!File size in bytes
contentHashStringSHA-256 content hash
createdAtDateTime!Upload timestamp

Protocol parity: GET /v1/extensions/{id}/versions/{version}/files in REST -- see REST API.

Platform Metadata

Queries

platformMetadata: [PlatformMetadata!]!

Returns static platform info (name, slug, icon, features, OAuth scopes). Public query -- no authentication required.

query {
platformMetadata {
name
slug
icon
features
oauthScopes
}
}

Types

PlatformMetadata

FieldTypeDescription
nameString!Platform display name
slugString!Platform slug (e.g. twitch, youtube)
iconString!Platform icon identifier
hasLoginBoolean!Platform can be used to sign in
hasChannelBoolean!Platform supports a channel connection
hasBotBoolean!Platform supports a custom bot identity
hasIntegrationBoolean!Platform exposes a non-channel integration

Sounds

All sound operations require the feature:sounds feature flag to be enabled for the account.

Queries

sounds(offset: Int! = 0, limit: Int! = 20, search: String): SoundsResult!

List sounds in the account's library with optional pagination and name search.

Permission: sounds:read

query {
sounds(limit: 20, offset: 0, search: "tada") {
items {
id
name
fileName
contentType
sizeBytes
durationSecs
createdAt
}
total
}
}

sound(id: UUID!): SoundGql

Fetch a single sound by ID.

Permission: sounds:read

Mutations

updateSound(id: UUID!, input: UpdateSoundInput!): SoundGql

Update the metadata of an existing sound.

Permission: sounds:edit

mutation {
updateSound(id: "uuid", input: { name: "Fanfare" }) {
id
name
updatedAt
}
}

deleteSound(id: UUID!): Boolean!

Delete a sound from the library. Returns true on success.

Permission: sounds:delete

playSound(id: UUID!, volume: Float! = 1.0, target: SoundTargetInput): Boolean!

Trigger playback of a sound in browser sources. volume is 0.0–1.0 (default 1.0). target is an optional overlay key; omit to broadcast to all browser sources.

Permission: sounds:play

stopSound(id: UUID!, target: SoundTargetInput): Boolean!

Stop playback of a sound in browser sources. target is an optional overlay key; omit to broadcast to all browser sources.

Permission: sounds:play

Types

SoundGql

FieldTypeDescription
idUUID!Sound ID
accountIdUUID!Owning account
nameString!Display name
filenameString!Original file name
contentTypeString!MIME type (e.g. audio/mpeg)
sizeBytesInt!File size in bytes
durationMsIntDuration in milliseconds (null until processed)
waveformJSONPrecomputed waveform peaks (null until processed)
createdAtString!Upload timestamp
sourceExtensionNameStringExtension that bundled this sound, when applicable

SoundsResult

FieldTypeDescription
sounds[SoundGql!]!Sound list for the current page
totalCountInt!Total count matching the query
userSoundCountInt!Sounds uploaded by the account (excludes extension-bundled)
maxSoundsInt!Plan limit on sound count
maxSoundFileSizeInt!Plan limit on a single upload, in bytes
maxSoundStorageBytesInt!Plan limit on total storage, in bytes
usedStorageBytesInt!Storage currently used, in bytes

UpdateSoundInput

FieldTypeDescription
nameStringNew display name
durationMsIntDuration in milliseconds
waveformJSONWaveform peaks

SoundTargetInput

Passed to playSound / stopSound to address a single browser source instead of every listener.

FieldTypeDescription
typeString!Target kind (e.g. "overlay", "widget")
idUUID!Target ID

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

Widgets

Account-scoped queries and mutations for widget access management and duplication.

Queries

widgetAccess(widgetId: UUID!): [WidgetAccessEntry!]!

List access entries for a widget. Returns all users with explicit access grants.

Permission: widgets:access-read

widgetAccessCandidates(widgetId: UUID!): [WidgetAccessCandidate!]!

List account members eligible for the widget access dialog. Excludes users who already have an access entry.

Permission: widgets:access-read

Mutations

setWidgetAccess(widgetId: UUID!, userId: UUID!, role: String!): WidgetAccessEntry!

Set a user's access role on a widget. role accepts "viewer", "editor", or "none".

Permission: widgets:access-grant

removeWidgetAccess(widgetId: UUID!, userId: UUID!): WidgetDeleteResult!

Remove a user's access entry from a widget.

Permission: widgets:access-revoke

duplicateWidgetInstance(id: UUID!): CreateWidgetResult!

Duplicate a widget instance. Creates a copy of the widget with its configuration and returns the new widget with an access token.

Permission: widgets:create

Types

WidgetAccessEntry

FieldTypeDescription
idUUID!Access-entry ID
widgetIdUUID!Widget instance the grant applies to
userIdUUID!User ID
userNameString!User display name
userAvatarStringUser avatar URL
roleString!Access role ("viewer", "editor")
grantedByUUID!User who granted access
createdAtString!When access was granted

WidgetAccessCandidate

FieldTypeDescription
idUUID!User ID
displayNameString!User display name
avatarUrlStringUser avatar URL

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

Popout Tokens

Create, update, and revoke the long-lived popout/API tokens (lm_pop_*) used by overlays and embedded browser sources. See Tokens for the full model, permissions, and REST twins.

Mutations

createPopoutToken(input: CreatePopoutTokenInput!): CreatePopoutTokenResult!

Requires tokens:create (and the feature:tokens flag). Returns the full token string (shown only once) plus the token metadata. Binds to the creator (the acting first-party user) when userId is omitted.

First-party session required (ZAF-1017 / ZAF-469): minting a popout token is itself a first-party action — the omitted-userId (NULL) binding keys off the creator, never the account-owner fallback an unassigned popout token's user_id resolves to. A popout/overlay/widget/extension token calling createPopoutToken is rejected with FORBIDDEN, so a member can no longer mint an owner-bound popout via the NULL path. Passing an explicit userId for another account member stays a legitimate admin action (validated as an account member). Same gate and status behaviour as REST POST /v1/tokens.

CreatePopoutTokenInput

FieldTypeDescription
labelStringHuman-readable label.
permissions[String!]!Permission strings the token grants (must be a subset of the creator's rights).
userIdUUIDUser to bind the token to (defaults to the authenticated user).
expiresAtStringOptional lifetime (RFC3339). null/absent = never expires (the default). Must not be in the past — a back-dated value is rejected with code VALIDATION_ERROR and message Validation error: expires_at cannot be in the past, identical to REST POST /v1/tokens. Optional and additive.

updatePopoutToken(input: UpdatePopoutTokenInput!): PopoutToken!

Requires tokens:edit. Uses double-option semantics (absent = don't change, null = clear, value = set) for label/userId/expiresAt, plus a revoke boolean. Unlike create, the update path allows a back-dated expiresAt — that is a supported soft-revoke.

Protocol parity: matching REST endpoints under /v1/tokens — see REST API. GraphQL and REST share the same fields, permissions, validation, and error messages.

Popout Sessions

Exchange a raw popout token (lm_pop_*) for a short-lived popout session so the raw token stays out of the page URL. See Authentication → Popout Tokens & Popout Sessions for the full model.

Mutations

exchangePopoutToken(token: String!): PopoutSession!

Validates the popout token (prefix + revoked_at / expires_at), mints a 15-minute session JWT carrying the token's permission subset, and sets it as an httpOnly cookie (lumio-popout-token, Path=/, SameSite=Lax). No permission guard — possession of a valid popout token IS the authorization. A revoked, expired, unknown, or non-popout token is rejected with Invalid or expired popout token.

The minted session resolves to the same popout auth context as the raw token (identical account, permission subset, OBS credential gate) — never a full user session.

issuePopoutWsToken: String!

Mint a WebSocket-scoped token for the current popout session — the popout counterpart of issueWsToken. issueWsToken is first-party gated (ZAF-469) and returns FORBIDDEN for a popout, so a popout cannot mint there; this path mints a WS token that carries the popout's own permission subset (never the account owner's). No permission guard — possession of a valid popout session IS the authorization; a non-popout caller is rejected with UNAUTHENTICATED (anonymous) or FORBIDDEN (any non-popout credential).

The token carries a WebSocket-only use tag and no session_id, so the API accepts it only on the /v1/ws upgrade path — a leaked ?token= WS URL cannot drive a REST/GraphQL mutation. It is short-lived (auth.ws_token_expiration, ~15 min); the popout re-mints on reconnect.

Protocol parity: matching REST endpoint POST /v1/auth/popout/ws-token — see REST API.

Types

PopoutSession

FieldTypeDescription
tokenString!The popout-session JWT (lm_...). Store as an httpOnly cookie.
expiresAtDateTime!Absolute expiry (now + 15 min).
accountIdUUID!Account the session is scoped to.

Protocol parity: matching REST endpoint POST /v1/auth/popout/exchange — see REST API.

Stream History

Gated behind the feature:stream_history flag. All operations require a history:* permission and have exact REST parity under /v1/history/....

Queries

QueryPermissionDescription
channelHistory(page, perPage, from, to, sessionType, platform, search, category, sortBy, sortDir)history:readPaged session list with core aggregates. perPage defaults to 25 (max 100). All filter arguments are optional and freely combinable: from/to (DateTime) bound startedAt; sessionType is single/multi; platform matches a session with a broadcast on that platform; search is a case-insensitive substring over the session or any stream title; category matches the session or any stream category. total counts the filtered set. Sort with sortBy (started_at | duration_secs | peak_viewers | avg_viewers | total_messages | unique_chatters | new_followers, default started_at) and sortDir (asc | desc, default desc); the three nullable stat columns order NULLS LAST so a still-live session never floats to the top, and ties break by startedAt descending for stable pagination. An invalid sessionType/sortBy/sortDir, or from after to, → error.
channelHistoryFilterOptionshistory:readThe distinct platforms + categories that occur in the account's history — the dropdown values for the filter bar (both sorted).
channelHistoryReport(id)history:readFull report: summary, stream table, top chatters/emotes/gifs, and the server-computed viewerMarkers / followerMarkers (per platform + a total entry). The summary carries emoteCount and gifCount (ZAF-972); the report carries topEmotes and topGifstopGifs is [{id, url, provider, alt, count}], the GIF twin of topEmotes, with the provider URL rendered verbatim. [] for sessions finalized before the metric shipped or with no GIFs. Each stream-table entry also carries sharedChatSources (ZAF-819) — Twitch Shared Chat source attribution as [{platformChannelId, messageCount, displayName, avatarUrl}] (displayName / avatarUrl are the source channel's identity, ZAF-868, null when uncaptured), null for first-party sessions (only the public-stats crawler populates it today).
channelHistoryStats(sessionId, platform)history:readRaw 60-second time-series samples, optionally per platform.
historySharedLinks(sessionId)history:shareThe report's share links (secrets excluded).

Mutations

MutationPermissionDescription
exportHistoryReport(sessionId, format)history:exportExport a finalized report. File downloads are not a GraphQL fit, so — like upload's downloadUrl — this returns { downloadUrl, filename, contentType, format }, where downloadUrl is the authenticated REST endpoint GET /v1/history/reports/{id}/export?format=…. format is one of csv, txt, json, pdf; an unknown format → 400; a still-live session → 409; unknown session → 404 — the same guard/error picture as REST.
createHistorySharedLink(sessionId, durationSecs, password, allowExport)history:shareCreate a share link; returns the plaintext token + url once. Durations: 1h/6h/24h/7d/30d; max 20 active links per report.
extendHistorySharedLink(linkId, durationSecs)history:shareExtend a link's expiry.
revokeHistorySharedLink(linkId)history:shareRevoke a link.
deleteChannelHistory(id)history:deleteDelete a stored session and its data.

Types are platform-neutral: core metrics are fixed fields, everything platform-specific is an opaque JSON scalar (platformMetrics / platformBreakdown) or a per-platform marker list — adding a platform never changes the schema.

Public Stats

Per-channel publicness for the public Stats app, gated behind the feature:public_stats_page flag. Publicness is opt-out / public-by-default: a channel with no settings row is public. Mirrors the REST /v1/public-stats/channels endpoints exactly (same fields, guards, validation and errors).

Queries

QueryPermissionDescription
channelPublicSettingspublic-stats:readThe account's connected channels with their publicness settings (platform, platformChannelId, channelName, isPublic, showTopChatters, optedOutVia, updatedAt). Channels default to isPublic: true (opt-out) but showTopChatters: false (leaderboard opt-in, Art. 25(2)) until changed.

Mutations

MutationPermissionDescription
setChannelPublicSettings(input: { platform, isPublic, showTopChatters })public-stats:editToggle a channel's publicness (opt-out / opt-in) and chatter-leaderboard suppression. The channel is resolved to the caller's own connected channel for platform (the channel id is never taken from the client). No connection → error; an unresolved channel identity → error. Emits a public_stats:channel_opted_{out,in} audit event.

Public read model (unauthenticated)

The read surface the public Stats app consumes. No authentication, no permission — reachable anonymously — and no per-user data, so responses are cache-friendly. Gated globally by the system:public_stats kill-switch flag (disabled → "not available"). Mirrors the REST /v1/public-stats/{channels,streams} endpoints exactly. Serves only the §5 public-safe field set; there is no field for donations, subscriber/gifter identities, revenue splits, or the raw platformMetrics / platformBreakdown JSONB. First-party and crawled rows use the same types, distinguished only by the source badge.

QueryPermissionDescription
publicChannelStats(platform, channel)none — publicA channel's public aggregate by platform + URL handle (channel = channelLogin): platformChannelId, channelDisplayName, source, streamCount, totalDurationSecs, peakViewers, avgViewers, totalMessages, lastStreamedAt, broadcasterType (Twitch channel type: "partner" / "affiliate", else null — no badge; ZAF-841), and recentStreams (public stream summaries). Returns null when the channel has no addressable streams or has opted out (isPublic = false). Unsupported platform → error.
publicStreamStats(platform, broadcastId)none — publicOne stream by its public key (platform, broadcastId): the stream summary, emoteCount, topEmotes, gifCount and topGifs (ZAF-968 — topGifs is [{id, url, provider, alt, count}], the GIF twin of topEmotes; on the public-stats crawler path provider/alt are null, and both are erased after the 90-day GIF-content retention), de-identified newPaidSubs / giftedSubs counts, newFollowers, the viewerCurve timeseries, and topChatters only when the channel opted the leaderboard in (showTopChatters = true) — otherwise null. Returns null when the stream is unknown or its channel opted out.

Every public stream summary (the publicStreamStats summary and the publicChannelStats.recentStreams items) carries sharedChatSources (ZAF-819): a list of { platformChannelId, messageCount, displayName, avatarUrl } naming which source channels contributed Twitch Shared Chat messages to that broadcast. displayName + avatarUrl (ZAF-868) are the source channel's public identity, resolved from the crawler channel-profile store so the participant list can render source avatars/names; both are null when the anonymous crawler never captured that channel's identity (fail-open — the client falls back to the id). Shared-chat messages are attributed to their source channel instead of being counted toward the host, so messageCount / uniqueChatters reflect only the host channel's own chat; the list is empty for a normal (non-shared) broadcast. Attribution is by channel (public broadcaster identity), never by viewer — aggregates only. | publicChannelProfileHistory(platform, channel, limit, offset) | none — public | A channel's About-box change timeline (ZAF-836) — bio / social-link / team changes over time, most-recent first, paginated (limit default 50, max 200; offset default 0). Each entry: changeType (bio / link / team), oldValue / newValue (opaque JSON — a string for bio, an array for link / team; null old = initial capture, null bio new = deletion), and changedAt. Returns null when the channel has no addressable streams or has opted out; an empty list when it has no recorded changes. Public data (founder ruling ZAF-791): no §5 minimisation, no retention cap. | | publicStreamMetadataHistory(platform, broadcastId, limit, offset) | none — public | A broadcast's stream-metadata change timeline (ZAF-890) — title / category / tags changes over time, most-recent first, paginated (limit default 50, max 200; offset default 0). Each entry: changeType (title / category / tags), oldValue / newValue (opaque JSON — a string for title / category, an array of tag strings for tags; null old = initial capture, null new = emptied field / all tags removed), and changedAt. Returns null when the stream is unknown or its channel has opted out; an empty list when the broadcast has no recorded changes. Public data (founder ruling ZAF-791/ZAF-794): no §5 minimisation, no retention cap. | | publicChannelTrends(platform, channel, range) | none — public | A channel's "Stats"-tab trends (ZAF-891) over the selected range (DAYS_30 / DAYS_90 / DAYS_180 / DAYS_365): followerSeries (dated follower-total growth) and avgViewerSeries (dated average-viewers-over-time). Each is an oldest-first list of { date (YYYY-MM-DD, UTC), … } day points — followerSeries items carry followerCount, avgViewerSeries items carry avgViewers (the exact sample-weighted daily mean). Follower points come from dated crawler snapshots (last value per day); avg-viewer points from the channel_history_stats_1h aggregate scoped to the channel's sessions. Returns null when the channel has no addressable streams or has opted out; either series is an empty list when the channel has no history in the window (frontend renders "not enough history"). Aggregate counts only — no viewer identities. | | publicChannelPanelHistory(platform, channel) | none — public | A channel's ad-panel lifecycle + image-change history (ZAF-892) for the "Placements" tab — the ad-marked panels grouped by panel, most-recently-active first. Each entry: panelId, latest content (title / description / imageUrl / linkUrl), full span (firstSeenAt / lastSeenAt), lifecycle (status = "active" / "removed", removedAt), and images: [{ imageUrl, firstSeenAt, lastSeenAt }] — the distinct image versions over time, oldest-first (the slideshow). Returns null when the channel has no addressable streams or has opted out; an empty list when it has no ad-marked panels. Artwork hotlinked from the Twitch CDN (never re-hosted). The flat publicChannelStats.panels also carries the status / removedAt lifecycle fields. |

Cross-channel browse (Form B)

Public, unauthenticated browse/ranking across all channels — the metric nav, a Games directory, and channel search — gated only by the account-less system:public_stats kill-switch. Opted-out channels are excluded. GraphQL + REST only (no WebSocket: a browse/search directory has no live-push consumer).

QueryPermissionDescription
publicChannelBrowse(platform, category, metric, live, search, page, limit): PublicChannelBrowsePage!none — publicRanked channel list. metric (enum, default WATCHTIME): WATCHTIME (Σ avg-viewers·duration), VIEWERS (avg), PEAK, FOLLOWER_GAIN (Σ new-followers), MESSAGES, RECENT. Optional filters: platform, category (Games), live (true/false), search (case-insensitive substring on handle + display name). page 1-based; limit clamped 1..=100 (default 50). Each item: identity, source badge, lastCategory, isLive, streamCount, watchSeconds, avgViewers, peakViewers, totalMessages, followerGain, lastStartedAt, broadcasterType (Twitch channel type: "partner" / "affiliate", else null; ZAF-841). Empty-string filters are treated as absent.
publicGames(platform, search, page, limit): PublicGamesPage!none — publicGames directory: distinct categories with channelCount, streamCount, liveStreamCount, peakViewers, watchSeconds, lastStartedAt. Optional platform filter and search (case-insensitive substring on category). Page-based pagination.

Emote Directory

Public, unauthenticated browse of the cross-platform emote catalog (chat_emotes) for the public Stats app. Read-only and edge-cacheable, gated only by the account-less system:public_stats operator kill-switch — no auth and no RBAC permission. Mirrors the REST GET /v1/public-stats/emotes endpoint exactly (same fields, filters, pagination and errors). There is no WebSocket channel: a static browse / search surface has no live-push consumer (a deliberate single-protocol omission).

Queries

QueryAuthDescription
publicEmotes(platform, provider, channelId, animated, search, page, limit): PublicEmotePage!none (public)Paginated browse of the emote catalog. All filters optional: platform (twitch/youtube/kick/trovo), provider (the platforms plus 7tv/bttv/ffz), channelId, animated, and search (case-insensitive substring on name). page is 1-based; limit is clamped to 1..=100 (default 50). Empty-string filters are treated as absent. When the kill-switch is off the query errors with Feature 'system:public_stats' is not available.

Types

  • PublicEmote { id, platform, provider, channelId, name, url, animated, ownerId, source, firstSeenAt, lastSeenAt } — a public-safe projection of a chat_emotes row. channelId is null for a global (platform-wide) set; source is api_fetch | observed; firstSeenAt / lastSeenAt are RFC-3339. The internal providerEmoteId is not exposed.
  • PublicEmotePage { emotes, page, limit, total, totalPages } — a page of PublicEmote with page-based metadata (identical to the REST pagination object).

Caching: the browse is public reference data. The REST mirror sets edge-cache headers; the GraphQL surface (a POST, not edge-cacheable) is cached at the SSR layer of the public Stats app.

Audit Log

Three scoped readers over the audit_events table, one per scope column value (user | account | system). The scope is set by the writer and never inferred at read time, so each reader returns only its own audience's rows.

Queries

QueryPermissionDescription
myAuditLog(filter)none — self-onlyThe caller's own scope=user rows. No RBAC permission, but requires a first-party principal — a logged-in session or the user's own API key; a popout/overlay/widget/extension token cannot read it. Current writers record successful logins, failed login attempts tied to a known user, and OAuth grant consent.
accountAuditLog(filter)audit-log:readThe active account's scope=account events. Current emitters write role/permission, member-role, channel-connection, and account-scoped GDPR erasure rows. Requires the account-scope audit-log:read permission and returns only the caller's account's rows.
adminAuditLog(filter)audit-log:read (admin)The operator audit view. Guarded by the admin-scope audit-log:read, a distinct permission from the account one of the same name. Accepts an optional scope filter to narrow the superuser view to user, account, or system; without it, admins can read rows from every scope.

Types

  • AuditEvent { id, accountId, userId, eventType, scope, ipAddress, userAgent, country, city, metadata, createdAt }metadata is a JSON string; scope is user | account | system.
  • AuditLogPage { items, total, page, limit } — a page of AuditEvent.
  • AuditLogFilter { page, limit, eventType, dateFrom, dateTo }dateFrom / dateTo are RFC-3339; an unparseable value is ignored.

Scope isolation is enforced by the reader: myAuditLog never returns account or system rows, and accountAuditLog never returns another account's rows. Overlap rule: an account-context action that has an actor lives only in the account log (the actor is shown), never additionally in the user log; personal-security events live only in the user log.

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.