Skip to main content

REST API

Contract declarations​

All declaration endpoints have GraphQL twins at POST /v1/gql. JSON fields are snake_case. Public requests are rate limited (10 per IP per hour, 200 per hour globally) and require a Turnstile token with action withdraw or cancel. Repeating a client_request_id for the same declaration returns the first receipt. Reusing a client_request_id for a different declaration (another user, channel, kind, contract or purchase, or another e-mail address on the public forms) returns 409 IDEMPOTENCY_KEY_REUSED and no receipt.

Method and pathRequest or filterGuard and result
POST /v1/withdrawalsname, email, contract_reference; optional message, locale, turnstile_token, client_request_idPublic withdrawal; receipt with status: open.
POST /v1/cancellationsname, email, contract_reference; optional kind, reason, effective, effective_date, locale, turnstile_token, client_request_idPublic cancellation; receipt with status: open.
POST /v1/billing/withdrawalcontract (plan, extension_purchase, other); optional extension_purchase_id, contract_reference, message, name, receipt_email, client_request_id, account_idInteractive session. plan needs plan:edit; extension_purchase needs extension-store:uninstall in the target channel; other needs only the session.
GET /v1/billing/withdrawable-contracts—Interactive session; eligible plan and paid extension contracts across manageable channels.
GET /v1/billing/cancellable-contracts—Interactive session; plan and extension subscriptions across manageable channels, including cancellation state.
POST /v1/billing/cancelOptional kind, reason, effective, effective_date, receipt_email, name, client_request_id, account_idplan:edit in the target channel and interactive session; stores a plan cancellation declaration.
POST /v1/extension-purchases/\{id\}/cancelOptional kind, reason, effective, effective_date, name, receipt_email, client_request_id, account_idextension-store:uninstall in the target channel and interactive session; that channel must own the active subscription purchase.
GET /v1/admin/contract-declarationsstatus (open default, processed, rejected, all), kind (withdrawal, cancellation), first (1–100, default 50), afterAdmin contract-declarations:read; oldest first, items and next_cursor.
GET /v1/admin/contract-declarations/\{id\}—Admin contract-declarations:read; one case.
POST /v1/admin/contract-declarations/\{id\}/closestatus (processed or rejected); optional note, refund_reference, stripe_appliedAdmin contract-declarations:edit and interactive session; no automatic refund.
POST /v1/admin/contract-declarations/\{id\}/matchaccount_id, contract (plan or extension_purchase), optional extension_purchase_idAdmin contract-declarations:edit and interactive session; match an open public case.
POST /v1/admin/contract-declarations/\{id\}/retentionretain_until (YYYY-MM-DD, later than the current date)Admin contract-declarations:edit and interactive session.

kind is ordinary by default or extraordinary, which requires reason. effective is period_end for ordinary cancellation or immediate by default for extraordinary cancellation; date needs effective_date (YYYY-MM-DD, today or later). Each ContractOption in the two list responses includes account_id and account_name as well as the plan or extension details; the active channel comes first, then other manageable channels by switcher name. Omit account_id on a signed-in write to use the active channel, or pass a listed option's account_id to check permission in that channel without switching the active channel. account_id is ignored for a withdrawal with contract: other. A declaration receipt carries id, eight-character reference, kind, status, received_at, receipt_email, and nullable period_end. Plan cancellation also returns declaration_id and the same case reference and status. An ordinary signed-in cancellation can be processed once Stripe accepts it; an outstanding Stripe operation still returns success with open and is retried. Extraordinary and public cancellations remain open for operator review.

A missing permission in the target channel returns 403; a foreign extension purchase returns 404 PURCHASE_NOT_FOUND; an already cancelled subscription returns 409 SUBSCRIPTION_ALREADY_CANCELLED. Public validation and Turnstile failures return 400, rate limits 429, and a storage outage 503 TRY_AGAIN. Admin actions return 409 DECLARATION_CLOSED for an already closed case. See Withdrawal & Cancellation.

Abuse reports​

POST /v1/abuse-reports is public and accepts song_name, artist, reporter_name, reporter_email and reason. Optional fields are spotify_track_id, proof_url (HTTP or HTTPS) and turnstile_token. A Turnstile token for action abuse is required when verification is enabled. The response is 201 with data: { "id": "..." }; the report enters the admin queue without immediately blocking the song. The limit is five reports per IP address per hour and 100 reports per hour globally.

Validation and Turnstile errors return 400 with MISSING_SONG_NAME, MISSING_ARTIST, MISSING_REPORTER_NAME, INVALID_EMAIL, MISSING_REASON, INVALID_PROOF_URL, INVALID_SPOTIFY_TRACK_ID, TURNSTILE_REQUIRED or TURNSTILE_FAILED. Rate limiting returns 429 RATE_LIMITED; an unavailable verification or storage dependency returns 503 TRY_AGAIN. The GraphQL mutation uses the same fields, checks and codes in camelCase.

Developer balance release (admin)​

GraphQL twins: adminReleaseFundsAndDeleteUser / adminReleaseFundsAndDeleteDeveloper (GraphQL), with the same guard, validation, errors and audit events.

Method and pathRequest bodyGuard and result
POST /v1/admin/users/\{id\}/release-funds-deleterequest_reference (1–64 characters), reason (1–500 characters)Admin developer-funds:release-deletion and users:delete, interactive session; releases the user's developer balance as a liability, then deletes the user like DELETE /v1/admin/users/\{id\}.
POST /v1/admin/developers/\{id\}/release-funds-deleteas aboveAdmin developer-funds:release-deletion and developer-verification:edit, interactive session; releases the balance, then deletes the profile like DELETE /v1/admin/developers/\{id\}.

The response data is { deleted, released_balance_id, amount, currency, release_case, mail_sent, claim_code }. A release is only possible when the balance cannot be paid out (release_case not_verified or no_payout_method); a payable balance returns 409 DEVELOPER_FUNDS_PAYABLE, an in-flight payout 409 DEVELOPER_FUNDS_OUTSTANDING. claim_code is set only when the release mail could not be sent. Invalid input returns 400 INVALID_REQUEST_REFERENCE or 400 INVALID_RELEASE_REASON.

Settling a released balance​

GraphQL twins: adminStartReleasedBalanceClaim / adminSettleReleasedBalance (GraphQL), with the same guard, validation, errors and audit events. The claim code goes only in the request body.

Method and pathRequest bodyGuard and result
POST /v1/admin/released-balances/claimclaim_codeAdmin developer-funds:settle, interactive session; records that the person presented the code (claim_started_at, set once). data: { released_balance_id, amount, currency, release_case, released_at, claim_started_at, newly_started }.
POST /v1/admin/released-balances/settleclaim_code, developer_idAdmin developer-funds:settle, interactive session; credits the amount to the verified profile developer_id and marks the record settled. data: { released_balance_id, amount, currency, developer_id, settled_at, new_balance }.

