GraphQL
Lumio provides a GraphQL endpoint for flexible data querying.
Endpoint
POST /v1/gql
Base URLs
| Environment | URL |
|---|---|
| Production | https://api.lumio.vision/v1/gql |
| Production Preview | https://lumio.api.prod.zaflun.dev/v1/gql |
| Staging | https://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 (excludessystem:*flags).featureStatuses: [FeatureStatusGql!]!— full status list withkey,enabled, andreasonfor each flag (excludessystem:*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.kind—CHANNELorBOT. Channel and bot can differ per platform (e.g. YouTube channel isACCOUNT, YouTube bot isSYSTEM).mode—SYSTEM(global Lumio app) orACCOUNT(per-accountapp_credentials). This is the configured intent, before fail-safe degradation.systemConfigured— whether global OAuth keys are actually configured. Not derivable frommode:SYSTEM+systemConfigured=falsemeans "should be Lumio-managed but is not set up" (it degrades toACCOUNTat 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
| Field | Type | Description |
|---|---|---|
key | String! | Feature flag key (e.g., feature:bots) |
enabled | Boolean! | Whether the feature is enabled for this caller |
reason | FeatureDisabledReasonGql | Why the feature is disabled; null when enabled |
FeatureDisabledReasonGql (enum)
| Value | Meaning |
|---|---|
GLOBAL_OFF | The flag is turned off globally by an admin kill-switch |
PLAN_LOCKED | The account's plan does not include this feature |
ACCOUNT_OVERRIDE | An explicit per-account override blocks this feature |
USER_OVERRIDE | An explicit per-user override blocks this feature (system:account_creation only) |
AccountCreationOverride (enum)
| Value | Description |
|---|---|
DEFAULT | Inherit the global system:account_creation flag setting |
ALLOW | User is always allowed to create accounts (overrides global OFF) |
DENY | User 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,updateUserApiKeyanddeleteUserApiKeyall 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 — whoseuser_idis bound at token-create time and could be the account owner — is rejected with aFORBIDDENerror, 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-keystwins.
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
| Field | Type | Description |
|---|---|---|
label | String! | Required display name |
permissions | [String!]! | Permission subset granted to the key |
expiresAt | String | Optional RFC3339 expiry; null means no expiry |
CreateUserApiKeyResult
| Field | Type | Description |
|---|---|---|
key | String! | Full lm_usr_ key, returned once |
apiKey | UserApiKey! | Stored key metadata |
UserApiKey
| Field | Type | Description |
|---|---|---|
id | UUID! | Key ID |
userId | UUID! | Owning user |
accountId | UUID! | Owning account |
keyPrefix | String! | Display prefix for identification |
label | String! | Display name |
permissions | [String!]! | Granted permission subset |
createdAt | String! | Creation timestamp |
expiresAt | String | Optional expiry timestamp |
lastUsedAt | String | Last 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
| Field | Type | Description |
|---|---|---|
label | String! | Required display name |
permissions | [String!]! | Permission subset granted to the key |
expiresAt | String | Optional RFC3339 expiry; null means no expiry |
CreateAccountServiceKeyResult
| Field | Type | Description |
|---|---|---|
key | String! | Full lm_svc_ key, returned once |
serviceKey | AccountServiceKey! | Stored key metadata |
AccountServiceKey
| Field | Type | Description |
|---|---|---|
id | UUID! | Key ID |
accountId | UUID! | Owning account |
createdBy | UUID | Member who minted the key; null once they leave the account |
keyPrefix | String! | Display prefix for identification |
label | String! | Display name |
permissions | [String!]! | Granted permission subset |
createdAt | String! | Creation timestamp |
updatedAt | String! | Last modification timestamp |
expiresAt | String | Optional expiry timestamp |
lastUsedAt | String | Last 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:accessinto 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: nullclears 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 = trueroles are fully editable (not protected from edits)
adminDeleteRole(id: UUID!): Boolean!
Delete an admin role. Requires admin-roles:delete.
- Returns
trueif deleted - Rejects with
"Cannot delete system admin role"ifis_system = true - Cascades to
admin_role_permissionsanduser_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
| Field | Type | Description |
|---|---|---|
id | UUID! | Role ID |
name | String! | Display name |
description | String | Optional description |
isSystem | Boolean! | System roles cannot be deleted |
permissions | [String!]! | Sorted permission strings |
memberCount | Int! | Number of assigned users |
createdAt | String! | ISO-8601 timestamp |
updatedAt | String! | ISO-8601 timestamp |
AdminRoleMember
| Field | Type | Description |
|---|---|---|
userId | UUID! | User ID |
displayName | String! | User display name |
email | String | User email |
avatarUrl | String | User avatar URL |
assignedAt | String! | ISO-8601 timestamp |
AdminPermissionInfo
| Field | Type | Description |
|---|---|---|
permission | String! | Permission string (e.g., admin-roles:read) |
category | String! | Category label (e.g., Admin Role Management) |
CreateAdminRoleInput
| Field | Type | Required |
|---|---|---|
name | String! | Yes |
description | String | No |
permissions | [String!]! | Yes (may be empty) |
UpdateAdminRoleInput
| Field | Type | Notes |
|---|---|---|
name | String | Optional; trimmed |
description | String | null = 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-rolesand/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
slugagainst 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". currencydefaults 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.
slugis immutable and is not part ofAdminUpdatePlanInput. 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_featuresvia 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-mintingissueWsTokenare 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 — whoseuser_idis bound at token-create time and could be the account owner — is rejected with aFORBIDDENerror (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,developerExtensionsand the ownership-scopeddeveloperExtension*reads,updateDeveloperPayoutSettings,requestPayout,requestDeveloperLimitIncrease,createExtension/submitExtensionVersion/requestExtensionDeletion/inviteExtensionTesterand their ownership-scoped siblings, thedeveloperTeam*reads andDeveloperTeamMutationoperations, andgrantExtensionAccess/createExtensionAccessInvite. An unassigned popout token resolves itsuser_idto 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 withFORBIDDENon both protocols. Account-scoped resolvers a popout is meant to drive — extension secrets and billing (createCheckout,createPortal, …), keyed onaccount_id— are unaffected.
Note: This mutation supersedes the previous provider-based disconnect operation. The old form accepted a
providerstring; the new form accepts aloginConnectionIdUUID to uniquely identify the connection even when a user has multiple connections to the same platform.
Types
GqlLoginAssignment
| Field | Type | Description |
|---|---|---|
id | UUID! | Assignment ID |
accountId | UUID! | Account the login is assigned to |
userId | UUID! | ID of the owning user |
loginConnectionId | UUID! | ID of the linked login connection |
provider | String! | Platform slug (e.g. "twitch", "google") |
createdAt | String! | 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
| Operation | Args | Returns | Permission |
|---|---|---|---|
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
| Query | Args | Returns | Permission |
|---|---|---|---|
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}, andGET /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/tiersandDELETE /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, orDELETEplatform: String!—"twitch","youtube","kick", or"trovo"userId: String— platform user ID (required forBAN/TIMEOUT)messageId: String— required forDELETEdurationSecs: Int— timeout length in seconds, required forTIMEOUT(validation rejects aTIMEOUTwithout it, identically on REST; YouTube accepts1–86400)reason: String— optional moderator reason (logged tomoderation_log)liveChatId: String— only used by YouTube. Optional: when omitted the server resolves the active broadcast'sliveChatIdfrom 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:
| Platform | Supported actions | Notes |
|---|---|---|
twitch | BAN, TIMEOUT, DELETE | Calls Helix; needs moderator:manage:banned_users / moderator:manage:chat_messages scope on the moderator's login token |
youtube | BAN, TIMEOUT, DELETE | BAN/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. |
kick | DELETE only | Kick's public mod API only exposes message deletion; ban/timeout return BadRequest. |
trovo | BAN only | Trovo'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 platformUserProfile — feature: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
| Query | Arguments | Returns | Permission |
|---|---|---|---|
ideas | filter: IdeaFilterInput, sort: IdeaSortInput, limit: Int! = 20, offset: Int! = 0 | GqlIdeaConnection! | Public |
idea | id: UUID! | GqlIdea | Public |
ideaComments | ideaId: UUID! | [GqlIdeaComment!]! | Public |
ideaCategories | — | [GqlIdeaCategory!]! | Public |
ideaTags | search: String | [GqlIdeaTag!]! | Public |
ideaVoters | ideaId: UUID! | [GqlIdeaVoter!]! | Public |
ideaTimeline | ideaId: UUID! | [GqlIdeaTimelineEntry!]! | Public |
ideaParticipants | ideaId: 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
| Mutation | Arguments | Returns | Permission |
|---|---|---|---|
createIdea | input: CreateIdeaInput! | GqlIdea! | ideas:create |
updateIdea | id: UUID!, input: UpdateIdeaInput! | GqlIdea! | ideas:edit or ideas:moderate_edit |
deleteIdea | id: UUID! | Boolean! | ideas:delete or ideas:moderate_delete |
voteIdea | id: UUID!, voteType: String! | GqlIdea! | ideas:vote |
removeVote | id: UUID! | GqlIdea! | ideas:vote |
createIdeaComment | input: CreateIdeaCommentInput! | GqlIdeaComment! | ideas:comment_create |
updateIdeaComment | id: UUID!, body: String! | GqlIdeaComment! | ideas:comment_edit |
deleteIdeaComment | id: UUID! | Boolean! | ideas:comment_delete or ideas:moderate_comment |
voteIdeaComment | id: UUID!, voteType: String! | GqlIdeaComment! | ideas:comment_vote |
removeIdeaCommentVote | id: UUID! | GqlIdeaComment! | ideas:comment_vote |
updateIdeaStatus | id: UUID!, status: String! | GqlIdea! | ideas:moderate_status |
createIdeaCategory | input: CreateIdeaCategoryInput! | GqlIdeaCategory! | Admin ideas:edit |
updateIdeaCategory | id: UUID!, input: UpdateIdeaCategoryInput! | GqlIdeaCategory! | Admin ideas:edit |
deleteIdeaCategory | id: UUID! | Boolean! | Admin ideas:delete |
createIdeaTag | name: String! | GqlIdeaTag! | ideas:create |
deleteIdeaTag | id: UUID! | Boolean! | Admin ideas:delete |
Types
GqlIdea
| Field | Type | Description |
|---|---|---|
id | UUID! | Idea ID |
author | GqlIdeaAuthor! | Author info |
category | GqlIdeaCategory! | Category |
tags | [GqlIdeaTag!]! | Assigned tags |
title | String! | Idea title |
description | String! | Idea description (plain text or HTML) |
status | String! | Status slug (e.g. open, in_progress, done, declined) |
voteCountUp | Int! | Number of upvotes |
voteCountDown | Int! | Number of downvotes |
commentCount | Int! | Total comment count |
myVote | String | Authenticated caller's vote ("up", "down", or null) |
createdAt | String! | ISO-8601 timestamp |
updatedAt | String! | ISO-8601 timestamp |
GqlIdeaConnection
| Field | Type | Description |
|---|---|---|
items | [GqlIdea!]! | Page of ideas |
total | Int! | Total matching the filter |
GqlIdeaAuthor
Also the element type of ideaParticipants.
| Field | Type | Description |
|---|---|---|
id | UUID! | User ID |
displayName | String! | Display name |
avatarUrl | String | Avatar URL |
GqlIdeaComment
| Field | Type | Description |
|---|---|---|
id | UUID! | Comment ID |
ideaId | UUID! | Parent idea ID |
author | GqlIdeaAuthor! | Comment author |
parentId | UUID | Parent comment ID for nested replies |
body | String! | Sanitized HTML from rich text editor |
createdAt | String! | ISO-8601 timestamp |
updatedAt | String! | ISO-8601 timestamp |
voteCountUp | Int! | Number of upvotes on the comment |
voteCountDown | Int! | Number of downvotes on the comment |
myVote | String | Authenticated 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
| Field | Type | Description |
|---|---|---|
id | UUID! | Category ID |
name | String! | Machine-readable slug |
label | String! | Display label |
color | String | Hex color for UI display |
sortOrder | Int! | Sort position |
createdAt | String! | ISO-8601 timestamp |
GqlIdeaTag
| Field | Type | Description |
|---|---|---|
id | UUID! | Tag ID |
name | String! | Tag name |
createdBy | UUID | Creating user |
createdAt | String! | ISO-8601 timestamp |
GqlIdeaTimelineEntry
| Field | Type | Description |
|---|---|---|
id | UUID! | Entry ID |
actor | GqlIdeaAuthor | Who performed the action |
action | String! | Action type (e.g. status_changed, edited) |
oldValue | String | Previous value |
newValue | String | New value |
createdAt | String! | ISO-8601 timestamp |
GqlIdeaVoter
| Field | Type | Description |
|---|---|---|
userId | UUID! | Voting user |
voteType | String! | "up" or "down" |
displayName | String! | Display name |
avatarUrl | String | Avatar URL |
IdeaFilterInput
| Field | Type | Description |
|---|---|---|
status | String | Filter by status slug |
categoryId | UUID | Filter by category |
tagIds | [UUID!] | Filter by one or more tags |
authorId | UUID | Filter by author |
search | String | Full-text search on title and description |
IdeaSortInput (enum)
| Value | Description |
|---|---|
NEWEST | Most recently created first |
MOST_VOTED | Highest net vote count first |
MOST_COMMENTED | Most comments first |
RECENTLY_UPDATED | Most 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
| Field | Type | Description |
|---|---|---|
id | UUID! | Application ID |
userId | UUID! | Applicant's user ID |
applicationType | String! | "solo" or "team" |
displayName | String! | Developer/team display name |
slug | String! | URL slug |
description | String! | Description |
motivation | String! | Why the user wants developer access |
whatToBuild | String! | Planned extensions |
githubUrl | String | GitHub profile URL |
websiteUrl | String | Website URL |
experience | String | Development experience |
avatarKey | String | Upload key for avatar |
status | String! | "pending", "approved", or "rejected" |
reviewNotes | String | Reviewer notes |
reviewedBy | UUID | Reviewer's user ID |
reviewedAt | DateTime | Review timestamp |
createdAt | DateTime! | Submission timestamp |
updatedAt | DateTime! | Last update timestamp |
SubmitDeveloperApplicationInput
| Field | Type | Required |
|---|---|---|
applicationType | String! | Yes |
displayName | String! | Yes |
slug | String! | Yes |
description | String! | Yes |
motivation | String! | Yes |
whatToBuild | String! | Yes |
githubUrl | String | No |
websiteUrl | String | No |
experience | String | No |
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 alreadyverified"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
| Field | Type | Required |
|---|---|---|
legalName | String! | Yes |
companyName | String | No |
addressLine1 | String! | Yes |
addressLine2 | String | No |
postalCode | String! | Yes |
city | String! | Yes |
country | String! | Yes (2-letter ISO) |
taxId | String | No |
tradeRegisterId | String | No |
documentKey | String | No |
VerificationDetailGql
| Field | Type | Description |
|---|---|---|
id | UUID! | Verification ID |
developerId | UUID! | Developer profile ID |
legalName | String! | Legal name |
companyName | String | Company name (company type) |
addressLine1 | String! | Address line 1 |
addressLine2 | String | Address line 2 |
postalCode | String! | Postal code |
city | String! | City |
country | String! | ISO-3166-1 alpha-2 country code |
taxId | String | Tax ID |
tradeRegisterId | String | Trade register ID |
documentKey | String | Upload key for a supporting document |
status | String! | "pending", "verified", or "rejected" |
reviewNotes | String | Reviewer notes (e.g. rejection reason) |
reviewedBy | UUID | Reviewer's user ID |
reviewedAt | String | Review timestamp (RFC 3339) |
createdAt | String! | First submission timestamp (RFC 3339) |
updatedAt | String! | 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
| Field | Type | Description |
|---|---|---|
id | UUID! | Application ID |
userId | UUID! | Applicant's user ID |
applicationType | String! | "solo" or "team" |
displayName | String! | Display name |
slug | String! | URL slug |
status | String! | Application status |
createdAt | String! | Submission timestamp |
userDisplayName | String | Applicant's display name |
userAvatarUrl | String | Applicant's avatar URL |
AdminApplicationDetail
Same fields as DeveloperApplication with string-formatted timestamps.
ApplicationActionResult
Returned by approveDeveloperApplication and rejectDeveloperApplication.
| Field | Type | Description |
|---|---|---|
success | Boolean! | Whether the action succeeded |
newStatus | String! | The new application status |
ApproveApplicationInput / RejectApplicationInput
| Field | Type | Description |
|---|---|---|
id | UUID! | Application ID |
notes | String on approve, String! on reject | Reviewer 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
| Query | Arguments | Returns | Permission |
|---|---|---|---|
developerTeams | — | [DeveloperTeam!]! | Auth (developer) |
developerTeam | teamId: UUID! | DeveloperTeam | team-settings:read |
developerTeamMembers | teamId: UUID! | [DeveloperTeamMember!]! | team-members:read |
developerTeamInvites | teamId: UUID! | [DeveloperTeamInvite!]! | team-members:invite |
developerTeamInviteByCode | code: String! | DeveloperTeamInviteDetail | Auth |
developerTeamRoles | teamId: UUID! | [DeveloperTeamRole!]! | team-members:read |
developerTeamRole | teamId: UUID!, roleId: UUID! | DeveloperTeamRoleDetail! | team-members:read |
availableTeamPermissions | — | [TeamPermission!]! | Auth |
Mutations
| Mutation | Arguments | Returns | Permission |
|---|---|---|---|
createDeveloperTeam | name: String! | DeveloperTeam! | Auth (developer) |
updateDeveloperTeam | teamId: UUID!, name: String! | Boolean! | team-settings:edit |
deleteDeveloperTeam | teamId: UUID! | Boolean! | Team owner only |
updateDeveloperTeamMemberRole | teamId: UUID!, memberId: UUID!, roleId: UUID! | Boolean! | team-members:edit |
removeDeveloperTeamMember | teamId: UUID!, memberId: UUID! | Boolean! | team-members:remove |
createDeveloperTeamInvite | teamId: UUID!, input: CreateTeamInviteInput! | DeveloperTeamInvite! | team-members:invite |
deleteDeveloperTeamInvite | inviteId: UUID!, teamId: UUID! | Boolean! | team-members:invite |
acceptDeveloperTeamInvite | code: String! | AcceptTeamInviteResult! | Auth |
createDeveloperTeamRole | teamId: UUID!, input: CreateTeamRoleInput! | DeveloperTeamRoleDetail! | team-settings:edit |
updateDeveloperTeamRole | teamId: UUID!, roleId: UUID!, input: UpdateTeamRoleInput! | DeveloperTeamRoleDetail! | team-settings:edit |
deleteDeveloperTeamRole | teamId: UUID!, roleId: UUID! | Boolean! | team-settings:edit |
Types
DeveloperTeam
| Field | Type | Description |
|---|---|---|
id | UUID! | Team ID |
name | String! | Team name |
slug | String! | URL slug |
ownerId | UUID! | Owning user |
createdAt | String! | Creation timestamp |
updatedAt | String! | Update timestamp |
DeveloperTeamMember
| Field | Type | Description |
|---|---|---|
id | UUID! | Membership ID |
teamId | UUID! | Team ID |
userId | UUID! | Member's user ID |
roleId | UUID! | Assigned role ID |
roleName | String! | Role display name |
roleSlug | String! | Role slug |
roleColor | String | Role colour |
displayName | String! | Display name |
email | String! | Email address |
avatarUrl | String | Avatar URL |
createdAt | String! | When they joined |
DeveloperTeamInvite
| Field | Type | Description |
|---|---|---|
id | UUID! | Invite ID |
teamId | UUID! | Team ID |
invitedBy | UUID! | Inviting user |
invitedByName | String | Inviting user's display name |
email | String | Target email, when the invite is addressed |
inviteCode | String! | Invite code |
roleId | UUID! | Role assigned on accept |
roleName | String! | Role name |
roleSlug | String! | Role slug |
maxUses | Int! | Maximum uses |
useCount | Int! | Current use count |
expiresAt | String | Expiration timestamp |
acceptedAt | String | When it was accepted |
acceptedBy | UUID | Accepting user |
createdAt | String! | Creation timestamp |
DeveloperTeamInviteDetail
Returned by developerTeamInviteByCode — the invite as seen by the recipient, without team-internal fields.
| Field | Type | Description |
|---|---|---|
id | UUID! | Invite ID |
teamId | UUID! | Team ID |
teamName | String! | Team name |
teamSlug | String! | Team slug |
invitedByName | String | Inviting user's display name |
inviteCode | String! | Invite code |
roleId | UUID! | Role assigned on accept |
roleName | String! | Role name |
roleSlug | String! | Role slug |
maxUses | Int! | Maximum uses |
useCount | Int! | Current use count |
expiresAt | String | Expiration timestamp |
isExpired | Boolean! | Whether the invite has expired |
isFull | Boolean! | Whether useCount has reached maxUses |
DeveloperTeamRole
List shape — carries counts rather than the permission strings.
| Field | Type | Description |
|---|---|---|
id | UUID! | Role ID |
teamId | UUID! | Owning team ID |
name | String! | Role name |
slug | String! | Role slug |
description | String | Role description |
isDefault | Boolean! | Default role |
isSystem | Boolean! | System role (cannot be deleted) |
color | String | Role colour |
sortOrder | Int! | Sort order |
permissionCount | Int! | Number of permissions granted |
memberCount | Int! | Number of members holding it |
createdAt | String! | Creation timestamp |
updatedAt | String! | 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
| Field | Type | Description |
|---|---|---|
key | String! | Permission string (e.g. team-extensions:create) |
category | String! | 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
| Field | Type | Description |
|---|---|---|
id | UUID! | Developer profile ID |
userId | UUID! | Owning user ID |
stripeConnectId | String | Stripe Connect account id (Phase 9) |
paypalEmail | String | PayPal payout email |
bankIban | String | SEPA IBAN |
bankBic | String | SEPA BIC |
bankName | String | Account holder name |
payoutMethod | String | "stripe", "paypal", or "bank" |
revenueBalance | Int! | Current balance, minor units |
totalEarned | Int! | Lifetime earnings, minor units |
createdAt | DateTime! | Profile creation date |
updatedAt | DateTime! | 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
| Field | Type | Description |
|---|---|---|
id | UUID! | Developer profile ID |
displayName | String! | Display name |
slug | String! | URL slug |
description | String | Bio/description |
githubUrl | String | GitHub URL |
websiteUrl | String | Website URL |
avatarKey | String | Avatar upload key |
createdAt | DateTime! | Profile creation date |
stats | DeveloperStats! | Aggregate statistics |
extensions | [ProfileExtension!]! | Published extensions |
DeveloperTeamProfile
| Field | Type | Description |
|---|---|---|
id | UUID! | Team ID |
name | String! | Team name |
slug | String! | URL slug |
description | String | Description |
githubUrl | String | GitHub URL |
websiteUrl | String | Website URL |
avatarKey | String | Avatar upload key |
createdAt | DateTime! | Creation date |
stats | DeveloperStats! | Aggregate statistics |
members | [TeamMemberSummary!]! | Public member list |
extensions | [ProfileExtension!]! | Published extensions |
DeveloperStats
| Field | Type | Description |
|---|---|---|
extensionCount | Int! | Number of published extensions |
totalInstalls | Int! | Total installs across all extensions |
avgRating | Float! | Average rating across all extensions |
ProfileExtension
| Field | Type | Description |
|---|---|---|
id | UUID! | Extension ID |
shortId | String! | Short identifier |
slug | String! | URL slug |
name | String! | Extension name |
description | String | Short description |
category | String! | Extension category |
iconKey | String | Icon upload key |
pricingType | String! | "free", "paid", etc. |
pricingAmount | Int | Price in minor units |
pricingCurrency | String! | ISO-4217 currency code |
installCount | Int! | Number of installs |
ratingAvg | Float! | Average rating |
ratingCount | Int! | Number of ratings |
publishedAt | DateTime | Publish date |
TeamMemberSummary
| Field | Type | Description |
|---|---|---|
userId | UUID! | Member's user ID |
displayName | String! | Display name |
avatarUrl | String | Avatar URL |
role | String! | Role name |
InlineDeveloperPanel
| Field | Type | Description |
|---|---|---|
developerId | UUID! | Developer/team ID |
displayName | String! | Display name |
slug | String | URL slug |
description | String | Short description |
avatarKey | String | Avatar upload key |
extensionCount | Int! | Number of extensions |
isTeam | Boolean! | Whether this is a team profile |
Protocol parity:
developerProfileanddeveloperTeamProfilehave 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
| Field | Type | Description |
|---|---|---|
maxSounds | Int! | Resolved max bundled sounds |
maxSoundsSource | String! | Resolution source: default, developer_override, or extension_override |
maxSoundFileSize | Int! | Resolved max size of a single bundled sound, in bytes |
maxSoundFileSizeSource | String! | Resolution source for maxSoundFileSize |
maxSoundStorageBytes | Int! | Resolved total sound storage budget, in bytes |
maxSoundStorageBytesSource | String! | 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
| Field | Type | Description |
|---|---|---|
installId | UUID! | Install ID |
extensionId | UUID! | Extension ID |
extensionName | String! | Extension name |
extensionSlug | String! | Extension slug |
nodeType | String! | "trigger", "action", or "logic" |
inputSchema | JSON! | JSON Schema for node input |
outputSchema | JSON! | JSON Schema for node output |
triggerMode | String | "webhook", "polling", or "both" (triggers only) |
pollIntervalSeconds | Int | Poll interval for polling triggers |
icon | String | Lucide icon name |
color | String | Hex color for canvas rendering |
AutomationWebhookUrl
| Field | Type | Description |
|---|---|---|
webhookUrl | String! | Full webhook URL for the external service |
webhookSecret | String! | 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
| Field | Type | Description |
|---|---|---|
id | UUID! | File record ID |
versionId | UUID! | Parent version ID |
filePath | String! | Path within the bundle (e.g. layer.js, assets/logo.png) |
contentType | String! | MIME type (e.g. application/javascript, text/css) |
sizeBytes | Int! | File size in bytes |
contentHash | String | SHA-256 content hash |
createdAt | DateTime! | Upload timestamp |
Protocol parity:
GET /v1/extensions/{id}/versions/{version}/filesin 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
| Field | Type | Description |
|---|---|---|
name | String! | Platform display name |
slug | String! | Platform slug (e.g. twitch, youtube) |
icon | String! | Platform icon identifier |
hasLogin | Boolean! | Platform can be used to sign in |
hasChannel | Boolean! | Platform supports a channel connection |
hasBot | Boolean! | Platform supports a custom bot identity |
hasIntegration | Boolean! | 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
| Field | Type | Description |
|---|---|---|
id | UUID! | Sound ID |
accountId | UUID! | Owning account |
name | String! | Display name |
filename | String! | Original file name |
contentType | String! | MIME type (e.g. audio/mpeg) |
sizeBytes | Int! | File size in bytes |
durationMs | Int | Duration in milliseconds (null until processed) |
waveform | JSON | Precomputed waveform peaks (null until processed) |
createdAt | String! | Upload timestamp |
sourceExtensionName | String | Extension that bundled this sound, when applicable |
SoundsResult
| Field | Type | Description |
|---|---|---|
sounds | [SoundGql!]! | Sound list for the current page |
totalCount | Int! | Total count matching the query |
userSoundCount | Int! | Sounds uploaded by the account (excludes extension-bundled) |
maxSounds | Int! | Plan limit on sound count |
maxSoundFileSize | Int! | Plan limit on a single upload, in bytes |
maxSoundStorageBytes | Int! | Plan limit on total storage, in bytes |
usedStorageBytes | Int! | Storage currently used, in bytes |
UpdateSoundInput
| Field | Type | Description |
|---|---|---|
name | String | New display name |
durationMs | Int | Duration in milliseconds |
waveform | JSON | Waveform peaks |
SoundTargetInput
Passed to playSound / stopSound to address a single browser source instead of every listener.
| Field | Type | Description |
|---|---|---|
type | String! | Target kind (e.g. "overlay", "widget") |
id | UUID! | 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
| Field | Type | Description |
|---|---|---|
id | UUID! | Access-entry ID |
widgetId | UUID! | Widget instance the grant applies to |
userId | UUID! | User ID |
userName | String! | User display name |
userAvatar | String | User avatar URL |
role | String! | Access role ("viewer", "editor") |
grantedBy | UUID! | User who granted access |
createdAt | String! | When access was granted |
WidgetAccessCandidate
| Field | Type | Description |
|---|---|---|
id | UUID! | User ID |
displayName | String! | User display name |
avatarUrl | String | User 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'suser_idresolves to. A popout/overlay/widget/extension token callingcreatePopoutTokenis rejected withFORBIDDEN, so a member can no longer mint an owner-bound popout via the NULL path. Passing an explicituserIdfor another account member stays a legitimate admin action (validated as an account member). Same gate and status behaviour as RESTPOST /v1/tokens.
CreatePopoutTokenInput
| Field | Type | Description |
|---|---|---|
label | String | Human-readable label. |
permissions | [String!]! | Permission strings the token grants (must be a subset of the creator's rights). |
userId | UUID | User to bind the token to (defaults to the authenticated user). |
expiresAt | String | Optional 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
| Field | Type | Description |
|---|---|---|
token | String! | The popout-session JWT (lm_...). Store as an httpOnly cookie. |
expiresAt | DateTime! | Absolute expiry (now + 15 min). |
accountId | UUID! | 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
| Query | Permission | Description |
|---|---|---|
channelHistory(page, perPage, from, to, sessionType, platform, search, category, sortBy, sortDir) | history:read | Paged 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. |
channelHistoryFilterOptions | history:read | The distinct platforms + categories that occur in the account's history — the dropdown values for the filter bar (both sorted). |
channelHistoryReport(id) | history:read | Full 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 topGifs — topGifs 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:read | Raw 60-second time-series samples, optionally per platform. |
historySharedLinks(sessionId) | history:share | The report's share links (secrets excluded). |
Mutations
| Mutation | Permission | Description |
|---|---|---|
exportHistoryReport(sessionId, format) | history:export | Export 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:share | Create 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:share | Extend a link's expiry. |
revokeHistorySharedLink(linkId) | history:share | Revoke a link. |
deleteChannelHistory(id) | history:delete | Delete 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
| Query | Permission | Description |
|---|---|---|
channelPublicSettings | public-stats:read | The 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
| Mutation | Permission | Description |
|---|---|---|
setChannelPublicSettings(input: { platform, isPublic, showTopChatters }) | public-stats:edit | Toggle 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.
| Query | Permission | Description |
|---|---|---|
publicChannelStats(platform, channel) | none — public | A 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 — public | One 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).
| Query | Permission | Description |
|---|---|---|
publicChannelBrowse(platform, category, metric, live, search, page, limit): PublicChannelBrowsePage! | none — public | Ranked 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 — public | Games 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
| Query | Auth | Description |
|---|---|---|
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 achat_emotesrow.channelIdisnullfor a global (platform-wide) set;sourceisapi_fetch|observed;firstSeenAt/lastSeenAtare RFC-3339. The internalproviderEmoteIdis not exposed.PublicEmotePage { emotes, page, limit, total, totalPages }— a page ofPublicEmotewith page-based metadata (identical to the RESTpaginationobject).
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
| Query | Permission | Description |
|---|---|---|
myAuditLog(filter) | none — self-only | The 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:read | The 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 }—metadatais a JSON string;scopeisuser|account|system.AuditLogPage { items, total, page, limit }— a page ofAuditEvent.AuditLogFilter { page, limit, eventType, dateFrom, dateTo }—dateFrom/dateToare 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.