Errors: 400 INVALID_CLAIM_CODE, 403 DEVELOPER_VERIFICATION_REQUIRED (the profile's identity is not verified), 404 RELEASED_BALANCE_NOT_FOUND or developer profile not found, 409 RELEASED_BALANCE_SETTLED.

Profile security and data routes​

All paths below use the /v1/ prefix. JSON fields use snake_case. Public sign-in routes have rate limits; profile routes require the caller's interactive session unless a narrower guard is stated. A fresh step-up is a successful identity confirmation recorded on that session within the last ten minutes. Where 2FA is enabled, step-up accepts a passkey, authenticator app code or recovery code.

Method and pathPurpose
GET /v1/auth/turnstile-configPublic runtime Turnstile site key and widget requirement.
POST /v1/auth/registerEmail/password registration; gives the same check-your-inbox response for existing addresses.
POST /v1/auth/register/resendResend a registration confirmation.
POST /v1/auth/email/verifyConfirm a link after a user clicks Confirm; never signs the user in. Public: works signed out, on another device and for every registration. A bearer is optional and only tells "same profile" (same_profile: true) from another signed-in profile (403 LINK_OTHER_PROFILE); an expired or revoked bearer counts as signed out.
POST /v1/auth/password/loginEmail/password sign-in; may return mfa_required, mfa_token and mfa_methods instead of tokens.
POST /v1/auth/authorizePKCE authorization; when a second step is needed, returns mfa_required, mfa_token and mfa_methods (passkey and/or totp) instead of a code.
POST /v1/auth/password/resetRequest a reset link without disclosing account existence.
POST /v1/auth/password/reset/completeSet the password, end prior sessions, then continue sign-in and 2FA if enabled.
POST /v1/auth/mfa/verifyComplete the pending 2FA challenge with an app/recovery code or passkey assertion before tokens or a PKCE code are issued.
POST /v1/auth/passkey/optionsGet a public, IP-limited passkey sign-in challenge.
POST /v1/auth/passkey/verifySign in with a passkey; the passkey also completes 2FA.
POST /v1/auth/mfa/passkey/optionsGet a challenge for the profile named by a pending mfa_token.
POST /v1/auth/step-up/passkey/optionsGet a challenge to confirm a session or a 2FA change.
POST /v1/auth/step-up/passkeyConfirm the interactive session with a passkey.
POST /v1/auth/step-up/oauthConfirm with an already linked platform account; requires an interactive session.
POST /v1/auth/step-up/passwordConfirm with the email password when 2FA is off.
POST /v1/auth/step-up/mfaConfirm with an app code, recovery code or passkey assertion.

Registration, password reset, resend, dashboard email setup and the contact form use a server-verified Turnstile token. Password login requires it after repeated failures or suspicion. The API checks the token action and the exact ID-app or web-app hostname. Login can proceed through its temporary lockout path if Turnstile cannot answer; other protected actions fail closed.

Method and pathPurpose
GET /v1/users/me/email-loginCurrent email sign-in and pending-link state.
POST /v1/users/me/email-loginRequest or resend the setup link; fresh step-up and Turnstile.
POST /v1/users/me/email-login/changeRequest confirmation of a replacement address.
POST /v1/users/me/email-login/passwordSet the password after address confirmation.
DELETE /v1/users/me/email-login/pendingCancel a pending setup or address change.
DELETE /v1/users/me/email-loginRemove email sign-in; fresh step-up and at least one other sign-in method required.
POST /v1/users/me/passwordChange the password using the current password.
DELETE /v1/users/me/emailRemove a profile email that came from a platform sign-in. The email sign-in address answers 409 EMAIL_FROM_SIGN_IN; change or remove the email sign-in instead.
GET /v1/users/me/mfa2FA status, recovery-code count, trusted devices and factors (apps and passkeys).
POST /v1/users/me/mfa/setupBegin setup; requires fresh step-up.
POST /v1/users/me/mfa/setup/confirmCheck an authenticator code and return ten recovery codes.
POST /v1/users/me/mfa/enableFinish setup; requires fresh step-up.
POST /v1/users/me/mfa/disableTurn off all methods with a valid second factor.
POST /v1/users/me/mfa/recovery-codesReplace all unused recovery codes after a second-factor check.
PATCH /v1/users/me/mfa/factors/\{id\}Rename an app or passkey (label, 1–40 characters).
DELETE /v1/users/me/mfa/factors/\{id\}Remove an app or passkey with another second factor; passkeys return credential_id for the WebAuthn Signal API.
POST /v1/users/me/passkeys/optionsBegin registration; first-method response includes ten recovery codes.
POST /v1/users/me/passkeysFinish registration; the first method requires recovery_codes_stored: true.
DELETE /v1/users/me/mfa/trusted-devices/\{id\}Revoke one trusted device.
DELETE /v1/users/me/mfa/trusted-devicesRevoke every trusted device.
GET /v1/users/me/step-upAvailable confirmation methods (mfa_methods) and freshness. Each entry of connections carries connected_at (RFC 3339) and last_used_at (RFC 3339, last platform sign-in or step-up through that connection; null when unknown).
GET /v1/users/me/support-codeCurrent hourly support code; interactive session only.
GET /v1/users/me/flagged-connectionsSign-in, channel and bot connections that need reconnecting.

POST /v1/auth/link requires fresh step-up when adding a platform sign-in, except a reconnect to the same provider account. DELETE /v1/login-connections/\{id\} requires it before removing a connection. Both reject operations that would leave the user with no sign-in method. A 403 STEP_UP_REQUIRED tells the dashboard to reopen its confirmation dialog.

Method and pathPurpose
GET /v1/users/me/data-exportsList the caller's export requests.
POST /v1/users/me/data-exportsRequest user or user_channels data; one active export per model and one request per model per 24 hours.
POST /v1/users/me/data-exports/\{id\}/cancelCancel an own export while it is pending or running; no step-up. The row becomes cancelled and does not count towards the 24-hour limit as long as it had not started yet; once the job had started it (running), it counts, so a new request answers 429 EXPORT_COOLDOWN. Another user's export returns 404 NOT_FOUND, a finished one 409 EXPORT_NOT_CANCELLABLE. GraphQL: cancelDataExport.
POST /v1/users/me/data-exports/\{id\}/download-linkIssue a five-minute signed link after fresh step-up.
GET /v1/data-exports/\{id\}/downloadStream the ZIP using that signed link; intentionally REST-only.

An export row carries status (pending, running, ready, failed, expired, cancelled) and progress_percent (0–100 while the archive is built, only ever rising; null while pending).

GET /v1/users/me lists each membership in accounts[] with platforms: the distinct platforms of that channel's connections (channel connections, not sign-ins) in the order twitch, youtube, kick, then the rest alphabetically. A connection waiting for a reconnect counts, a removed one does not; no connection gives []. Same values and order as GraphQL AccountMembership.platforms.

Method and pathPurpose
POST /v1/users/me/deletionSchedule deletion after the chosen grace period, or delete at once; fresh step-up. See below.
DELETE /v1/users/me/deletionCancel scheduled deletion, possible until the date. Response { "scheduled_for": null, "deleted": false }. 409 DELETION_IN_PROGRESS ("The deletion is already running") once the date has passed, 409 NO_DELETION_SCHEDULED when nothing is scheduled.
GET /v1/me/audit-log/exportExport personal activity as CSV or JSON.
GET /v1/account/audit-log/exportExport account activity; requires account audit-log:read.

Audit exports accept the current filter or up to 1,000 selected ids. Their file responses are REST-only.

POST /v1/users/me/deletion takes an optional JSON body { "grace_days": 0 | 7 | 30 }. No body or no grace_days means 30. Any other value returns 400 INVALID_GRACE ("grace_days must be 0, 7 or 30"); a malformed body returns 400. The response is { "scheduled_for": string | null, "deleted": bool, "closing_payout_cents": number | null }. grace_days: 0 deletes the profile at once through the same path as the daily job (export files, uploaded avatar, the user row and with it every session, system:user_deleted with self_service: true) and answers { "scheduled_for": null, "deleted": true }. Every value needs a fresh step-up (403 STEP_UP_REQUIRED). The grace period is stored per profile (users.deletion_grace_days); the reminder mail goes out 7 days before the end of a 30-day period and 1 day before the end of a 7-day period. GraphQL: scheduleProfileDeletion(graceDays: Int), identical codes.

Owning channels blocks scheduling with 409 OWNS_CHANNELS, unless every owned channel is already scheduled for dissolution on or before the profile deletion date (dissolution_scheduled_for <= scheduled_for). grace_days: 0 still requires owning no channel at all. A developer revenue balance above 0 is paid out with a grace period of 7 or 30 days: the request creates the closing payout for the whole balance, even below the minimum payout amount, and returns its amount in closing_payout_cents (null when nothing was owed). Scheduling fails with 409 DEVELOPER_FUNDS_OUTSTANDING ("The developer profile still has a revenue balance or a payout in progress. It can be deleted once Lumio has paid it out") while a payout is pending or processing, with grace_days: 0 while any money is owed, and when the balance cannot be paid out (no payout method, no verified developer identity); nothing is paid out or scheduled then. The closing payout carries its self-billed credit note like every payout, so incomplete tax details answer 422 TAX_DETAILS_REQUIRED and an incomplete [billing.issuer] 503 CREDIT_NOTE_ISSUER_NOT_CONFIGURED (same texts as POST /v1/developer/payouts/request), again without a payout or a schedule. DELETE /v1/admin/users/{id} and DELETE /v1/admin/developers/{id} answer the same code while money is owed. A due deletion is held by the job until the profile is paid out and retried every run. Revenue that arrives while the deletion is scheduled is paid out with POST /v1/developer/payouts/request { "closing": true }. The daily job dissolves due channels first and deletes due profiles in the same run.

GET /v1/users/me accounts[] (AccountMembershipResponse) and GET /v1/accounts/{id} (AccountResponse) carry dissolution_scheduled_for: an ISO-8601 timestamp while the channel is scheduled for dissolution, otherwise null. Same value as GraphQL dissolutionScheduledFor. The dashboard reads it from the membership list for the dissolution banner and the account-switcher label.

Both also carry identity, the channel's resolved name and picture (source_kind, source_connection_id, source_platform, name, avatar_url); see Channel name and picture. The membership's account_name is the same name. owner_avatar_url on the membership is deprecated and equals identity.avatar_url.

Method and pathPurpose and guard
POST /v1/admin/users/\{id\}/mfa/resetReset a user's 2FA with a mandatory reason; admin users:edit.
POST /v1/admin/users/\{id\}/support-code/revealReveal the current support code; admin support:verify, audited.
POST /v1/admin/users/\{id\}/support-code/checkCheck a code; admin support:verify, limited to five attempts per profile per ten minutes and audited.
GET /v1/admin/users/\{id\}/support-code/checksRecent checks; admin support:verify.

Lumio exposes a RESTful API. Most resource endpoints wrap their payload in a HATEOAS envelope.

Base URLs​

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

Response Format​

A single resource is returned in an envelope whose _links.self is a string URL, not a link object:

{
"data": { "id": "…", "name": "…" },
"_links": { "self": "https://api.lumio.vision/v1/overlays/123" }
}

A paginated collection adds _meta and the navigation links, omitting prev/next at the ends of the range:

{
"data": [ … ],
"_links": {
"self": "/v1/events?page=2&limit=25",
"first": "/v1/events?page=1&limit=25",
"prev": "/v1/events?page=1&limit=25",
"next": "/v1/events?page=3&limit=25",
"last": "/v1/events?page=4&limit=25"
},
"_meta": { "total": 100, "page": 2, "limit": 25, "pages": 4 }
}

GET /v1/events and GET /v1/chat/history count at most 10 000 matching rows. When more match, _meta.total is 10000 and _meta adds "total_capped": true (show it as "10 000+"); pages covers the capped total, and a page past it returns an empty data array. Without from, both read only the 30 days before to (or now). Pass an explicit from to reach older history.

Not every endpoint uses the envelope - action endpoints (playback control, toggles, deletes) commonly return a bare body such as {"success": true}, and webhook receivers return platform-mandated shapes. Request and response bodies use snake_case.

Error Format​

Errors return a flat body with a stable machine-readable error code:

{ "error": "VALIDATION_ERROR", "message": "expires_at cannot be in the past" }

An optional details object is present only when the handler supplies one. When an error says how long to wait (details.retry_after_secs, e.g. RATE_LIMITED, AVATAR_BUSY), the response also carries the same value as a Retry-After header.

A missing permission returns { "error": "FORBIDDEN", "message": "Missing permission: chat:ban" } — the same message, character for character, that GraphQL returns with extensions.code: "FORBIDDEN".

errorHTTPMeaning
BAD_REQUEST400Malformed request
VALIDATION_ERROR400Request body failed validation
UNAUTHORIZED401No or invalid credential
FORBIDDEN403Authenticated but missing the required resource:action
PLAN_LIMIT_REACHED403The account's plan quota for this resource is exhausted
FEATURE_DISABLED403The feature flag gating this endpoint is off for the account
NOT_FOUND404Resource does not exist or is not visible to the caller
CONFLICT409Conflicts with current state (duplicate slug, live session, …)
BILLING_DISABLED409Billing (Stripe) is not set up on this deployment (stripe.enabled = false); message Billing is not enabled
WRONG_PLATFORM_ACCOUNT409A channel reconnect came back with a different platform account than the connected one; nothing was changed. details.signed_in_as names that account (login, else display name — never a token or ID) when the platform reported it. See Connections
ADMIN_MFA_REQUIRED403The caller holds admin grants but has no 2FA factor (authenticator app or passkey), so every admin endpoint refuses them. Message: Admin access requires two-factor authentication. Turn on an authenticator app or a passkey in Settings › Security. See Admin panel → Two-factor authentication
CHANNEL_DISSOLVING403The channel is scheduled for dissolution and suspended until then; details.dissolution_scheduled_for names the date. See Channels being dissolved
AVATAR_BUSY503Picture upload only: all decode slots for profile and channel pictures are taken; retry after Retry-After seconds. See POST /users/me/avatar
RATE_LIMIT_EXCEEDED429Per-auth-context rate limit exceeded (see Authentication), or Spotify throttled an upstream request (see Spotify)
DATABASE_ERROR, REDIS_ERROR, INTERNAL_SERVER_ERROR500Server-side failure

4xx message values are user-facing and returned verbatim. 5xx messages are scrubbed to a generic string (An internal server error occurred, An internal database error occurred, An internal cache error occurred) so no internal detail leaks; the full message is logged server-side only. GraphQL returns the identical error code and message for the same failure.

When Spotify throttles a read or playback, queue, device, or playlist action under /v1/spotify/*, the response is HTTP 429 with error: "RATE_LIMIT_EXCEEDED", message: "Spotify rate limit reached. Try again shortly.", details.retry_after_secs, and a Retry-After header containing the same number of seconds (at least 1). The wait is supplied by Spotify. It is separate from Lumio's per-auth-context API rate limit. The GraphQL Spotify operations carry the same code and message with extensions.retryAfterSecs.

Interactive Documentation​

  • Swagger UI - /v1/swagger-ui/ when docs.swagger_ui is enabled in config
  • OpenAPI spec - /v1/api-doc/openapi.json (served with Swagger UI) or /v1/openapi.json when docs.openapi_json is enabled

Both are config-gated so a deployment can lock them down. The generated spec covers the endpoints that carry utoipa annotations.

[docs] keydefault.toml (dev, staging)production.tomlENV override
swagger_uitruefalseLUMIO__DOCS__SWAGGER_UI
openapi_jsontruetrue (inherited)LUMIO__DOCS__OPENAPI_JSON

Production (api.lumio.vision) does not serve the interactive Swagger UI: it is an unauthenticated HTML/JS page on the API origin, so it stays off there. The raw spec at /v1/openapi.json stays available for SDK generators. Enabling swagger_ui implies openapi_json at runtime. The Swagger UI assets are compiled in from the checksum-pinned utoipa-swagger-ui-vendored crate (vendored feature) — the build never downloads them, so SWAGGER_UI_DOWNLOAD_URL has no effect.

Spec completeness policy​

The spec is hand-enumerated in apps/api/src/openapi.rs, so historically live routes could silently drift out of it. A build-time test (openapi::completeness) now enforces that every route module wired into the live router is either registered in the spec or on a documented exclusion allowlist (SPEC_EXCLUDED_MODULES, with a per-module reason) - so the spec is a trustworthy completeness oracle for everything not on that list. The intentional exclusions are:

  • internal_extension_handlers - internal service-to-service endpoint (the V8 bot-module/automation workers fetch handler code), not a consumer-facing API operation.
  • webhooks_payments - inbound Stripe Connect / PayPal payout webhooks; a provider-called receiver, not a callable consumer operation.
  • /.well-known/security.txt - a static RFC 9116 resource, not a JSON API operation.

The sounds, widgets, and history modules were previously excluded as an annotation gap; their handlers are now annotated and present in the spec. The entire extension / developer-portal / marketplace / payout surface has since graduated into the spec (ZAF-1107): developer_teams, developer_applications, developer_store_profiles, developer_verifications, admin_developer_applications, admin_developer_teams, developer_extensions (Developer Extensions - manage your extensions, versions, secrets, testers, analytics, payouts, and limit requests), extension_actions, extension_purchases, extension_bundles, extension_storage, extension_uploads, extension_access (Extension Access - overlay access grants and shared access links), extension_reviews (Extension Reviews - admin extension review queue and moderation), extension_store (Extension Store - browse, install, review, and serve store assets), extension_functions (Extension Functions - invoke extension query/mutate/execute functions), bot_module_extensions (Bot Module Extensions - bot-module extension configuration and control), automation_nodes, stripe_connect, and marketplace (Marketplace - public extension store browsing, listings, reviews, featured, categories). Only the two internal modules above (internal_extension_handlers, webhooks_payments) remain deliberately excluded, because they are not public consumer operations.

Treat this page and the feature docs as authoritative for the excluded surfaces above.

Endpoints Overview​

All paths are under /v1. Request and response bodies use snake_case. Permissions use the resource:action format.

Resource groupPath prefixDescription
Auth/v1/authToken exchange, refresh, logout, OAuth link/authorize, popout-token exchange
Users/v1/users/meCurrent-user profile, login connections, sessions
Contact/v1/contactPublic contact form: forwards a message to the Lumio inbox by e-mail, nothing stored (see Contact form)
Accounts/v1/accountsCreate/dissolve/leave accounts, channel name and picture, member invites, login assignments
Login Connections/v1/login-connections/{id}Delete a login connection by UUID; PUT /v1/login-connections/{id}/reconnect-flag clears its reconnect prompt
Members & Invites/v1/accounts/{id}/members, /v1/invitesTeam management
Roles/v1/rolesRBAC role CRUD and permission catalog
Tokens/v1/tokens, /v1/api-keysPopout token and user API key management. POST /v1/tokens requires a first-party session (logged-in user or the user's own API key; ZAF-1017/ZAF-469) - a popout/overlay/widget/extension token is rejected with 403 FORBIDDEN, and an omitted user_id binds to the creator. PATCH /v1/tokens/{id} with "user_id": null (unassign) binds the token to the editor, under the same first-party gate - a popout token is always bound to an explicit user and an unbound token never resolves (ZAF-1922). An explicit user_id (on create, or reassigned via PATCH) must be an account member whose role does not exceed the caller's own permissions; the owner counts as holding every account permission (ZAF-1740 A1). It accepts an optional expires_at (RFC3339) to set the token lifetime at creation - null/absent = never expires; a past value is rejected with VALIDATION_ERROR "expires_at cannot be in the past", identical to GraphQL createPopoutToken (the PATCH update path still allows a back-dated expires_at as a soft-revoke). The /v1/api-keys routes are also first-party gated (see API Keys). /tokens/me returns the caller's effective permissions - a scoped API key or popout token reports its own scope capped to its bound member's current role (ZAF-2430) and a logged-in user reports their account-role permissions; identical to GraphQL myPermissions, and never the ["*"] wildcard.
Overlays/v1/overlaysOverlay configuration CRUD
Sounds/v1/soundsSound library management, upload, playback control, and streaming
Widgets/v1/widgetsWidget instance CRUD, access management, and duplication
Setting Presets/v1/setting-presetsNamed snapshots of chat/events/music display settings, keyed (user, account). Create/edit/delete are scoped to the user + active account (no RBAC, fail-closed on membership); sharing with your account is gated presets:share. Reads work with a popout token (own presets only); writes require a first-party session. Gated by feature:setting_presets
Uploads/v1/uploadsFile uploads (multipart) and presigned download URLs
Chat/v1/chat/*History (filter by platform, user, date, keyword search, YouTube live_chat_id), merged messages+events timeline (/v1/chat/history/merged), send, moderate, user profiles, user name-completion search, notes, raid/poll/prediction
Events/v1/eventsEvent history, single event, emit/test
Emotes/v1/emotesChannel and user emote fetching
YouTube Memberships/v1/youtube/memberships/tiers, /v1/admin/privacy/youtube/member/{id}Read observed YouTube member-tier badges (account-scoped); GDPR-Art-17 erasure of a member on one channel's instruction (account_id, instruction_reference and initiated_by = channel
Storage Erasure/v1/admin/storage/orphaned-extension-uploads/sweepPOST with {"apply": false} (default, dry run: counts extension upload files no extension_uploads row references) or {"apply": true} (queues them for deletion, audit system:orphaned_storage_swept); returns applied, extensions_scanned, extensions_failed, orphaned_objects, queued_objects (admin:privacy-erase). Mirrors GraphQL adminSweepOrphanedExtensionUploads
Chat Privacy/v1/admin/privacy/chat/erasePOST {account_id, instruction_reference, reason, initiated_by, lumio_user_id} or {account_id, instruction_reference, reason, initiated_by, platform, user_id}: GDPR-Art-17 erasure of a data subject in the one instructing account - chat messages, platform_users row, moderator notes, AutoMod log, moderation-log rows targeting them, bot-list entry, stream-history entries (announcements and pins included) and still-open AutoMod held-queue messages (events stay); records them in the account's account_chatter_suppressions with initiated_by (required: channel when the channel initiated the erasure, subject when the person asked for it); a subject entry is never downgraded by a later channel erasure, and its errors are initiated_by is required / initiated_by must be one of: channel, subject. Other accounts are not touched. Afterwards the channel owner gets a confirmation e-mail (counts and a case number from case_id, never the person). reason (why the subject is erased, trimmed, at most 500 characters) is required and goes into the audit event; errors reason is required / reason must be at most 500 characters, as on records/erase. 404 for an unknown account (admin:privacy-erase; only support operators call it). Returns {account_id, case_id, deleted_message_count, deleted_platform_user_count, deleted_user_note_count, deleted_automod_message_count, deleted_automod_moderation_log_count, deleted_moderation_log_count, scrubbed_history_session_count, deleted_account_bot_count, removed_held_queue_entry_count, suppressed}. Mirrors GraphQL eraseChatSubjectData
Chat Privacy/v1/admin/privacy/chat/erase/withdrawPOST {account_id, platform, user_id, instruction_reference} (all required): withdraws one channel's erasure instruction for one chatter - deletes the chatter's row in that account's account_chatter_suppressions, so the channel's stream reports frozen from then on name the chatter again. What the erasure deleted or blanked stays gone; other channels and the global chatter_suppressions list are not touched. Validation and error texts as the objection plus account_id is required / instruction_reference is required; 404 Account not found for an unknown account and no channel erasure is recorded for this chatter without an entry; only an erasure the channel initiated can be withdrawn - an entry the person asked for (and every entry recorded before the initiator was stored) is a 409 this erasure was requested by the person, not initiated by the channel; it cannot be withdrawn on the channel's instruction and stays (admin:privacy-erase-withdraw). Audit privacy:channel_suppression_withdrawn on success only (system scope, without the user id). Returns {account_id, platform, suppressed} (suppressed is false). Mirrors GraphQL withdrawChannelChatterSuppression
Chat Privacy/v1/admin/privacy/lumio/erasePOST {platform, user_id, login?, instruction_reference, reason}: erasure of a person on Lumio's own surfaces only - an erasure entry in the global chatter_suppressions list (Lumio Stats leaderboard) and, on YouTube, Lumio's member cache across every account. No channel's chat, profiles, logs or reports are touched. Idempotent; validation and error texts as the objection plus instruction_reference is required and, for the required reason (trimmed, at most 500 characters, in the audit event), reason is required / reason must be at most 500 characters (admin:privacy-erase; only support operators call it). Returns {platform, suppressed, erased_key_count}. Mirrors GraphQL eraseChatSubjectAtLumio
Chat Privacy/v1/admin/privacy/records/erasePOST {kind, account_id, instruction_reference, reason, record_id?, created_at?, session_id?, platform?, user_id?, login?}: erasure of one record in one channel on that channel's instruction. kind is chat_message (record_id + created_at, matched within ±1 s; also removes the message's text copy from the moderation log), user_note, moderation_log, automod_log (also its automod_allow / automod_deny moderation-log rows and a still-open held-queue entry), bot_list (each by record_id) or stream_report (session_id + platform + user_id, optional Twitch/Kick login: the person's entries in that report's top chatters, links, commands and keywords, and their slots in its announcements and pins, keep place and counts and lose the identity). account_id, instruction_reference and reason (at most 500 characters) are required. A record of another account is 404 Record not found, like a missing one; an unknown account is 404 Account not found; a stream report that is not finalized yet is 409. No suppression entry is written. Errors: kind must be one of: …, account_id is required, instruction_reference is required, reason is required, reason must be at most 500 characters, record_id is required, created_at is required for a chat_message, session_id is required for a stream_report, plus the objection's chatter errors (admin:privacy-erase). Returns {account_id, case_id, kind, deleted_record_count, moderation_log_text_removed_count, deleted_moderation_log_count, held_queue_entry_removed, scrubbed_entry_count}. Mirrors GraphQL eraseChatRecord
Chat Privacy/v1/admin/privacy/chat/objectionsPOST {platform, user_id, login?} records a chatter's GDPR-Art-21 objection in the global chatter_suppressions: the public Stats leaderboard masks the chatter on read, also on streams published earlier; channel reports, exports, share links and AutoMod logs are not affected; nothing is erased; idempotent (admin:privacy-objection-create). Returns {platform, suppressed, reason}. Mirrors GraphQL addChatterObjection
Chat Privacy/v1/admin/privacy/chat/objections/{platform}/{user_id}DELETE lifts the objection; the public Stats leaderboard names the chatter again; 404 when none is recorded, 400 for an erasure entry (admin:privacy-objection-delete). Mirrors GraphQL removeChatterObjection
YouTube Streams/v1/youtube/active-streamsLive and upcoming YouTube broadcasts with a resolved live_chat_id (reads the YouTube worker's Redis cache; an upcoming broadcast without a chat ID can still appear in dashboard channel status)
Connections/v1/connectionsApp credentials and channel OAuth flow
Bot Connections/v1/bot-connections, /v1/bot-status, /v1/bot-toggle, /v1/bot-rejoinCustom bot identity OAuth and control. GET /v1/bot-connections/discord/authorize (bot-connections:create, feature:bots, session-only) returns the verified Discord server invite of the global Lumio bot; Discord completes on the unified callback (POST /v1/connections/oauth/discord/exchange), never on POST /v1/bot-connections/discord/exchange
Bot Commands/v1/bot-commandsCross-platform command CRUD and global overrides
Bot Modules/v1/bot-modulesModeration module configs (link/spam/word/timed), extension bot module kill switch
Bot Module Triggers/v1/internal/bot-modulesInternal trigger resolution, handler-bundle feed and handler-result actions for the bots and the Bot Module Worker (System key with bots:internal)
Extension Handler Feeds/v1/internal/{bot-modules,automation-nodes}/handlersInternal handler-code bundle + context feed for the workers' InstallRegistry (System key with bots:internal)
Automations/v1/automationsVisual automation CRUD and manual execution
Automation Extension Nodes/v1/automation/extension-nodes, /v1/automation/webhooksExtension node listing and webhook receiver
Channel Status/v1/channel-status, /v1/channels/schedule, /v1/spotify/manual-connectLive status and the stream plan of the connected channels (?from=&to=, default now…+30 days, at most 60 days; both connections:read), Spotify manual polling (spotify:read)
Spotify/v1/spotify/*State, playback, queue, devices, playlists, search
Copyright/v1/copyright/*Safe/blocked songs, playlist imports, community voting
Notifications/v1/notificationsIn-app notifications, actions, and delivery preferences
Ideas Hub/v1/ideasCommunity ideas, votes, comments, @mention autocomplete, categories, tags. Moderation actions live on the same paths and are gated on the ideas:moderate_* admin permissions
OBS/v1/integrations/obs, /v1/obs-remote/*OBS config + remote stream/recording/scene control
SE Tokens/v1/se-tokensStreamElements JWT token storage
Discord GuildsGET /v1/discord-guilds, DELETE /v1/discord-guilds/{guild_id}List the account's Discord servers (bot-connections:read; fields as GraphQL discordGuildConnections, incl. bot_removed and missing_permissions) and disconnect one (bot-connections:delete, returns { success }). Connecting a server is the Discord branch of the bot connections above
Abuse Reports/v1/abuse-reportsUser-submitted abuse reports
Webhooks/v1/webhooks/*Platform webhooks (Twitch, YouTube, Kick, Shopify, Stripe)
Billing/v1/billing/*Stripe checkout/portal/status/invoices/coupon; resolved account limits (account-limits). POST /checkout, POST /portal and POST /cancel are session-only - an API key gets 403 (see Authentication → Session-only Surfaces). GET /status carries billing_enabled; when it is false (no Stripe on this deployment), checkout, portal, cancel, invoices and coupon validation answer 409 BILLING_DISABLED after their auth check, while GET /status itself still succeeds. POST /checkout requires accept_withdrawal_waiver: true in its body (the buyer ticked the withdrawal-waiver checkbox); a missing or false value answers 400 VALIDATION_ERROR "You must accept the withdrawal waiver (accept_withdrawal_waiver) before checkout" before any Stripe call. POST /portal opens the customer portal without plan changes (see Billing → Withdrawal waiver and order confirmation)
Playlists/v1/playlists, /v1/playlists/{id}Read-only system-curated playlists. Public, gated by feature:copyright_detection (403 when off), see Copyright → Public REST endpoints
Songs/v1/songs, /v1/songs/check, /v1/songs/sitemap, /v1/songs/{id}Read-only global song metadata, copyright status and the sitemap id enumeration (after, limit ≤ 5000). Public, gated by feature:copyright_detection (403 when off)
Platforms/v1/platforms/metadataStatic platform metadata (name, icon, features, scopes)
Features/v1/features/enabled, /v1/providers/enabledPublic feature/provider flag reads
Extension Bundles/v1/extension-bundlesServe extension bundle files (JS, CSS, assets)
Extension Version Files/v1/extensions/{id}/versions/{version}/filesList files in an extension version bundle
Extension Uploads/v1/extensions/{id}/uploadsUpload/serve files from extension editors (icons, images)
Fonts/v1/fonts/{family}/css, /v1/fonts/{family}/{weight}.woff2DSGVO-compliant font proxy (CSS + woff2). Public scope, no auth, permissive CORS. Inputs are allowlist-validated on both routes (400 otherwise): family is 1–64 characters of A-Z a-z 0-9, space, + or -; a weight is a multiple of 100 between 100 and 900 (on /css, invalid weights entries are ignored and duplicates collapse, 400 when none remain); display is one of auto, block, swap, fallback, optional (default swap). Responses carry X-Content-Type-Options: nosniff
Developer Applications/v1/developer/applicationSubmit and view developer applications
Developer Verification/v1/developer/verificationSubmit and view own KYC verification (self-service)
Developer Credit Notes/v1/developer/credit-notes, /v1/admin/credit-notes, /v1/admin/developer-payoutsSelf-billed credit notes (Gutschrift) of developer payouts: own list and PDF; operator payout list and settlement (mark paid / failed); operator list, PDF and cancellation
Developer Limits/v1/developer/limitsView resolved limits and request increases
Developer Teams/v1/developer/teamsTeam CRUD, members, invites, roles, permissions
Developer Store Profiles/v1/developer/profiles/{slug}, /v1/developer/teams/{slug}/profilePublic developer and team profile pages
Admin Developer Applications/v1/admin/developer-applicationsReview, approve, reject developer applications
Admin Limit Requests/v1/admin/limit-requests, /v1/admin/developers/{id}/limit-requestsReview developer limit increase requests
Admin Extension Limits/v1/admin/extensions/{id}/limitsSet per-extension limit overrides
Admin Extension Purchases/v1/admin/extension-purchases/undeliveredGET ?first=&after=: paid extension checkouts without delivery (still completed, never installed, older than 1 h), newest first. Returns {items, next_cursor}; each item has id, extension_id, extension_name, account_id, account_name (the four are null once deleted), stripe_checkout_session_id, stripe_payment_id, amount (cents), currency, undelivered_reason (detached, not_installable, install_failed, refund_failed or null) and created_at. first is 1-100 (default 50); after is the previous page's next_cursor. Admin extension-purchases:read; twin of GraphQL adminUndeliveredExtensionPurchases. See Admin › Extension Purchases
Overlay Folders/v1/overlay-foldersOverlay folder CRUD and move
Overlay Shared Links/v1/overlay-shared-linksTime-limited shared overlay links (lm_share_*)
Channel Connections/v1/connections/channel, /v1/connections/status, /v1/connections/credential-modes, /v1/connections/oauth/{platform}/exchange, /v1/channel-connections/{id}/reconnect-flagChannel OAuth, reconnect prompts, the channel list (/channel - each row carries channel_name the display name (may be localized) and channel_login the URL-safe login/handle; GraphQL parity channelConnections.channelLogin, ZAF-1461; plus missing_scopes - the required base scopes the stored grant lacks, [] when nothing is missing or the grant was never recorded (scopes is null), a non-empty list means reconnect; GraphQL parity channelConnections.missingScopes, same connections:read guard as scopes), the per-platform status overview (/status - health-aware is_connected + reconnect_required + expires_at, plus the acting user's personal login-grant health login_connected + login_reconnect_required reported separately from the channel (ZAF-1045, GraphQL parity connectionStatuses), connections:read), the credential mode per (platform, kind) (/credential-modes - platform, kind "channel"/"bot", mode "system"/"account", system_configured; twin of GraphQL platformCredentialModes, connections:read + feature:connections), and the unified OAuth exchange (/oauth/{platform}/exchange - the single web-host callback shared by the channel, account-bot and admin global-bot system flows; redeems the structured state handle, dispatches on kind, binds channel / bot handles to the session of the user who started the authorize step (re-checking account access + the connect permission; the admin global_bot handle needs no session), and validates return_to fail-closed against [web, admin]). The channel and bot authorize steps are session-only - see Authentication → Session-only Surfaces; ?include=follower_count fills the optional cached follower_count and ?include=paid_subscriber_count the optional cached paid_subscriber_count - current paid subs, Twitch Helix total / Kick active_subscribers_count, always null on YouTube and without history:read (field guard, no error); both combine as ?include=follower_count,paid_subscriber_count (otherwise null, unknown include = 400; GraphQL parity channelConnections.followerCount / paidSubscriberCount)
Channel Info/v1/channel-infoLive channel metadata for the active account
Stream History/v1/historySession list, reports, stats, exports, and shared report links
Public Stats/v1/public-statsPer-channel publicness (opt-out) settings + the unauthenticated public read model (channel + stream stats)
Public Stats browse/v1/public-stats/{browse,games}Public, unauthenticated cross-channel browse/ranking (metric nav) + Games directory + channel search. No auth; gated by the system:public_stats kill-switch; edge-cacheable
Emote Directory/v1/public-stats/emotesPublic, unauthenticated emote-catalog browse (filter + name search + pagination). No auth; gated by the system:public_stats kill-switch; edge-cacheable
Watchlist/v1/watchlistPer-user favourite-channel list for the public Stats app. Authenticated viewer (JWT); no account/RBAC permission, no plan gate. GraphQL + REST (no WS)
Audit Log/v1/me/audit-log, /v1/account/audit-logSelf-scope personal-security log and account-scope tenant audit log; the operator audit surface lives under /v1/admin/audit-log
Marketplace/v1/marketplace, /v1/store, /v1/categories, /v1/featuredPublic extension store browsing, listings, reviews, icons (/v1/store/extensions/{id}/icon-data serves the bytes), screenshots
Extension Installs/v1/extension-installsInstall lifecycle, per-install storage, actions, and query/mutate/execute function calls
Extension Purchases/v1/extension-purchasesPaid-extension checkout. Body \{ extension_id, accept_withdrawal_waiver \}: a paid checkout requires accept_withdrawal_waiver: true (400 VALIDATION_ERROR, same message as POST /v1/billing/checkout) and a signed-in person (403 for tokens without one); a free-access install needs neither. 409 Extension already purchased when the account owns it; for a subscription extension also 409 Active subscription already exists while an earlier subscription is still running, even if its first payment was refunded
Extension Access/v1/extension-access-invitesPrivate-extension access invites
User Roles/v1/user-rolesUser-scoped role CRUD (admin)
Well-Known/v1/.well-known/*Service discovery documents
Health/v1, /v1/healthLiveness probes; /v1/health also carries service-dependency status and live WebSocket gauges
Metrics/metricsPrometheus scrape endpoint (origin root, not under /v1)
Admin/v1/admin/*System-admin-only endpoints: admin-roles, admin-permissions, feature-flags, plans, coupons, users, accounts, api-keys (operator management of tenant/user keys), oauth-clients, system-keys, system-connections, providers, bot-control, bot-commands, bot-connections, discord-guilds, se-tokens, audit-log, abuse-reports, privacy

This page and the feature docs cover the endpoints in day-to-day use; the API serves more, and the OpenAPI document intentionally omits the surfaces listed under Spec completeness policy above (everything else wired into the router is guaranteed present by a build-time test). For the exhaustive parameter list of the annotated endpoints, download /v1/openapi.json (or use the Swagger UI at /v1/swagger-ui/ on deployments that enable it — not production). Feature-specific docs under Features document the most commonly used endpoints with their resource:action permissions.

Public Songs and Playlists​

All six public GET /v1/songs* and GET /v1/playlists* routes work without authentication and apply the fail-closed feature:copyright_detection gate. With an authenticated caller the gate resolves for that account; otherwise it resolves globally. A disabled feature returns 403 FEATURE_DISABLED. Song reads expose global safe and blocked rows only; playlist reads expose system-curated playlists. Their GraphQL twins use the same feature gate.

Method and pathParametersResult
GET /v1/songspage (default 1), limit (default 25, maximum 100), optional search, status (safe or blocked), sourcePaginated global songs.
GET /v1/songs/checkspotify_track_id, or a song title and artistGlobal status for a song.
GET /v1/songs/sitemapOptional after (exclusive song UUID cursor), limit (default and maximum 5,000)data.items[] with id and nullable lastmod, plus data.has_more. Pass the last ID as after while has_more is true.
GET /v1/songs/\{id\}Song UUIDOne global song; 404 when unknown or not global.
GET /v1/playlistsNoneSystem-curated playlist summaries.
GET /v1/playlists/\{id\}Playlist UUIDOne system-curated playlist with tracks; 404 when unknown.

The sitemap song date is the row's created_at or null. Which global song lists are included is fixed on the server (safe and blocked); clients cannot change it with a query parameter. REST fields use snake_case, including has_more; GraphQL uses camelCase.

The public marketplace reviews (GET /v1/marketplace/extensions/\{slug\}/reviews, GraphQL marketplaceExtensionReviews) return id, rating, comment, created_at and updated_at per review. They contain no reviewer identity, neither a user ID nor a name. The store pages do not show reviewers either.

GET /v1/connections/oauth-scopes/\{platform\} returns data: { platform, required, optional } with full provider scope names. required contains base scopes and optional contains scopes requested on every authorization but excluded from missing_scopes. YouTube's optional list contains https://www.googleapis.com/auth/yt-analytics-monetary.readonly; Kick's required list includes ads:read. The route requires feature:connections and connections:read, as does its GraphQL twin channelOAuthScopes(platform). An unsupported platform returns 400 with Unsupported platform: X (the GraphQL twin reports the same message).

Health​

GET /v1/health​

Public, unauthenticated. Returns the API version, uptime, per-dependency health (PostgreSQL, TimescaleDB, Redis), and live WebSocket-server gauges. The websocket block reports aggregate operational counters only - total active sessions and broadcast channels, with no per-user or per-channel detail - and is read lock-free, so scraping it never contends with the WebSocket broadcast path.

{
"data": {
"status": "healthy",
"version": "2026.8.31",
"uptime_seconds": 3600,
"services": {
"postgres": { "status": "healthy", "latency_ms": 2 },
"timescaledb": { "status": "healthy", "latency_ms": 3 },
"redis": { "status": "healthy", "latency_ms": 1 }
},
"websocket": { "sessions": 128, "channels": 342 }
},
"_links": { "self": "https://api.lumio.vision/v1/health" }
}

status is healthy when every dependency is healthy, degraded when at least one is up but not all, and unhealthy when all dependencies are down.

Metrics (Prometheus)​

GET /metrics (internal only)​

Not on the public ingress. /metrics is an operations endpoint - metric names and route lists are recon material - so for the API it is served by a separate internal server bound to loopback (127.0.0.1:9100 by default), outside the /v1 scope and never on the public api.lumio.vision origin. A scraper reaches it only over an internal network (bind it with LUMIO__METRICS__HOST=0.0.0.0 and publish that port to the scraper alone). It serves the Prometheus 0.0.4 text exposition format (Content-Type: text/plain; version=0.0.4). See Operational Metrics for the full /metrics layout, the shared series, and the cardinality rules.

The endpoint renders the shared lo-metrics exposition - http_requests_total, http_request_duration_seconds, and the Linux process_* series - plus the API's own live operational gauges below. Every series additionally carries the service and env constant labels. Each value is read lock-free (atomic gauge loads and connection-pool counters) at scrape time by a pull-time collector over the running AppState, so a scrape never contends with the WebSocket broadcast hot path. The WebSocket gauges mirror the websocket block on /v1/health; exposing them here lets operators confirm the connection-leak fix (ZAF-396) holds in production without waiting ~72h to observe the absence of a wedge.

MetricTypeLabelsMeaning
lumio_build_infogaugeversionConstant 1; running build version in the label
lumio_uptime_secondsgauge-Seconds since the API process started
lumio_websocket_sessionsgauge-Active WebSocket sessions
lumio_websocket_channelsgauge-Active WebSocket broadcast channels
lumio_db_pool_connectionsgaugepoolConnections owned by the pool (postgres, timescaledb)
lumio_db_pool_idlegaugepoolIdle connections available for checkout
# HELP lumio_websocket_sessions Active WebSocket sessions.
# TYPE lumio_websocket_sessions gauge
lumio_websocket_sessions{env="prod",service="api"} 128
# HELP lumio_websocket_channels Active WebSocket broadcast channels.
# TYPE lumio_websocket_channels gauge
lumio_websocket_channels{env="prod",service="api"} 342
# HELP lumio_db_pool_connections Connections owned by the pool (idle + in-use).
# TYPE lumio_db_pool_connections gauge
lumio_db_pool_connections{env="prod",pool="postgres",service="api"} 8
lumio_db_pool_connections{env="prod",pool="timescaledb",service="api"} 3

The endpoint carries only aggregate operational counters - no per-user, per-channel, or per-request detail (the hard cardinality rule) - matching the websocket, version, and uptime fields also visible as JSON on /v1/health.

Auth​

POST /v1/auth/popout/exchange​

Exchange a raw popout token (lm_pop_*) for a short-lived popout session - the REST twin of GraphQL exchangePopoutToken. See Authentication → Popout Tokens & Popout Sessions for the full model.

Auth: none - possession of a valid popout token IS the authorization (as ?token= is today).

Request body:

{ "token": "lm_pop_..." }

Response - also sets the session as an httpOnly cookie lumio-popout-token (Path=/, SameSite=Lax, Secure outside development, Max-Age = 15 min):

{
"data": {
"token": "lm_...",
"expires_at": "2026-08-12T12:15:00Z",
"account_id": "…"
}
}

Validates the token (prefix + revoked_at / expires_at) and mints a 15-minute session JWT carrying the token's permission subset. The session resolves to the same popout auth context as the raw token (identical account, permission subset, OBS credential gate) - never a full user session. A revoked, expired, unknown, or non-popout token returns 401 with Invalid or expired popout token.

GET /v1/auth/popout/session​

Read the (account_id, user_id) pair the current popout session represents - the REST twin of GraphQL popoutSession. See Authentication → Popout Tokens & Popout Sessions for the full model.

Auth: any authenticated context, including a popout token / session (the ?token=lm_pop_* query param or the httpOnly lumio-popout-token cookie). It is not first-party gated. Anonymous is rejected with 401 Authentication required - the same message the GraphQL AuthGuard emits.

Response:

{
"data": {
"account_id": "…",
"user_id": "…",
"locale": "auto",
"timezone": "auto",
"date_format": "auto",
"time_format": "auto",
"season_look_enabled": true,
"season_strength": "full",
"season_motion": "auto",
"season_creatures": {},
"season_banner_dismissed": [],
"motion_preference": "system"
}
}

The account_id / user_id pair is read straight from the auth context (auth.account_id() / auth.user_id()) - never a client parameter, so the scope cannot be forged, and the response carries only the caller's own session ids. user_id is always the user the popout token is bound to - every popout token is bound to an explicit user and there is no owner fallback (ZAF-1922). No permission guard and no audit event (a read of the caller's own session).

The four display-preference fields (locale, timezone, date_format, time_format) are the represented user's stored preferences (same value space as GET /users/me), so a popout - which cannot reach the first-party-gated me - can render language, timezone and date/time format. The five season_* fields carry the seasonal-look preferences and motion_preference the "Animations in Lumio" preference the same way (written via PATCH /users/me, which a popout cannot reach; a popout session's only two writes are the one-way season routes below). They carry the represented user's values (assigned member, or account owner for an unassigned token).

POST /v1/auth/popout/season-banner/dismiss​

Dismiss the season banner for one season instance on behalf of the current popout session - the REST twin of GraphQL popoutDismissSeasonBanner. One of exactly two one-way profile writes a popout may make (see Authentication → Popout Tokens & Popout Sessions).

Auth: popout-only - a popout token or popout session. Anonymous → 401 Authentication required; any other credential (user session, API key, overlay, shared-overlay, widget, extension) → 403 This endpoint requires a popout session. A logged-in user writes the same list through PATCH /users/me. The guard runs before validation.

Request:

{ "instance": "halloween:2026" }

instance is <seasonId>:<year>. The season id must be a known season - the server-side list user_preferences::SEASON_IDS is halloween, nicholas, christmas, new-year, easter - every season of the web registry (apps/web/src/lib/seasons/registry.ts) plus the planned ones; a season outside it extends both. The year must be within ±1 of the current year. Anything else is a 400 validation error - Invalid season instance: must be '<seasonId>:<year>', Invalid season instance: unknown season id (allowed: halloween, nicholas, christmas, new-year, easter) or Invalid season instance: year must be between <y-1> and <y+1>. Free text is never echoed back.

Response:

{ "data": { "season_look_enabled": true, "season_banner_dismissed": ["halloween:2026"] } }

Appends instance to the represented user's season_banner_dismissed and changes nothing else. Idempotent: an instance already in the list is a no-op (no write). The same dedupe and 50-entry cap as PATCH /users/me apply - a full list drops its oldest entry. No audit event (UI bookkeeping) and no WebSocket push (the account events channel would leak one user's preference to every viewer).

POST /v1/auth/popout/season-look/off​

Switch the seasonal look off on behalf of the current popout session - the REST twin of GraphQL popoutSwitchOffSeasonLook. Same auth, request body, validation and error text as POST /v1/auth/popout/season-banner/dismiss.

Sets the represented user's season_look_enabled = false and, in the same write, appends instance to season_banner_dismissed, so switching the look back on later does not bring this season's banner back. season_look_enabled is the global switch (all seasons), the same one the Settings toggle writes. Off only: no popout route can set it to true or change season_strength, season_motion or season_creatures - turning the look on again is a dashboard Settings action (PATCH /users/me). Idempotent (look already off + instance present = no write). Response shape as above, with "season_look_enabled": false. No audit event, no WebSocket push.

POST /v1/auth/popout/ws-token​

Mint a WebSocket-scoped token for the current popout session - the REST twin of GraphQL issuePopoutWsToken, and the popout counterpart of POST /v1/auth/ws-token.

Auth: a popout session (the httpOnly lumio-popout-token cookie, or a popout session bearer). This is popout-only: POST /v1/auth/ws-token is session-only (see the note above) and returns 403 for a popout, so a popout cannot mint there. A non-popout caller here is rejected with 401 UNAUTHORIZED (no credentials) or 403 FORBIDDEN (any non-popout credential).

Response:

{ "data": { "ws_token": "lm_..." } }

The minted token carries the popout's own permission subset (never the account owner's), a WebSocket-only use tag, and no session_id. The API accepts it only on the /v1/ws upgrade path - a leak of the ?token= WS URL cannot be replayed against a REST/GraphQL mutation. It is short-lived (auth.ws_token_expiration, ~15 min); the popout re-mints on reconnect.

Contact form​

POST /v1/contact​

Public, no authentication. Forwards one message to the Lumio inbox ([contact] recipient, default hey@lumio.vision) as an e-mail whose Reply-To is the sender's address. Nothing is stored: there is no table and no retention job. Logs record only the outcome and the subject category, never the name, address or message. The GraphQL twin submitContactMessage calls the same service, so validation, limits and error codes and texts are identical.

FieldTypeRule
namestring, optionalAt most 100 characters, no control characters; blank counts as absent.
emailstringA valid e-mail address; becomes the Reply-To.
subjectstringOne of general, privacy, legal, billing, other.
messagestring1 to 5000 characters after trimming.
turnstile_tokenstringCloudflare Turnstile token for action contact, issued on a web-app hostname (turnstile.web_hostnames).

The checks run in this order: fields, then 5 messages per IP per hour, then Turnstile, then 100 forwarded messages per hour in total (only requests that passed Turnstile count). A field error therefore never uses up the single-use Turnstile token. The generic per-IP limit of the /v1 scope applies as well.

Response 200: { "data": { "status": "sent" } }. The mail is sent in the background.

Statuserrormessage
400INVALID_EMAILEnter a valid email address
400INVALID_CONTACT_NAMEThe name can have at most 100 characters
400INVALID_CONTACT_SUBJECTChoose a subject: general, privacy, legal, billing or other
400INVALID_CONTACT_MESSAGEThe message needs 1 to 5000 characters
400TURNSTILE_REQUIREDPlease complete the security check
400TURNSTILE_FAILEDThe security check failed. Please try again
429RATE_LIMITEDToo many requests. Try again later (details.retry_after_secs and a Retry-After header)
503TRY_AGAINPlease try again in a moment (Turnstile or the limit store unavailable)

API Keys​

User API keys are personal bearer credentials with the lm_usr_ prefix. Each key is bound to the current (user_id, account_id) and can only carry permissions the creator already has. The dashboard uses these endpoints for Settings → Channel → Access → API Keys; the full key is returned once from the create response and cannot be retrieved later.

All paths live under /v1/api-keys and are gated on feature:apikeys. Bodies use snake_case.

First-party session required (ZAF-1017 / ZAF-469): personal API keys are a first-party surface - every path below requires 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 403 FORBIDDEN on both REST and GraphQL. This stops a permission-capped member from listing/minting/renaming/revoking a key bound to the owner (an owner-bound lm_usr_ key would otherwise launder past this same gate - e.g. PATCH /v1/users/me email change → owner takeover).

MethodPathPermissionDescription
GET/v1/api-keys/permissions?kind=personal|serviceapikeys:readList the scopes assignable to an API key of the given kind
GET/v1/api-keysapikeys:readList the caller's own user API keys for the active account
POST/v1/api-keysapikeys:createCreate a user API key; returns the full lm_usr_ key once. Session-only - an API key cannot mint keys
PATCH/v1/api-keys/\{id\}apikeys:editRename one of the caller's own keys (label only)
POST/v1/api-keys/\{id\}/rotateapikeys:editRotate one of the caller's own keys - regenerate the secret in place; returns the new full lm_usr_ key once. Session-only - an API key cannot rotate keys
DELETE/v1/api-keys/\{id\}apikeys:deleteRevoke one of the caller's own keys by hard-deleting it

Assignable scopes​

An API key may carry any permission the account/user behind it can - not the deliberately narrow popout-token list. GET /v1/api-keys/permissions?kind=personal|service returns the assignable-scope registry (key + label + category), derived from the RBAC registries minus a small deny-list; it is the source of truth for the picker (parity with GraphQL apiKeyAssignablePermissions). kind is required - an unknown value returns 400 VALIDATION_ERROR.

  • Personal keys (kind=personal, lm_usr_) carry the 129 account permissions left after the four-key API-key deny-list plus the 10 user-scoped ideas:* permissions (139 assignable scopes) - a personal key acts with its member's user identity, so ideas:* are reachable via the key (ZAF-1169).
  • Service keys (kind=service, lm_svc_) carry those 129 assignable account permissions only - an account-owned key has no user identity, so the user-scoped family is not offered.
  • Deny-list (both kinds): apikeys:create, apikeys:edit, tokens:create (credential minting - a key that mints credentials would outlive the human it belongs to) and account:delete (tenant destruction). These are never assignable.

On create, each requested scope is validated against this registry first - an unknown or deny-listed string is rejected with 400 VALIDATION_ERROR "Invalid permission: {perm}" (identical to role writes) - and then against the subset rule (a key can never exceed its creator; over-reach → 403 FORBIDDEN). Wildcards (resource:*) are never offered in the picker but are accepted from a principal that legitimately holds them. Scopes are immutable after creation - a too-narrow key must be re-minted.

Create request:

{
"label": "CI/CD pipeline",
"permissions": ["events:read", "overlays:read"],
"expires_at": "2026-09-01T00:00:00Z"
}

label is required. expires_at is optional; omit it or send null for a non-expiring key.

Every key object (list, create, rename, rotate) carries permissions, the stored scope list, and effective_permissions, the stored scopes the owner's current role still grants. Only effective_permissions authorize requests; a scope missing from it is dormant and returns when the owner regains it. /v1/tokens objects carry the same pair, capped to the token's bound member. If the owner's permissions cannot be read, the request fails instead of reporting the stored list. Parity with GraphQL UserApiKey.effectivePermissions / PopoutToken.effectivePermissions.

Create response:

{
"data": {
"key": "lm_usr_xxxxxxxxxxxx",
"id": "uuid",
"user_id": "uuid",
"account_id": "uuid",
"key_prefix": "lm_usr_ab",
"label": "CI/CD pipeline",
"permissions": ["events:read", "overlays:read"],
"effective_permissions": ["events:read", "overlays:read"],
"created_at": "2026-08-16T10:00:00Z",
"expires_at": "2026-09-01T00:00:00Z",
"last_used_at": null
},
"_links": {
"self": {
"href": "/v1/api-keys/uuid"
}
}
}

Rotate. POST /v1/api-keys/{id}/rotate (gated apikeys:edit + feature:apikeys) regenerates the secret of one of the caller's own keys in place - the key id, label and scopes are unchanged, the old secret stops working immediately, and the response returns the new full lm_usr_ key once (same shape as the create response). It emits the user:api_key_rotated audit event (metadata api_key_id, old_key_prefix, new_key_prefix). Parity with GraphQL rotateUserApiKey.

Service Keys​

Account-owned service keys are bearer credentials with the lm_svc_ prefix, for CI/CD pipelines and long-lived integrations. Unlike personal lm_usr_ API keys they are owned by the account and keep authenticating after the member who created them leaves - they carry no member identity. They share the feature:apikeys flag and the apikeys:* permission family with personal keys, and the create path enforces the same permission-subset guard (a key can only carry permissions the creator holds). The full key is returned once from the create response and cannot be retrieved later.

All paths live under /v1/service-keys. Bodies use snake_case. Every endpoint is gated on feature:apikeys. Create and rotate mint a credential, so they additionally require an interactive sign-in session (a logged-in user only - a popout/overlay/widget/extension token, a personal lm_usr_ key, a service lm_svc_ key or a system key is rejected with FORBIDDEN "This action requires an interactive sign-in session"; ZAF-1742 rule, applied to service keys by ZAF-1740 C3).

MethodPathPermissionDescription
GET/v1/service-keysapikeys:readList the account's service keys
POST/v1/service-keysapikeys:create + interactive sessionCreate a service key; returns the full lm_svc_ key once
PATCH/v1/service-keys/\{id\}apikeys:editRename a service key (label only)
POST/v1/service-keys/\{id\}/rotateapikeys:edit + interactive session; caller must hold every scope on the keyRotate a service key - regenerate the secret in place; returns the new full lm_svc_ key once
DELETE/v1/service-keys/\{id\}apikeys:deleteRevoke a service key by hard-deleting it

Create request:

{
"label": "CI/CD deploy",
"permissions": ["events:read", "overlays:read"],
"expires_at": null
}

label is required. expires_at is optional; omit it or send null for a non-expiring key.

Create response:

{
"data": {
"key": "lm_svc_xxxxxxxxxxxx",
"id": "uuid",
"account_id": "uuid",
"created_by": "uuid",
"key_prefix": "lm_svc_ab",
"label": "CI/CD deploy",
"permissions": ["events:read", "overlays:read"],
"created_at": "2026-09-04T10:00:00Z",
"updated_at": "2026-09-04T10:00:00Z",
"expires_at": null,
"last_used_at": null
},
"_links": {
"self": {
"href": "/v1/service-keys/uuid"
}
}
}

created_by records the member who minted the key; it becomes null once that member is removed from the account (the key itself keeps working). Lifecycle changes emit the account-scoped audit events account:service_key_created / account:service_key_updated / account:service_key_revoked / account:service_key_rotated. Parity with GraphQL accountServiceKeys / createAccountServiceKey / updateAccountServiceKey / deleteAccountServiceKey / rotateAccountServiceKey.

Rotate. POST /v1/service-keys/{id}/rotate (gated apikeys:edit + feature:apikeys + an interactive sign-in session, checked before the key is looked up) regenerates a service key's secret in place - id, label and scopes unchanged, the old secret invalidated immediately - and returns the new full lm_svc_ key once (same shape as the create response). The caller must hold every scope on the key - rotating hands out a fresh secret with those scopes, so it is held to the same ceiling as create and refused with 403 FORBIDDEN "Cannot grant permission '…'" otherwise (ZAF-1740 M5). It emits account:service_key_rotated (metadata service_key_id, old_key_prefix, new_key_prefix). Parity with GraphQL rotateAccountServiceKey.

Developer API Keys​

Developer API keys (lm_dev_ prefix) are the publishing credential for extension developers - the key lumio deploy and CI use instead of a personal lm_usr_ key. Unlike service keys they are not owned by an account: a developer key is bound to a developer profile or a developer team, carries no account and no member identity, and (like a service key) keeps authenticating after the member who minted it leaves the team. A developer key can carry only the extension-dev:* permission family, and - unlike every other key - is decoupled from the plan gate: eligibility is approved-developer status, not the Pro plan, so it is not gated on feature:apikeys. The full key is returned once from the create response and cannot be retrieved later.

All paths live under /v1/developer/api-keys. Bodies use snake_case. Management is first-party only - these endpoints require a logged-in user session, so a lm_dev_ key can never mint or revoke another developer key. Minting (POST) is session-only: a user's own lm_usr_ key is refused too (ZAF-1742, see Authentication → Session-only Surfaces).

MethodPathPermissionDescription
GET/v1/developer/api-keysAuth (developer)List the caller's developer keys (own profile + member teams)
POST/v1/developer/api-keysAuth (developer) + team-secrets:edit for a team keyCreate a developer key; returns the full lm_dev_ key once
PATCH/v1/developer/api-keys/\{id\}Auth (developer) + team-secrets:edit for a team keyRename a developer key (label only)
DELETE/v1/developer/api-keys/\{id\}Auth (developer) + team-secrets:edit for a team keyRevoke (soft-delete) a developer key

Create request:

{
"label": "CI deploy",
"permissions": ["extension-dev:edit", "extension-dev:publish"],
"team_id": null,
"expires_at": null
}

label and permissions are required; every permission must be in the extension-dev:* family and cannot exceed what the creator holds. team_id binds the key to a developer team you belong to (requires the team's team-secrets:edit right) - omit it or send null to bind the key to your own developer profile. expires_at is optional (RFC3339; null = never).

Rename / revoke authorization. Both are scoped to the caller's developer identity: a key that does not exist or belongs to a profile or team the caller is not part of returns 404 Not found: Developer key not found (key existence is never disclosed). A profile key can be renamed or revoked by its owner. A team key additionally requires the caller's current developer-team right team-secrets:edit on that team - the same right that creating it requires. A member without it (e.g. the default Member role, which only holds team-secrets:read) can see the key but gets 403 Missing team permission: team-secrets:edit for both rename and revoke; there is no membership-only revoke - a member who suspects a leak asks a team-secrets:edit holder to revoke it. Re-revoking an already-revoked key is an authorized no-op 204.

Lifecycle changes emit the user-scoped audit events user:developer_api_key_created / user:developer_api_key_updated / user:developer_api_key_revoked (there is no owning account to hang an account-scoped event on). Parity with GraphQL developerApiKeys / createDeveloperApiKey / updateDeveloperApiKey / revokeDeveloperApiKey.

Sounds​

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

GET /v1/sounds​

List sounds in the account's library.

Permission: sounds:read

Query parameters:

ParameterTypeDescription
limitintegerPage size (default 20, max 100)
offsetintegerPagination offset
searchstringFilter by name (partial match)

Response - the list also carries the account's resolved sound limits and current usage:

{
"data": {
"sounds": [
{
"id": "uuid",
"account_id": "uuid",
"name": "Tada",
"filename": "tada.mp3",
"content_type": "audio/mpeg",
"size_bytes": 45312,
"duration_ms": 2100,
"waveform": null,
"created_at": "2026-05-28T10:00:00Z",
"source_extension_name": null
}
],
"total_count": 1,
"user_sound_count": 1,
"max_sounds": 50,
"max_sound_file_size": 5242880,
"max_sound_storage_bytes": 104857600,
"used_storage_bytes": 45312
},
"_links": { "self": "https://api.lumio.vision/v1/sounds" }
}

GET /v1/sounds/\{id\}​

Get a single sound by ID.

Permission: sounds:read

Response: Sound object (same shape as list items).

POST /v1/sounds​

Upload a new sound file.

Permission: sounds:create

Body: multipart/form-data

FieldTypeRequiredDescription
filefileYesAudio file (MP3, WAV, OGG, FLAC). Max size set by max_sound_file_size plan limit.
namestringNoDisplay name (defaults to filename without extension)

Errors:

  • 400 BAD_REQUEST / VALIDATION_ERROR - Unsupported file type or file exceeds size limit
  • 403 PLAN_LIMIT_REACHED - Account has reached the max_sounds or storage plan limit

Response: 201 Created with the new sound object.

PATCH /v1/sounds/\{id\}​

Update sound metadata.

Permission: sounds:edit

Body: all fields optional.

FieldTypeDescription
namestringNew display name
duration_msintegerDuration in milliseconds
waveformJSONPrecomputed waveform peaks

Response: Updated sound object.

DELETE /v1/sounds/\{id\}​

Delete a sound.

Permission: sounds:delete

Response: 204 No Content.

POST /v1/sounds/\{id\}/play​

Trigger playback of a sound in browser sources.

Permission: sounds:play

Body:

FieldTypeRequiredDescription
volumefloatNoPlayback volume 0.0–1.0 (default 1.0)
targetobjectNo\{ "type": "widget" | "overlay", "id": "uuid" \}. Omit to broadcast to all browser sources; any other type is rejected.

Response: 200 { "success": true } (bare body, no HATEOAS envelope)

POST /v1/sounds/\{id\}/stop​

Stop playback of a sound in browser sources.

Permission: sounds:play

Body:

FieldTypeRequiredDescription
targetobjectNo\{ "type": "widget" | "overlay", "id": "uuid" \}. Omit to broadcast to all browser sources; any other type is rejected.

Response: 200 { "success": true } (bare body, no HATEOAS envelope)

GET /v1/sounds/\{id\}/stream​

Stream the audio bytes for a sound. Used by browser sources to load and play the audio.

Permission: sounds:read

Response: Raw audio bytes with the correct Content-Type header (e.g. audio/mpeg) and Cache-Control: private, max-age=86400, immutable. The payload is account-scoped and carries no Vary, so it is marked private - long-lived in the caller's own browser cache but never storable in a shared/CDN cache.

Protocol parity: All endpoints have matching GraphQL queries/mutations - see GraphQL.

Widgets​

Instances​

GET /v1/widgets​

List the widget instances of the active account.

Permission: widgets:read

Visibility: per-widget grants apply. A widget on which the caller has the role none is left out; a widget without an entry for the caller, or with viewer/editor, is listed. A caller with widgets:access-read sees every widget of the account. Same rule as GET /v1/overlays and GraphQL widgetInstances.

Widget object: id, account_id, name, widget_type, extension_id, install_id, config, width, height, created_at, updated_at and extension. extension is null for a built-in widget. For an extension widget it is the GraphQL WidgetExtensionRef in snake_case: id, slug, name, visibility, icon_key, developer_name, version, targets, config_schema, designer_schema, permissions. permissions holds the manifest permissions of that version, [] when it declares none. Every endpoint that returns a widget (GET /v1/widgets, GET /v1/widgets/\{id\}, POST /v1/widgets, PATCH /v1/widgets/\{id\}, POST /v1/widgets/\{id\}/duplicate) uses this shape.

POST /v1/widgets​

Create a widget instance with a one-time access token and OBS URL.

Permission: widgets:create

Body: name, widget_type, optional install_id, extension_id, config, width, height.

Extension binding: extension_id is never stored as sent. With an install_id, the install must be a live install of the active account (not soft-uninstalled), and the widget takes that install's extension. The check runs in the same transaction as the insert.

ConditionResponse
install_id unknown, from another account, or soft-uninstalled404 Extension install not found
extension_id differs from the install's extension, or widget_type names another extension (its slug, ext:<slug>, or a built-in type of another system extension)400 Widget extension does not match the install
extension_id or an ext: widget_type without install_id400 An extension widget requires an install_id
Plan widget limit reached403 Widget limit reached (…). Upgrade your plan for more widgets.

Response: 201 Created with the widget object, access_token and obs_url.

Access Management​

GET /v1/widgets/\{id\}/access​

List access entries for a widget.

Permission: widgets:access-read

Response: List of access entries with id, widget_id, user_id, user_name, user_avatar, role, granted_by, and created_at.

GET /v1/widgets/\{id\}/access/candidates​

List account members eligible for the widget access dialog.

Permission: widgets:access-read

Response: List of account members with id (the user ID), display_name, and avatar_url.

PUT /v1/widgets/\{id\}/access/\{user_id\}​

Set a user's access role on a widget.

Permission: widgets:access-grant

Body:

FieldTypeRequiredDescription
rolestringYes"viewer", "editor", or "none". Any other value is rejected with 400 BAD_REQUEST (Invalid role).

Response: 200 with the updated access entry.

DELETE /v1/widgets/\{id\}/access/\{user_id\}​

Remove a user's access entry from a widget.

Permission: widgets:access-revoke

Response: 204 No Content.

Duplication​

POST /v1/widgets/\{id\}/duplicate​

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, plus Editor access on the source widget (a copy carries the full configuration). widgets:access-grant bypasses the per-widget check; widgets:access-read does not. A source the caller cannot edit answers 404 Widget not found, the same as a missing one.

Limits: the plan widget limit is checked inside the insert, so concurrent creates and duplicates cannot exceed it (403 Widget limit reached (…). Upgrade your plan for more widgets.). A source bound to an extension install that is no longer live is not copied (404 Extension install not found).

Response: 201 Created with the new widget object including access_token.

Protocol parity: All endpoints have matching GraphQL queries/mutations - see GraphQL.

Setting Presets​

Named snapshots of chat/events/music display settings, keyed to the pair (user, account) (ZAF-622 §52c; see Setting Presets). All endpoints require the feature:setting_presets feature flag. Create/edit/delete are scoped to (the authenticated user, their active account) with no resource:action permission and fail-closed on membership; a preset owned by another user, living in another account, or in an account you have left is never visible or mutable (it reads as 404). The one RBAC gate is presets:share on the share/un-share endpoints.

Popout access: the read endpoints (GET) accept a popout token so the OBS-source popout can list and load presets. The write endpoints (POST/PATCH/DELETE, incl. share/un-share) require a first-party user session (a logged-in user or the user's own API key) - a popout/overlay/widget/extension token is rejected with 403 FORBIDDEN. This is deliberate: a popout token authenticates as the member it is bound to, not as the person holding it, so allowing writes there would let whoever holds the token act as that member (ZAF-450/469).

Account sharing (ZAF-622 §19): a user can share one of their own presets with the members of their active account. GET /v1/setting-presets for a first-party caller then also returns presets shared into that account by a still-member owner - each such foreign row is marked shared: true with owner_display_name. Over a popout token the list stays restricted to your own presets (no shared branch). Sharing is a flag on the existing row (no copy) and costs no quota; loading a shared preset writes nothing server-side. See the .../share endpoints below.

The quota is one number spanning all kinds combined, resolved per account from that account's own plan (plans.max_setting_presets, overridable per account via account_limits.max_setting_presets; 0 = unlimited) - a full account never blocks a different one. The effective limit is also readable without creating a preset - as maxSettingPresets on the GraphQL accountLimits query and its REST twin GET /v1/billing/account-limits (both gated on plan:read) - so a UI can render "N of M used".

GET /v1/setting-presets​

List the current user's presets.

Query parameters:

ParameterTypeDescription
kindstringOptional filter: chat | events | music

Response: data is an array of preset objects (id, user_id, kind, name, payload, is_default, shared_account_id, shared, owner_display_name, created_at, updated_at). Own rows sort first (default-first, then most-recently-changed) with shared: false; foreign rows shared into your active account follow, with shared: true + owner_display_name. A popout token sees only own rows.

GET /v1/setting-presets/quota​

Your own preset quota in the active account. Per-account (no resource:action permission, popout-token readable) - deliberately not plan:read, so every member sees their limit.

Response: data is { used, limit } - used counts your own presets in the active account across all kinds (foreign rows shared into your account never count), limit is the account's resolved cap with 0 meaning unlimited. Both come from the functions the create path gates on, so this read and the create rejection can never disagree.

GET /v1/setting-presets/\{id\}​

Get a single preset by ID. Returns 404 if it does not exist or belongs to another user.

POST /v1/setting-presets​

Create a preset. First-party session only.

Body:

FieldTypeRequiredDescription
kindstringYeschat | events | music
namestringYesDisplay name (non-empty, ≤ 255 chars). Duplicate names are allowed.
payloadobjectYesThe settings snapshot (the ZAF-622 export format, \{ "format": 1, ... \}). Opaque to the backend.
is_defaultbooleanNoMark as this kind's default (unsets the previous default of the same kind)

Errors:

  • 400 BAD_REQUEST - invalid kind, empty name, or non-object/oversized payload
  • 403 FORBIDDEN - wrong token class (not a first-party session) / feature disabled
  • 409 PLAN_LIMIT_REACHED - the user is at their preset quota

Response: the created preset object.

PATCH /v1/setting-presets/\{id\}​

Rename and/or overwrite a preset (same route serves both; moves updated_at). First-party session only.

Body: at least one of:

FieldTypeDescription
namestringNew name (rename)
payloadobjectNew settings snapshot (overwrite)

Response: the updated preset object; 404 if not found or foreign.

POST /v1/setting-presets/\{id\}/default​

Mark the preset as its kind's default (unsets the previous default of that kind). First-party session only.

Response: the updated preset object; 404 if not found or foreign.

DELETE /v1/setting-presets/\{id\}​

Hard-delete a preset (no trash). First-party session only.

Response: 200 { "success": true }; 404 if not found or foreign.

POST /v1/setting-presets/\{id\}/share​

Share one of your own presets with the members of your active account. First-party session + presets:share. The target account is always the caller's active account; a second share re-points the same row (no copy) and costs no quota. Emits account:setting_preset_shared.

Errors:

  • 403 FORBIDDEN - Missing permission: presets:share, wrong token class, or feature disabled
  • 404 NOT_FOUND - Setting preset not found (missing or not owned by the caller)

Response: the updated preset object (shared_account_id set).

DELETE /v1/setting-presets/\{id\}/share​

Un-share one of your own presets (make it private again). First-party session + presets:share. Idempotent - un-sharing an already-private preset succeeds and emits no event; otherwise emits account:setting_preset_unshared keyed to the account it was shared into.

Response: the updated preset object (shared_account_id cleared); 404 if not found or foreign.

Protocol parity: All endpoints have matching GraphQL queries/mutations - see GraphQL.

Automation Extension Nodes​

GET /v1/automation/extension-nodes​

List all installed automation node extensions for the current account.

Permission: automations:read

Response:

{
"data": [
{
"extension_id": "uuid",
"install_id": "uuid",
"name": "Shopify Order Trigger",
"node_type": "trigger",
"input_schema": { ... },
"output_schema": { ... },
"trigger_mode": "webhook",
"icon": "shopping-cart",
"color": "#96bf48"
}
]
}

GET /v1/automation/webhooks/\{install_id\}/\{automation_id\}​

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

Permission: automations:read

Response:

{
"data": {
"webhook_url": "https://api.lumio.vision/v1/automation/webhooks/{extension_id}/{install_id}",
"webhook_secret": "64-char-hex-string"
}
}

POST /v1/automation/webhooks/\{extension_id\}/\{install_id\}​

Receive an external webhook for a trigger node. The external service must include the X-Webhook-Secret header matching the stored secret.

Auth: X-Webhook-Secret header (not JWT).

Request body: Arbitrary JSON payload. Forwarded to the extension handler as ctx.webhookBody.

Response: 200 on success, 401 if the secret is invalid, 404 if the extension/install is not found.

Protocol parity: installedAutomationNodes query and automationWebhookUrl query in GraphQL -- see GraphQL.

Extension Actions​

POST /v1/extension-installs/\{install_id\}/actions/\{action\}​

Run an action on behalf of an extension install - the backend of useLumioAction(). Body: { "params": { ... } }. 200 carries { "success": true, "data": { ... } }, or { "success": false, "error": "..." } when the action itself fails. 400 for Unknown action: … / Invalid params: …, 401 Authentication required, 403 for every token, permission or manifest denial, 404 Extension install not found. Messages are character-for-character those of GraphQL.

Auth (identical on both protocols, one shared resolver):

CredentialMay act on
Extension token (lm_ext_*)Only its own install; any other id → Extension token install_id does not match the requested install
User session / personal API keyA live install of the caller's account, with extension-store:configure (Missing permission: extension-store:configure; another account's install → Extension install belongs to a different account; unknown or uninstalled → Extension install not found)
Overlay token (lm_overlay_*)Only a live install of the token's account that an extension layer (ext:{slug}) of that overlay renders; anything else → Extension install belongs to a different overlay
Widget token (lm_widget_*)Only the widget's own install; anything else → Extension install belongs to a different widget
Shared overlay link (lm_share_*), popout token, anything elseNever → Authentication required

Overlay and widget tokens never run a chat:* action (chat:send, chat:delete, chat:ban, … and any future chat: action): their links are permanent and leak through stream captures and shared screenshots, and a chat action runs as the account's bot. Such a call is refused with 403 before the install is looked up: Permission denied: chat actions require a signed-in session, not an overlay or widget token. Spotify, OBS and the other actions stay available to them. Chat actions need a user session, an API key or the extension token.

The extension identity is always read from the token or the install row, never from the request. The install's manifest must then declare the permission the action needs (Permission denied: Extension does not declare the required permission: …). Feature: feature:extensions. Actions run on behalf of the install. Only the chat moderation actions chat:delete, chat:ban, chat:unban and chat:timeout emit an audit event (account:extension_moderation, see Audit Log); every other action emits none.

Protocol parity: GraphQL executeExtensionAction - see GraphQL.

Shopify​

The account connection routes need integration:shopify plus the listed permission; hiding a stored order name needs events:edit without the plan flag. GraphQL twins in GraphQL → Shopify use the same guards, fields and messages. Link flow, errors and storage: Shopify.

MethodPathPermissionNotes
GET/v1/connections/shopifyconnections:read{ configured, app_store_url, connection }; connection is null or { shop_host, shop_name, status ("connected" | "needs_reconnect"), customer_display, show_items, show_price, scopes, enabled, connected_at }. Never a token
POST/v1/connections/shopify/link/inspectconnections:create, interactive sessionBody { "handle": "<link handle>" }; returns { shop_host, state ("pending" | "confirmed") }, the store a link request comes from. 409 when another user already confirmed it: the request is then deleted and must be started again in Shopify; 422 when the request has expired
POST/v1/connections/shopify/link/confirmconnections:create, interactive sessionBody { "handle": "<link handle>" }; binds the request to the active account ({ shop_host, state: "confirmed" }). Refuses a browser request whose Sec-Fetch-Site is not same-origin (403). 409 when the store belongs to another account, the account holds another store, the request is being completed, or it was confirmed for another account (it is then deleted and must be started again in Shopify); 422 when expired; 429 after 10 per user in 10 min
PUT/v1/connections/shopify/settingsconnections:editBody { "customer_display"?: "none" | "first_name" | "first_initial" | "first_name_last_initial" | "custom_name", "show_items"?: bool, "show_price"?: bool }, at least one field; returns the connection. 400 for another form or an empty body, 404 when not connected. Each changed value emits account:shopify_settings_updated
POST/v1/connections/shopify/events/{event_id}/hide-nameevents:editBody { "created_at": "<RFC 3339>" } (the event's timestamp, ±1 s); removes raw.customer_display from the shopify:order / shopify:order_paid event and from the other event of the same order. Returns { hidden } (false = nothing left to hide). 404 when it is not a Shopify order event of the account, 400 for an unreadable created_at. Not gated on integration:shopify. A hide emits account:shopify_order_name_hidden
DELETE/v1/connections/shopifyconnections:delete204; clears the storefront metafields (best effort), deletes the row and writes the app/uninstalled tombstone. Shopify keeps sending order webhooks while the app is installed; they are dropped. 404 when not connected

customer_display defaults to none; show_items defaults to true and show_price to false. These settings affect orders received after the update. When items are off, order event raw has no line_items; when price is off, it has no total_price, currency or item price. Absent details do not reach automations or widgets. The handle travels in the request body, never in a URL.

Embedded app (/v1/shopify/embedded/*)​

The endpoints the embedded Shopify app (inside the Shopify admin) calls through its own server. Auth: Shopify ID token — the App Bridge session token as Authorization: Bearer <JWT> (OpenAPI scheme shopify_id_token). HS256 with the app's client secret (or client_secret_previous during a rotation), aud = the client id, exp/nbf checked with 5 s leeway, iss/dest must name the same *.myshopify.com store, and sub (the staff member) is required. A Lumio token (lm_*, JWT or API key) is refused here, and an ID token is refused everywhere else. The store comes only from the token's dest; no route takes a store or an account id. A linked store acts on its own account's Shopify connection and nothing else, with no Lumio permission check (any staff member with app access).

Every refused token, and a token exchange Shopify answers with 400, is 401 with X-Shopify-Retry-Invalid-Session-Request: 1 and the body invalid session, so App Bridge fetches a fresh token and retries once. REST-only: the caller is not a Lumio principal, so there is no GraphQL twin.

MethodPathNotes
GET/v1/shopify/embedded/status{ linked, account_name, connection }; account_name is the linked account's display name (the only account data), connection as above or null. Also renews a dead token by token exchange and re-pushes lagging storefront metafields
POST/v1/shopify/embedded/linkStarts a link: { handle, url, expires_in: 600 }; url is the Lumio page with the handle in its fragment (…/connections/shopify#link=<handle>). A new request replaces the store's open one. 409 when the store is already linked; 429 after 5 per store in 10 min
GET/v1/shopify/embedded/link/{handle}{ state ("pending" | "confirmed" | "completing"), account_name, confirmed_by } — once confirmed, the Lumio account and user. Reads only. 422 when expired or not this store's
DELETE/v1/shopify/embedded/link/{handle}204; cancels the request (also when it no longer exists). 409 while it is being completed
POST/v1/shopify/embedded/link/completeBody { "handle" }; completes a confirmed request with the token exchange and returns the connection. Only the staff member who started it (sub). 409 when not confirmed yet, being completed, or the store/account is taken; 403 for another staff member or a plan without Shopify; 422 when expired; 429 after 10 per store per minute. A failure leaves the request confirmed, so a retry works. Emits account:integration_connected and notifies the owner and the confirming user
PUT/v1/shopify/embedded/settingsBody as PUT /v1/connections/shopify/settings, same validation and messages; 404 when not linked. Emits account:shopify_settings_updated (source: shopify_admin) per changed value and sends the account owner an in-app notice
POST/v1/shopify/embedded/disconnect204; as DELETE /v1/connections/shopify, audit reason: shopify_admin, in-app notice to the account owner. 404 when not linked

Reads and writes of the embedded routes are limited to 120 per store per minute.

Shopify's mandatory compliance webhooks (customers/data_request, customers/redact, shop/redact) arrive at the public POST /v1/webhooks/shopify/compliance (signed with the app secret; 401 on a bad hmac, 200 once queued). They have no GraphQL twin: Shopify calls them, not a client. See Webhooks → Shopify compliance topics.

Discord Bot Connections​

GET /v1/bot-connections/discord/authorize returns a Discord invite URL for the one global Lumio bot. It requires an interactive session, the active account's bot-connections:create permission, and feature:bots. The invite uses scopes bot applications.commands identify, integration_type=0, and targeted permissions. The browser returns to /api/connections/oauth/discord/callback; the unified exchange verifies the code and uses the server ID in Discord's token response, not an untrusted browser parameter. POST /v1/bot-connections/discord/exchange rejects Discord because that legacy exchange handles account-source bot OAuth only.

GET /v1/discord-guilds requires bot-connections:read and lists the active account's servers. Each row includes bot_removed and missing_permissions booleans. DELETE /v1/discord-guilds/{guild_id} requires bot-connections:delete, removes that account's server mapping and settings, and returns {"success":true} when a row was deleted. The GraphQL twins use the same guards for authorize, list and disconnect. See Bot Connections.

Login Assignments​

Login assignments link a user's assignable login connection (e.g. their Twitch login) to a specific Lumio account. A single user can own multiple accounts; login assignments control which platform identity is associated with which account. Discord is sign-in-only and cannot be assigned.

Own assignments are always allowed without permissions. The login-assignments:* permissions only apply when managing another user's assignments.

Which account the write targets. The create and delete endpoints take an optional account_id and fall back to the caller's active account when it is omitted — so a user-centric surface such as the Profile page must always send the account the user picked, never rely on the account switcher. A supplied account_id must be an account the caller belongs to: acting on one's own login connection, any account in the caller's membership list is accepted; acting on another user's connection (user_id supplied) the target is pinned to the active account, because login-assignments:* is resolved against the active account's roles. Anything else is rejected with 403 and You do not have access to the target account.

GET /v1/accounts/login-assignments​

Returns all login assignments for the caller's active account.

Permission: login-assignments:read (or own account - no permission required for the account owner).

Response:

{
"data": [
{
"provider": "twitch",
"login_connection_id": "00000000-0000-0000-0000-000000000001",
"user_id": "00000000-0000-0000-0000-000000000002",
"assigned_at": "2026-01-15T10:00:00Z"
}
]
}

POST /v1/accounts/login-assignments​

Assign a login connection to an account.

Permission: login-assignments:create (or own account).

Request body:

FieldTypeRequiredNotes
login_connection_idUUIDYesID of the login connection to assign
providerstringYesPlatform slug, e.g. "twitch", "google"
user_idUUIDNoDefaults to the authenticated user
account_idUUIDNoTarget account. Defaults to the active account

Response: 200 with the created or updated assignment object.

Audit: emits account:login_connection_assigned (Account scope) with provider, target_user_id and connection_id.

Discord: if either the requested provider or the stored connection's provider is Discord, the API rejects the assignment with 400 and This sign-in method is login-only and cannot be assigned to a channel. GraphQL assignLoginConnection uses the same check and message.

DELETE /v1/accounts/login-assignments/:provider​

Remove the login assignment for a given provider from an account.

Permission: login-assignments:delete (or own account).

Path parameter: :provider - platform slug (e.g. twitch, google).

Query parameters: user_id (UUID, defaults to the caller), account_id (UUID, defaults to the active account).

Response: 200 with {"data":{"success":true}}.

Audit: emits account:login_connection_removed (Account scope) when a row was actually removed.

DELETE /v1/login-connections/:id​

Delete a login connection by its UUID. The connection must belong to the authenticated user.

Permission: None (own connections only).

Path parameter: :id - UUID of the login connection.

Step-up: after the "last sign-in method" check, the calling session needs a step-up in the last 10 minutes; otherwise 403 with code STEP_UP_REQUIRED ("Please confirm that it's you first") and nothing is deleted. Same code and message as GraphQL disconnectLoginConnection. POST /v1/auth/link (a new link, not a reconnect of the same connection), POST /v1/users/me/mfa/setup and POST /v1/users/me/mfa/enable need the same step-up. See Authentication → Step-up before sign-in changes.

Response: 204 No Content.

Session-only (ZAF-1742): the create, delete and login-connection delete endpoints require an interactive sign-in session - a user's own API key is refused with 403 This action requires an interactive sign-in session. The two list endpoints keep the first-party gate. See Authentication → Session-only Surfaces.

Protocol parity: All four endpoints have matching GraphQL operations - see GraphQL.

Feature Flags & Status​

GET /users/me​

Returns the authenticated user's profile, account memberships, permissions, feature statuses, login connections, and preferences.

The response includes streamer_mode (boolean) - the user's Streamer Mode preference. Use PATCH /users/me with { "streamer_mode": true } to toggle it.

The UserResponse also includes admin_permissions (admin-scope permission strings), admin_mfa_required (boolean - true when the user holds admin grants but has no 2FA factor, so admin endpoints answer 403 ADMIN_MFA_REQUIRED; GraphQL twin MeResult.adminMfaRequired), user_permissions (user-scoped, cross-account non-admin permission strings - e.g. Ideas Hub ideas:moderate_*; the exact set the backend enforces via AuthContext::require_user_permission, and the GraphQL twin of MeResult.userPermissions), enabled_features (list of enabled feature keys for the active account), login_connections (OAuth login identities linked to the user, filtered by enabled providers), is_developer (boolean - true when the user has a developer profile or the extension_dev_mode admin override), and extension_dev_mode (boolean - when true, extension bundle serving loads the latest draft/testing version instead of the published version for extensions the user develops).

The UserResponse also carries the user-global display preferences (all string, stored on users):

FieldValuesMeaning
localeauto · en · deUI language; auto uses the request-time fallback (locale cookie if valid, otherwise default language)
timezoneauto or an IANA name (Europe/Berlin, …)auto uses the browser-detected zone mirrored into lumio-tz; server render falls back to UTC until that mirror exists
date_formatauto · dmy_dot · dmy_slash · mdy_slash · ymd_dashNumeric date format: automatic language-locale format, 31.12.2026, 31/12/2026, 12/31/2026, or 2026-12-31
time_formatauto · h12 · h24Numeric time format: automatic format-locale hour cycle, 11:30 PM, or 23:30

These are the user's own preferences, applied across the webapp and every account the user can access. Set them with PATCH /users/me; an invalid value is rejected with 400, never coerced back to auto. Changing them emits no audit event (a display preference is neither security- nor lifecycle-relevant).

It also carries the user-global seasonal-look preferences (the seasonal dashboard look, e.g. Halloween). They live on the profile so they follow the user to every device and into popouts (also inside OBS):

FieldTypeDefaultValues
season_look_enabledbooltrueOne switch for every season
season_strengthstringfullfull · subtle
season_motionstringautoauto (animations play unless the OS asks for reduced motion) · off
season_creaturesobject{}{ "<creature>": bool }; an absent key means the creature's registry default (spider off, bat on). Keys [a-z_]{1,32}, at most 32 entries
season_banner_dismissedstring[][]Dismissed season banners as <seasonId>:<year> (season id [a-z0-9-]{1,32}, the year the season window starts in), oldest first

GraphQL parity: me { seasonLookEnabled seasonStrength seasonMotion seasonCreatures seasonBannerDismissed }. No audit event on change (a display preference).

And the user-global "Animations in Lumio" preference, also read by popouts via GET /v1/auth/popout/session:

FieldTypeDefaultValues
motion_preferencestringsystemsystem (full motion unless the OS asks for reduced motion) · reduced · off

There is no "always on" (the OS reduced-motion request is never overridden); it is independent of season_motion, and the stricter of the two applies to the seasonal decoration. GraphQL parity: me { motionPreference }. No audit event on change (a display preference).

The feature_statuses field is a merged list of account-scope feature statuses and the user-scope system:account_creation status.

{
"data": {
"id": "...",
"feature_statuses": [
{ "key": "feature:bots", "enabled": true, "reason": null },
{ "key": "feature:music", "enabled": false, "reason": "plan_locked" },
{ "key": "system:account_creation", "enabled": true, "reason": null }
],
...
}
}

The reason field is one of: "global_off", "plan_locked", "account_override", "user_override", or null (when enabled).

PATCH /users/me​

Updates the authenticated user's profile, active account, or preferences. All fields are optional - at least one must be provided.

First-party session required (ZAF-450 S1 / ZAF-469): this endpoint - and the whole first-party user surface keyed on the acting user's own identity (GET /users/me, the session endpoints under /users/me/sessions, login-connection and login-assignment management, PUT /users/me/name-source-connection, the /notifications endpoints, POST/GET /developer/application, POST/GET /developer/verification, account create/dissolve/leave, invite accept (POST /invites/{code}/accept, ZAF-1740 A6), and the JWT-minting POST /auth/ws-token) - apply the first-party gate. It admits a logged-in user session or the user's own API key, except for the identity-changing operations and POST /auth/ws-token listed as session-only below. A popout/overlay/widget/extension token, whose user_id is bound at token-create time and could be set to the account owner, is rejected with 403 FORBIDDEN on both REST and GraphQL. This closes the profile-edit (email → password-reset) account-takeover path, the owner-bound-JWT minting path, and the broader authorization/disclosure gap. The same first-party bar extends to the extension-developer REST surface keyed on the developer identity (ZAF-471): the developer/profile, developer/revenue, developer/payout-settings, developer/payout-history, developer/limits* reads, the developer/payouts (update) / developer/payouts/request (request) writes, extension create/submit/delete and tester invites, the developer-team endpoints, and extension-access grants/invites - all reject a popout/overlay/widget/extension token with 403 FORBIDDEN. The payout writes require an interactive session (ZAF-1750); ownership-scoped extension operations, including the extension secret store, may also admit developer keys under their separate guard.

What happens to a requested payout (ZAF-1257): the payout is pending and the balance is debited. The payout cycle sends Stripe and PayPal payouts within about 15 minutes (Stripe completed, PayPal processing until its webhook); SEPA payouts and payouts with an unknown provider answer are settled by an operator. Every request emits developer:payout_requested (user scope) on both protocols. See Developer Payouts.

Payouts need a verified developer (ZAF-2804): POST /developer/payouts/request answers 403 DEVELOPER_VERIFICATION_REQUIRED ("Payouts require a verified developer identity. Complete the verification in the developer settings first") until the developer verification is verified; the automatic payout cycle skips unverified developers. GraphQL twin: requestPayout, same code and text.

Closing payout (ZAF-2825): POST /developer/payouts/request takes an optional JSON body { "closing": true }. It pays out the whole balance even below the method's minimum payout amount, but only while the user has a profile deletion scheduled (grace period running, or held by the deletion job); otherwise 409 CLOSING_PAYOUT_REQUIRES_DELETION ("A closing payout below the minimum payout amount is only possible while a profile deletion is scheduled"). A balance of 0 still fails with the below-minimum 400. Payout method, verified identity and the interactive session stay required. No body means a regular payout. GraphQL twin: requestPayout(closing: true), same code and text.

Every payout carries a self-billed credit note (ZAF-2807): POST /developer/payouts/request issues the payout's credit note (Gutschrift) in the same transaction. The payout amount is the note's gross amount: for a German developer who charges VAT, 19 % VAT is added on top of the balance (the balance is debited by the net amount). It answers 422 TAX_DETAILS_REQUIRED while the tax details of the verification are incomplete (Tax details required: enter your tax number (Steuernummer) or VAT ID, … state whether you are a small business under section 19 UStG, … enter your VAT ID) and 503 CREDIT_NOTE_ISSUER_NOT_CONFIGURED while [billing.issuer] is incomplete; nothing is booked in either case. See Developer Credit Notes. GraphQL twin: requestPayout, same codes and texts.

Money-moving writes are session-only (ZAF-1750): POST /developer/stripe-connect/onboard (creates the Stripe Connect account and so sets the payout destination), POST /developer/payouts (payout settings), POST /developer/payouts/request, and the billing POST /billing/checkout, POST /billing/portal and POST /billing/cancel require an interactive sign-in session. A user's own API key is refused with 403 This action requires an interactive sign-in session, whatever its scopes, before any row is written or Stripe is called; every token type is refused the same way. The reads (GET /developer/stripe-connect/status, developer/payout-settings, developer/payout-history, developer/revenue, billing status and invoices) keep the first-party gate, so a scripted key can still read them. GraphQL twins identical. See Authentication → Session-only Surfaces.

email is not settable: the profile email is set by the email sign-in only. A request with email is refused for every caller with 400 EMAIL_MANAGED_BY_LOGIN (Set your e-mail under Security › Sign-in › E-mail & password) before anything is written (GraphQL updateMe identical). With an email sign-in the profile email is always the confirmed sign-in address (email_source: "email_login"); without one it is a platform sign-in's address (platform:<provider>, filled only when empty), otherwise null. Removing the email sign-in brings back the platform address it replaced, or leaves none.

Session-only fields (ZAF-1922): the account switch / clear (active_account_id, clear_active_account) is session-only too (ZAF-1922): both re-mint the session JWT, and a JWT is only minted for a caller that already holds a session - a user API key or a session-less JWT gets the same 403. Every other field keeps the first-party gate above. Session-only as a whole: POST /auth/link (linking / reconnecting a login provider), the login-assignment create/delete, DELETE /login-connections/{id}, PUT /users/me/name-source-connection, PATCH /users/me/display-name, POST/DELETE /users/me/avatar (they rearrange, rename or remove the identities you log in with), the channel name/picture writes PUT /accounts/{id}/identity-source, PATCH /accounts/{id}/name, POST/DELETE /accounts/{id}/avatar (see Channel name and picture) and POST /auth/ws-token (the minted JWT carries the full user permission set). See Authentication → Session-only Surfaces.

FieldTypeDescription
emailstringDeprecated. Always refused with 400 EMAIL_MANAGED_BY_LOGIN
active_account_idUUIDSwitch active account (must be a member; interactive sign-in session only)
clear_active_accountboolClear active account (go to user-only mode; interactive sign-in session only)
streamer_modeboolToggle Streamer Mode on/off
extension_dev_modeboolToggle Extension Developer Mode - loads draft/testing versions in editors
localestringUI language: auto · en · de
timezonestringauto or a valid IANA timezone name
date_formatstringauto · dmy_dot · dmy_slash · mdy_slash · ymd_dash
time_formatstringauto · h12 · h24
display_currencystringauto, EUR, or an ECB reference currency; auto resolves to EUR for German and USD otherwise
season_look_enabledboolSeasonal look on/off
season_strengthstringfull · subtle
season_motionstringauto · off
season_creaturesobjectReplaces the creature map: { "<creature>": bool }, keys [a-z_]{1,32}, at most 32 entries; {} resets every creature to its default
season_banner_dismissedstring[]Replaces the dismissed-banner list: <seasonId>:<year> entries; duplicates are dropped and beyond 50 the oldest entries are dropped
motion_preferencestring"Animations in Lumio": system · reduced · off

An invalid locale, timezone, date_format, or time_format is rejected with 400 (fail-closed, identical message on GraphQL and REST) - never silently coerced to auto. The same holds for the seasonal fields: an unknown season_strength / season_motion, a non-object or non-boolean season_creatures, a bad creature key, more than 32 creatures, or a malformed season_banner_dismissed entry returns 400 with the message the GraphQL twin returns (e.g. Invalid season_strength 'loud': allowed values are full, subtle). An unknown motion_preference likewise returns 400 with the GraphQL twin's message (Invalid motion_preference 'auto': allowed values are system, reduced, off).

display_name is no longer accepted here (the field was removed): the Lumio display name + avatar follow the name source. Change the source via PUT /users/me/name-source-connection; with own details, set the name via PATCH /users/me/display-name. GET /users/me exposes the current source as name_source_kind + name_source_connection_id.

Response: full UserResponse (same shape as GET /users/me), including display_currency and resolved effective_display_currency. When switching accounts, a token field with a fresh JWT bound to the caller's session is included; a non-session caller never receives a token.

Stale-membership filtering on active_account_id​

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 active_account_id: 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.

PUT /users/me/name-source-connection​

Chooses where the current user's Lumio display name + avatar come from (name_source_kind on GET /users/me):

  • connection - mirrored on every login from a single name-source login connection, so a login via a secondary platform never rewrites them.
  • own - the user's own details. Sign-ins never overwrite them. Set the name with PATCH /users/me/display-name and the picture with POST /users/me/avatar.

Session-only (ZAF-469 / ZAF-1742): rejected with 403 for an API key or a popout/overlay/widget/extension token. GraphQL twin: setNameSourceConnection(loginConnectionId, kind).

FieldTypeDescription
kindstring / absentconnection (default) or own. Anything else returns 400 INVALID_NAME_SOURCE.
login_connection_idUUID / nullWith connection: the connection to source from. null clears the pin, so resolution falls back to the first-ever-connected login (created_at ascending). Ignored with own.

With connection, the connection must belong to the caller; a foreign or unknown id returns 400 (same message as the GraphQL twin). A profile without any platform sign-in returns 409 NO_LOGIN_CONNECTION. The name/avatar are re-materialized immediately, and an uploaded own picture is deleted from storage. With own, the current name and picture stay as the starting values. Response (also returned by the display-name and avatar endpoints below):

{ "data": { "display_name": "…", "avatar_url": "…", "name_source_connection_id": "…", "name_source_kind": "connection" } }

Audit: switching between own and connection writes user:profile_identity_changed (changed: ["source"], plus "avatar" when an uploaded picture was deleted, and source). Choosing among one's own connections is not audited (a display preference).

PATCH /users/me/display-name​

Sets the own display name. Only while name_source_kind is own, otherwise 409 NAME_SOURCE_NOT_OWN. Session-only. GraphQL twin: updateDisplayName(displayName).

{ "display_name": "René" }

The name is trimmed and NFC-normalised, then needs 2-32 characters without control or invisible characters (zero-width spaces/joiners, bidi controls, BOM). Errors: 400 DISPLAY_NAME_TOO_SHORT, 400 DISPLAY_NAME_TOO_LONG, 400 DISPLAY_NAME_INVALID_CHARACTERS - the same codes and messages as on GraphQL. Writes user:profile_identity_changed (changed: ["display_name"]) when the name actually changes.

POST /users/me/avatar​

Uploads the own profile picture as multipart/form-data with one field file. Only while name_source_kind is own, otherwise 409 NAME_SOURCE_NOT_OWN. Session-only.

REST-only by design - the picture is binary data and the API has no GraphQL file uploads. GraphQL has the counterpart removeAvatar, and the result shape is the same NameSourceResponse / NameSourceResult.

The server validates the file itself and never relies on the browser's crop:

CheckError
At most 5 MB413 AVATAR_TOO_LARGE
Type by magic bytes: PNG, JPEG or WebP (no SVG, no GIF, no renamed files)415 AVATAR_UNSUPPORTED_TYPE
At most 16 megapixels, checked from the header before decoding (decompression bombs)400 AVATAR_TOO_MANY_PIXELS
The image decodes completely400 AVATAR_UNREADABLE
At most 10 uploads per profile and hour (every attempt counts; counted before the body is read)429 RATE_LIMITED with retry_after_secs
No file field400 AVATAR_MISSING
File storage is not configured503 AVATAR_UNAVAILABLE
All picture decode slots are taken (at most 2 profile and channel pictures are decoded at a time across the API)503 AVATAR_BUSY with a Retry-After header and retry_after_secs (5 s)

An accepted picture is turned upright (EXIF orientation), cropped to a centred square and re-encoded as 512 × 512 and 128 × 128 lossless WebP. The re-encode keeps pixels only, so EXIF, GPS, ICC and XMP metadata are gone. Both files are stored through lo-storage under users/{user_id}/avatar/{uuid}-512.webp and …-128.webp. avatar_url becomes {storage.public_url}/users/{user_id}/avatar/{uuid}-512.webp; with the default storage.public_url ({api}/v1/uploads/file) that is the public picture route. An earlier upload is deleted. Writes user:profile_identity_changed (changed: ["avatar"]).

DELETE /users/me/avatar​

Removes the own profile picture: avatar_url becomes null (initials) and an uploaded file is deleted. Only while name_source_kind is own, otherwise 409 NAME_SOURCE_NOT_OWN. Session-only. GraphQL twin: removeAvatar. Writes user:profile_identity_changed (changed: ["avatar"]) when there was a picture.

The uploaded picture is part of the "user data only" GDPR export (profile_picture.webp) and is deleted together with the profile.

GET /uploads/file/users/{user_id}/avatar/{file}​

Serves an uploaded profile picture. Public, no sign-in, like platform avatars: the picture is shown to anyone who sees the profile. The path is the default storage.public_url ({api}/v1/uploads/file) plus the storage key, so the avatar_url of an upload lands here; the files in storage never have to be public. Not audited (read). No GraphQL twin (binary download, like the upload); GraphQL me and REST GET /users/me return the same avatar_url.

{file} is {uuid}-512.webp or {uuid}-128.webp (lower-case UUID). Only the user's current upload is served, never a replaced or removed one.

CaseResponse
Current upload200, Content-Type: image/webp, Cache-Control: public, max-age=31536000, immutable (the name is new per upload), X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none'; sandbox
{file} does not match the pattern400 Invalid picture file name
Not the user's current upload (replaced, removed, another user's id)404 Picture not found
File storage is not configured503 AVATAR_UNAVAILABLE

GET /v1/accounts/{id}/enabled-features​

Returns the list of enabled feature keys for the given account. The caller's active account must match {id}. Works with popout-token auth (no account:read permission required). Excludes system:* flags.

Response:

{ "data": ["feature:bots", "feature:music", "feature:connections"] }

GET /v1/accounts/{id}/feature-statuses​

Returns the full feature status list for the given account (key, enabled, reason). The caller's active account must match {id}. Works with popout-token auth (no account:read permission required). Excludes system:* flags.

Response:

{
"data": [
{ "key": "feature:bots", "enabled": true, "reason": null },
{ "key": "feature:music", "enabled": false, "reason": "plan_locked" },
{ "key": "integration:shopify", "enabled": true, "reason": null }
]
}

POST /v1/accounts - account creation disabled (403)​

When the system:account_creation flag is disabled (globally or per user), this endpoint returns:

HTTP 403
{
"error": "Account creation is currently disabled",
"error_code": "account_creation_disabled"
}

This mirrors the GraphQL error extensions.code: "ACCOUNT_CREATION_DISABLED" with message "Account creation is currently disabled".

POST /v1/accounts / DELETE /v1/accounts/:id - token in the response​

Account creation returns the new account plus a token: a fresh JWT carrying the new account, bound to the caller's existing session. Dissolution returns a fresh JWT without an account the same way. A JWT is only minted for a caller that already holds an interactive session (ZAF-1922) - for any other first-party caller (e.g. a user API key) account creation proceeds under its usual guards and token is null; dissolution refuses such a caller (step-up, below). GraphQL twins: createAccount (CreateAccountResult.token is a nullable String) and dissolveAccount (returns { success }, no token).

DELETE /v1/accounts/:id - step-up​

After the account:delete, owner, active-account and confirm_name checks, the calling session needs a step-up in the last 10 minutes; otherwise 403 with code STEP_UP_REQUIRED ("Please confirm that it's you first") and nothing is deleted. Only an interactive sign-in session can hold a step-up, so a user API key, service key or popout token is refused even with account:delete. Same code and message as GraphQL dissolveAccount. See Authentication → Step-up before sign-in changes.

POST /v1/accounts/:id/dissolution - dissolve after a grace period​

Schedules the dissolution of a channel, or dissolves it at once. Guard: require_permission("account:delete"), the same as GraphQL scheduleAccountDissolution(accountId, graceDays). Optional body { "grace_days": 0 | 7 | 30 }, default 30. The checks run in this order, with the same codes on both protocols:

CheckResponse
First-party user (popout, overlay, widget and extension tokens refused)403 FORBIDDEN
grace_days is 0, 7 or 30400 INVALID_GRACE
:id is the caller's active account403
Caller is the owner403 "Only the account owner can dissolve an account"
No dissolution already scheduled409 DISSOLUTION_ALREADY_SCHEDULED
No running Stripe subscription that is not set to cancel at period end409 ACTIVE_SUBSCRIPTION ("Cancel it in the billing settings first")
Step-up in the last 10 minutes (only an interactive session holds one, so user API keys are refused)403 STEP_UP_REQUIRED

Response: { "dissolution_scheduled_for": string | null, "dissolved": bool }. grace_days: 0 dissolves the channel at once through the same path as DELETE /v1/accounts/:id and returns { "dissolution_scheduled_for": null, "dissolved": true }. A scheduled dissolution writes account:dissolution_scheduled and mails the owner and every other member. The account security worker sends the owner a reminder (7 days before the end of a 30-day period, 1 day before the end of a 7-day period) and dissolves the channel when the date is reached (account:dissolved with via: "scheduled").

DELETE /v1/accounts/:id/dissolution - cancel a scheduled dissolution​

Owner only, interactive session only (no user API key, no popout token), no step-up - cancelling is the safe direction. Works while the channel is suspended, up to the end of the grace period. Returns 409 PROFILE_DELETION_SCHEDULED while the owner's own profile is scheduled for deletion (cancel the profile deletion first), 409 DISSOLUTION_IN_PROGRESS ("The dissolution is already running") once the date has passed, and 409 NO_DISSOLUTION_SCHEDULED when nothing is scheduled. Writes account:dissolution_cancelled and mails the owner and the other members. GraphQL: cancelAccountDissolution(accountId).

DELETE /v1/accounts/:id (immediate dissolve) refuses with 409 ACTIVE_SUBSCRIPTION too; that check runs before the step-up.

Channel name and picture​

A channel's name and picture come from one source: connection (one of the channel's own Twitch, YouTube or Kick channel connections - never Spotify - whose channel name and platform profile picture are used) or own (an editable name and an optional uploaded picture; without one, avatar_url is null and the initial is shown). New channels start with own. Background: Accounts › Channel name and picture.

identity on GET /v1/accounts/{id}, POST /v1/accounts and every accounts[] entry of GET /v1/users/me (AccountIdentityResponse), also the response of the four writes below:

{ "source_kind": "connection", "source_connection_id": "…", "source_platform": "twitch", "name": "…", "avatar_url": "https://static-cdn.jtvnw.net/…" }

Guard (all four writes): require_permission("account:edit") (owner and administrator by default), an interactive sign-in session (a user API key or popout token gets 403 This action requires an interactive sign-in session), and the path {id} must be the session's active channel (403 Account id does not match the active account). While the channel is scheduled for dissolution the refusal is 403 CHANNEL_DISSOLVING. GraphQL twins use the same guard, validation and error codes.

Audit: an effective change writes account:identity_changed (scope account, actor = caller) with metadata changed (list of source, name, avatar), source_kind and source_platform - never a name or URL. Nothing is written when nothing changed.

PUT /v1/accounts/{id}/identity-source​

Chooses the source. GraphQL twin: setAccountIdentitySource(accountId, kind, connectionId).

{ "kind": "connection", "connection_id": "uuid-of-channel-connection" }
CaseError
kind is not connection / own, connection without connection_id, own with a connection_id, or a Spotify connection400 INVALID_SOURCE
The connection does not exist or belongs to another channel404 Connection not found

Switching to own keeps the current name as the starting value; the picture starts as the initial. Switching back to a connection deletes an uploaded picture (changed: ["source", "avatar"]). While a connection is the source, the channel's stored name mirrors its channel name.

PATCH /v1/accounts/{id}/name​

Sets the own channel name. Only with source own, otherwise 409 SOURCE_NOT_OWN. GraphQL twin: updateAccountName(accountId, name).

{ "name": "My Channel" }

The name is trimmed and NFC-normalised, then needs 2-50 characters without control or invisible characters: 400 NAME_TOO_SHORT, 400 NAME_TOO_LONG, 400 NAME_INVALID_CHARACTERS.

POST /v1/accounts/{id}/avatar​

Uploads the own channel picture as multipart/form-data with one field file. Only with source own, otherwise 409 SOURCE_NOT_OWN. REST-only by design (no GraphQL file uploads); the GraphQL counterpart is removeAccountAvatar. Same pipeline as POST /users/me/avatar:

CheckError
At most 5 MB413 AVATAR_TOO_LARGE
Type by magic bytes: PNG, JPEG or WebP415 AVATAR_UNSUPPORTED_TYPE
At most 16 megapixels400 AVATAR_TOO_MANY_PIXELS
The image decodes completely400 AVATAR_UNREADABLE
At most 10 uploads per channel and hour (every attempt counts; counted before the body is read)429 RATE_LIMITED with retry_after_secs
No file field400 AVATAR_MISSING
File storage is not configured503 AVATAR_UNAVAILABLE
All picture decode slots are taken (at most 2 profile and channel pictures are decoded at a time across the API)503 AVATAR_BUSY with a Retry-After header and retry_after_secs (5 s)

An accepted picture is re-encoded as 512 × 512 and 128 × 128 WebP without metadata and stored through lo-storage under accounts/{id}/avatar/{uuid}-512.webp and …-128.webp. avatar_url becomes {storage.public_url}/accounts/{id}/avatar/{uuid}-512.webp. An earlier upload is deleted.

DELETE /v1/accounts/{id}/avatar​

Removes the own channel picture (avatar_url becomes null, the initial is shown) and deletes the files. Only with source own, otherwise 409 SOURCE_NOT_OWN. GraphQL twin: removeAccountAvatar(accountId).

GET /v1/uploads/file/accounts/{id}/avatar/{file}​

Serves an uploaded channel picture. Public, no sign-in: the picture is shown wherever the channel is. The files in storage are never public. Not audited (read). No GraphQL twin (binary download).

{file} is {uuid}-512.webp or {uuid}-128.webp. Only the channel's current upload is served, never a replaced or removed one.

CaseResponse
Current upload200, Content-Type: image/webp, Cache-Control: public, max-age=31536000, immutable, X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none'; sandbox
{file} does not match the pattern400 Invalid picture file name
Not the channel's current upload404 Picture not found
File storage is not configured503 AVATAR_UNAVAILABLE

Disconnecting the channel connection that is the source switches the channel to own with the connection's last channel name and the initial; the owner gets an in-app notification (channel_identity_source_removed). Dissolving the channel or deleting it from the admin app deletes the uploaded files.

Channels being dissolved​

While a channel has dissolution_scheduled_for set, one check in the API auth middleware, right after the credential is resolved, suspends it:

  • Credentials bound to the channel - user API keys (lm_usr_), service keys (lm_svc_), popout tokens, overlay (lm_overlay_), shared overlay (lm_share_), widget (lm_widget_) and extension (lm_ext_) tokens - are refused:

    HTTP 403
    {
    "error": "CHANNEL_DISSOLVING",
    "message": "This channel is being dissolved. Only cancelling the dissolution is possible",
    "details": { "dissolution_scheduled_for": "2026-11-08T03:00:00Z" }
    }

    They are not revoked and work again after the dissolution is cancelled.

  • A member's dashboard session keeps its identity but holds no account permission: every permission-gated route answers 403 CHANNEL_DISSOLVING. User-level routes keep working - the profile, GET /v1/users/me with the account list and dissolution_scheduled_for, switching and leaving accounts, and the owner's DELETE /v1/accounts/:id/dissolution.

  • Feature flags all resolve off for the account, which stops automations and feature-gated routes.

  • Bots - the channel drops out of the bot channel and Discord guild lists; bots leave on their next poll and rejoin by themselves after a cancel. Bot-module and automation-node handler feeds report the account's installs as disabled.

  • Webhook ingest (YouTube PubSubHubbub, Kick) drops deliveries for the channel.

  • Checkout - POST /v1/billing/checkout answers 403 CHANNEL_DISSOLVING.

The WebSocket upgrade is refused the same way; see WebSocket.

PATCH /v1/admin/users/{id} - account creation override​

The request body accepts an optional account_creation_override field:

{ "account_creation_override": "default" }

Valid values: "default" (inherit global flag), "allow" (always permitted), "deny" (always blocked). The override is cached in Redis and invalidated immediately on save. Requires users:edit admin permission.

The account_creation_override field is also present in GET /v1/admin/users/{id} and the user-list response.

Platform-filtered connection lists​

GET /users/me/login-connections, GET /connections/channel, GET /bot-connections, and GET /admin/providers filter by platform flags:

  • Login connections are filtered by platform:{x}:login - platforms with the login sub-flag disabled are excluded.
  • Channel connections are filtered by platform:{x}:channel.
  • Bot connections are filtered by platform:{x}:bot.
  • Admin providers exclude integration-only entries (e.g., Shopify does not appear - its flag is integration:shopify in the Feature Flags page, not a platform provider).

Protocol parity: All endpoints above have matching GraphQL queries/mutations - see GraphQL.

Admin API Keys​

Platform-operator management of any account's or user's API keys, for support and incident response. All paths are under /v1/admin and check admin-scope permissions (the account-apikeys:* family, require_admin_permission) - distinct from the account-scope apikeys:* a member uses on their own keys under /v1/api-keys. {kind} is personal (lm_usr_, in api_keys) or service (lm_svc_, in account_service_keys). Bodies use snake_case; no response ever includes a key's secret except the one-time rotate result.

MethodPathPermissionDescription
GET/v1/admin/accounts/\{account_id\}/api-keysaccount-apikeys:readList an account's keys - both kinds (its members' personal lm_usr_ keys and the account's lm_svc_ service keys)
GET/v1/admin/users/\{user_id\}/api-keysaccount-apikeys:readList a user's personal lm_usr_ keys (service keys have no owning user)
PATCH/v1/admin/api-keys/\{kind\}/\{id\}account-apikeys:editEdit a key's label and permissions (scopes)
POST/v1/admin/api-keys/\{kind\}/\{id\}/rotateaccount-apikeys:rotateRotate a key - regenerate its secret in place; returns the new full key once
DELETE/v1/admin/api-keys/\{kind\}/\{id\}account-apikeys:deleteRevoke (delete) a key; idempotent - an already-gone key resolves to the same not-found result

Scope-edit rule. PATCH validates each requested scope against the assignable-scope registry for the key's kind (ZAF-1170) - an unknown, deny-listed, or user-scoped-on-a-service-key scope is rejected. For a personal key the scopes are additionally capped to the key owner's own grantable permissions; for a service key the account is the principal, so the registry minus the deny-list is the ceiling. An operator can never push a key above its own principal.

Audit. Each write emits a system-scope audit event - system:api_key_updated (PATCH), system:api_key_rotated (rotate), system:api_key_revoked (delete) - with the operator as the actor (user_id) and the target kind / api_key_id / account_id / (personal only) user_id in metadata; rotate/update additionally carry old_key_prefix+new_key_prefix / permissions. These rows land only in the operator log, never in the target account's or user's own audit log. Parity with the GraphQL adminAccountApiKeys / adminUserApiKeys queries and adminUpdateApiKey / adminRotateApiKey / adminDeleteApiKey mutations - see GraphQL.

Admin Account Membership​

These endpoints check admin-scope permissions and mirror adminAccountRoles and adminAddAccountMember in GraphQL.

GET /v1/admin/accounts/\{id\}/roles​

Requires accounts:read. Returns the account's roles in sort_order order in the standard data envelope; each item has id, slug, name, nullable color, is_system, is_default, and sort_order. Returns 404 "Not found: Account not found" for an unknown account. The API includes the Owner role; the admin dialog excludes it from its choices.

POST /v1/admin/accounts/\{id\}/members​

Requires accounts:edit. The JSON body requires both user_id and role_id:

{ "user_id": "<user-uuid>", "role_id": "<role-uuid>" }

The role must belong to the target account. Missing fields fail request validation (400); an unknown role or one belonging to another account returns 404 "Not found: Role not found for this account". On success, returns the updated account detail and emits system:account_member_added with the selected role_id; adding a user who is already a member changes nothing and emits no event.

Admin Account Limits​

PUT /v1/admin/accounts/\{id\}/limits requires accounts:edit. Its optional chat_retention_days override must be 0–1095 days; values outside that range return 400 with "chat_retention_days must be between 0 and 1095 days (the published maximum)". Omit the field to keep the existing override. GET /v1/admin/accounts/\{id\}/limits requires accounts:read. The retention sweeps use the account override when set, otherwise the plan value.

Admin Role Management​

All endpoints below are under the /v1/admin scope and check admin-scope permissions (not account permissions). The caller must have the admin permission listed for each endpoint.

GET /v1/admin/admin-roles​

Requires admin-roles:read. Returns [AdminRoleResponse] - the full list of admin roles with their permissions and member counts.

POST /v1/admin/admin-roles​

Requires admin-roles:create. Body: CreateAdminRoleRequest. Returns 201 + AdminRoleResponse.

Validation errors (400):

  • "Name is required" - empty name
  • "Name must be 100 characters or less" - name too long
  • "Invalid permission: <perm>" - unknown permission string
  • "Role name already in use" - duplicate name

admin:access is auto-injected if not included in the permissions list.

GET /v1/admin/admin-roles/{id}​

Requires admin-roles:read. Returns AdminRoleResponse. Returns 404 "Admin role not found" if not found.

PATCH /v1/admin/admin-roles/{id}​

Requires admin-roles:edit. Body: UpdateAdminRoleRequest (all fields optional). Returns AdminRoleResponse.

  • Omit a field to leave it unchanged
  • Set description to null to clear it
  • Permissions are applied as a diff; unknown/legacy permissions are preserved
  • Same validation error wording as POST

DELETE /v1/admin/admin-roles/{id}​

Requires admin-roles:delete. Returns 204 on success.

  • 400 "Cannot delete system admin role" - if is_system = true
  • 404 "Admin role not found" - if not found

GET /v1/admin/admin-roles/{id}/members​

Requires admin-roles:read. Returns [AdminRoleMemberResponse] - all users assigned to the role.

PUT /v1/admin/admin-roles/{id}/members/{userId}​

Requires admin-roles:edit. Assigns the given user to the role. Idempotent. Returns 204.

  • 404 "Admin role not found" - if the role does not exist

DELETE /v1/admin/admin-roles/{id}/members/{userId}​

Requires admin-roles:edit. Removes the user's role assignment. Returns 204.

GET /v1/admin/admin-permissions​

Requires admin-roles:read. Returns [AdminPermissionInfoResponse] - the full catalog of admin-scope permissions with category labels. Source: lo_auth::rbac::all_admin_permissions().

GET /v1/admin/system-key-permissions​

Requires system-keys:read. Returns [AdminPermissionInfoResponse] - every scope assignable to a System key, each with its category. Source: lo_auth::rbac::system_key::get_assignable_permission_infos() - the derived registry (admin ∪ account ∪ user minus the credential-minting / tenant-destruction deny-list, ZAF-1213). This is the same registry POST /v1/admin/system-keys validates against, so the create picker can never drift from what the mint accepts. Guarded on system-keys:read (not admin-roles:read), so the operator who mints keys can read it. Parity: GraphQL adminSystemKeyPermissions.

Request / Response Types​

CreateAdminRoleRequest

FieldTypeRequired
namestringYes
descriptionstring | nullNo
permissionsstring[]Yes (may be empty)

UpdateAdminRoleRequest

FieldTypeNotes
namestringOptional; trimmed
descriptionstring | nullnull = clear; omit = leave unchanged
permissionsstring[]Optional; replaces via diff

AdminRoleResponse

FieldType
idUUID
namestring
descriptionstring or null
is_systemboolean
permissionsstring[]
member_countinteger
created_atISO-8601 string
updated_atISO-8601 string

AdminRoleMemberResponse

FieldType
user_idUUID
display_namestring
emailstring or null
avatar_urlstring or null
assigned_atISO-8601 string

AdminPermissionInfoResponse

FieldType
permissionstring
categorystring

Protocol parity: All 9 endpoints have matching GraphQL queries/mutations - see GraphQL.

Admin Plan Management​

All endpoints below are under the /v1/admin scope and check admin-scope permissions. Request and response bodies use snake_case.

Reading the plan list is GraphQL-only - use the adminPlans query. There is no GET /v1/admin/plans; the REST surface covers create, update, delete, and the feature mapping.

POST /v1/admin/plans​

Requires plans:create. Body: CreatePlanRequest. Returns 201 + AdminPlanResponse.

Validation errors (400):

  • "Invalid slug format" - slug does not match ^[a-z0-9]+(?:-[a-z0-9]+)*$ or is outside 2–40 chars
  • "Price cannot be negative" - monthly or yearly price is negative
  • "Limit cannot be negative" - a numeric limit is negative
  • "chat_retention_days must be between 0 and 1095 days (the published maximum)" - retention is outside the published range

Conflict errors (409):

  • "Plan slug already in use" - another plan already uses this slug

Auth errors: 401 UNAUTHORIZED for a missing or invalid JWT; 403 FORBIDDEN (Missing permission: plans:create) when the admin role lacks plans:create.

Fields of CreatePlanRequest:

FieldTypeRequiredNotes
slugstringYesRegex ^[a-z0-9]+(?:-[a-z0-9]+)*$, length 2–40, immutable after creation
namestringYesDisplay name
descriptionstring | nullNo
price_monthlyintegerYesCents (or the minor unit of currency)
price_yearlyintegerYesCents
currencystring | nullNoISO-4217 code; defaults to "USD"
is_publicbooleanYesWhether the plan is visible on the public pricing page
sort_orderintegerYesSort position in pricing pages
max_overlaysintegerYes
max_storage_bytesintegerYes
max_upload_size_bytesintegerYes
max_integrationsintegerYes
chat_retention_daysintegerYes0–1095 days. 0 keeps chat and events indefinitely; chatter and moderation data are still deleted after 1095 days. A value outside this range returns 400.
max_soundsintegerYesMaximum number of sounds per account
max_sound_file_sizeintegerYesMaximum size per sound file in bytes
max_sound_storage_bytesintegerYesTotal sound storage quota in bytes
max_setting_presetsintegerYesSetting-preset quota across all kinds; 0 = unlimited (defaults: Free 5, Pro 20)
stripe_product_idstring | nullNoPaste from Stripe dashboard
stripe_monthly_price_idstring | nullNoPaste from Stripe dashboard
stripe_yearly_price_idstring | nullNoPaste from Stripe dashboard

PATCH /v1/admin/plans/{id}​

Requires plans:edit. Body: UpdatePlanRequest. Returns 200 + AdminPlanResponse.

  • The slug is immutable and is therefore not part of UpdatePlanRequest.
  • All fields are required on update - the handler rewrites the full editable field set.
  • On success, the feature cache is invalidated for every account currently on the plan.

Errors:

  • 400 "Price cannot be negative" / "Limit cannot be negative" / "chat_retention_days must be between 0 and 1095 days (the published maximum)" - validation failures
  • 403 FORBIDDEN - missing plans:edit (Missing permission: plans:edit)
  • 404 "Plan not found" - no plan with that ID

Fields of UpdatePlanRequest:

FieldTypeNotes
namestring
descriptionstring | null
price_monthlyinteger
price_yearlyinteger
currencystring
is_publicboolean
sort_orderinteger
max_overlaysinteger
max_storage_bytesinteger
max_upload_size_bytesinteger
max_integrationsinteger
chat_retention_daysinteger0–1095 days; outside this range returns 400. 0 keeps chat and events indefinitely, while chatter and moderation data have a 1095-day ceiling.
max_soundsinteger
max_sound_file_sizeinteger
max_sound_storage_bytesinteger
stripe_product_idstring | null
stripe_monthly_price_idstring | null
stripe_yearly_price_idstring | null

DELETE /v1/admin/plans/{id}​

Requires plans:delete. Returns 204 No Content on success.

  • 403 FORBIDDEN - missing plans:delete (Missing permission: plans:delete)
  • 404 "Plan not found" - no plan with that ID
  • 409 "Cannot delete plan: N account(s) still reference it. Migrate them to a different plan first." - one or more accounts still point at this plan; migrate them before retrying

Deletion cascades to plan_features via the foreign key constraint. No other data is affected.

Request / Response Types​

AdminPlanResponse

FieldType
idUUID
slugstring
namestring
descriptionstring or null
price_monthlyinteger
price_yearlyinteger
currencystring
is_publicboolean
sort_orderinteger
max_overlaysinteger
max_storage_bytesinteger
max_upload_size_bytesinteger
max_integrationsinteger
chat_retention_daysinteger
max_soundsinteger
max_sound_file_sizeinteger
max_sound_storage_bytesinteger
max_setting_presetsinteger
stripe_product_idstring or null
stripe_monthly_price_idstring or null
stripe_yearly_price_idstring or null
features[AdminPlanFeatureResponse]
accounts_usinginteger

AdminPlanFeatureResponse

FieldType
feature_idUUID
feature_keystring
labelstring
enabledboolean

Protocol parity: The three write endpoints have matching GraphQL mutations, plus PUT /v1/admin/plans/{id}/features for the feature mapping; the plan list is GraphQL-only (adminPlans) - see GraphQL.

Admin - Setting Presets​

Operator view/delete of a user's setting presets (ZAF-1282; see Setting Presets). Presets are user-bound, so an operator manages them by owning user; deleting one removes it for that user across every account they belong to. The payload is never exposed.

GET /v1/admin/users/\{id\}/setting-presets​

Requires users:read. Returns a list of AdminSettingPresetResponse (id, user_id, kind, name, is_default, created_at, updated_at).

DELETE /v1/admin/setting-presets/\{id\}​

Requires users:edit. Returns 204 No Content on success, 404 "Setting preset not found" if no such preset. Emits system:user_setting_preset_deleted.

Protocol parity: Matching GraphQL adminUserSettingPresets (query) / adminDeleteUserSettingPreset (mutation) - see GraphQL.

Chat History​

GET /v1/chat/shared-chat​

Reads the account's current Twitch Shared Chat session with chat:read and feature:multichat. A popout token with those grants can use it. The body is snake_case SharedChatSessionView: status (session, none, unknown), revision, session_id, host_broadcaster_id, own_broadcaster_id, started_at, updated_at, participants and total_viewers. Participants carry broadcaster identity, joined_at, optional viewer/category/title/ follower data and inferred, is_host, is_own_channel. unknown means the state could not be determined and must not clear a previously known session; its revision is null. A newer revision wins over an older read or WebSocket frame. GraphQL twin: sharedChatSession.

Twitch announcements are chat rows with message_type: "announcement" and announcement_color: "primary" | "blue" | "green" | "orange" | "purple". Other messages have announcement_color: null. A missing or unrecognised announcement colour resolves to primary. The same fields appear in GET /v1/chat/history and the message part of the merged history read.

Current pins and pin actions​

GET /v1/chat/pins returns {twitch, kick, available} for the active account. It requires chat:read and feature:multichat and accepts a popout token. available: false means the state could not be read; null platform pins then mean unknown. A pin has platform, channel_id, message_id, sender, pinned_by, text, emotes, sent_at, starts_at, ends_at and updated_at. sent_at is the message's write time, or null when unknown, not the pin time. Each person has platform, user_id, login, display_name, avatar_url, color and badges.

MethodPathBodyEffect
POST/v1/chat/pinsplatform, message_id, duration_secondsPin a Twitch message, replacing the current pin.
PATCH/v1/chat/pinsSameChange the current pin's duration from now.
DELETE/v1/chat/pins/\{platform\}/\{message_id\}NoneUnpin the current Twitch message.

All actions require chat:pin and feature:multichat. The duration is 30–1,800 seconds or null / absent for until stream end. The caller's own Twitch login must moderate the channel and grant moderator:manage:chat_messages. Kick actions return a result with success: false and error_code: "pin_platform_unsupported". Every result has success, platform, message_id, action, error_code and details. Invalid platform, message ID or duration gives a 400; a refused platform action is represented in the result. The GraphQL twins have the same guard, validation and outcome fields in camelCase.

GET /v1/chat/history​

Returns paginated account chat history. Query parameters use snake_case and match the GraphQL chatHistory(filter: ChatFilterInput) surface, except that REST encodes keyword search as one comma-separated string.

Query parameterTypeDescription
platformstringOptional platform filter: twitch, youtube, or kick
platform_user_idstringOptional platform-native user filter
live_chat_idstringOptional YouTube live-chat/broadcast filter
fromISO 8601 timestampOptional inclusive lower time bound. When omitted, the read covers the 30 days before to (or now)
toISO 8601 timestampOptional inclusive upper time bound
searchcomma-separated stringOptional keyword search over message text, for example ?search=hello,world. Matching is case-insensitive and uses the same validation as GraphQL: at most 20 keywords, each at most 100 characters after trimming and dropping blanks. A keyword search always has a lower time bound: the supplied from, or the last 12 hours when from is omitted. A keyword that itself contains a comma cannot be expressed through REST; use GraphQL ChatFilterInput.search: [String!] for that case.
pageinteger1-based page number, default 1
limitintegerItems per page, default 50, max 100

_meta.total is capped at 10 000; _meta.total_capped: true means more messages match (see Response format for the paging rules).

Each returned message is a snake_case chat row. A reply carries the parent author under two fields: reply_parent_username (the display name) and reply_parent_login (the login/handle twin, ZAF-1452 F1) — reply_parent_login is null for platforms/messages without a distinct login (Kick's username already is the handle; YouTube carries no reply handle) and for pre-existing rows (no backfill). For a Twitch Shared Chat message that originated in a different channel's room, the row additionally carries is_shared_chat: true and source_channel: { platform_channel_id, login, display_name, avatar_url } (the origin channel). For normal and host-origin messages is_shared_chat is false and source_channel is null. Attribution is by channel (public broadcaster identity), never by viewer.

Protocol parity: mirror at GraphQL chatHistory(filter: ChatFilterInput). GraphQL accepts ChatFilterInput.search as a real [String!] list; REST accepts the same logical terms through the comma-separated search query parameter. The replyParentLogin, isSharedChat / sourceChannel fields are present on both protocols and on the chat:message WebSocket payload.

GET /v1/chat/history/merged​

Returns one timestamp-ordered page of chat messages, platform events and Twitch AutoMod holds interleaved, for the multichat history view. It replaces the client-side merge of the two separate /v1/chat/history and /v1/events windows (which drift on a chat-quiet, event-loud channel): messages and events for the same time span always arrive together, in created_at order, with nothing capped or hidden.

Pagination is keyset, not page/offset: pass the opaque before cursor from the previous response to fetch the strictly-older page. A single cursor spans both sources so the two streams can never page apart.

One request reads at most 30 days below its cursor, or down to the account's retention cutoff when retention is enforced and shorter. The history ends 7 days before the account was created. A page can therefore be short or empty while has_more is true: the window was exhausted and older history still exists below it, so next_cursor points at the window edge. Keep paging until has_more is false; only that marks the end of the history (see Chat → Server-merged history read).

Query parameterTypeDescription
platformstringOptional platform-tab filter (twitch, youtube, kick); applied to every source; a non-Twitch value skips AutoMod holds
live_chat_idstringOptional YouTube live-chat/broadcast filter; messages only; skips AutoMod holds
typecomma-separated stringOptional enabled event-type set (e.g. ?type=twitch:follower,kick:subscribe); events only. Maps to GraphQL types: [String!]
beforestringOpaque keyset cursor from a prior page's next_cursor (or any item's cursor); omit for the newest page
limitintegerTotal items across all sources, default 50, max 100

Response body:

{
"items": [
{ "kind": "message", "id": "…", "created_at": "…", "cursor": "…", "message": { /* ChatMessageResponse */ }, "event": null },
{ "kind": "event", "id": "…", "created_at": "…", "cursor": "…", "message": null, "event": { /* EventResponse */ } },
{ "kind": "automod", "id": "…", "created_at": "…", "cursor": "…", "message": null, "event": null, "automod": { /* AutoMod log entry */ } }
],
"next_cursor": "…", // cursor for the next (older) page; null when has_more is false
"has_more": true
}

Each item is tagged kind: "message" | "event" | "automod" and carries the full row in the matching field — message reuses the GET /v1/chat/history shape, event reuses the GET /v1/events shape (see Events), automod reuses the entry shape of GET /v1/history/reports/\{id\}/automod (see AutoMod log) (outcome open/approved/denied/expired, resolved_via, moderator, reason, text, emotes, sender look), so no field drifts from the standalone endpoints. An AutoMod item's id is the log row id and its created_at is held_at. Event raw is returned as stored on both endpoints, including the Twitch Shared Chat partner-event fields is_shared_chat, source_channel and shared_chat_notice_type (Events → Twitch Shared Chat partner events). A chat message sent through a channel-points reward with text input carries message_type: "channel_points_custom_reward".

Gates are server-side and independent. Messages are included only with feature:multichat and chat:read; events only with feature:events and events:read; AutoMod holds only with feature:multichat and chat:read and chat:automod-read — with history:read across the account's full chat window, without it for the last 24 hours. The AutoMod source always applies the plan-window floor and ends the history instead of paging empty windows. A member missing a gate receives only the streams they may read — no error, no empty state; a member holding none receives an empty page. Popout tokens (lm_pop_*) are accepted and scoped exactly like a member; overlay, shared-overlay, widget, extension, developer and system keys and anonymous callers get no AutoMod items. A malformed before cursor is a 400.

Protocol parity: mirror at GraphQL mergedChatHistory(filter: MergedChatHistoryFilterInput): ChatHistoryPage! — identical fields, filters, independent gates, and errors. WebSocket live-append is unchanged; this is the history read path only.

Chatter List​

GET /v1/chat/chatters​

Who is in the chat right now, per platform — see Chatter List. Requires chat:read and the feature feature:chat-viewer-list (same guard as GraphQL chatters and the chatters:\{account_id\} WebSocket channel); popout tokens with chat:read are accepted.

QueryNotes
platformOptional, comma-separated (e.g. twitch,kick). Omitted = every supported platform. An unknown platform is 400 BAD_REQUEST Bad request: Unsupported chatter platform: <name>. Kick without a usable channel connection is omitted even when requested explicitly
{
"data": {
"supported_platforms": ["twitch"],
"platforms": [
{ "platform": "twitch", "available": true, "reason": null,
"total": 12400, "truncated": true, "updated_at": "2026-10-02T18:00:00Z" }
],
"chatters": [
{ "platform": "twitch", "user_id": "141981764", "login": "nightbot",
"display_name": "Nightbot", "avatar_url": "https://…", "color": null,
"role": "bot", "last_message_at": null, "present": true,
"first_seen_at": "2026-10-02T17:12:00Z", "last_seen_at": "2026-10-02T18:00:00Z" },
{ "platform": "twitch", "user_id": "22484632", "login": "lena_plays",
"display_name": "lena_plays", "avatar_url": null, "color": null,
"role": "viewer", "last_message_at": null, "present": false,
"first_seen_at": "2026-10-02T17:40:00Z", "last_seen_at": "2026-10-02T17:56:00Z" }
]
},
"links": { "self": "…/v1/chat/chatters" }
}

chatters lists present people first, then the recently-left entries (present: false, kept 30 min after last_seen_at). first_seen_at is when the person (re)entered the chat in the current stream; last_seen_at is the later of the last snapshot that listed them and their last message, frozen once they left. total never counts recently-left entries. See GraphQL → Chatter List for the field semantics.

supported_platforms contains Twitch and, when the account has a usable Kick channel connection, Kick. A platform status has available: false with reason: "source_unavailable" when its list source fails. Kick’s undocumented web source can return that reason; recent writers remain listed without a reconnect. In this state total is null and truncated is false. Kick’s ordinary chatter group is otherwise capped at 100, so truncated: true can accompany a larger platform total.

Same fields as the GraphQL type (snake_case), same errors: 403 Missing permission: chat:read, 403 Feature 'feature:chat-viewer-list' is not available.

GET /v1/chat/users/search​

Prefix-search the account's known chatters (the platform_users table) by username or display name, for the multichat filter name-completion. Requires chat:userinfo and the feature:multichat feature - without chat:userinfo the search returns nothing, so it never leaks user data. The account is derived server-side from the token (cookie or popout lm_pop_*), never from a client parameter.

Query parameterTypeDescription
qstringCase-insensitive prefix over username and display_name. Shorter than 2 characters (after trimming) returns an empty list, not an error. %, _, \ match literally.
platformcomma-separated stringOptional platform filter, for example ?platform=twitch,youtube. Omit for all platforms. Maps to GraphQL platforms: [String!].
limitintegerOptional, default 10, hard-capped at 25.

Each result is the slim shape { platform, platform_user_id, username, display_name, avatar_url, last_seen_at, message_count }, ordered last_seen_at DESC, message_count DESC (most-recently-active first).

Protocol parity: mirror at GraphQL searchPlatformUsers(query, platforms, limit) - same fields, same validation, same empty-below-2-characters behaviour. See GraphQL and Chat.

Chat Ingest (internal)​

POST /v1/chat/message is the bot fleet's ingest path for chat messages read from the platforms (buffer + broadcast). It is internal: it requires a System key carrying bots:internal (non-System → 403 System access required; System key without the grant → 403 Missing permission: bots:internal), an explicit account_id in the body (400 account_id is required when absent), and feature:multichat for that account. Tenant credentials - including a chat:write grant - cannot call it. GraphQL twin: sendChatMessage(input: { accountId, … }), same guard, validation and messages. Sending a message to a platform is POST /v1/chat/send.

Chat Send​

POST /v1/chat/send (chat:write + feature:multichat) sends with the acting user's personal login grant for the platform, refreshing it through the central chokepoint if expired. The response mirrors the GraphQL sendChatToPlatform result 1:1 and always returns HTTP 200 for a send outcome (only a hard validation error - unsupported platform, no channel connection - is a non-200):

{ "sent": true, "message_id": "abc", "platform": "youtube" }
{ "sent": false, "error_code": "login_reconnect_required", "platform": "youtube" }
{ "sent": false, "error": "…platform message…", "error_code": "send_failed", "platform": "twitch" }

error_code is machine-readable so the client renders a translated message 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). GraphQL parity: GqlSendResult.errorCode + platform.

Kick emotes on send. For platform: "kick" every whitespace-separated word that exactly matches a known Kick emote (channel, Kick Global and Emojis sets from the emote catalog) is sent as Kick's [emote:ID:NAME] markup, which is the only form Kick renders as an emote. Same conversion on GraphQL sendChatToPlatform (one shared send function).

Emotes​

GET /v1/emotes/info​

Returns hover-card details for up to 50 emotes. This read requires authentication but no RBAC permission or plan feature. Dashboard sessions, API / service / developer keys, popout tokens and System keys may call it; overlay, shared overlay, widget and extension tokens are refused with 403 Emote details are not available to overlay, widget or extension tokens. platform is required and accepts twitch, youtube, or kick. items is a URL-encoded JSON array of objects with required emote_id and optional owner_id and url; omitting items returns an empty array.

GET /v1/emotes/info?platform=youtube&items=%5B%7B%22emote_id%22%3A%22UC...%2Femote%22%2C%22url%22%3A%22https%3A%2F%2Fyt3.ggpht.com%2F...%22%7D%5D

The response envelope's data is an array in request order. Each element has emote_id, nullable animated, and nullable owner (owner_id, nullable login, display_name, nullable avatar_url). An unknown owner stays null; without an account context all owners are null. Twitch animation is always null. Kick animation is checked from a server-built files.kick.com URL for a numeric ID, ignoring any client URL. YouTube animation requires an HTTPS yt3.ggpht.com URL in the item. The server reads at most 512 KiB with a 2-second deadline and at most three same-origin redirects; an undecidable result is null. One request resolves at most 10 distinct owners and probes at most 10 images; items beyond those caps come back with owner: null / animated: null. Probe results are cached by the fetched image URL (hashed), not by the client's emote id - decided results for 30 days, undecidable results for one hour.

Unsupported platforms, more than 50 items, an empty or over-200-character emote_id, an owner_id over 200 characters, or a url over 2048 characters return a validation error. Malformed items JSON returns Validation error: items must be a JSON array of \{emote_id, owner_id?, url?\}. The GraphQL twin is emoteInfo and uses the same resolver, guard, and validation.

GET /v1/emotes/\{platform\}/\{channel_id\} (authenticated) and GraphQL emotes(platform, channelId) (feature:multichat + chat:read) return the same sets through one shared resolver. For platform = kick the response includes Kick's native sets — one EmoteSet per section with provider: "kick" and section = channel | global | emojis — and each Kick emote carries subscribers_only (omitted when false). GraphQL exposes them as GqlEmoteSet.section and GqlEmote.subscribersOnly. Kick channel emotes carry owner_id = "kick:<user id>", and the response's owners map (keyed by that id) carries the channel owner's display_name and avatar_url; GraphQL returns the same entries as owners: [{ id, displayName, avatarUrl }]. owners is null when no set has an owner. See Emotes.

Each emote also has nullable owner_name (a 7TV, FFZ, or BTTV creator name) and nullable scope (channel or global for a third-party set; null when unknown or for native sets). Their GraphQL names are ownerName and scope. Chat-message badge JSON on REST history rows can carry image_url_hd for Twitch's Helix 4x badge image when available; older rows and badges without that image retain image_url alone. The GraphQL chat row and WebSocket payload carry the same badge JSON.

Chat Moderation​

Twitch AutoMod held messages​

RoutePermissionResponse
GET /v1/chat/automod/heldchat:automod-readOpen holds, oldest first, as messages; queue_available: false means the queue could not be read.
POST /v1/chat/automod/held/\{message_id\}/allowchat:automod-allowDecision result; Twitch publishes an allowed message with a new ID.
POST /v1/chat/automod/held/\{message_id\}/denychat:automod-denyDecision result; Twitch drops a denied message.

All three require feature:multichat and mirror heldChatMessages, allowHeldChatMessage and denyHeldChatMessage on GraphQL. A hold carries platform, message_id, sender (with optional avatar_url, color and badges: the sender's look at hold time from Lumio's chatter record, absent for a chatter Lumio has not seen), text, optional fragments, optional emotes (Twitch emotes and cheermotes in the chat-message emotes shape; absent or null when there are none or the fragments were dropped for size), reason_kind, reason, flagged_spans, held_at and optional expires_at. Spans use Unicode code-point offsets and an inclusive end. A decision uses the acting member's Twitch login and returns success, message_id, decision, error_code and details; a provider or queue refusal is a 200 result with success: false. When the acting user's stored Twitch login scopes are known and lack moderator:manage:automod, the decision returns error_code: login_scope_missing ("Your Twitch login does not grant moderator:manage:automod. Log in with Twitch again to allow AutoMod actions.") without a Helix call; an empty stored scope set counts as unknown and Helix decides, and a Helix 401 maps to the same code. Same on GraphQL. An unavailable queue is distinct from an empty queue. The history log uses a separate route and requires history:read as well.

POST /v1/chat/moderate is the REST counterpart of the GraphQL moderateChat mutation and behaves identically - same fields, same per-platform support, same chat:clear_user broadcast on ban/timeout. See Chat for the moderation-permission matrix and GraphQL for the field semantics.

Body (snake_case):

{
"action": "ban" | "timeout" | "delete",
"platform": "twitch" | "youtube" | "kick",
"user_id": "<platform user id>", // required for ban / timeout
"message_id": "<platform message id>", // required for delete
"duration_secs": 300, // required for timeout; YouTube 1..86400, Kick rounded up to minutes, max 604800 (7 days)
"reason": "spam", // optional, written to moderation_log
"live_chat_id": "<id>" // YouTube only, optional — auto-resolved from Redis when omitted
}

Provider ids (message_id, user_id, live_chat_id) must be 1–512 printable ASCII characters without whitespace; anything else is a validation error (message_id contains invalid characters, user_id contains invalid characters, live_chat_id contains invalid characters), identical on GraphQL. The server percent-encodes them (or sends them as query parameters) when building provider URLs.

Permission required matches the action: chat:ban, chat:timeout, or chat:delete. All three platforms support all three actions. Kick uses the official public API (POST /public/v1/moderation/bans for ban/timeout, DELETE /public/v1/chat/{message_id} for delete).

Request errors (validation, unsupported platform, non-numeric Kick user_id, Kick timeout above 7 days, missing login/channel connection) return the HTTP status with the standard error body. A refused action returns 200 with data.success = false, a readable data.details, and a stable data.error_code: login_scope_missing, login_reconnect_required, moderation_forbidden, moderation_failed, or internal_error. The codes are identical to GraphQL errorCode; see the table there for their meaning.

Protocol parity: mirror at GraphQL moderateChat(input: ModerationInput!) - see GraphQL.

Chat Profile Refresh​

POST /v1/chat/users/\{platform\}/\{platform_user_id\}/refresh​

Force a fresh enrichment of a platform user's profile, bypassing the normal staleness interval. Returns the refreshed profile in the same shape as GET /v1/chat/users/{platform}/{platform_user_id}.

Permission: chat:refresh_user

Path parameters:

ParameterDescription
platformPlatform slug: twitch, youtube, or kick
platform_user_idThe platform-native user identifier

Responses:

StatusBodyDescription
200Refreshed UnifiedProfile (snake_case)Enrichment succeeded; profile is updated in DB and Redis
429{ "error": "REFRESH_COOLDOWN", "retry_after_seconds": N }Cooldown active; retry after N seconds (max 600)

The cooldown is 10 minutes per (account, platform, user) triple, enforced via a Redis SETNX key.

Both GET /v1/chat/users/\{platform\}/\{platform_user_id\} and this refresh return the enrichment fields description, broadcaster_type, is_follower, followed_at, account_created_at, follower_count and enriched_at, all nullable. follower_count (integer) is the user's own follower count - filled on Twitch, best-effort on Kick (undocumented Kick endpoint, may be null), null on YouTube; it matches GraphQL followerCount.

Protocol parity: mirror at GraphQL refreshPlatformUserProfile(platform, platformUserId) - see GraphQL.

Notifications​

User-scoped in-app notifications. See Notifications for the full endpoint reference.

POST /v1/notifications/\{id\}/action takes { "action": "accept_team_invite" } (or another dispatched action) and returns the acted-on notification. The first-party caller must own it; no account RBAC permission is required. It executes accept_invite and decline_invite for account invites, or accept_team_invite and decline_team_invite for developer-team invites. Account acceptance joins the inviting account with the invite's role. Team acceptance joins with the team invite's role, checks feature:extension_development, expiry, use count and membership, and notifies the team owner. Declining an account invite deletes it when present. Declining a team invite deletes it only when it is unused and single-use; multi-use or already-used invites remain, and a missing invite is a successful no-op. The notification is marked acted on success.

Actions carrying a url open a page in the bell. Posting one of them, including view_idea, view_developer_application, view_developer, view_extension, or install_extension, returns 400 NOTIFICATION_ACTION_NAVIGATES. The same validation and invite handling backs executeNotificationAction.

HTTP statuscodeMeaning
400NOTIFICATION_NO_ACTIONSThe notification has no actions.
400NOTIFICATION_ACTION_NOT_AVAILABLEThe requested action is absent from this notification.
400NOTIFICATION_ACTION_NAVIGATESThe action opens a page; there is nothing to execute.
400NOTIFICATION_ALREADY_HANDLEDThe notification has already been acted on.
400NOTIFICATION_UNKNOWN_ACTIONThe action is present but has no dispatcher.
400INVALID_INVITE_IDA required invite ID is missing or invalid.
400INVITE_EXPIRED / INVITE_FULLThe invite has expired or reached its use limit.
400 / 409ALREADY_MEMBERThe caller is already in the account (400) or developer team (409).
403FEATURE_DISABLEDTeam invite acceptance requires feature:extension_development.
404NOTIFICATION_NOT_FOUNDThe notification does not exist or belongs to another user.
404INVITE_NOT_FOUNDThe invite to accept no longer exists.
409INVITE_NO_LONGER_VALIDThe invite became invalid during acceptance.

The REST error body carries a plain error message and the stable code. The GraphQL twin returns the same message with extensions.code.

Notification preferences​

MethodPathPermissionDescription
GET/v1/notifications/preferencesAuthList all delivery-channel preferences for the current user
PATCH/v1/notifications/preferences/{type}AuthSet the delivery channel for a notification type

PATCH body: { "channel": "off" | "in_app" | "email" | "in_app_email" }. Returns 400 for unknown channel values or locked types (e.g. invite).

Protocol parity: notificationPreferences query and updateNotificationPreference mutation in GraphQL - see GraphQL.

Ideas Hub​

Community idea board with voting, comments, moderation, categories, and tags. All endpoints require the system:ideas_hub feature flag to be enabled. GET endpoints are public (no auth required). Mutation endpoints require authentication and the permissions noted below.

Field visibility. Some fields depend on who is calling. The same rules apply to the GraphQL twins:

  • login on an author or timeline actor is returned only to Ideas staff (ideas:moderate_read on the user or admin role) and to the user themself. Everyone else gets null, including anonymous callers and other signed-in users. The public pages and the dashboard show only display_name and avatar_url.
  • In the timeline of GET /v1/ideas/:id, the old_value / new_value of title_changed and description_changed entries hold the previous and new text. Only the idea's author and Ideas staff see them. Everyone else gets null for both, and action is still returned. status_changed (status key) and category_changed (category label) values are returned to everyone, because the public timeline shows them.

Ideas​

MethodPathDescriptionPermission
GET/v1/ideasList ideas (filter, sort, paginate)Public
GET/v1/ideas/:idGet idea with timelinePublic
POST/v1/ideasCreate ideaideas:create
PATCH/v1/ideas/:idUpdate ideaideas:edit / ideas:moderate_edit
DELETE/v1/ideas/:idDelete ideaideas:delete / ideas:moderate_delete

Deleting an idea that other people commented on, or a comment that has replies, keeps it as a tombstone so the other people's posts stay. A tombstone reads as "deleted": true with "author": null and an empty title/description (idea) or body (comment); every live idea and comment carries "deleted": false. Writes to a tombstone return 409 with Idea was deleted or Comment was deleted. See Ideas Hub → Deletion and tombstones.

Query parameters for GET /v1/ideas:

ParameterTypeDescription
statusstringFilter by status slug
category_idUUIDFilter by category
tag_idsstringComma-separated list of tag UUIDs
author_idUUIDFilter by author
searchstringFull-text search on title and description
sortstringnewest, most_voted, most_commented, recently_updated
limitintegerPage size
offsetintegerPage offset

Voting​

MethodPathDescriptionPermission
POST/v1/ideas/:id/voteVote on an ideaideas:vote
DELETE/v1/ideas/:id/voteRemove voteideas:vote

POST body: { "vote_type": "up" | "down" }.

Comments​

MethodPathDescriptionPermission
GET/v1/ideas/:id/commentsList comments (nested)Public
POST/v1/ideas/:id/commentsCreate commentideas:comment_create
PATCH/v1/ideas/comments/:idUpdate commentideas:comment_edit
DELETE/v1/ideas/comments/:idDelete commentideas:comment_delete / ideas:moderate_comment
POST/v1/ideas/comments/:id/voteVote on a commentideas:comment_vote
DELETE/v1/ideas/comments/:id/voteRemove comment voteideas:comment_vote

Comment voting mirrors idea voting exactly: one vote per user per comment, and voting the active direction again removes the vote (toggle-off). POST body: { "vote_type": "up" | "down" }. Both endpoints return the updated comment with recounted vote_count_up / vote_count_down and the caller's my_vote. Comment listings include those three fields per comment and reply.

Comment bodies contain sanitized HTML from a rich text editor. @mentions are stored as <span data-mention-id="UUID" class="mention">@Name</span>.

Participants​

MethodPathDescriptionPermission
GET/v1/ideas/:id/participantsList participants for @mention autocompleteSigned-in user only (session or lm_usr_*; embed, popout, service, developer and system credentials get 403)

Returns the union of the idea author, voters, and commenters. Supports an optional search query parameter.

Voters​

MethodPathDescriptionPermission
GET/v1/ideas/:id/votersList voters for an ideaPublic

Categories​

MethodPathDescriptionPermission
GET/v1/ideas/categoriesList all categoriesPublic
POST/v1/ideas/categoriesCreate categoryAdmin ideas:edit
PATCH/v1/ideas/categories/:idUpdate categoryAdmin ideas:edit
DELETE/v1/ideas/categories/:idDelete categoryAdmin ideas:delete

Tags​

MethodPathDescriptionPermission
GET/v1/ideas/tagsList / search tagsPublic
POST/v1/ideas/tagsCreate tagideas:create
DELETE/v1/ideas/tags/:idDelete tagAdmin ideas:delete

GET /v1/ideas/tags supports an optional search query parameter.

Status (Moderation)​

MethodPathDescriptionPermission
PATCH/v1/ideas/:id/statusChange idea statusideas:moderate_status

PATCH body: { "status": "<status_slug>" } (e.g. open, in_progress, done, declined).

Protocol parity: All endpoints above have matching GraphQL queries/mutations - see GraphQL.

Developer Applications​

Endpoints for the developer application flow. Users apply to become developers; admins review and approve or reject applications.

GET /v1/developer/application​

Returns the authenticated user's latest developer application.

Auth: JWT (user-level, no account context required).

Response:

FieldTypeDescription
idUUIDApplication ID
user_idUUIDApplicant's user ID
application_typestring"solo" or "team"
display_namestringDeveloper/team display name
slugstringURL slug (3-50 chars, lowercase, hyphens)
descriptionstringDescription of the developer/team
motivationstringWhy the user wants developer access
what_to_buildstringWhat extensions the user plans to build
github_urlstring or nullGitHub profile URL
website_urlstring or nullWebsite URL
experiencestring or nullDevelopment experience
avatar_keystring or nullUpload key for avatar image
statusstring"pending", "approved", or "rejected"
review_notesstring or nullNotes from the reviewer
reviewed_byUUID or nullReviewer's user ID
reviewed_atISO-8601 or nullReview timestamp
created_atISO-8601Submission timestamp
updated_atISO-8601Last update timestamp

Returns 404 "No developer application found" if the user has not submitted an application.

POST /v1/developer/application​

Submit a developer application.

Auth: JWT (user-level).

Request body:

FieldTypeRequiredDescription
application_typestringYes"solo" or "team"
display_namestringYesDeveloper/team display name
slugstringYesURL slug (3-50 chars, starts with lowercase letter, only lowercase letters, digits, hyphens)
descriptionstringYesDescription
motivationstringYesWhy you want developer access
what_to_buildstringYesWhat you plan to build
github_urlstringNoGitHub profile URL
website_urlstringNoWebsite URL
experiencestringNoDevelopment experience

Validation errors (400):

  • "application_type must be 'solo' or 'team'" - invalid type
  • "Slug must be between 3 and 50 characters" - slug length
  • "Slug must start with a lowercase letter" - slug format
  • "Slug must contain only lowercase letters, digits, and hyphens" - slug characters

Conflict errors (409):

  • "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

Response: 201 Created with the application object.

Protocol parity: myDeveloperApplication query and submitDeveloperApplication mutation in GraphQL - see GraphQL.

Developer Verification (self-service)​

KYC verification an approved developer submits about themselves (individual or company) to unlock paid-extension publishing. This is the developer-facing counterpart to the admin review endpoints under /v1/admin/developer-verifications.

Auth: first-party user session or the user's own API key. A popout/overlay/widget/extension token is rejected with 403 FORBIDDEN (ZAF-469). The caller must be an approved developer (have a developer_profiles row - created when a developer application is approved).

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

GET /v1/developer/verification​

Returns the authenticated developer's own verification record, or null if none has been submitted (or the caller is not yet an approved developer). Returns 200 with a nullable data - not a 404 - mirroring the nullable GraphQL query.

Response data (or null):

FieldTypeDescription
idUUIDVerification ID
developer_idUUIDThe developer profile this record belongs to
legal_namestringLegal name
company_namestring or nullCompany name (company type only)
address_line1stringAddress line 1
address_line2string or nullAddress line 2
postal_codestringPostal code
citystringCity
countrystringISO-3166-1 alpha-2 country code
tax_idstring or nullTax ID (legacy free text)
tax_numberstring or nullSteuernummer
vat_idstring or nullUSt-IdNr. / foreign VAT ID (stored upper-case, without spaces)
small_business_declaredboolean or nullSmall business under § 19 UStG; null = not answered
tax_details_updated_atISO-8601 or nullWhen the tax details were last entered
trade_register_idstring or nullTrade register ID (company type only)
document_keystring or nullUpload key for a supporting document
statusstring"pending", "verified", or "rejected"
review_notesstring or nullNotes from the reviewer (e.g. rejection reason)
reviewed_byUUID or nullReviewer's user ID
reviewed_atISO-8601 or nullReview timestamp
created_atISO-8601First submission timestamp
updated_atISO-8601Last update timestamp

POST /v1/developer/verification​

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, so a rejected developer can correct and resubmit. An already-verified record is terminal via self-service and cannot be resubmitted.

Request body:

FieldTypeRequiredDescription
legal_namestringYesLegal name
company_namestringNoCompany name - its presence infers the company developer type
address_line1stringYesAddress line 1
address_line2stringNoAddress line 2
postal_codestringYesPostal code
citystringYesCity
countrystringYesISO-3166-1 alpha-2 country code (exactly 2 letters)
tax_idstringNoTax ID
trade_register_idstringNoTrade register ID
document_keystringNoUpload key for a supporting document
tax_numberstringNoSteuernummer (max. 50 characters)
vat_idstringNoUSt-IdNr. / foreign VAT ID (max. 50 characters)
small_business_declaredbooleanNoSmall business under § 19 UStG

A submit without any of the three tax fields keeps the stored tax details.

Validation errors (400): "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", "Tax number must be at most 50 characters", "VAT ID must be at most 50 characters".

Other errors: 403 "You must be an approved developer to submit verification" (caller has no developer profile); 409 "Your developer verification is already approved" (record is already verified).

Response: 201 Created with the verification object (same shape as the GET response).

Audit: emits developer:verification_submitted (user scope; no KYC/PII in metadata).

PUT /v1/developer/verification/tax-details​

Set the tax details a payout's credit note is derived from, in any verification status (also once verified). They are self-declared: an update does not reset the review and never changes a credit note that was already issued.

Request body: tax_number, vat_id (strings, max. 50 characters, blank = cleared), small_business_declared (boolean or omitted = not answered). The body replaces all three.

Which details a payout needs:

DeveloperNeededCredit note
Germany, small businesstax_number or vat_id, small_business_declared: trueNo VAT, § 19 UStG note
Germany, charges VATtax_number or vat_id, small_business_declared: false19 % VAT on top of the revenue share
Other EU countryvat_idNo VAT, reverse-charge note (§ 13b UStG)
Outside the EU—No VAT, reverse-charge note

Errors: 400 validation (as above); 403 "You must be an approved developer to submit verification"; 404 "Submit your developer verification before entering tax details".

Response: 200 OK with the verification object.

Audit: emits developer:tax_details_updated (user scope; metadata says which details are present, never their values).

Protocol parity: developerVerificationStatus query and the submitDeveloperVerification / updateDeveloperTaxDetails mutations in GraphQL - see GraphQL.

Developer Credit Notes​

Every developer payout is documented by a self-billed credit note (Gutschrift, § 14 Abs. 2 S. 2 UStG), issued in the payout's own transaction, numbered GS-<year>-<6 digits> without gaps per calendar year (Europe/Berlin). A failed payout is cancelled by a cancellation note (Stornierung) with its own number and negated amounts. The PDF is generated once after the payout and mailed to the developer with the PDF attached; until then pdf_status is pending and download_url is null. See Developer credit notes.

GET /v1/developer/credit-notes​

The caller's own credit notes and cancellations, newest first. Query: limit (default 20, max 100), offset.

Auth: extension-dev:payouts + feature:extension_development, first-party user (a popout token or developer key is refused, as for the payout history).

Response data: an array of:

FieldTypeDescription
idUUIDCredit note ID
kindstringcredit_note or cancellation
numberstringGS-2026-000001
issued_atISO-8601Date of issue
payout_idUUID or nullThe payout it documents (credit notes only)
cancels_idUUID or nullThe cancelled credit note (cancellations only)
cancelled_atISO-8601 or nullWhen this credit note was cancelled
cancellation_reasonstring or nullpayout_failed, developer_objection or correction
period_start, period_endISO-8601Service period
currencystringEUR
net_amount, vat_amount, gross_amountintegerCents; negative on a cancellation
vat_rate_bpintegerVAT rate in basis points (1900 = 19 %)
vat_treatmentstringdomestic_standard, domestic_small_business, reverse_charge_eu or reverse_charge_third_country
pdf_statusstringpending (being generated) or stored
download_urlstring or nullGET URL of the PDF (null while pending)
delivered_atISO-8601 or nullWhen the mail with the PDF was sent

GET /v1/developer/credit-notes/\{id\}​

One of the caller's own notes. Another developer's note answers 404 "Credit note not found". Same auth.

GET /v1/developer/credit-notes/\{id\}/pdf​

The PDF as issued (Content-Type: application/pdf, Content-Disposition: attachment, Cache-Control: private, no-store). The bytes are checked against the stored SHA-256 before they are served. 404 for another developer's note; 409 CREDIT_NOTE_PDF_PENDING while the PDF is being generated. Same auth.

GET /v1/admin/credit-notes​

Operators. Query: developer_id (optional), limit, offset. Includes notes whose developer profile was deleted (developer_id: null). Permission: billing:read (admin-scope). Each item is the developer view plus developer_id, supplier_legal_name, supplier_country, objection_received_at, recorded_by; download_url points at the admin PDF route.

GET /v1/admin/credit-notes/\{id\} and GET /v1/admin/credit-notes/\{id\}/pdf​

One note / its PDF. Permission: billing:read (admin-scope).

POST /v1/admin/credit-notes/\{id\}/cancel​

Record a developer's objection to a credit note (§ 14 Abs. 2 S. 3 UStG) or correct it. Issues the cancellation note and, with reissue: true, a corrected credit note for the same payout and service period from the developer's current verification, in one transaction. Permission: billing:edit (admin-scope).

FieldTypeRequiredDescription
reasonstringYesdeveloper_objection or correction
objection_received_atISO-8601For an objectionWhen the objection arrived; not allowed for a correction
reissuebooleanNoIssue a corrected credit note (default false)

Errors: 400 "Reason must be developer_objection or correction", "An objection needs the date it was received", "Only an objection carries a received date"; 404 "Credit note not found"; 409 "Only an effective credit note can be cancelled", "This credit note has no payout to re-issue it for", 409 CREDIT_NOTE_AMOUNT_MISMATCH (the corrected note would total a different amount than was paid, e.g. the VAT status changed; settle the difference first); 422 TAX_DETAILS_REQUIRED for the re-issue.

Response: { "cancellation": …, "reissued": … | null } (admin view).

Audit: emits system:developer_credit_note_cancelled (system scope; ids, numbers and reason only).

GET /v1/admin/developer-payouts​

Developer payouts for operators, newest first, including payouts of deleted profiles. Permission: billing:read (admin-scope). Query: status (pending, processing, completed, failed), limit (default 20, max 100), offset.

FieldTypeDescription
idUUIDPayout id
developer_idUUID | nullnull once the profile was deleted
amount / currencyinteger / stringCents, EUR (the credit note's gross amount)
methodstringstripe, paypal or bank
statusstringpending, processing, completed or failed
stripe_transfer_id / paypal_batch_idstring | nullProvider ids
payout_referencestring | nullMasked destination
created_at / completed_at / execution_started_atISO-8601execution_started_at: when the payout cycle sent it
needs_operatorbooleanpending SEPA payout, or a provider payout whose answer the cycle did not receive

Errors: 400 "Status must be one of pending, processing, completed, failed".

POST /v1/admin/developer-payouts/\{id\}/complete and POST /v1/admin/developer-payouts/\{id\}/fail​

Mark a pending or processing payout paid, or failed. Failed credits the debited amount back to the balance and cancels the credit note. Permission: billing:edit (admin-scope). No body.

Response: { "payout": …, "refunded": <cents> | null, "cancellation_number": "GS-…" | null } (refunded and cancellation_number are only set by /fail).

Errors: 404 "Payout not found"; 409 PAYOUT_NOT_IN_FLIGHT ("Only a pending or processing payout can be settled"); /fail only: 409 PAYOUT_BEING_SENT ("The payout cycle is sending this payout right now; try again in 15 minutes").

Audit: emits system:developer_payout_completed or system:developer_payout_failed (system scope). See Developer Payouts.

Protocol parity: developerCreditNotes, developerCreditNote, adminCreditNotes, adminCreditNote, cancelCreditNote, adminDeveloperPayouts, completeDeveloperPayout and failDeveloperPayout in GraphQL - see GraphQL.

Admin Developer Applications​

Admin endpoints for reviewing developer applications. All require developer-verifications:read or developer-verifications:edit admin-scope permissions.

GET /v1/admin/developer-applications​

List developer applications with optional status filter.

Permission: developer-verifications:read (admin-scope).

Query parameters:

ParameterTypeDescription
statusstringFilter by status: "pending", "approved", "rejected"

Response: { "items": [...], "total": N } where each item contains id, user_id, application_type, display_name, slug, status, created_at, user_display_name, user_avatar_url.

GET /v1/admin/developer-applications/\{id\}​

Get full details of a specific application.

Permission: developer-verifications:read (admin-scope).

Returns 404 "Developer application not found" if not found.

POST /v1/admin/developer-applications/\{id\}/approve​

Approve a developer application. Creates the developer profile, seeds default team roles (for team applications), and sends approval notifications (in-app + email).

Permission: developer-verifications:edit (admin-scope).

Request body:

FieldTypeRequiredDescription
notesstringNoOptional reviewer notes

Response: { "success": true, "new_status": "approved" }

POST /v1/admin/developer-applications/\{id\}/reject​

Reject a developer application. Sends rejection notifications.

Permission: developer-verifications:edit (admin-scope).

Request body:

FieldTypeRequiredDescription
notesstringYesRejection reason (required)

Response: { "success": true, "new_status": "rejected" }

Protocol parity: All four endpoints have matching GraphQL queries/mutations - see GraphQL.

Admin Limit Requests​

Admin endpoints for reviewing developer limit increase requests. All require developer-limits:read or developer-limits:edit admin-scope permissions.

GET /v1/admin/limit-requests​

List all limit requests across all developers with optional status filter.

Permission: developer-limits:read (admin-scope).

Query parameters:

ParameterTypeDescription
statusstringFilter by status: "open", "in_review", "approved", "rejected"

Response: { "data": [...] } - list of limit request objects.

GET /v1/admin/developers/\{id\}/limit-requests​

List limit requests for a specific developer.

Permission: developer-limits:read (admin-scope).

Response: { "data": [...] } - list of limit request objects for the given developer.

PUT /v1/admin/limit-requests/\{id\}​

Review (approve or reject) a limit request. Approving automatically applies the requested limits. Rejecting requires review notes.

Permission: developer-limits:edit (admin-scope).

Request body:

FieldTypeRequiredDescription
actionstringYes"approve" or "reject"
notesstringFor rejectReview notes (required when rejecting)

Response: Updated limit request object.

GET /v1/admin/limit-requests/\{id\}/events​

Get timeline events for a limit request.

Permission: developer-limits:read (admin-scope).

Response: { "data": [...] } - list of event objects with event_type, actor_id, details, and created_at.

PATCH /v1/admin/extensions/\{id\}/limits​

Set or clear per-extension limit overrides. Replaces the previous /max-sounds endpoint with support for all three sound limits.

Permission: developer-limits:edit (admin-scope).

Request body:

FieldTypeDescription
max_soundsinteger or nullMax bundled sounds (null to clear)
max_sound_file_sizeinteger or nullMax per-file size in bytes (null to clear)
max_sound_storage_bytesinteger or nullMax total sound storage in bytes (null to clear)

Response: { "data": { "success": true } }

Protocol parity: All endpoints have matching GraphQL queries/mutations - see GraphQL.

Developer Teams​

Team management endpoints with team-scoped RBAC. All team operations (except create and list) check require_team_permission() against the caller's team role. Every endpoint except the public invite lookup requires a first-party user (session or the user's own API key). Creating a team and accepting an invite additionally require the feature:extension_development plan feature on the caller's account; when it is off they fail with Feature 'feature:extension_development' is not available. The first-party check runs first.

Team CRUD​

MethodPathPermissionDescription
POST/v1/developer/teamsAuth (developer) + feature:extension_developmentCreate a new team
GET/v1/developer/teamsAuth (developer)List teams the user belongs to
GET/v1/developer/teams/{id}Team memberGet team details
PATCH/v1/developer/teams/{id}team-settings:editUpdate team name/description/URLs
DELETE/v1/developer/teams/{id}Team owner onlyDelete a team and its extensions (uninstalled from every channel; see Accounts)

Team Members​

MethodPathPermissionDescription
GET/v1/developer/teams/{id}/membersteam-members:readList team members
PATCH/v1/developer/teams/{id}/members/{member_id}/roleteam-members:editChange a member's role
DELETE/v1/developer/teams/{id}/members/{member_id}team-members:removeRemove a member

Team Invites​

MethodPathPermissionDescription
POST/v1/developer/teams/{id}/invitesteam-members:inviteCreate an invite link
GET/v1/developer/teams/{id}/invitesteam-members:readList active invites
DELETE/v1/developer/teams/{id}/invites/{invite_id}team-members:inviteRevoke an invite
GET/v1/developer/team-invites/{code}PublicLook up an invite by code
POST/v1/developer/team-invites/{code}/acceptAuth + feature:extension_developmentAccept an invite

An addressed invite email links to https://lumio.vision/invite/dev/\{code\} (or the configured web app origin in another environment). This page accepts by code. The bell accepts the same invite by its stored invite ID; the notification never contains the secret code. Both paths apply the same feature, expiry, use-limit and membership checks. Acceptance errors use INVITE_NOT_FOUND (404), INVITE_EXPIRED / INVITE_FULL (400), and ALREADY_MEMBER / INVITE_NO_LONGER_VALID (409). The bell also offers Decline: it removes an unused single-use invite, retains shared or used invites, and treats a missing invite as handled.

Team Roles (RBAC)​

MethodPathPermissionDescription
GET/v1/developer/teams/{id}/rolesteam-members:readList roles for a team
POST/v1/developer/teams/{id}/rolesteam-settings:editCreate a custom role
PATCH/v1/developer/teams/{id}/roles/{rid}team-settings:editUpdate a role
DELETE/v1/developer/teams/{id}/roles/{rid}team-settings:editDelete a role
GET/v1/developer/teams/permissionsAuth (first-party user)List all available team permissions

Protocol parity: All endpoints have matching GraphQL queries/mutations - see GraphQL.

Developer Store Profiles​

Public endpoints for developer and team profile pages. No authentication required.

GET /v1/developer/profiles/\{slug\}​

Returns a public developer profile by slug, including aggregate stats (extension count, total installs, average rating) and a list of published extensions.

Auth: None (public).

Response:

FieldTypeDescription
idUUIDDeveloper profile ID
display_namestringDeveloper display name
slugstringURL slug
descriptionstring or nullBio/description
github_urlstring or nullGitHub profile URL
website_urlstring or nullWebsite URL
avatar_keystring or nullAvatar upload key
created_atISO-8601Profile creation date
statsobject{ extension_count, total_installs, avg_rating }
extensionsarrayList of published extensions with id, short_id, slug, name, description, category, icon_key, pricing_type, pricing_amount, pricing_currency, install_count, rating_avg, rating_count, published_at

Returns 404 if no developer has the given slug.

GET /v1/developer/teams/\{slug\}/profile​

Returns a public team profile by slug, including aggregate stats, team members (with roles), and published extensions.

Auth: None (public).

Response:

FieldTypeDescription
idUUIDTeam ID
namestringTeam name
slugstringURL slug
descriptionstring or nullTeam description
github_urlstring or nullGitHub URL
website_urlstring or nullWebsite URL
avatar_keystring or nullAvatar upload key
created_atISO-8601Team creation date
statsobject{ extension_count, total_installs, avg_rating }
membersarray[{ user_id, display_name, avatar_url, role }]
extensionsarrayPublished extensions (same shape as developer profile)

Returns 404 if no team has the given slug.

Protocol parity: Both endpoints have matching GraphQL queries - see GraphQL.

Extension Store Icons​

GET /v1/store/extensions/\{slug\}/icon​

Returns icon metadata for a store extension: extension_id, icon_key and icon_url (the absolute URL of the byte route below, or null when the extension has no icon). Returns JSON, not the image.

Auth: None (public). 404 Extension not found unless the extension is visible on the store detail page (status published or deprecated, visibility public, paid, unlisted or system).

GET /v1/store/extensions/\{id\}/icon-data​

Serves the icon image of a store extension by its UUID.

Auth: None (public). Same visibility as the store detail page.

Response: the stored bytes with Content-Type image/png, image/jpeg or image/webp (from the stored key), X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none'; sandbox and Cache-Control: public, max-age=3600.

StatusWhen
404 Extension icon not foundUnknown extension, not store-visible, no icon, a stored key that is not PNG/JPEG/WebP, or the object is missing from storage
503 ICON_UNAVAILABLEFile storage is not configured

REST only: raw image bytes have no GraphQL representation. Every icon_url / iconUrl the API returns for an extension points at this route — the store icon metadata and the developer icon upload as an absolute URL, the marketplace lists and detail (REST /v1/marketplace/* and the GraphQL marketplace* queries) as the API-relative path /v1/store/extensions/\{id\}/icon-data.

Extension Bot Module Endpoints​

GET /v1/internal/bot-modules/\{account_id\}/triggers​

Returns the resolved bot-module trigger set for the given account. This is an internal endpoint the platform bots call on connect (and on trigger_sync) to build their in-memory matcher. Per install, the effective triggers are resolved by layering per-install overrides from extension_installs.config.triggers over the version manifest's triggers (COALESCE: config wins, manifest is the default).

Auth: System key carrying bots:internal only. A non-System caller gets 403 System key required for internal endpoints; a System key without the grant gets 403 Missing permission: bots:internal.

Path parameter: account_id - UUID of the account.

Response: the raw body (no data envelope) - the bots deserialize it directly. Each trigger's handler is the function_name the bot sends to the Worker's /v1/execute (command → command name, keyword → keyword text, pattern → regex source). killed is the account's stored kill switch (account_extension_kill_switch), so a restarted bot starts killed; live changes arrive over Redis pub/sub on top.

{
"installs": [
{
"install_id": "uuid",
"extension_id": "uuid",
"extension_name": "My Bot Module",
"commands": [
{ "name": "rank", "alias": null, "cooldown_global": 5, "cooldown_user": 10, "min_role": "everyone", "enabled": true, "handler": "rank" }
],
"keywords": [
{ "value": "gg", "enabled": true, "handler": "gg" }
],
"patterns": [
{ "regex": "^!\\w+$", "enabled": true, "handler": "^!\\w+$" }
],
"has_moderate": false,
"platforms": ["twitch", "youtube"]
}
],
"killed": false
}

GET /v1/internal/bot-modules/handlers​

Returns the resolved handler-code bundle plus trusted context for every installed bot-module extension. The Bot Module Worker consumes this to populate its in-memory InstallRegistry at startup and to refresh it incrementally on install / uninstall / update. Where the trigger feed above delivers only trigger metadata, this feed delivers the executable handler code.

Auth: System key carrying bots:internal only. A non-System caller gets 403 System key required for internal endpoints; a System key without the grant gets 403 Missing permission: bots:internal.

Query parameters (all optional):

FieldTypeDescription
account_idUUIDScope to a single account (incremental account refresh).
install_idUUIDScope to a single install (incremental install/update refresh).
bundlesby_versionBundle layout. by_version ships each distinct bundle once in a top-level bundles map and enables the ETag / 304 change check. Omitted: every install carries its own handler_code. Any other value is a 400.

Omitting account_id and install_id returns a full cross-account snapshot (initial registry load). Disabled installs are included (with enabled: false) so the worker can short-circuit them.

Response: the raw body (no data envelope) - the worker deserializes it directly. handlers lists the valid function_names the bots dispatch (command name / keyword text / regex source / "moderate" / "timer", plus subscribed event types and timer names); every name maps to the same bundle. command_min_roles is each command's effective min_role, events the subscribed event types (manifest triggers.events), timers the timers the worker schedules ([{ "name", "interval_secs" }]), and killed whether the account's extension kill switch is on. timers covers the SDK form triggers.timers[{ name, interval }] (entries under 60 s or without a name are skipped) and the legacy triggers.timer: true form, which yields one timer named "timer" whose interval is the install's intervalSeconds setting, clamped to 60..86400, default 300.

Where the bundle lives depends on bundles:

  • bundles=by_version (what the Bot Module Worker sends): every install's handler_code is null, and bundles maps each install's version_id to the compiled server.js bundle, or to null when it cannot be resolved (missing file / storage unavailable). Installs sharing a version share one entry, so each bundle is downloaded from storage and sent at most once per response.
  • omitted: handler_code is the bundle (one string per install) or null when unresolvable; there is no bundles key.

Change check (bundles=by_version only): the response carries an ETag over the install metadata (everything above except the bundles; version_id identifies the bundle). Send it back as If-None-Match and the API answers 304 Not Modified with no body while nothing has changed. It decides that before downloading any bundle. The ETag is omitted while any bundle is unresolvable, so the next request downloads again.

{
"installs": [
{
"install_id": "uuid",
"extension_id": "uuid",
"account_id": "uuid",
"extension_name": "Link Protection",
"version": "1.0.2",
"version_id": "uuid",
"install_config": { "action": "delete" },
"enabled": true,
"platforms": ["twitch", "youtube", "kick"],
"has_moderate": true,
"handlers": ["moderate"],
"handler_code": null,
"command_min_roles": {},
"events": [],
"timers": [],
"killed": false
}
],
"bundles": {
"uuid": "async (ctx, args) => { /* compiled server.js */ }"
}
}

POST /v1/internal/bot-modules/\{install_id\}/actions​

Runs the actions of one bot-module handler result. The Bot Module Worker (keyword, pattern, event, timer handlers) and the chat bots (command handlers, moderate decisions) call it after the reply, at most once - there is no retry, because a retried chat:ban is not idempotent at the platform. Not part of openapi.json, like the other internal feeds.

Auth: System key carrying bots:internal only. A non-System caller gets 403 System key required for internal endpoints; a System key without the grant gets 403 Missing permission: bots:internal.

Path parameter: install_id - UUID of the bot-module install. The account always comes from the install row, never from the request.

Request body:

{
"trigger_type": "moderate",
"handler": "moderate",
"platform": "twitch",
"actions": [
{ "action": "chat:timeout", "params": { "platform": "twitch", "user_id": "123", "duration_secs": 600 } }
]
}
FieldTypeDescription
trigger_typestringcommand, keyword, pattern, event, timer or moderate
handlerstringHandler function name (command name, event type, timer name, …), 1-200 characters
platformstring, optionalPlatform the trigger came from
actionsarray{ "action", "params" } items, at most 20; params defaults to {}

Errors:

StatusWhen
400Unknown trigger_type, empty or over-long handler, or more than 20 actions
404Bot module install not found or not runnable - the install is uninstalled, disabled, not a bot module, its extension is suspended, its channel is in the dissolution grace period, or the account's kill switch is on

Response 200: the raw body, one result per action in request order. One failing action does not stop the next.

{
"results": [
{ "action": "chat:timeout", "success": true },
{ "action": "obs:set_scene", "success": false, "error": "Permission denied: Extension does not declare the required permission: obs:set_scene", "error_code": "denied" }
]
}

Each action runs through the same dispatcher and manifest-permission gate as widget and app actions (the 21 supported actions): the manifest must declare the action name as a permission, the account's plan must include the action's feature (spotify:* → feature:music, obs:* → feature:obs_remote, chat:* → feature:multichat), and the params must be valid.

error_codeMeaning
deniedRefused before dispatch: unknown action, invalid params, permission not declared, plan feature off, or params not an object. The message is the one the widget path returns
rate_limitedThe install exceeded [extensions] bot_module_action_rate_per_minute (default 120) in the current minute
rate_limit_unavailableRedis could not count the action; refused (fail-closed)
dispatcher codes (bot_not_connected, bot_scope_missing, moderation_forbidden, platform_not_supported, …) or noneThe action failed during execution

The rate cap is counted per install in Redis lumio:botmod:act_rl:{install_id}:{epoch_minute} (TTL 120 s). Every outcome is written to the install's log (extension_install_logs, level info on success, warn otherwise), at most one row per second and install per API process; after the cap is hit only the first refused action of the minute is logged. A row's args hold only the action name and, for chat:*, platform / user_id / message_id - never message text or other params.

chat:ban, chat:unban and chat:timeout emit the audit event account:extension_moderation with source: "bot_module" and an empty origin (service-to-service call). A bot module's chat:delete is not audited; it appears in the install log only. See Audit Log.

GET /v1/internal/automation-nodes/handlers​

The automation-node counterpart of the bot-module handler feed - resolves the handler bundle + context for every installed automation_node extension for the Automation Worker's InstallRegistry.

Auth: System key carrying bots:internal only. A non-System caller gets 403 System key required for internal endpoints; a System key without the grant gets 403 Missing permission: bots:internal.

Query parameters: identical to the bot-module handler feed (account_id, install_id, both optional).

Response: raw body. node_type is the node's kind (trigger / action / logic); handler_code is the single compiled server.js bundle (automation nodes have exactly one handler), or null when unresolvable.

{
"installs": [
{
"install_id": "uuid",
"extension_id": "uuid",
"account_id": "uuid",
"extension_name": "HTTP Request",
"version": "1.0.0",
"install_config": { "url": "https://example.com" },
"enabled": true,
"node_type": "action",
"handler_code": "async (ctx, args) => { /* compiled server.js */ }"
}
]
}

POST /v1/bot-modules/kill​

Sets the extension kill switch for the caller's account: while killed is true, the bots match no extension bot-module trigger for the account, and every handler server function (query() / mutation() / action(), /execute) of the account's extensions of any category, widgets included, is refused with 403 EXTENSIONS_KILLED. Declarative server functions (/query, /mutate) keep working. The state is stored in Postgres (account_extension_kill_switch), so it survives a bot restart, and is pushed live over lumio:botmod:kill:{account_id}. Install configuration is untouched.

Auth: Account auth (JWT, API key, or popout token).

Permission: bot-modules:edit

Request body:

FieldTypeRequiredDescription
account_idUUIDYesMust be the caller's active account (403 Cannot control kill switch for another account otherwise)
killedbooleanYestrue stops extension triggers, false resumes them

Response: 200 {"ok": true}. Emits the account-scoped audit event account:extension_kill_switch_set (metadata: killed).

GraphQL twin: setBotModuleKillSwitch(accountId: UUID!, killed: Boolean!): Boolean! (same permission, check, error and audit event).

Developer Extension Endpoints​

Create and update requests accept only private, unlisted, public, or paid visibility. system is reserved for Lumio, and a developer slug beginning with system- (ignoring case and surrounding whitespace) is rejected. REST returns 400 with Invalid visibility: <value>. Allowed: private, unlisted, public, paid or The slug prefix 'system-' is reserved for Lumio system extensions. GraphQL applies the same validators.

Extension secrets​

Secrets are the extension author's credentials (for example a third-party API key). Each extension has one set, shared by every install: bot-module handlers read it with ctx.secrets.get(key). The developer's active account plays no part, so a developer key (lm_dev_) manages secrets like a logged-in developer. Values are encrypted with AES-256-GCM before they are stored and are never returned.

MethodPathPermissionDescription
GET/v1/developer/extensions/\{id\}/secretsextension-dev:read (+ team-secrets:read on a team extension)List secret key names
PUT/v1/developer/extensions/\{id\}/secrets/\{key\}extension-dev:edit (+ team-secrets:edit on a team extension)Create or replace a secret. Body {"value": "..."}
DELETE/v1/developer/extensions/\{id\}/secrets/\{key\}extension-dev:edit (+ team-secrets:edit on a team extension)Delete a secret (204, also when no secret had that key)
  • Order of checks: ownership first (404 Extension not found for an extension the caller's profile or teams do not own), then the team right (403 Missing team permission: team-secrets:edit), then validation.
  • Validation: the key must match ^[A-Z0-9_]{1,64}$ (400 Secret key must match ^[A-Z0-9_]{1,64}$); the value must be 1 to 4096 bytes (400 Secret value must be between 1 and 4096 bytes).
  • Response row: id, extension_id, account_id (always null), key, created_at, updated_at.
  • Audit: set emits developer:extension_secret_set, a delete that removed a row emits developer:extension_secret_deleted (user scope, metadata extension_id, key, and developer_key_id for a developer key; never the value).
  • Runtime: a changed or deleted secret reaches running bot-module handlers within 60 seconds.

GraphQL twins: developerExtensionSecrets(extensionId), setExtensionSecret(extensionId, key, value), deleteExtensionSecret(extensionId, key): Boolean! (same permissions, checks, messages and audit events).

Version creation, upload, and submission​

Method and pathRequestValidation
POST /v1/developer/extensions/\{id\}/versionsJSON version, manifest, optional data_schemaManifest schema, extension ID, and manifest version must match the request; data_schema is validated and checked to be additive.
POST /v1/developer/extensions/\{id\}/versions/uploadMultipart files, source, manifest, optional data_schema (JSON), and version metadataManifest schema and extension ID are validated; bundle storage keys are server-derived; data_schema as above. Each files filename is a relative /-separated path: .., absolute paths, backslashes, empty or . segments, NUL bytes and file types outside the allow-list return 400.
POST /v1/developer/extensions/\{id\}/versions/\{vid\}/submitDraft version ID in the pathStored manifest is revalidated before the version enters review.

data_schema is the defineSchema result of server/schema.ts ({"tables": {"<name>": {"columns": {"<col>": {"type": "string|number|boolean|array|object", "isOptional": bool}}, "storageScope": "instance"}}}). It is stored once, normalised, with the version; omitted or null means the version declares no data. An invalid schema (name rule ^[a-z][a-z0-9_]{0,39}$ without pg_* / ext_*, at most 32 tables, 64 columns per table, 64 KiB, storage scope instance only) returns 422 INVALID_DATA_SCHEMA. A change that is not additive against the extension's tables (type change, new required column on an existing table, optional → required) returns 422 DATA_SCHEMA_NOT_ADDITIVE with every violation in details.violations (table, column, rule, applied, declared). The tables are created at approval, not at upload: POST /v1/extensions/reviews/\{id\}/approve (GraphQL approveExtensionVersion) provisions the version's schema on the extensions database before the status changes. A version that is no longer additive is not approved: 422 DATA_SCHEMA_NOT_ADDITIVE with the violation list, which is also stored as the version's review notes. When live queries hold the tables for longer than the lock timeout (three retries), approval returns 503 EXTENSION_TABLES_BUSY and can be retried. The first install of an approved version re-creates tables an earlier last uninstall removed. See the developer docs → Server Functions → Schema.

Manifest failures return 400 Manifest validation failed: ...; a version mismatch returns 400 Version '<input>' does not match the manifest version '<manifest>'. Submission leaves the draft unchanged when validation fails. For a team-owned extension, create and upload require team-versions:create, and submit requires team-extensions:publish. A member without the role gets 403 Missing team permission: <permission>; a caller without ownership gets 404 first. The same team-role check applies to editing, deleting, analytics, and tester/access management as described in Team-Scoped Permissions.

Developer API keys (lm_dev_)

These publishing routes accept a developer API key (lm_dev_) as well as a logged-in user session or account API key. A developer key is bound to a developer profile or team, is decoupled from the feature:extension_development plan gate, and reaches exactly the ownership-scoped publishing operations here - its own extensions, versions (create / upload / list), analytics, limits, purchases, testers (list / remove), screenshots and the extension's secrets (see Extension secrets). It is refused (fail-closed) from:

  • the first-party routes - the developer profile / payout / revenue / limit-request surface, plus POST …/extensions (create), …/versions/\{vid\}/submit, …/testers (invite) and …/deletion-request, which require a logged-in user.

See Authentication → Developer API Keys.

Telemetry retention & availability

Extension telemetry lives in TimescaleDB hypertables with bounded retention policies, so the tables cannot grow without bound - aged chunks are dropped automatically:

  • Runtime errors (extension_errors, surfaced by /errors): 90 days.
  • Per-install execution logs (extension_install_logs): 30 days.
  • Install/uninstall/config-change analytics (extension_events, feeding /metrics): 180 days.

A query window wider than a table's retention returns only the rows still inside the window.

An extension_errors row keeps no viewer or account data: account and install IDs are HMAC-hashed, the error message and stack trace are scrubbed of UUIDs, @-mentions, tokens and IPs, the install config is reduced to its key names, and the handler args are stored as shape only - their keys and value types ("<string>", "<number>", "<bool>"), never the values. The /errors endpoint does not return the args.

The retention policies are active today. extension_errors and extension_install_logs now have their writers wired: the bot-module and automation workers record one row per handler execution (an extension_errors row on failure, an extension_install_logs row on every run), fail-open, once a worker has a TimescaleDB pool configured. Rows only begin to flow when the workers actually execute handlers against live installs - i.e. once the worker InstallRegistry is populated at runtime (tracked separately); until then these tables stay empty even though the writers are present. extension_events (install/uninstall/config-change analytics feeding /metrics) is still unwired. Where a table has no rows yet, its endpoint returns an empty result set - the example payloads below illustrate the response shape, not live data.

GET /v1/developer/extensions/\{id\}/errors​

Returns recent runtime errors for an extension owned by the authenticated developer. Useful for debugging server function failures and trigger handler exceptions.

Auth: Account auth (JWT or API key). The caller must be the extension owner or a member of the owning developer team.

Permission: extensions:read (developer scope)

Path parameter: id - UUID of the extension.

Query parameters:

ParameterTypeDescription
limitintegerMax results (default 50, max 200)
offsetintegerPagination offset
sinceISO-8601Only errors after this timestamp

Response:

{
"data": [
{
"id": "uuid",
"error_type": "handler_timeout",
"message": "Handler exceeded 10s CPU timeout",
"stack_trace": "at handler (server/functions.ts:42:5)",
"occurred_at": "2026-05-20T10:30:00Z",
"install_id": "uuid",
"account_id": "uuid"
}
]
}

GET /v1/developer/extensions/\{id\}/metrics​

Returns aggregated invocation metrics for an extension owned by the authenticated developer: total invocations, error count, average and 99th-percentile execution time, a per-handler breakdown, and an hourly time series. Metrics are computed from the extension_install_logs hypertable, so the counts are empty until installs start emitting execution telemetry.

This endpoint is the REST twin of the GraphQL extensionMetrics query - identical fields, permission, and ownership check on both protocols.

Auth: Account auth (JWT or API key). The caller must be the extension owner or a member of the owning developer team.

Permission: extension-dev:read

Feature: feature:extension_development

Path parameter: id - UUID of the extension.

Query parameters:

ParameterTypeDescription
sincestring (RFC 3339)Optional. Only include data at or after this timestamp. Omit for all-time.

Response:

{
"data": {
"total_invocations": 12450,
"total_errors": 23,
"avg_execution_ms": 45.2,
"p99_execution_ms": 280.0,
"by_handler": [
{
"handler": "onMessage",
"total_invocations": 9800,
"total_errors": 12,
"avg_execution_ms": 41.7,
"p99_execution_ms": 255.0
}
],
"time_series": [
{
"bucket": "2026-05-20T10:00:00+00:00",
"total_invocations": 512,
"total_errors": 1,
"avg_execution_ms": 44.9
}
]
}
}

by_handler is ordered by invocation count (descending) and capped at 100 handlers; time_series is bucketed to the hour, ordered oldest-first, and capped at 1000 buckets.

GET /v1/developer/limits​

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

Auth: Account auth (JWT or API key). The caller must be a developer.

Permission: extension-dev:read

Feature: feature:extension_development

Response:

{
"data": {
"categories": [
{
"category": "bundled_sounds",
"limits": [
{
"key": "max_sounds",
"label": "Sounds per extension",
"value": 50,
"source": "default",
"default_value": 50,
"configurable": true
}
]
}
],
"extensions": [
{
"extension_id": "uuid",
"extension_name": "My Extension",
"limits": { "max_sounds": 100, "max_sounds_source": "extension_override" }
}
]
}
}

GET /v1/developer/limit-requests/\{id\}/events​

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

Auth: Account auth (JWT or API key).

Permission: extension-dev:read

Feature: feature:extension_development

Response:

{
"data": [
{
"id": "uuid",
"request_id": "uuid",
"event_type": "submitted",
"actor_id": "uuid",
"details": null,
"created_at": "2026-05-30T10:00:00Z"
}
]
}

GET /v1/developer/extensions/\{id\}/limits​

Returns the resolved sound limits for an extension owned by the authenticated developer.

Auth: Account auth (JWT or API key). The caller must be the extension owner or a member of the owning developer team.

Permission: extension-dev:read

Feature: feature:extensions

Response:

{
"data": {
"max_sounds": 50,
"max_sounds_source": "default"
}
}

max_sounds_source is one of: "default" (platform default 50), "developer_override" (admin set on developer), "extension_override" (admin set on this extension).

GET /v1/developer/payout-settings​

Returns the authenticated developer's saved payout settings - payout method, Stripe Connect id, PayPal email, and SEPA bank IBAN/BIC/name - for prefilling the payouts form. Companion read to the POST /v1/developer/payouts update (same private DeveloperProfile shape, gated on the payout permission). REST twin of the GraphQL developerPayoutSettings query. The update (POST /v1/developer/payouts) bounds the plaintext with the same rule and error text as GraphQL updateDeveloperPayoutSettings: IBAN at most 34 characters not counting spaces (50 with spaces), BIC at most 11, PayPal email and bank name at most 255 each. A longer value is refused with 400 (IBAN must be at most 34 characters, not counting spaces, BIC must be at most 11 characters, PayPal email must be at most 255 characters, Bank name must be at most 255 characters) before anything is stored. The four fields are stored encrypted.

Auth: Account auth (JWT or API key). The caller must be a developer; a popout/overlay/widget/extension token is rejected with 403 FORBIDDEN (ZAF-471 first-party gate).

Permission: extension-dev:payouts

Feature: feature:extension_development

Response:

{
"data": {
"id": "uuid",
"user_id": "uuid",
"stripe_connect_id": null,
"paypal_email": "dev@example.com",
"bank_iban": null,
"bank_bic": null,
"bank_name": null,
"payout_method": "paypal",
"revenue_balance": 12500,
"total_earned": 48000,
"created_at": "2026-08-01T10:00:00Z",
"updated_at": "2026-09-05T12:00:00Z"
}
}

Returns 404 NOT FOUND (Developer profile not found) if the caller has not applied as a developer.

Extension Bundle Serving​

GET /v1/extension-bundles/\{extension_id\}/\{version\}/\{path\}​

Serve a file from an extension version's bundle. The path is a catch-all that resolves to a file in the version's extension_version_files table.

Auth: Published versions are publicly accessible (no auth required). Draft and testing versions require developer authentication (JWT or API key of a developer of the extension, its own extension token, or a system key carrying bots:internal - a system key without the grant gets 403 Missing permission: bots:internal).

Path parameters:

ParameterDescription
extension_idUUID of the extension
versionSemver version string (e.g. 1.2.0)
pathFile path within the bundle (e.g. layer.js, styles.css, assets/logo-a1b2c3.png)

Security:

  • Path traversal attempts (.., null bytes, absolute paths) are rejected with 400
  • source.tar.gz is never served (returns 403)
  • Bundle and source storage keys are derived by the server. Legacy bundle and file records are served only when their key belongs to that extension version's storage namespace.

Response: The raw file bytes with the stored Content-Type header. Cache headers depend on whether the resolved version is published:

  • Published - auth-independent bytes, identical for every caller: Cache-Control: public, max-age=86400, immutable.
  • Draft / testing - auth-dependent bytes, resolved only for a developer (via extension_dev_mode or an extension token) or a system key carrying bots:internal: Cache-Control: private, no-store plus Vary: Authorization, Cookie. They must never sit in a shared/URL-keyed cache that could serve draft bytes to a non-developer request without re-running the draft-access check.

Every successful bundle response includes X-Content-Type-Options: nosniff. HTML, SVG, JSON, source maps, Lottie, and other content outside JavaScript, CSS, raster images, fonts, audio, and video also receive Content-Security-Policy: sandbox and Content-Disposition: attachment. The dashboard bundle proxy applies the same content-handling headers; SVG remains usable as an image resource.

Legacy compatibility: GET /v1/extension-bundles/{extension_id}/{version}/bundle.js is preserved as a dedicated route and falls back to the bundle_key column for pre-migration versions.

GET /v1/extensions/\{id\}/versions/\{version\}/files​

List all files in an extension version's bundle.

Auth: Published versions are publicly accessible. Draft and testing versions require developer authentication.

Path parameters:

ParameterDescription
idUUID of the extension
versionSemver version string

Response:

{
"data": [
{
"id": "uuid",
"version_id": "uuid",
"file_path": "layer.js",
"content_type": "application/javascript",
"size_bytes": 312000,
"content_hash": "sha256-abc123...",
"created_at": "2026-05-26T10:00:00Z"
}
]
}

Protocol parity: extensionVersionFiles(extensionId, version) query in GraphQL -- see GraphQL.

Extension Upload Endpoints​

POST /v1/extensions/\{extension_id\}/uploads​

Upload a file from an extension editor (icons, images). Used by the SDK's ctx.upload() method inside extension iframes.

Auth: Extension Token (lm_ext_*). The token's extension_id must match the path parameter.

Body: multipart/form-data with a file field.

Limits: Max 256 KB per file. Max 50 uploads per extension per account. Accepted formats: PNG, JPEG, GIF, SVG, WebP, AVIF.

Deduplication: Files are deduplicated by SHA-256 content hash. Uploading an identical file returns the existing URL without re-uploading.

Response:

{
"data": {
"url": "https://api.lumio.vision/v1/extensions/{extension_id}/uploads/{hash}.png"
}
}

GET /v1/extensions/\{extension_id\}/uploads/\{filename\}​

Serve an uploaded file. Public access (URLs are unguessable due to content-hash filenames).

Response: Raw file bytes with correct Content-Type and Cache-Control: public, max-age=86400, immutable.

Extension Install Endpoints​

Every install object (GET /v1/extension-installs, the install, configure, auto-update and pin responses, and GraphQL InstalledExtension) carries targets: the manifest targets of the installed version (editor, layer), or [] when the manifest lists none. The overlay editor offers every install with a layer target as a layer, a bot module with a layer included.

POST /v1/extension-installs accepts a published extension with public, paid, unlisted, or system visibility. A published private extension also needs an access grant for the account or a tester invitation for the acting user. Other targets return 404 Extension not found, including after a private access grant is revoked. The GraphQL installExtension mutation uses the same eligibility check. POST /v1/extension-purchases applies it before it creates a Stripe Checkout session or installs through a free-access grant (404 Extension not found otherwise), and the payment webhook applies it again before its install. A payment that completes after access was revoked, or after the extension or its version was deleted or unpublished, is recorded without an install and refunded in full in the same webhook pass (a subscription is cancelled at once first); the purchase turns refunded when Stripe reports the refund. Purchase has no GraphQL twin.

Installing an extension the account already has returns 409 Extension is already installed. DELETE /v1/extension-installs/{id} removes the install from every install read at once: lists, lookups, the per-install function/action/storage endpoints (404 Extension install not found) and the internal bot/automation handler feeds. Unless delete_data_immediately=true is passed, the extension's stored data is kept for 30 days. Either way only this account's data is deleted: the extension's database schema ext_{extension_id} is shared by every account that installs it, so the account's rows are deleted from it and the schema is dropped only with the extension's last install. The uninstall emits the account-scoped audit event account:extension_uninstalled (extension_id, install_id, delete_data_immediately). a POST /v1/extension-installs for the same extension within that window restores the install on the latest published version with its saved configuration and returns 201. GraphQL installExtension / uninstallExtension behave the same.

GET /v1/extension-installs/\{install_id\}/storage​

All key/value entries of an install's extension storage (useExtensionStorage()), ordered by key. Auth: the install's own extension token, or a user session / personal key / service key of the owning account with extension-store:read. An overlay token whose overlay renders the install in an extension layer (ext:{slug}), or a widget token whose widget renders it, may read too; the install must be runnable (its extension and version not suspended, the install enabled, the account not dissolving), else 404 Extension install not found. A token of another overlay or widget gets 403 Extension install belongs to a different overlay / … widget; shared overlay links and popout tokens get 401. Feature: feature:extensions. An uninstalled install (pending its 30-day data deletion) answers 404. GraphQL twin: extensionStorage(installId).

{ "data": [ { "key": "volume", "value": 0.8 } ] }

POST /v1/extension-installs/\{install_id\}/query/\{function_name\} and /mutate/\{function_name\}​

Declarative server functions on the extension's tables, called by name. function_name is an export of the extension's server/functions.ts; the API looks it up in the functions manifest (functions.json) of the version the install runs (its pinned version, else its current one). A version's manifest never changes after upload, so the API parses it once per version and process and keeps it in memory (16 MiB budget); a call costs one database lookup, the install check that also names the version. The body is {"params": {...}}: the values for the function's arg() references.

  • Resolution: query runs a queryRows definition, mutate an insertRow / patchRow / deleteRow definition. The definition supplies the table, filters (=, !=, >, >=, <, <=, like, in), order (default created_at ascending), limit (default 100, clamped to 1000), offset (clamped to 100 000) and row values. arg() values are bound as typed SQL parameters, never as SQL text; a value the definition fixes cannot be overridden by params. identity() is the calling user's id (null for token callers). A missing arg() is null in values and filters, "not set" for limit / offset, and 400 missing argument `id` for a row id.
  • Errors: 404 Unknown server function: {name} (also when the version ships no functions); 400 Server function '{name}' is a mutation, not a query (and the reverse); 400 Server function '{name}' is a handler; call it through execute; 400 invalid argument ... / row id must be a UUID.
  • Editor-only: a definition with editorOnly: true runs only for the editor surface. The surface comes from the credential, never from the request: a dashboard session, personal API key or System key is the editor; an extension, overlay or widget token is a rendered extension and gets 403 Editor-only function called outside editor.
  • Raw operations: dashboard sessions, personal API keys and System keys may instead send the operation itself. query: table, filters ([{field, op, value}], op ∈ eq|neq|gt|gte|lt|lte|like|in) and limit (newest first). mutate: table, function_type (insert_row | patch_row | delete_row), args (the column values) and row_id (patch/delete). A request with table is a raw operation and params is ignored. Extension, overlay and widget tokens get 403 Raw data operations need a signed-in session or an API key; call the server function by name.
  • Response: {rows, affected_count}; every row is the full row as JSON including _id, _account_id, _created_at and _updated_at.
  • Scope: every statement is limited to the install's account; another account's _id matches nothing. _-prefixed columns cannot be written or filtered.
  • Validation: unknown tables/columns and values of the wrong type return 400 (unknown table: ..., unknown column ..., invalid value for column ...: expected ..., column ... is not nullable, cannot order by column ...). An extension whose installed versions declare no data returns 404 NO_DATA_SCHEMA (this extension has no data schema).
  • Limits: per install and minute, 100 reads (query) and 200 writes (mutate), shared with the extension's bot-module ctx.db; over the limit 429 RATE_LIMIT_EXCEEDED. When the rate-limit store is unreachable, reads run and writes return 503 EXTENSION_DATA_UNAVAILABLE (fail-closed). A statement over 2 s returns 504 EXTENSION_QUERY_TIMEOUT; no free extensions-database connection within 2 s returns 503 EXTENSION_DATA_UNAVAILABLE (db busy).
  • Callers: User / ApiKey of the install's account need extension-data:read (query) or extension-data:edit (mutate). System keys need bots:internal. An extension token works only for its own install; an overlay token only for an install that an extension layer (ext:{slug}) of its overlay renders; a widget token only for its widget's install. Shared overlay links and popout tokens are refused.

The manifest is checked when the version is uploaded (POST /v1/developer/extensions/{id}/versions/upload with the functions.json file, or GraphQL uploadExtensionVersion with functionsManifest): format version, names, operators, fixed row ids, and every table against the version's data schema. A failed check returns 400 with the reason, e.g. functions.json: `getRules` uses table `missing`, which the version's data schema does not declare.

GraphQL twins: extensionQuery(input) and extensionMutate(input) (functionName + params, or the raw fields): same callers, resolution, validation, limits, messages and codes.

POST /v1/extension-installs/\{install_id\}/execute/\{function_name\}​

Handler server functions: a query(), mutation() or action() export of the extension's server/functions.ts. Body: {"args": {...}}, passed to the handler as its args. The API looks function_name up in the functions manifest of the version the install runs; the definition must be a handler (400 Server function '{name}' is not a handler; call it through query or mutate). The handler code is that version's server.js, never taken from the request. The API sends the call to the Bot Module Worker (POST {bot_module_worker.url}/v1/server-functions/execute, Authorization: SystemKey), which runs it in a V8 isolate. V8 does not run in the API process.

  • Callers and checks: as for query / mutate; an install whose code may not run (its extension or the version it runs suspended, the install disabled, the account in its dissolution grace period) is 404 Extension install not found for every caller and never reaches the worker. User / ApiKey need extension-data:read for a query() handler and extension-data:edit for mutation() / action(). editorOnly comes from the definition; tokens get 403 Editor-only function called outside editor. While the account's extension kill switch is on, every call gets 403 EXTENSIONS_KILLED.
  • Handler context: ctx.db is the shared data executor on the install's account (same limits as above); a query() handler is read-only - its ctx.db writes, ctx.cache writes and ctx.defer jobs are refused inside the handler, so extension-data:read never causes a stored write (it can still trigger the handler's own ctx.realtime.push broadcasts, under the install's realtime cap); ctx.auth is {userId, accountId, role} (userId is null for token callers); ctx.config the install's config; ctx.cache, ctx.secrets and ctx.defer as for bot modules; ctx.fetch refuses every host (deny-all egress).
  • Limits: 5 s wall-clock and 256 MB heap per call; per install and minute 100 query() calls (shared with /query), 50 mutation() and 20 action() calls (429 RATE_LIMIT_EXCEEDED; when the worker's rate-limit Redis fails, calls are refused the same way rather than let through); per worker replica at most 16 handlers at a time and 4 per account ([server_functions] in the worker config) - a call that finds no free slot gets 429 RATE_LIMIT_EXCEEDED at once instead of waiting; server.js at most 5 MiB.
  • Realtime: the worker returns the handler's ctx.realtime.push(topic, payload) calls with its value. The API validates them (topic 1-64 characters of A-Z a-z 0-9 _ . : - starting with a letter or digit, payload at most 16 KiB serialized, at most 32 pushes per call; the isolate applies the same rules at ctx.realtime.push, so a bad push throws inside the handler, and a batch that still arrives invalid is dropped while the call succeeds, since the handler's writes are already committed) and publishes them in order on lumio:extension_install:{install_id}, which reaches WebSocket subscribers of extension_install:{install_id} (see WebSocket). At most 300 pushes per install and minute (shared with the install's bot-module handlers); pushes over that are dropped and the call still succeeds.
  • Response: {"data": <the handler's return value>}.
  • Errors: 404 Unknown server function: {name} / The installed version has no server bundle; 404 Extension install not found (see callers above); 422 HANDLER_ERROR (the first line of the message the handler threw, at most 500 characters, without its stack trace; Handler execution failed for a failure outside the handler's code, whose text never reaches the caller); 422 HANDLER_MEMORY_LIMIT; 504 HANDLER_TIMEOUT; 503 EXECUTOR_UNAVAILABLE (Server-function executor is unavailable; try again later) when [bot_module_worker] has no system_key, the worker is unreachable or fails, or the bundle cannot be read from storage.

GraphQL twin: extensionExecuteHandler(input: {installId, functionName, args}): same callers, checks, limits, messages and codes.

POST /v1/extension-installs/\{install_id\}/storage​

Set one key. Body: { "key": "volume", "value": 0.8 } (value is any JSON). Returns the stored entry and broadcasts storage:update on the WebSocket channel ext-storage:\{install_id\}. Auth as above, with extension-store:configure. Overlay and widget tokens never write storage: 403 Overlay and widget tokens cannot write extension storage. A rendered layer that has to change state calls a server function instead. GraphQL twin: setExtensionStorage.

Limits per install, checked on every write: key at most 256 bytes (UTF-8, not empty, no :; surrounding whitespace is trimmed), value at most 64 KiB of JSON, at most 1 000 keys and 1 MiB in total (keys + JSON values). Overwriting an existing key with a smaller value is always allowed. A rejected write stores nothing and returns 400 BAD_REQUEST with the same message as GraphQL, e.g. Bad request: Extension storage is limited to 1000 keys per install.

The data lives in PostgreSQL (extension_storage) and is deleted with the install: immediately on uninstall with delete_data_immediately, after the 30-day retention window otherwise, and on account dissolution.

GET /v1/extension-installs/\{id\}/logs​

Returns the newest handler-execution logs (up to 100, newest first) of one bot-module or automation-node install of the caller's active account.

Auth: Account auth (JWT, API key, or popout token).

Permission: follows the install's extension category — bot-modules:read for a bot module, automations:read for an automation node. An install of another account, an unknown install, an install of any other category, and a caller without the category's permission all get the same 404 Install not found, so the endpoint reveals neither install ids nor categories.

Path parameter: id - UUID of the extension install.

Query parameters:

ParameterTypeDescription
sinceRFC 3339 timestampOnly entries at or after this time
levelstringFilter by log level: info, warn, error

Redaction: message and stack_trace are scrubbed of UUIDs, @-mentions, tokens and IPv4 addresses. args depends on the category:

  • Bot module — the positional command arguments, string values scrubbed the same way.
  • Automation node — nodeConfig keeps its values, with every secret-looking key (key, token, secret, password, auth) masked as "***" at any depth; inputData (the triggering event: viewer, message, tip, Shopify or custom data) is returned as shape only — keys and nesting are kept, every value becomes "<string>", "<number>" or "<bool>" (null stays null).

The workers store rows already redacted; rows written before that are redacted when read.

Response:

{
"data": {
"entries": [
{
"time": "2026-10-09T10:30:00+00:00",
"handler": "onAction",
"trigger_type": "action",
"platform": null,
"level": "info",
"message": null,
"stack_trace": null,
"args": {
"nodeConfig": { "label": "Tip alert", "webhook_secret": "***" },
"inputData": { "user": { "name": "<string>" }, "amount": "<number>" }
},
"user_name": null,
"user_role": null,
"execution_ms": 34,
"success": true
}
]
}
}

Protocol parity: GraphQL twin extensionInstallLogs -- same guard, redaction and error (both call db::extension_install_logs::read_install_logs). See GraphQL.

Account Bots​

The active account's bot list supports twitch, kick and youtube. Responses use the usual data envelope and snake_case fields. The GraphQL twins enforce the same account-scoped guards and validation.

EndpointPermissionResult
GET /v1/account-botschat-bots:readAccount entries, newest first: platform, platform_user_id, login, display_name, source, added_by, added_by_name, added_at.
GET /v1/account-bots/auto-detectedchat-bots:readRead-only badge, curated-list and own-connection entries with platform, source, identity, name.
POST /v1/account-botschat-bots:editBody: platform and exactly one of platform_user_id or name. A name or @handle must resolve before saving. Returns { bot, created } with 201 for an add or 200 and created: false for an existing entry.
DELETE /v1/account-bots/\{platform\}/\{platform_user_id\}chat-bots:editReturns { removed }; false when no entry existed.

A real add starts background recalculation of retained history sessions. Removing a mark does not restore already recalculated figures. Real changes emit account:bot_list_updated; no-op writes do not.

Stream History​

All owner routes require feature:stream_history and the permission shown below. The public share routes are anonymous and IP-rate-limited. Structured routes mirror GraphQL Stream History; the filtered-list file download is REST-only by design.

GET /v1/history​

List sessions with history:read. page defaults to 1; per_page defaults to 25 (max 100). Filters from, to (RFC3339), session_type (single/multi), platform, platforms, category, and search combine. platforms=twitch,youtube keeps sessions with a broadcast on at least one listed platform; it cannot be combined with platform, and include=sparkline then sums only the listed platforms. sort_by accepts started_at, duration_secs, peak_viewers, avg_viewers, total_messages, unique_chatters, new_followers, new_paid_subs, or estimated_revenue; sort_dir is asc or desc (default date descending). Nullable stats sort last; ties sort newest first. estimated_revenue orders by the revenue a row shows (the converted total in currency, else the raw estimate), computed over the whole filtered set before paging. Without history:revenue-read it is accepted but keeps the default order, so the order never reveals revenue. include is a comma-separated set of sparkline, revenue, milestones, platform_stats, shared_chat; unknown values return 400. include=shared_chat fills shared_chat for a session that ran Twitch Shared Chat and omits it otherwise: host_broadcaster_id (the host of the newest or running Shared Chat, null when unknown), active (a Shared Chat of the live session is running now), channel_count and channels (platform_channel_id, login, display_name, avatar_url, is_host, is_own_channel; host first, then the own channel, then join order). Sessions stored before the Shared Chat timeline list only the partner channels and have no host. include=platform_stats fills platform_stats: each platform's part of the session (platform, duration_secs, peak_viewers, avg_viewers, new_followers, messages, paid_subs, gifted_subs). include=sparkline fills viewer_sparkline (at most 32 total-viewer points, or null). include=revenue fills estimated_revenue only with history:revenue-read; otherwise the field is omitted. include=milestones fills each session's persisted milestones. Optional currency chooses converted revenue currency, defaulting to the user's display currency.

A session summary in the list, report, public share or latest_session.session carries live for an unfinalized session started within the last 48 hours. The object has computed_at, nullable sampled_at, samples, chat, top_chatters and nullable youtube_subscribers. The state values are live, unavailable and after_stream. An unavailable group may contain zero or empty-list placeholders; clients must display it as unknown. YouTube subscriber gain is after_stream during a YouTube broadcast. The session live is null after finalization or beyond the 48-hour window. It is distinct from latest_session.live, the dashboard snapshot. GraphQL twin: HistorySessionSummary.live.

Every session object (list, report, public share, JSON export) carries bot_messages (integer or null): the chat messages left out of total_messages, unique_chatters, the per-platform counts, the top lists and the emote/GIF counts because a bot wrote them. null means unknown: the live chat calculation is unavailable, or the session was finalized before bot filtering with its chat log already gone. A live session with a completed chat calculation has a running count. See Stream History → Bot messages. GraphQL twin: botMessages.

?shared_chat=with|without filters sessions that have or never had Twitch Shared Chat, including live sessions. It combines with the other history filters and applies to the overview, trend, heatmap and list export. Invalid values return the same bad-request message as GraphQL sharedChat; filter options expose has_shared_chat. No additional permission is required.

GET /v1/history/overview​

Return totals, platform counts, month summaries, and activity days with history:read. Uses the list filters and optional time_zone (IANA; UTC default) and currency. A session is grouped by its start in that zone. months spans the filtered set; days contains today plus the preceding 370 days and ignores from/to while honoring other filters. An invalid zone is 400. estimated_revenue and months[].revenue require history:revenue-read and are otherwise absent. GraphQL twin: channelHistoryOverview.

range=today|7d|30d uses local day boundaries in time_zone and fills range (key, from, to, previous_from, previous_to), current and previous KPIs (sessions, total_duration_secs, avg_viewers, new_followers, messages, new_paid_subs, gifted_subs), plus a zero-filled daily kpi_series. The overview's existing totals and estimated revenue cover the selected range; days still spans 371 days. Combining range with from or to returns 400. latest_session always refers to the account's newest session, ignoring filters, and includes session, up to 60 viewer_series points per platform, live sampled totals and milestones; it is null without sessions. GraphQL computes it only if selected.

platforms=twitch,youtube (comma-separated twitch, youtube, kick) is the share filter. It keeps only sessions with a selected platform and counts only the selected platforms' share in the totals, months, days, current, previous and kpi_series. A session whose every platform is selected keeps its own values, so listing all platforms gives the unfiltered result. Otherwise the stream time is the time at least one selected platform was live. Ø viewers are the selected platforms' stream averages weighted by their live time and divided by that stream time, so concurrent platforms add up. The peak is the sum of their stream peaks. Followers, messages, Subs + Members and gifted Subs come from their platform_breakdown entries, and YouTube followers are the subscriber delta. platforms in the response lists only the selected ones. estimated_revenue and months[].revenue cover the whole sessions. latest_session becomes the newest session with a selected platform. Its viewer_series, viewer_total (the combined curve: per bucket the highest per-minute cross-platform sum, on the viewer_series bucket times), live and follower marks cover only those platforms, while tips stay session-wide, and its session carries platform_stats. An empty value means no filter. Combining it with platform returns 400 `platforms` cannot be combined with `platform`. An unknown platform returns 400 `platforms` must only contain: kick, twitch, youtube. Both messages are identical on GraphQL.

latest_session.shared_chat_series contains total (the highest concurrent own-plus-partner sum per viewer-series bucket) and channels (one partner curve each, with channel identity and points). A missing partner count makes a gap, not zero. It is null without Shared Chat, when Twitch is excluded or after raw samples age out. latest_session.live.shared_chat gives host_broadcaster_id, host-first participants, partner_viewers, total_viewers, partner_messages, session_chat_per_min and source (state or sample). The session chat rate adds own and partner messages over the two latest Twitch samples. It is null until the second sample, never the own chat_per_min as fallback. GraphQL twins: latestSession.sharedChatSeries and latestSession.live.sharedChat.

latest_session.live has sampled_at, currency, viewer_count, peak_viewers, messages_total, chat_per_min, new_followers, paid_subs, gifted_subs, bits, kicks, superchats, superstickers, tips and platforms. Each money kind has count, by_currency, unknown_cents and converted_cents; conversion is null if a part has no currency or rate. Tips are session-wide. chat_per_min uses the newest two probes; live is null before the first sample.

GET /v1/history/milestones​

Return up to 1,000 persisted milestones under history:read and feature:stream_history, oldest first. RFC3339 from is inclusive and to exclusive; reversed bounds return 400. platforms=twitch,youtube keeps only those platforms' follower marks; the cross-platform records (platform: null) stay, and an unknown platform returns 400. Each item has id, session_id, kind (viewer_record, sub_record, biggest_raid, follower_mark), platform, value, previous, and achieved_at (session start). The report's session.milestones is always present. GraphQL twin: channelHistoryMilestones(from, to, platforms).

GET /v1/history/filter-options​

Return the account's distinct platforms and categories with history:read. GraphQL twin: channelHistoryFilterOptions.

Tracked keywords​

All keyword endpoints require feature:stream_history. Reads and pattern tests need history:read; writes need history:keywords-edit. Their GraphQL twins use the same account-scoped guards, matcher and errors.

EndpointResult
GET /v1/history/keywordskeywords (oldest first), last_session_id, last_session_started_at, max_keywords (50). Each entry includes id, keyword, match, case_insensitive, added_by, added_by_name, added_at, last_stream_count; the last count is null when unknown.
POST /v1/history/keywords/testBody: pattern, match, optional case_insensitive, sample (at most 500 characters). Returns valid, error, and matches with UTF-16 start/end offsets and text; saves nothing. More than 30 calls per minute returns 429.
POST /v1/history/keywordsBody: keyword, match, optional case_insensitive. Returns keyword, created, changed; 201 for a new entry, 200 with created: false for a duplicate term and mode.
PUT /v1/history/keywords/\{id\}Same body and payload; changed: false for an unchanged entry, 404 for an unknown id, 409 for a duplicate.
DELETE /v1/history/keywords/\{id\}Returns removed: false when the entry was absent.

match is word, contains or regex; matching ignores case by default. Text modes permit 100 characters. Regex permits 200 characters, rejects empty-text matches and compiles with a 1 MiB size limit. At most 50 entries may be stored. Real writes emit account:history_keywords_updated and recount finalized sessions with complete chat logs in the background.

GET /v1/history/revenue-settings and PUT /v1/history/revenue-settings​

Read the account's revenue assumptions with history:revenue-read; when none are saved, the read returns defaults with updated_at/updated_by null. The response includes fixed_values, a read-only list of USD item, amount_cents, currency, share, source, source_url and note for Twitch tiers, Prime, Bits, Kick subs and KICKs. Unit prices for these items are not writeable. Both operations also require feature:stream_history.

The PUT requires history:revenue-edit. Send currency, twitch_share_bps, kick_share_bps, twitch_ad_cpm_cents, twitch_ad_share_bps, and twitch_ad_free_viewer_bps. Optional fields are youtube_share_bps (Super Chats), youtube_supersticker_share_bps, youtube_membership_share_bps, youtube_member_levels ([{"name":"Fan","price_cents":499}]), kick_ad_cpm_cents, kick_ad_share_bps, kick_ad_free_viewer_bps, youtube_ad_cpm_cents, youtube_midrolls_per_hour, youtube_ad_share_bps and youtube_ad_free_viewer_bps. All shares are 0–10,000 basis points; money inputs are 0–1,000,000 cents; the mid-roll rate is 0–60 per hour; currency is EUR or an ECB reference currency. The three YouTube creator shares default to 7,000 basis points when omitted. Omitting youtube_member_levels keeps the saved list; an empty list clears it. Omitting a Kick or YouTube ad field keeps its saved value; explicit null clears a nullable CPM or the mid-roll rate. twitch_ad_cpm_cents: null leaves Twitch ads without an amount. At most six membership levels are accepted, with trimmed, non-empty names of at most 100 characters that are unique ignoring case. GraphQL twins: channelHistoryRevenueSettings and updateChannelHistoryRevenueSettings, with the same validation.

GET /v1/history/youtube-revenue/status​

Return the account's YouTube Analytics state without amounts. The response data has connected (boolean), status (not_connected, not_granted, not_in_ypp, api_disabled, unchecked, or ok), nullable ypp_checked_at, and nullable revenue_auth_verified_at. It requires history:revenue-read and feature:stream_history; GraphQL twin: channelHistoryYoutubeRevenueStatus with camelCase fields.

GET /v1/history/revenue-settings/youtube-member-level-suggestions​

Return up to 50 level names from retained YouTube membership events, most frequent first. Each item has name, events, last_seen_at and entered (whether that name is already priced, ignoring case). The account-scoped read requires history:revenue-read and feature:stream_history. GraphQL twin: channelHistoryYoutubeMemberLevelSuggestions.

GET /v1/history/reports/\{id\}​

Return one owned report with history:read; optional currency controls session.display_money and converted revenue. session.display_money (currency, donations_cents, superchats_cents, superstickers_cents, rate_date) converts donations, Super Chats and Super Stickers at the stream-date rate and needs no revenue permission; an amount without a rate (or a known currency) is null, and the source amounts stay in donations_total_cents / donations_currency and platform_metrics. On the public share payload its amounts are null and currency is the link currency. The response includes the session, per-platform streams, chart markers, top lists (top_emotes holds up to 20 entries, top_chatters and top_gifs up to 10; a report keeps the list length it was finalized with), and Twitch ad_breaks (started_at, duration_secs, automatic, viewers_start, viewers_after) plus ad_stats (break counts, total/average/min/max seconds, minutes per hour, threshold, estimated impressions, viewer change). session.estimated_revenue requires history:revenue-read and is otherwise absent. The revenue object contains currency, total_cents, lines, unpriced, actuals, grand_total, and averages. A line's converted value has amount_cents, currency, source_amount_cents, source_currency, rate, rate_date, and convertible. grand_total separates estimated and actual cents and lists not_convertible amounts. Missing ECB rates within seven days leave an amount in its original currency, outside converted totals. Each stream carries its metadata timeline: tags (string list, the tags at stream end, deduplicated, in platform order), category_changes ([{ at, category, category_id }], ascending; the first entry is the category at stream start with at = the stream's started_at, then one entry per category change inside the stream) and title_changes ([{ at, title }], same rules). Twitch and Kick fill them from their stream_update events and the live tag polls; YouTube returns empty lists, as does any stream whose values were never observed. The finalizer freezes the timeline into the report, so it survives the event-log retention; a live stream derives it on read. A Twitch stream with a Shared Chat also has shared_viewers_peak / shared_viewers_avg (peak and average of the per-bucket total of own viewers plus partners; null without a Shared Chat) and shared_chat_sources: [{ platform_channel_id, message_count, login, display_name, avatar_url, peak_viewers, avg_viewers, active_from, active_to }], ordered by active_from, then message_count descending; null without a Shared Chat. The report also carries links, commands and keywords (at most 50 entries each; null = unknown, when neither a frozen list nor the complete chat log exists). A link has url (normalized), display (as first posted), domain, count, first_seen_at, last_seen_at, posters (top 5: platform, user_id, login, display_name, count, is_bot), other_posters, occurrences (first 100, oldest first: at, platform, user_id, login, display_name, is_bot) and occurrences_truncated; bot posters are included and flagged. A poster or occurrence of an erased chatter (and an erased top_chatters entry) carries erased: true with user_id null and empty login / name; the key is absent on every other entry (Stream History → Erased chatters). A command has name (prefix + lower-cased name, e.g. !discord), prefix, count, unique_users, first_seen_at, last_seen_at, posters, other_posters, top_args ([{ args, count }], "" = no arguments), occurrences (as for links, plus args), occurrences_truncated and known; bot authors are left out. See Stream History → Links and commands. The public share payload carries the same stream fields and samples. keywords contains one row for every tracked keyword, including count zero: keyword_id, keyword, match, case_insensitive, count, bot_count, unique_users, first_seen_at, last_seen_at, posters, other_posters, occurrences and occurrences_truncated. Bot posts count and are flagged. A leading command token that matches a keyword counts as a keyword rather than a command. null means no frozen list and no complete chat log. GraphQL twin: channelHistoryReport.

For a Twitch stream, shared_chat_timeline contains the host and the event-exact session/channel spans, frozen with shared_chat_sources at finalization. Older streams without transition events use minute-sample boundaries. shared_chat_insights contains frozen per-partner activity; partner keyword, link and command entries hold term and count only, without poster identities or occurrences. Own-channel chat and all revenue fields remain scoped to the own channel. The public share omits Whole session insights and timeline viewer figures.

On an owned report, session.estimated_revenue.youtube_analytics contains status (not_granted, not_in_ypp, api_disabled, pending, provisional, or final) and nullable fetched_at. It is absent or null without a YouTube stream and omitted from public shares. pending retains the Lumio estimate. With provisional or final, the lines youtube_analytics_ads, youtube_premium, and youtube_transactions replace YouTube ad and membership estimates and Super Chat/Sticker amounts; youtube_transactions is derived from YouTube's reported total. The note is youtube_analytics or derived_from_analytics. The account read needs history:revenue-read; the public share report and export use only Lumio's estimate, including the total.

The report also returns announcements and pins (at most 500 and 200, oldest first), with announcements_captured, pins_captured, announcements_truncated and pins_truncated. The capture flags distinguish a session that began before collection from one with zero entries. null lists mean unknown or not shared. A pin carries sender, pinned_by, planned, changes, duration_seconds, approx, and end_reason (expired, unpinned, replaced, message_deleted, stream_end, ran_past_stream_end, unknown, active). The lists retain person, time and message ID but do not freeze message text. text_stored: false calls for Text no longer stored. The GraphQL twin uses camelCase.

GET /v1/history/reports/\{id\}/automod​

The messages Twitch AutoMod held during the session, oldest first, with counts per outcome. Requires history:read and chat:automod-read (a missing one is 403 Missing permission: …, like GraphQL) and feature:stream_history; popout tokens get 403 Popout tokens cannot read the AutoMod log. Query: outcome (open, approved, denied, expired; default all), after (the previous page's next_cursor), first (1–100, default 50); anything else is 400. data: null means unavailable: the session has no Twitch broadcast, or it started before the chat retention window. A foreign or unknown session is 404 History session not found.

text_retention_days is the account's effective chat window in days: the entry, text included, is deleted that many days after the hold. Each entry carries message_id, sender (user_id, login, display_name, and the look at hold time avatar_url, color, badges; null = unknown), text and flagged_spans (code points, end inclusive), emotes (JSON array in the chat-message emotes shape: Twitch emotes {id, name, url, provider: "twitch", animated} and cheermotes {kind: "cheermote", …}; null when none; a cheer the channel's cheermote catalog does not know stays plain text), text_state (stored, or not_stored above the 4 KiB size cap), reason_kind and reason (category, level, terms[{term_id, text}]), outcome, resolved_via (lumio/twitch), moderator_login, moderator_name, held_at, resolved_at. A hold nobody decided reads expired once the queue TTL has passed, with no moderator and no resolved_at. counts (total, open, approved, denied, expired) cover the whole session, independent of outcome. The log is never part of the report, share links or exports. GraphQL twin: automodLog.

GET /v1/history/reports/\{id\}/stats​

Time-series samples for a session, optionally restricted by platform. Requires history:read. live_counters separates cumulative gifted_subs, bits, kicks, superchats, superstickers and tips; money kinds carry count, by_currency and unknown_cents. Older samples have live_counters: null. Tips are session-wide, repeated on each platform sample of the same tick and never summed across platforms. A Twitch sample during a Shared Chat also carries shared_viewers (own Twitch plus all partners in that bucket; null otherwise) and shared_chat_viewers ([{ platform_channel_id, viewer_count }], [] outside a Shared Chat). A partner missing from a bucket was not in the Shared Chat then; viewer_count: null means it was in it but not live. GraphQL twin: channelHistoryStats (liveCounters, sharedViewers, sharedChatViewers { platformChannelId viewerCount }).

GET /v1/history/export​

Download the filtered list with history:export. format=csv|json; accepts the same list filters and sorting, plus time_zone, currency, and locale. Maximum 5,000 sessions. CSV is a ZIP containing streams.csv, months.csv, and totals.csv. Revenue columns appear only with history:revenue-read. This file-download endpoint intentionally has no GraphQL twin.

GET /v1/history/reports/\{id\}/export​

Download a finalized report with history:export. format=pdf|csv|txt|json; optional locale, time_zone, and currency set language, dates, and converted amounts. PDF has up to four A4 sections (Overview, Timeline, Revenue & Ads, Chat & Streams) with page headers/footers; empty sections drop out. TXT has the same sections. CSV is a ZIP of report tables, including revenue.csv and ad_breaks.csv when applicable, and links.csv / link_posts.csv, commands.csv / command_posts.csv, and keywords.csv / keyword_posts.csv when the report carries those lists, plus announcements.csv and pins.csv when present (times in time_zone). pins.csv keeps planned_seconds and duration_seconds as numbers and adds duration, the real duration worded in the export language. PDF and TXT word pin durations the way the dashboard does: seconds or minutes under an hour (12:04 Min.), hours and minutes from one hour (1 Std. 8 Min.), days and hours from one day (2 Tage 20 Std.), rounded down. JSON keeps API field names and includes export metadata. Donations, Super Chats and Super Stickers are in the export currency without a revenue permission: the PDF overview lists them, CSV summary.csv adds display_currency, display_rate_date, *_display_cents and *_source (source amounts per currency), and JSON carries session.display_money. Revenue requires history:revenue-read. A live session returns 409. GraphQL exportHistoryReport validates the same request and returns this download URL.

DELETE /v1/history/reports/\{id\}​

Delete a stored, finalized session and its share links with history:delete. Returns 204. A session that is not finalized yet (live, in its grace window or waiting for the finalizer) is refused with 409 This stream session is still live; it can be deleted once it is finalized and nothing is deleted; GraphQL deleteChannelHistory returns the same code and text.

List link metadata with history:share: expiry, active state, options, and legacy_options, but never the full token.

POST /v1/history/reports/\{id\}/shared-links​

Create a link with history:share. Body has duration_secs (3600, 21600, 86400, 604800, or 2592000), optional password, allow_export, and options. options accepts sections (chat, top_chatters, ads, revenue, links, commands, keywords, announcements, pins), include_revenue, and currency. links, commands, keywords, announcements and pins are off unless listed; without them the corresponding public report lists are null and the public export leaves those sections out. A revenue option also requires history:revenue-read; otherwise both protocols reject it. Omitted options give the legacy view (chat and top chatters; no ads, revenue, links, commands, keywords, announcements or pins). New links with options default to chat and ads, no revenue, links, commands, keywords, announcements or pins. Maximum 20 active links per report. The response returns the plaintext token and URL once.

PATCH /v1/history/shared-links/\{id\} and DELETE /v1/history/shared-links/\{id\}​

Extend a link with { "duration_secs": … } or revoke it, respectively. Both require history:share; extending a link that shares revenue also requires history:revenue-read (403, same message as GraphQL). Delete returns 204.

GET /v1/history/shared/\{token\}​

Resolve a public link. The response contains the permitted report sections, time-series samples, options, channel (display_name, avatar_url, platform), expires_at, and allow_export. A shared sample carries only its time, platform, viewers (shared_viewers / shared_chat_viewers included), message, follower and sub totals and the money-free platform_metrics counts: donations_total_cents is 0 and live_counters is null, with or without include_revenue. The shared top_chatters and the posters and occurrences of links, commands and keywords are served as frozen, without a read-time chatter mask: chatters the channel had erased appear blanked (erased: true, counts kept) because the instructed erasure scrubbed the stored sessions and the channel's suppression list (account_chatter_suppressions) blanks them in sessions frozen afterwards. Lumio's global list (chatter_suppressions, objections) does not apply to share links. Send a protected link's password in X-Share-Password, never the URL. A locked link answers with exactly one of two fixed responses, identical within each class (status, body and headers):

CaseStatuserrormessage
Malformed, unknown, expired or revoked token410SHARE_UNAVAILABLEThis share link is no longer available
Active link with a password; password missing or wrong401SHARE_PASSWORD_REQUIREDA valid password is required to open this share link

The body is { "error": …, "message": … } with nothing about the link: no title, channel, date or expiry. Expiry and revocation are checked before the password, so a dead protected link returns 410 and never asks for a password.

After 10 wrong passwords on one link, or 20 from one client IP, within 15 minutes, a protected link returns 429 RATE_LIMIT_EXCEEDED to every password attempt until the window ends, including the correct one; a correct password never counts. Attempts are counted before the password is checked, so simultaneous requests cannot exceed the limit, and an IPv6 client is counted per /64 network. The endpoint is also rate-limited per end-user IP, including when the web app proxies it (see Authentication → Rate Limits), and returns 503 rather than going unthrottled while the rate limiter is unavailable. The public page at /share/history/\{token\} reveals no report metadata until unlocked.

GET /v1/history/shared/\{token\}/export​

Download a shared report when allow_export is true, with the same four formats, link section restrictions and frozen chatter lists as the shared read. A protected link needs X-Share-Password; optional locale and time_zone choose output language and date zone (default from Accept-Language and UTC). A locked link gets the same 410 SHARE_UNAVAILABLE / 401 SHARE_PASSWORD_REQUIRED / 429 responses as the shared read, and shares its password lockout; an open link with allow_export off returns 403.

Public Stats​

Per-channel publicness for the public Stats app. All endpoints require the feature:public_stats_page flag and mirror the GraphQL channelPublicSettings query / setChannelPublicSettings mutation exactly (same fields, guards, validation and errors). Publicness is opt-out / public-by-default: a channel with no settings row is public.

GET /v1/public-stats/channels​

List the account's connected channels with their current publicness settings. Permission: public-stats:read. Each entry: platform, platform_channel_id, channel_name, is_public, show_top_chatters, opted_out_via, updated_at. Channels default to is_public: true (opt-out) but show_top_chatters: false (the chatter leaderboard is opt-in - hidden until the broadcaster enables it) until changed.

PUT /v1/public-stats/channels/\{platform\}​

Toggle a channel's publicness (channel listing opt-out; chatter leaderboard opt-in). Permission: public-stats:edit. Body: { "is_public": bool, "show_top_chatters": bool }. The channel is resolved to the caller's own connected channel for platform (the channel id is never taken from the client), so an account can only change a channel it has connected. A missing connection → 404; an unsupported platform or a connection whose channel identity is not yet resolved → 422. Emits a public_stats:channel_opted_{out,in} audit event.

Public read model (unauthenticated)​

The read surface the public Stats app consumes. No token required (anonymous, OptionalAuth) and no per-user data, so responses are cache-friendly. Gated globally by the system:public_stats kill-switch flag - disabled → 403. Mirrors the GraphQL publicChannelStats / publicStreamStats queries exactly. Serves only the §5 public-safe field set: there is no field for donations, subscriber/gifter identities, revenue splits, or the raw platform_metrics / platform_breakdown JSONB. First-party and crawled rows use the same response shape, distinguished only by the source badge (first_party / external_crawler).

GET /v1/public-stats/channels/\{platform\}/\{channel\}​

A channel's public aggregate, matched by the stable (platform, platform_channel_id) and addressed by the URL handle (channel = channel_login, case-insensitive). Returns platform_channel_id, channel_display_name, source, stream_count, total_duration_secs, peak_viewers, avg_viewers, total_messages, last_streamed_at, broadcaster_type (Twitch channel type: "partner" / "affiliate", else null - no badge; ZAF-841), and recent_streams (public stream summaries). A channel that has opted out (is_public = false) or has no addressable streams → 404. An unsupported platform → 400.

GET /v1/public-stats/channels/\{platform\}/\{channel\}/profile-history​

A channel's About-box change timeline (ZAF-836) - bio / social-link / team changes over time, most-recent first. Query params limit (default 50, max 200) and offset (default 0) paginate. Each entry: change_type (bio / link / team), old_value / new_value (opaque JSON - a string for bio, an array for link / team; null old = initial capture, null bio new = deletion), and changed_at. Mirrors the GraphQL publicChannelProfileHistory query exactly. A channel that has opted out or has no addressable streams → 404; unsupported platform → 400. Public data (founder ruling ZAF-791): no §5 minimisation, no retention cap. The separate /{channel}/history page reads this via publicGql (SSR) and is noindex, follow; there is no WebSocket surface.

GET /v1/public-stats/channels/\{platform\}/\{channel\}/trends​

A channel's "Stats"-tab trends (ZAF-891) - the follower-growth line and the average-viewers-over-time line. Required query param range ∈ DAYS_30 | DAYS_90 | DAYS_180 | DAYS_365 selects the rolling window; an unknown value → 400. Returns follower_series (dated follower-total growth) and avg_viewer_series (dated average viewers), each an oldest-first list of day points: follower_series items are { date, follower_count }, avg_viewer_series items are { date, avg_viewers }, with date = YYYY-MM-DD (UTC). Follower points are the last recorded follower total per day (dated crawler snapshots); avg-viewer points are the exact sample-weighted daily mean from the channel_history_stats_1h aggregate scoped to the channel's own sessions. Mirrors the GraphQL publicChannelTrends query exactly. A channel that has opted out or has no addressable streams → 404; unsupported platform → 400. Either series is an empty list ([]) when the channel has no history in the window. Aggregate counts only - no viewer identities. Read via publicGql (SSR); no WebSocket surface.

GET /v1/public-stats/channels/\{platform\}/\{channel\}/panel-history​

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: panel_id, latest content (title / description / image_url / link_url), full span (first_seen_at / last_seen_at), lifecycle (status = "active" / "removed", removed_at), and images: [{ image_url, first_seen_at, last_seen_at }] - the distinct image versions over time, oldest-first (the slideshow). Mirrors the GraphQL publicChannelPanelHistory query exactly. A channel that has opted out or has no addressable streams → 404; unsupported platform → 400; a channel with no ad-marked panels → 200 with an empty list. image_url is the Twitch CDN URL; the Stats page shows it through its image proxy. The channel page renders current active panels only; the noindex, follow /{channel}/history page renders removed panels and earlier artwork. Public data: no §5 minimisation, no retention cap; read via publicGql (SSR), no WebSocket surface.

Sitemap and share-card reads (unauthenticated)​

These routes mirror the GraphQL publicSitemapChannelPageCount, publicSitemapChannels and publicChannelCard queries. No token or RBAC permission is required. The account-less system:public_stats kill switch is their only gate. When it is off, the count is 0, a page is [], and the card is 404 Channel not found, the REST equivalent of GraphQL null.

Routedata in the REST envelope
GET /v1/public-stats/sitemap/channels{ "page_count": number }, ceil(candidate channels / 45000). Opt-outs are not subtracted from the count, so the final file can be short or empty.
GET /v1/public-stats/sitemap/channels/\{page\}Up to 45,000 { platform, channel_login, lastmod } entries. page starts at 0; past the end returns [], while a negative page returns 400 with page must not be negative. lastmod is the most recent stream-start date, UTC YYYY-MM-DD.
GET /v1/public-stats/channels/\{platform\}/\{platform_channel_id\}/cardOne card by stable platform channel ID, not by handle. Unknown, opted-out and disabled all return 404 Channel not found. Unsupported platform returns 400 Unsupported platform: <p>; an ID outside 1–64 ASCII letters, digits, _ or - returns 400 Invalid platform channel id.

The sitemap includes only channel pages on public_stats.sitemap_platforms (default twitch,kick) with a login and a stream start in the last 12 months. It checks opt-outs at read time; clients cannot expand the platform allowlist. The card checks visibility on each hit. Both sitemap routes return Cache-Control: public, max-age=3600, s-maxage=86400 on 200; the card returns public, max-age=600, s-maxage=900, stale-while-revalidate=300 on 200.

The card has exactly 13 snake_case fields: platform, platform_channel_id, channel_login (current canonical handle), channel_display_name, is_live, current_viewers, follower_count, stream_count, avg_viewers, peak_viewers, watch_seconds (viewer-seconds), last_category, and last_streamed_at (RFC 3339). Unavailable figures are null, never zero. No chatter identity, profile text or change history is present.

GET /v1/public-stats/streams/\{platform\}/\{broadcast_id\}​

One stream by its public key (platform, broadcast_id). Returns the stream summary, emote_count, top_emotes, gif_count and top_gifs (ZAF-968 — top_gifs is [{ id, url, provider, alt, count }], the GIF twin of top_emotes; on the public-stats crawler path provider/alt are null, and both fields are erased after the 90-day GIF-content retention), de-identified new_paid_subs / gifted_subs counts, new_followers, the viewer_curve timeseries, and top_chatters only when the channel opted the leaderboard in (show_top_chatters = true) - otherwise null. Chatters the channel had erased appear blanked as frozen; chatters on Lumio's global list (chatter_suppressions: Lumio-level erasures and objections) are blanked in top_chatters on read (erased: true, count kept), also on streams frozen before the objection; if that lookup fails, top_chatters is null and the rest of the page is still served. An unknown stream or an opted-out channel → 404; unsupported platform → 400.

Every public stream summary (both here and in the channel endpoint's recent_streams) carries shared_chat_sources (ZAF-819): a list of source-channel message counts. It also carries shared_chat_sessions: the public host and participation timeline for a first-party stream. Crawled streams without EventSub session events return an empty list. No viewer counts or individual viewer identities occur in that timeline. GraphQL twin: PublicStreamSummary.sharedChatSessions.

shared_chat_sources is a list of { platform_channel_id, message_count, display_name, avatar_url } naming which source channels contributed Twitch Shared Chat messages to that broadcast, and how many each did - so the stream page can show "who these stats are coming from". display_name + avatar_url (ZAF-868) are the source channel's public identity, resolved from the crawler channel-profile store; 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 rather than counted toward the host, so the host's message_count / unique_chatters reflect only its own channel's chat. The list is empty for a normal (non-shared) broadcast. Attribution is by channel (public broadcaster identity), never by viewer - aggregates only.

GET /v1/public-stats/streams/\{platform\}/\{broadcast_id\}/metadata-history​

A broadcast's stream-metadata change timeline (ZAF-890) - title / category / tags changes over time, most-recent first. Query params limit (default 50, max 200) and offset (default 0) paginate. Each entry: change_type (title / category / tags), old_value / new_value (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 changed_at. Mirrors the GraphQL publicStreamMetadataHistory query exactly. An unknown stream or an opted-out channel → 404; unsupported platform → 400. Public data (founder ruling ZAF-791/ZAF-794): no §5 minimisation, no retention cap.

Cross-channel browse (Form B)​

Browse/rank across all channels - the metric nav, a Games directory, and channel search. No auth; gated by the account-less system:public_stats kill-switch; edge-cacheable (Cache-Control on the 200). Opted-out channels are excluded. Mirrors the GraphQL publicChannelBrowse / publicGames queries exactly. No WebSocket surface (a browse/search directory has no live-push consumer).

GET /v1/public-stats/browse​

Ranked channel list. Query parameters (all optional): platform, category (Games filter), metric (watchtime (default) / viewers / peak / follower_gain / messages / recent), live (bool - true = only live, false = only offline), search (case-insensitive substring on the channel handle + display name), page (1-based, default 1), limit (clamped 1..=100, default 50). An unknown metric → 400.

Response data: { "channels": [ { platform, platform_channel_id, channel_login, channel_display_name, source, last_category, is_live, stream_count, watch_seconds, avg_viewers, peak_viewers, total_messages, follower_gain, last_started_at, broadcaster_type } ], "pagination": { page, limit, total, total_pages, total_capped } }. watch_seconds = Σ avg-viewers·duration; follower_gain = Σ new-followers (absolute follower total is not a browse metric - see the feature doc); broadcaster_type is the Twitch channel type ("partner" / "affiliate", else null; ZAF-841). Timestamps are RFC-3339.

GET /v1/public-stats/games​

Games directory. Query parameters (all optional): platform, search (case-insensitive substring on the category name), page, limit (same clamping). Response data: { "games": [ { category, channel_count, stream_count, live_stream_count, peak_viewers, watch_seconds, last_started_at } ], "pagination": { page, limit, total, total_pages, total_capped } }.

Depth cap (both endpoints). A browse pages at most 10 000 rows deep: page is clamped so the page starts before row 10 000, and the returned page is the clamped value. total counts at most 10 000; total_capped is true when more rows match (render total as "10 000+"). Same cap and fields on GraphQL.

Cross-channel placements (/partners)​

GET /v1/public-stats/placements​

Active, broadcaster-marked advertising panels of every public channel, grouped by advertiser (the panel link's host, lower-cased, leading www. stripped). No auth; gated by the system:public_stats kill-switch; edge-cacheable (Cache-Control on the 200). Opted-out channels are excluded. Mirrors the GraphQL publicPlacementsBrowse query exactly - same fields, validation and error messages. No WebSocket surface.

Query parameters (all optional): platform (default and only supported value twitch), domain (one advertiser), sort (channel_count (default) / last_seen), after (the previous page's next_cursor), limit (advertisers, clamped 1..=100, default 25), placements_limit (per advertiser, clamped 1..=50, default 10). 400 for an unknown sort, an unsupported platform (Unsupported platform: …) or an invalid / other-sort cursor (Invalid cursor).

Response data: { "advertisers": [ { domain, channel_count, placement_count, first_seen_at, last_seen_at, "placements": [ { platform, platform_channel_id, channel_login, channel_display_name, avatar_url, panel_id, title, image_url, link_url, first_seen_at, last_seen_at } ] } ], "next_cursor", "has_more" }. Timestamps are RFC-3339.

Watchlist​

Per-user favourite-channel list for the public Stats app. Unlike the public read model above, this is an authenticated per-user surface - scoped to the signed-in Lumio-ID viewer (JWT me), with no account/RBAC permission and no plan gate (a favourite is a personal preference). Mirrors the GraphQL myWatchlist query / addToWatchlist / removeFromWatchlist mutations exactly (same fields, validation and errors). There is no WebSocket surface (a user's own single list has no live-push consumer) and no audit event (personal bookkeeping with no security consequence).

GET /v1/watchlist​

The viewer's watchlist, newest-first. Response data: { "items": [ { platform, platform_channel_id, channel_login, channel_display_name, added_at } ] } — [] when empty. Not signed in → 401.

POST /v1/watchlist​

Favourite a channel. Body: { "platform": string, "channel": string } where channel is the URL handle/login. The handle is resolved to the stable platform_channel_id server-side (the same resolution the public channel page uses), and channel_login / channel_display_name are denormalized. Idempotent upsert; a per-user cap of 500 channels is enforced. Response data: the stored item. Not signed in → 401; unsupported platform or cap reached → 400; handle that resolves to no addressable channel → 404.

DELETE /v1/watchlist​

Remove a channel by its stable id. Body: { "platform": string, "platform_channel_id": string }. Idempotent - returns { "success": true } even when the row was already absent. Not signed in → 401.

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 GraphQL publicEmotes query exactly (same fields, filters, pagination and errors). No WebSocket surface (a static browse / search page has no live-push consumer).

GET /v1/public-stats/emotes​

Paginated, filterable, name-searchable browse of the emote catalog. No auth. Query parameters (all optional): platform (twitch/youtube/kick), provider (the platforms plus 7tv/bttv/ffz), channel_id, animated (bool), search (case-insensitive substring on the emote name), page (1-based, default 1), and limit (clamped to 1..=100, default 50). Empty-string filters are treated as absent.

Response data: { "emotes": [ { id, platform, provider, channel_id, name, url, animated, owner_id, source, first_seen_at, last_seen_at } ], "pagination": { page, limit, total, total_pages } }. channel_id is null for a global (platform-wide) set; source is api_fetch | observed; timestamps are RFC-3339. The internal provider_emote_id is not exposed.

Rate-limiting: the anonymous-tier per-IP limiter that wraps every /v1 request. Caching: Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600 on the 200 - public reference data, safe to edge-cache. When the kill-switch is off the endpoint returns 403 FEATURE_DISABLED with Feature 'system:public_stats' is not available (parity with the GraphQL guard error).

Audit Log​

Scoped read surfaces over the audit_events table - one endpoint per scope column value. They mirror the GraphQL myAuditLog / accountAuditLog operations exactly (same fields, permission, validation and errors). The operator audit surface stays at GET /v1/admin/audit-log and can optionally filter by scope.

Both endpoints share the same query parameters and paginated response shape:

Query paramTypeEffect
pageintegerPage number (default 1).
limitintegerPage size (default 50, clamped 1..=200).
typestringExact event-type match.
fromRFC-3339 timestampOnly events at/after this instant. An unparseable value is ignored.
toRFC-3339 timestampOnly events at/before this instant. An unparseable value is ignored.

Response: { "items": [...], "total": N, "page": N, "limit": N, "total_pages": N }.

GET /v1/me/audit-log​

The caller's own scope=user rows. Self-only, no RBAC permission - but a first-party principal is required (a logged-in session or the user's own API key); a popout/overlay/widget/extension token gets 401. Returns only the caller's rows. Current writers record successful logins, failed login attempts tied to a known user, and OAuth grant consent. Mirrors GraphQL myAuditLog.

GET /v1/account/audit-log​

The active account's scope=account events. Current emitters write role/permission, member-role, channel-connection, and account-scoped GDPR erasure rows. Permission: the account-scope audit-log:read (403 otherwise; 401 if no account is active). Returns only the caller's account's rows. Mirrors GraphQL accountAuditLog.

GET /v1/me/audit-log/export and GET /v1/account/audit-log/export​

Download the personal or the account log as a file, with the same guard as the matching read above. format=csv|json (default csv); columns is a comma list of time, event, ip, device, location, details (default all); type, from and to filter like the read. ids selects rows: a comma list of at most 1000 event ids. More ids, an unknown column or format, or a malformed id returns 400. A file holds at most 10,000 rows. CSV cells that a spreadsheet would run as a formula start with '. This file-download endpoint intentionally has no GraphQL twin.

Every audit response item (here, on GET /v1/me/audit-log, and on the admin GET /v1/admin/audit-log) carries a resolved_names array of { id, kind, display_name } (kind ∈ user | account | role) - read-time resolutions of the entity UUIDs the event references (actor + an allowlist of metadata ids), computed once per page. Misses (deleted/unknown entities) are omitted; the raw UUIDs are never removed. This mirrors the GraphQL resolvedNames field exactly (ZAF-1233).

POST /v1/internal/audit-ingest​

Server-to-server ingest for user-scope login / 2FA / OAuth-grant security events. These originate in apps/id (NextAuth, TypeScript) at the auth boundary, but the Rust API is the single audit writer, so apps/id posts them here after the auth event instead of writing audit_events itself.

Auth: System key only, carrying the narrow audit:ingest grant. Every non-System principal (session, user API key, popout/overlay/widget/extension token) is rejected 403, and an anonymous request 401 - no public/user route can forge audit rows. A System key without audit:ingest (or audit:*) is also 403; a bare *:* does not grant it. Provision apps/id's key with audit:ingest.

Request body:

FieldTypeRequiredNotes
user_idUUIDyesThe subject user the event is about.
typestringyesOne of user:login, user:login_failed, user:mfa_enabled, user:mfa_disabled, user:oauth_granted. Any other value (including an account:* / system:* type) → 400.
ip_addressstringnoThe end-user's client IP at the auth boundary (this is a server-to-server call, so the request peer is the id service - forward the real client IP here). GeoIP-resolved fail-open into country/city.
user_agentstringnoThe end-user's user-agent.
metadataobjectnoFree-form JSON object (defaults to {}; a non-object value → 400).

The handler forces scope = user, user_id = <subject>, and account_id = NULL - an ingest caller can never widen scope, attach a tenant, or write a non-user-scope type. On success returns 201 { "id": "<uuid>" } with the new row id.

Passkey and 2FA request fields​

Passkey sign-in and assertion challenge responses contain challenge_id and browser options. Registration options contain browser options and, for the first method, recovery_codes; the finish call takes response without a separate challenge ID. Send the browser's PublicKeyCredentialJSON as response with that challenge ID to the matching verify route. A passkey second-factor answer uses passkey_challenge_id and passkey_response; alternatives are code and recovery_code. The mfa_methods list on pending sign-in and step-up responses contains passkey and/or totp after the first factor. GET /v1/users/me/mfa returns factors[] with id, kind, label, created_at, last_used_at, and, for passkeys, provider, backup_eligible, backup_state. The API limits a profile to five apps and ten passkeys. Admin user results report mfa_app_count and mfa_passkey_count without method names. Invalid labels return INVALID_MFA_FACTOR_LABEL; first-passkey completion without the recovery-code acknowledgment returns RECOVERY_CODES_NOT_STORED; a missing passkey method returns PASSKEY_NOT_SET_UP. Passkey failures include PASSKEY_INVALID, PASSKEY_CHALLENGE_EXPIRED and PASSKEY_ALREADY_REGISTERED; the per-kind limit returns MFA_FACTOR_LIMIT.