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 path | Request or filter | Guard and result |
|---|---|---|
POST /v1/withdrawals | name, email, contract_reference; optional message, locale, turnstile_token, client_request_id | Public withdrawal; receipt with status: open. |
POST /v1/cancellations | name, email, contract_reference; optional kind, reason, effective, effective_date, locale, turnstile_token, client_request_id | Public cancellation; receipt with status: open. |
POST /v1/billing/withdrawal | contract (plan, extension_purchase, other); optional extension_purchase_id, contract_reference, message, name, receipt_email, client_request_id, account_id | Interactive 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/cancel | Optional kind, reason, effective, effective_date, receipt_email, name, client_request_id, account_id | plan:edit in the target channel and interactive session; stores a plan cancellation declaration. |
POST /v1/extension-purchases/\{id\}/cancel | Optional kind, reason, effective, effective_date, name, receipt_email, client_request_id, account_id | extension-store:uninstall in the target channel and interactive session; that channel must own the active subscription purchase. |
GET /v1/admin/contract-declarations | status (open default, processed, rejected, all), kind (withdrawal, cancellation), first (1–100, default 50), after | Admin 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\}/close | status (processed or rejected); optional note, refund_reference, stripe_applied | Admin contract-declarations:edit and interactive session; no automatic refund. |
POST /v1/admin/contract-declarations/\{id\}/match | account_id, contract (plan or extension_purchase), optional extension_purchase_id | Admin contract-declarations:edit and interactive session; match an open public case. |
POST /v1/admin/contract-declarations/\{id\}/retention | retain_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 path | Request body | Guard and result |
|---|---|---|
POST /v1/admin/users/\{id\}/release-funds-delete | request_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-delete | as above | Admin 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 path | Request body | Guard and result |
|---|---|---|
POST /v1/admin/released-balances/claim | claim_code | Admin 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/settle | claim_code, developer_id | Admin 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 path | Purpose |
|---|---|
GET /v1/auth/turnstile-config | Public runtime Turnstile site key and widget requirement. |
POST /v1/auth/register | Email/password registration; gives the same check-your-inbox response for existing addresses. |
POST /v1/auth/register/resend | Resend a registration confirmation. |
POST /v1/auth/email/verify | Confirm 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/login | Email/password sign-in; may return mfa_required, mfa_token and mfa_methods instead of tokens. |
POST /v1/auth/authorize | PKCE 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/reset | Request a reset link without disclosing account existence. |
POST /v1/auth/password/reset/complete | Set the password, end prior sessions, then continue sign-in and 2FA if enabled. |
POST /v1/auth/mfa/verify | Complete the pending 2FA challenge with an app/recovery code or passkey assertion before tokens or a PKCE code are issued. |
POST /v1/auth/passkey/options | Get a public, IP-limited passkey sign-in challenge. |
POST /v1/auth/passkey/verify | Sign in with a passkey; the passkey also completes 2FA. |
POST /v1/auth/mfa/passkey/options | Get a challenge for the profile named by a pending mfa_token. |
POST /v1/auth/step-up/passkey/options | Get a challenge to confirm a session or a 2FA change. |
POST /v1/auth/step-up/passkey | Confirm the interactive session with a passkey. |
POST /v1/auth/step-up/oauth | Confirm with an already linked platform account; requires an interactive session. |
POST /v1/auth/step-up/password | Confirm with the email password when 2FA is off. |
POST /v1/auth/step-up/mfa | Confirm 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 path | Purpose |
|---|---|
GET /v1/users/me/email-login | Current email sign-in and pending-link state. |
POST /v1/users/me/email-login | Request or resend the setup link; fresh step-up and Turnstile. |
POST /v1/users/me/email-login/change | Request confirmation of a replacement address. |
POST /v1/users/me/email-login/password | Set the password after address confirmation. |
DELETE /v1/users/me/email-login/pending | Cancel a pending setup or address change. |
DELETE /v1/users/me/email-login | Remove email sign-in; fresh step-up and at least one other sign-in method required. |
POST /v1/users/me/password | Change the password using the current password. |
DELETE /v1/users/me/email | Remove 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/mfa | 2FA status, recovery-code count, trusted devices and factors (apps and passkeys). |
POST /v1/users/me/mfa/setup | Begin setup; requires fresh step-up. |
POST /v1/users/me/mfa/setup/confirm | Check an authenticator code and return ten recovery codes. |
POST /v1/users/me/mfa/enable | Finish setup; requires fresh step-up. |
POST /v1/users/me/mfa/disable | Turn off all methods with a valid second factor. |
POST /v1/users/me/mfa/recovery-codes | Replace 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/options | Begin registration; first-method response includes ten recovery codes. |
POST /v1/users/me/passkeys | Finish 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-devices | Revoke every trusted device. |
GET /v1/users/me/step-up | Available 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-code | Current hourly support code; interactive session only. |
GET /v1/users/me/flagged-connections | Sign-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 path | Purpose |
|---|---|
GET /v1/users/me/data-exports | List the caller's export requests. |
POST /v1/users/me/data-exports | Request 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\}/cancel | Cancel 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-link | Issue a five-minute signed link after fresh step-up. |
GET /v1/data-exports/\{id\}/download | Stream 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 path | Purpose |
|---|---|
POST /v1/users/me/deletion | Schedule deletion after the chosen grace period, or delete at once; fresh step-up. See below. |
DELETE /v1/users/me/deletion | Cancel 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/export | Export personal activity as CSV or JSON. |
GET /v1/account/audit-log/export | Export 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 path | Purpose and guard |
|---|---|
POST /v1/admin/users/\{id\}/mfa/reset | Reset a user's 2FA with a mandatory reason; admin users:edit. |
POST /v1/admin/users/\{id\}/support-code/reveal | Reveal the current support code; admin support:verify, audited. |
POST /v1/admin/users/\{id\}/support-code/check | Check a code; admin support:verify, limited to five attempts per profile per ten minutes and audited. |
GET /v1/admin/users/\{id\}/support-code/checks | Recent checks; admin support:verify. |
Lumio exposes a RESTful API. Most resource endpoints wrap their payload in a HATEOAS envelope.
Base URLs
| Environment | URL |
|---|---|
| Production | https://api.lumio.vision/v1 |
| Production Preview | https://lumio.api.prod.zaflun.dev/v1 |
| Staging | https://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".
error | HTTP | Meaning |
|---|---|---|
BAD_REQUEST | 400 | Malformed request |
VALIDATION_ERROR | 400 | Request body failed validation |
UNAUTHORIZED | 401 | No or invalid credential |
FORBIDDEN | 403 | Authenticated but missing the required resource:action |
PLAN_LIMIT_REACHED | 403 | The account's plan quota for this resource is exhausted |
FEATURE_DISABLED | 403 | The feature flag gating this endpoint is off for the account |
NOT_FOUND | 404 | Resource does not exist or is not visible to the caller |
CONFLICT | 409 | Conflicts with current state (duplicate slug, live session, …) |
BILLING_DISABLED | 409 | Billing (Stripe) is not set up on this deployment (stripe.enabled = false); message Billing is not enabled |
WRONG_PLATFORM_ACCOUNT | 409 | A 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_REQUIRED | 403 | The 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_DISSOLVING | 403 | The channel is scheduled for dissolution and suspended until then; details.dissolution_scheduled_for names the date. See Channels being dissolved |
AVATAR_BUSY | 503 | Picture upload only: all decode slots for profile and channel pictures are taken; retry after Retry-After seconds. See POST /users/me/avatar |
RATE_LIMIT_EXCEEDED | 429 | Per-auth-context rate limit exceeded (see Authentication), or Spotify throttled an upstream request (see Spotify) |
DATABASE_ERROR, REDIS_ERROR, INTERNAL_SERVER_ERROR | 500 | Server-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/whendocs.swagger_uiis enabled in config - OpenAPI spec -
/v1/api-doc/openapi.json(served with Swagger UI) or/v1/openapi.jsonwhendocs.openapi_jsonis enabled
Both are config-gated so a deployment can lock them down. The generated spec covers the endpoints that carry utoipa annotations.
[docs] key | default.toml (dev, staging) | production.toml | ENV override |
|---|---|---|---|
swagger_ui | true | false | LUMIO__DOCS__SWAGGER_UI |
openapi_json | true | true (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 group | Path prefix | Description |
|---|---|---|
| Auth | /v1/auth | Token exchange, refresh, logout, OAuth link/authorize, popout-token exchange |
| Users | /v1/users/me | Current-user profile, login connections, sessions |
| Contact | /v1/contact | Public contact form: forwards a message to the Lumio inbox by e-mail, nothing stored (see Contact form) |
| Accounts | /v1/accounts | Create/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/invites | Team management |
| Roles | /v1/roles | RBAC role CRUD and permission catalog |
| Tokens | /v1/tokens, /v1/api-keys | Popout 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/overlays | Overlay configuration CRUD |
| Sounds | /v1/sounds | Sound library management, upload, playback control, and streaming |
| Widgets | /v1/widgets | Widget instance CRUD, access management, and duplication |
| Setting Presets | /v1/setting-presets | Named 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/uploads | File 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/events | Event history, single event, emit/test |
| Emotes | /v1/emotes | Channel 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/sweep | POST 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/erase | POST {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/withdraw | POST {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/erase | POST {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/erase | POST {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/objections | POST {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-streams | Live 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/connections | App credentials and channel OAuth flow |
| Bot Connections | /v1/bot-connections, /v1/bot-status, /v1/bot-toggle, /v1/bot-rejoin | Custom 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-commands | Cross-platform command CRUD and global overrides |
| Bot Modules | /v1/bot-modules | Moderation module configs (link/spam/word/timed), extension bot module kill switch |
| Bot Module Triggers | /v1/internal/bot-modules | Internal 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}/handlers | Internal handler-code bundle + context feed for the workers' InstallRegistry (System key with bots:internal) |
| Automations | /v1/automations | Visual automation CRUD and manual execution |
| Automation Extension Nodes | /v1/automation/extension-nodes, /v1/automation/webhooks | Extension node listing and webhook receiver |
| Channel Status | /v1/channel-status, /v1/channels/schedule, /v1/spotify/manual-connect | Live 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/notifications | In-app notifications, actions, and delivery preferences |
| Ideas Hub | /v1/ideas | Community 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-tokens | StreamElements JWT token storage |
| Discord Guilds | GET /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-reports | User-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/metadata | Static platform metadata (name, icon, features, scopes) |
| Features | /v1/features/enabled, /v1/providers/enabled | Public feature/provider flag reads |
| Extension Bundles | /v1/extension-bundles | Serve extension bundle files (JS, CSS, assets) |
| Extension Version Files | /v1/extensions/{id}/versions/{version}/files | List files in an extension version bundle |
| Extension Uploads | /v1/extensions/{id}/uploads | Upload/serve files from extension editors (icons, images) |
| Fonts | /v1/fonts/{family}/css, /v1/fonts/{family}/{weight}.woff2 | DSGVO-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/application | Submit and view developer applications |
| Developer Verification | /v1/developer/verification | Submit and view own KYC verification (self-service) |
| Developer Credit Notes | /v1/developer/credit-notes, /v1/admin/credit-notes, /v1/admin/developer-payouts | Self-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/limits | View resolved limits and request increases |
| Developer Teams | /v1/developer/teams | Team CRUD, members, invites, roles, permissions |
| Developer Store Profiles | /v1/developer/profiles/{slug}, /v1/developer/teams/{slug}/profile | Public developer and team profile pages |
| Admin Developer Applications | /v1/admin/developer-applications | Review, approve, reject developer applications |
| Admin Limit Requests | /v1/admin/limit-requests, /v1/admin/developers/{id}/limit-requests | Review developer limit increase requests |
| Admin Extension Limits | /v1/admin/extensions/{id}/limits | Set per-extension limit overrides |
| Admin Extension Purchases | /v1/admin/extension-purchases/undelivered | GET ?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-folders | Overlay folder CRUD and move |
| Overlay Shared Links | /v1/overlay-shared-links | Time-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-flag | Channel 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-info | Live channel metadata for the active account |
| Stream History | /v1/history | Session list, reports, stats, exports, and shared report links |
| Public Stats | /v1/public-stats | Per-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/emotes | Public, unauthenticated emote-catalog browse (filter + name search + pagination). No auth; gated by the system:public_stats kill-switch; edge-cacheable |
| Watchlist | /v1/watchlist | Per-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-log | Self-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/featured | Public extension store browsing, listings, reviews, icons (/v1/store/extensions/{id}/icon-data serves the bytes), screenshots |
| Extension Installs | /v1/extension-installs | Install lifecycle, per-install storage, actions, and query/mutate/execute function calls |
| Extension Purchases | /v1/extension-purchases | Paid-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-invites | Private-extension access invites |
| User Roles | /v1/user-roles | User-scoped role CRUD (admin) |
| Well-Known | /v1/.well-known/* | Service discovery documents |
| Health | /v1, /v1/health | Liveness probes; /v1/health also carries service-dependency status and live WebSocket gauges |
| Metrics | /metrics | Prometheus 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 path | Parameters | Result |
|---|---|---|
GET /v1/songs | page (default 1), limit (default 25, maximum 100), optional search, status (safe or blocked), source | Paginated global songs. |
GET /v1/songs/check | spotify_track_id, or a song title and artist | Global status for a song. |
GET /v1/songs/sitemap | Optional 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 UUID | One global song; 404 when unknown or not global. |
GET /v1/playlists | None | System-curated playlist summaries. |
GET /v1/playlists/\{id\} | Playlist UUID | One 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.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
lumio_build_info | gauge | version | Constant 1; running build version in the label |
lumio_uptime_seconds | gauge | - | Seconds since the API process started |
lumio_websocket_sessions | gauge | - | Active WebSocket sessions |
lumio_websocket_channels | gauge | - | Active WebSocket broadcast channels |
lumio_db_pool_connections | gauge | pool | Connections owned by the pool (postgres, timescaledb) |
lumio_db_pool_idle | gauge | pool | Idle 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 [email protected]) 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.
| Field | Type | Rule |
|---|---|---|
name | string, optional | At most 100 characters, no control characters; blank counts as absent. |
email | string | A valid e-mail address; becomes the Reply-To. |
subject | string | One of general, privacy, legal, billing, other. |
message | string | 1 to 5000 characters after trimming. |
turnstile_token | string | Cloudflare 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.
| Status | error | message |
|---|---|---|
| 400 | INVALID_EMAIL | Enter a valid email address |
| 400 | INVALID_CONTACT_NAME | The name can have at most 100 characters |
| 400 | INVALID_CONTACT_SUBJECT | Choose a subject: general, privacy, legal, billing or other |
| 400 | INVALID_CONTACT_MESSAGE | The message needs 1 to 5000 characters |
| 400 | TURNSTILE_REQUIRED | Please complete the security check |
| 400 | TURNSTILE_FAILED | The security check failed. Please try again |
| 429 | RATE_LIMITED | Too many requests. Try again later (details.retry_after_secs and a Retry-After header) |
| 503 | TRY_AGAIN | Please 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_idis bound at token-create time and could be the account owner, is rejected with403 FORBIDDENon both REST and GraphQL. This stops a permission-capped member from listing/minting/renaming/revoking a key bound to the owner (an owner-boundlm_usr_key would otherwise launder past this same gate - e.g.PATCH /v1/users/meemail change → owner takeover).
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/api-keys/permissions?kind=personal|service | apikeys:read | List the scopes assignable to an API key of the given kind |
GET | /v1/api-keys | apikeys:read | List the caller's own user API keys for the active account |
POST | /v1/api-keys | apikeys:create | Create 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:edit | Rename one of the caller's own keys (label only) |
POST | /v1/api-keys/\{id\}/rotate | apikeys:edit | Rotate 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:delete | Revoke 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-scopedideas:*permissions (139 assignable scopes) - a personal key acts with its member's user identity, soideas:*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) andaccount: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).
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/service-keys | apikeys:read | List the account's service keys |
POST | /v1/service-keys | apikeys:create + interactive session | Create a service key; returns the full lm_svc_ key once |
PATCH | /v1/service-keys/\{id\} | apikeys:edit | Rename a service key (label only) |
POST | /v1/service-keys/\{id\}/rotate | apikeys:edit + interactive session; caller must hold every scope on the key | Rotate a service key - regenerate the secret in place; returns the new full lm_svc_ key once |
DELETE | /v1/service-keys/\{id\} | apikeys:delete | Revoke 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).
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/developer/api-keys | Auth (developer) | List the caller's developer keys (own profile + member teams) |
POST | /v1/developer/api-keys | Auth (developer) + team-secrets:edit for a team key | Create a developer key; returns the full lm_dev_ key once |
PATCH | /v1/developer/api-keys/\{id\} | Auth (developer) + team-secrets:edit for a team key | Rename a developer key (label only) |
DELETE | /v1/developer/api-keys/\{id\} | Auth (developer) + team-secrets:edit for a team key | Revoke (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:
| Parameter | Type | Description |
|---|---|---|
limit | integer | Page size (default 20, max 100) |
offset | integer | Pagination offset |
search | string | Filter 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
| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | Audio file (MP3, WAV, OGG, FLAC). Max size set by max_sound_file_size plan limit. |
name | string | No | Display name (defaults to filename without extension) |
Errors:
400BAD_REQUEST/VALIDATION_ERROR- Unsupported file type or file exceeds size limit403PLAN_LIMIT_REACHED- Account has reached themax_soundsor 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.
| Field | Type | Description |
|---|---|---|
name | string | New display name |
duration_ms | integer | Duration in milliseconds |
waveform | JSON | Precomputed 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:
| Field | Type | Required | Description |
|---|---|---|---|
volume | float | No | Playback volume 0.0–1.0 (default 1.0) |
target | object | No | \{ "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:
| Field | Type | Required | Description |
|---|---|---|---|
target | object | No | \{ "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.
| Condition | Response |
|---|---|
install_id unknown, from another account, or soft-uninstalled | 404 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_id | 400 An extension widget requires an install_id |
| Plan widget limit reached | 403 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:
| Field | Type | Required | Description |
|---|---|---|---|
role | string | Yes | "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:
| Parameter | Type | Description |
|---|---|---|
kind | string | Optional 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:
| Field | Type | Required | Description |
|---|---|---|---|
kind | string | Yes | chat | events | music |
name | string | Yes | Display name (non-empty, ≤ 255 chars). Duplicate names are allowed. |
payload | object | Yes | The settings snapshot (the ZAF-622 export format, \{ "format": 1, ... \}). Opaque to the backend. |
is_default | boolean | No | Mark as this kind's default (unsets the previous default of the same kind) |
Errors:
400BAD_REQUEST- invalidkind, empty name, or non-object/oversized payload403FORBIDDEN- wrong token class (not a first-party session) / feature disabled409PLAN_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:
| Field | Type | Description |
|---|---|---|
name | string | New name (rename) |
payload | object | New 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:
403FORBIDDEN-Missing permission: presets:share, wrong token class, or feature disabled404NOT_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:
installedAutomationNodesquery andautomationWebhookUrlquery 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):
| Credential | May 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 key | A 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 else | Never → 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.
| Method | Path | Permission | Notes |
|---|---|---|---|
GET | /v1/connections/shopify | connections: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/inspect | connections:create, interactive session | Body { "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/confirm | connections:create, interactive session | Body { "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/settings | connections:edit | Body { "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-name | events:edit | Body { "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/shopify | connections:delete | 204; 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.
| Method | Path | Notes |
|---|---|---|
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/link | Starts 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/complete | Body { "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/settings | Body 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/disconnect | 204; 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_idand 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 suppliedaccount_idmust 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_idsupplied) the target is pinned to the active account, becauselogin-assignments:*is resolved against the active account's roles. Anything else is rejected with403andYou 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:
| Field | Type | Required | Notes |
|---|---|---|---|
login_connection_id | UUID | Yes | ID of the login connection to assign |
provider | string | Yes | Platform slug, e.g. "twitch", "google" |
user_id | UUID | No | Defaults to the authenticated user |
account_id | UUID | No | Target 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):
| Field | Values | Meaning |
|---|---|---|
locale | auto · en · de | UI language; auto uses the request-time fallback (locale cookie if valid, otherwise default language) |
timezone | auto 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_format | auto · dmy_dot · dmy_slash · mdy_slash · ymd_dash | Numeric date format: automatic language-locale format, 31.12.2026, 31/12/2026, 12/31/2026, or 2026-12-31 |
time_format | auto · h12 · h24 | Numeric 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):
| Field | Type | Default | Values |
|---|---|---|---|
season_look_enabled | bool | true | One switch for every season |
season_strength | string | full | full · subtle |
season_motion | string | auto | auto (animations play unless the OS asks for reduced motion) · off |
season_creatures | object | {} | { "<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_dismissed | string[] | [] | 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:
| Field | Type | Default | Values |
|---|---|---|---|
motion_preference | string | system | system (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/notificationsendpoints,POST/GET /developer/application,POST/GET /developer/verification, account create/dissolve/leave, invite accept (POST /invites/{code}/accept, ZAF-1740 A6), and the JWT-mintingPOST /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 andPOST /auth/ws-tokenlisted as session-only below. A popout/overlay/widget/extension token, whoseuser_idis bound at token-create time and could be set to the account owner, is rejected with403 FORBIDDENon 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): thedeveloper/profile,developer/revenue,developer/payout-settings,developer/payout-history,developer/limits*reads, thedeveloper/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 with403 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
pendingand the balance is debited. The payout cycle sends Stripe and PayPal payouts within about 15 minutes (Stripecompleted, PayPalprocessinguntil its webhook); SEPA payouts and payouts with an unknown provider answer are settled by an operator. Every request emitsdeveloper:payout_requested(user scope) on both protocols. See Developer Payouts.
Payouts need a verified developer (ZAF-2804):
POST /developer/payouts/requestanswers403 DEVELOPER_VERIFICATION_REQUIRED("Payouts require a verified developer identity. Complete the verification in the developer settings first") until the developer verification isverified; the automatic payout cycle skips unverified developers. GraphQL twin:requestPayout, same code and text.
Closing payout (ZAF-2825):
POST /developer/payouts/requesttakes 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); otherwise409 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-minimum400. 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/requestissues the payout's credit note (Gutschrift) in the same transaction. The payoutamountis 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 answers422 TAX_DETAILS_REQUIREDwhile 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) and503 CREDIT_NOTE_ISSUER_NOT_CONFIGUREDwhile[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 billingPOST /billing/checkout,POST /billing/portalandPOST /billing/cancelrequire an interactive sign-in session. A user's own API key is refused with403 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.
400 EMAIL_MANAGED_BY_LOGIN(Set your e-mail under Security › Sign-in › E-mail & password) before anything is written (GraphQLupdateMeidentical). 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), otherwisenull. 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 same403. 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 writesPUT /accounts/{id}/identity-source,PATCH /accounts/{id}/name,POST/DELETE /accounts/{id}/avatar(see Channel name and picture) andPOST /auth/ws-token(the minted JWT carries the full user permission set). See Authentication → Session-only Surfaces.
| Field | Type | Description |
|---|---|---|
email | string | Deprecated. Always refused with 400 EMAIL_MANAGED_BY_LOGIN |
active_account_id | UUID | Switch active account (must be a member; interactive sign-in session only) |
clear_active_account | bool | Clear active account (go to user-only mode; interactive sign-in session only) |
streamer_mode | bool | Toggle Streamer Mode on/off |
extension_dev_mode | bool | Toggle Extension Developer Mode - loads draft/testing versions in editors |
locale | string | UI language: auto · en · de |
timezone | string | auto or a valid IANA timezone name |
date_format | string | auto · dmy_dot · dmy_slash · mdy_slash · ymd_dash |
time_format | string | auto · h12 · h24 |
display_currency | string | auto, EUR, or an ECB reference currency; auto resolves to EUR for German and USD otherwise |
season_look_enabled | bool | Seasonal look on/off |
season_strength | string | full · subtle |
season_motion | string | auto · off |
season_creatures | object | Replaces the creature map: { "<creature>": bool }, keys [a-z_]{1,32}, at most 32 entries; {} resets every creature to its default |
season_banner_dismissed | string[] | Replaces the dismissed-banner list: <seasonId>:<year> entries; duplicates are dropped and beyond 50 the oldest entries are dropped |
motion_preference | string | "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 withPATCH /users/me/display-nameand the picture withPOST /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).
| Field | Type | Description |
|---|---|---|
kind | string / absent | connection (default) or own. Anything else returns 400 INVALID_NAME_SOURCE. |
login_connection_id | UUID / null | With 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:
| Check | Error |
|---|---|
| At most 5 MB | 413 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 completely | 400 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 field | 400 AVATAR_MISSING |
| File storage is not configured | 503 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.
| Case | Response |
|---|---|
| Current upload | 200, 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 pattern | 400 Invalid picture file name |
| Not the user's current upload (replaced, removed, another user's id) | 404 Picture not found |
| File storage is not configured | 503 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:
| Check | Response |
|---|---|
| First-party user (popout, overlay, widget and extension tokens refused) | 403 FORBIDDEN |
grace_days is 0, 7 or 30 | 400 INVALID_GRACE |
:id is the caller's active account | 403 |
| Caller is the owner | 403 "Only the account owner can dissolve an account" |
| No dissolution already scheduled | 409 DISSOLUTION_ALREADY_SCHEDULED |
| No running Stripe subscription that is not set to cancel at period end | 409 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" }
| Case | Error |
|---|---|
kind is not connection / own, connection without connection_id, own with a connection_id, or a Spotify connection | 400 INVALID_SOURCE |
| The connection does not exist or belongs to another channel | 404 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:
| Check | Error |
|---|---|
| At most 5 MB | 413 AVATAR_TOO_LARGE |
| Type by magic bytes: PNG, JPEG or WebP | 415 AVATAR_UNSUPPORTED_TYPE |
| At most 16 megapixels | 400 AVATAR_TOO_MANY_PIXELS |
| The image decodes completely | 400 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 field | 400 AVATAR_MISSING |
| File storage is not configured | 503 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.
| Case | Response |
|---|---|
| Current upload | 200, 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 pattern | 400 Invalid picture file name |
| Not the channel's current upload | 404 Picture not found |
| File storage is not configured | 503 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/mewith the account list anddissolution_scheduled_for, switching and leaving accounts, and the owner'sDELETE /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/checkoutanswers403 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:shopifyin 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.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/admin/accounts/\{account_id\}/api-keys | account-apikeys:read | List 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-keys | account-apikeys:read | List a user's personal lm_usr_ keys (service keys have no owning user) |
PATCH | /v1/admin/api-keys/\{kind\}/\{id\} | account-apikeys:edit | Edit a key's label and permissions (scopes) |
POST | /v1/admin/api-keys/\{kind\}/\{id\}/rotate | account-apikeys:rotate | Rotate a key - regenerate its secret in place; returns the new full key once |
DELETE | /v1/admin/api-keys/\{kind\}/\{id\} | account-apikeys:delete | Revoke (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
descriptiontonullto 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"- ifis_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
| Field | Type | Required |
|---|---|---|
name | string | Yes |
description | string | null | No |
permissions | string[] | Yes (may be empty) |
UpdateAdminRoleRequest
| Field | Type | Notes |
|---|---|---|
name | string | Optional; trimmed |
description | string | null | null = clear; omit = leave unchanged |
permissions | string[] | Optional; replaces via diff |
AdminRoleResponse
| Field | Type |
|---|---|
id | UUID |
name | string |
description | string or null |
is_system | boolean |
permissions | string[] |
member_count | integer |
created_at | ISO-8601 string |
updated_at | ISO-8601 string |
AdminRoleMemberResponse
| Field | Type |
|---|---|
user_id | UUID |
display_name | string |
email | string or null |
avatar_url | string or null |
assigned_at | ISO-8601 string |
AdminPermissionInfoResponse
| Field | Type |
|---|---|
permission | string |
category | string |
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:
| Field | Type | Required | Notes |
|---|---|---|---|
slug | string | Yes | Regex ^[a-z0-9]+(?:-[a-z0-9]+)*$, length 2–40, immutable after creation |
name | string | Yes | Display name |
description | string | null | No | |
price_monthly | integer | Yes | Cents (or the minor unit of currency) |
price_yearly | integer | Yes | Cents |
currency | string | null | No | ISO-4217 code; defaults to "USD" |
is_public | boolean | Yes | Whether the plan is visible on the public pricing page |
sort_order | integer | Yes | Sort position in pricing pages |
max_overlays | integer | Yes | |
max_storage_bytes | integer | Yes | |
max_upload_size_bytes | integer | Yes | |
max_integrations | integer | Yes | |
chat_retention_days | integer | Yes | 0–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_sounds | integer | Yes | Maximum number of sounds per account |
max_sound_file_size | integer | Yes | Maximum size per sound file in bytes |
max_sound_storage_bytes | integer | Yes | Total sound storage quota in bytes |
max_setting_presets | integer | Yes | Setting-preset quota across all kinds; 0 = unlimited (defaults: Free 5, Pro 20) |
stripe_product_id | string | null | No | Paste from Stripe dashboard |
stripe_monthly_price_id | string | null | No | Paste from Stripe dashboard |
stripe_yearly_price_id | string | null | No | Paste from Stripe dashboard |
PATCH /v1/admin/plans/{id}
Requires plans:edit. Body: UpdatePlanRequest. Returns 200 + AdminPlanResponse.
- The
slugis immutable and is therefore not part ofUpdatePlanRequest. - 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- missingplans:edit(Missing permission: plans:edit) - 404
"Plan not found"- no plan with that ID
Fields of UpdatePlanRequest:
| Field | Type | Notes |
|---|---|---|
name | string | |
description | string | null | |
price_monthly | integer | |
price_yearly | integer | |
currency | string | |
is_public | boolean | |
sort_order | integer | |
max_overlays | integer | |
max_storage_bytes | integer | |
max_upload_size_bytes | integer | |
max_integrations | integer | |
chat_retention_days | integer | 0–1095 days; outside this range returns 400. 0 keeps chat and events indefinitely, while chatter and moderation data have a 1095-day ceiling. |
max_sounds | integer | |
max_sound_file_size | integer | |
max_sound_storage_bytes | integer | |
stripe_product_id | string | null | |
stripe_monthly_price_id | string | null | |
stripe_yearly_price_id | string | null |
DELETE /v1/admin/plans/{id}
Requires plans:delete. Returns 204 No Content on success.
- 403
FORBIDDEN- missingplans: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
| Field | Type |
|---|---|
id | UUID |
slug | string |
name | string |
description | string or null |
price_monthly | integer |
price_yearly | integer |
currency | string |
is_public | boolean |
sort_order | integer |
max_overlays | integer |
max_storage_bytes | integer |
max_upload_size_bytes | integer |
max_integrations | integer |
chat_retention_days | integer |
max_sounds | integer |
max_sound_file_size | integer |
max_sound_storage_bytes | integer |
max_setting_presets | integer |
stripe_product_id | string or null |
stripe_monthly_price_id | string or null |
stripe_yearly_price_id | string or null |
features | [AdminPlanFeatureResponse] |
accounts_using | integer |
AdminPlanFeatureResponse
| Field | Type |
|---|---|
feature_id | UUID |
feature_key | string |
label | string |
enabled | boolean |
Protocol parity: The three write endpoints have matching GraphQL mutations, plus
PUT /v1/admin/plans/{id}/featuresfor 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.
| Method | Path | Body | Effect |
|---|---|---|---|
POST | /v1/chat/pins | platform, message_id, duration_seconds | Pin a Twitch message, replacing the current pin. |
PATCH | /v1/chat/pins | Same | Change the current pin's duration from now. |
DELETE | /v1/chat/pins/\{platform\}/\{message_id\} | None | Unpin 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 parameter | Type | Description |
|---|---|---|
platform | string | Optional platform filter: twitch, youtube, or kick |
platform_user_id | string | Optional platform-native user filter |
live_chat_id | string | Optional YouTube live-chat/broadcast filter |
from | ISO 8601 timestamp | Optional inclusive lower time bound. When omitted, the read covers the 30 days before to (or now) |
to | ISO 8601 timestamp | Optional inclusive upper time bound |
search | comma-separated string | Optional 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. |
page | integer | 1-based page number, default 1 |
limit | integer | Items 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 acceptsChatFilterInput.searchas a real[String!]list; REST accepts the same logical terms through the comma-separatedsearchquery parameter. ThereplyParentLogin,isSharedChat/sourceChannelfields are present on both protocols and on thechat:messageWebSocket 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 parameter | Type | Description |
|---|---|---|
platform | string | Optional platform-tab filter (twitch, youtube, kick); applied to every source; a non-Twitch value skips AutoMod holds |
live_chat_id | string | Optional YouTube live-chat/broadcast filter; messages only; skips AutoMod holds |
type | comma-separated string | Optional enabled event-type set (e.g. ?type=twitch:follower,kick:subscribe); events only. Maps to GraphQL types: [String!] |
before | string | Opaque keyset cursor from a prior page's next_cursor (or any item's cursor); omit for the newest page |
limit | integer | Total 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.
| Query | Notes |
|---|---|
platform | Optional, 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.
Chat User Search
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 parameter | Type | Description |
|---|---|---|
q | string | Case-insensitive prefix over username and display_name. Shorter than 2 characters (after trimming) returns an empty list, not an error. %, _, \ match literally. |
platform | comma-separated string | Optional platform filter, for example ?platform=twitch,youtube. Omit for all platforms. Maps to GraphQL platforms: [String!]. |
limit | integer | Optional, 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
| Route | Permission | Response |
|---|---|---|
GET /v1/chat/automod/held | chat:automod-read | Open holds, oldest first, as messages; queue_available: false means the queue could not be read. |
POST /v1/chat/automod/held/\{message_id\}/allow | chat:automod-allow | Decision result; Twitch publishes an allowed message with a new ID. |
POST /v1/chat/automod/held/\{message_id\}/deny | chat:automod-deny | Decision 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:
| Parameter | Description |
|---|---|
platform | Platform slug: twitch, youtube, or kick |
platform_user_id | The platform-native user identifier |
Responses:
| Status | Body | Description |
|---|---|---|
200 | Refreshed 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 status | code | Meaning |
|---|---|---|
| 400 | NOTIFICATION_NO_ACTIONS | The notification has no actions. |
| 400 | NOTIFICATION_ACTION_NOT_AVAILABLE | The requested action is absent from this notification. |
| 400 | NOTIFICATION_ACTION_NAVIGATES | The action opens a page; there is nothing to execute. |
| 400 | NOTIFICATION_ALREADY_HANDLED | The notification has already been acted on. |
| 400 | NOTIFICATION_UNKNOWN_ACTION | The action is present but has no dispatcher. |
| 400 | INVALID_INVITE_ID | A required invite ID is missing or invalid. |
| 400 | INVITE_EXPIRED / INVITE_FULL | The invite has expired or reached its use limit. |
| 400 / 409 | ALREADY_MEMBER | The caller is already in the account (400) or developer team (409). |
| 403 | FEATURE_DISABLED | Team invite acceptance requires feature:extension_development. |
| 404 | NOTIFICATION_NOT_FOUND | The notification does not exist or belongs to another user. |
| 404 | INVITE_NOT_FOUND | The invite to accept no longer exists. |
| 409 | INVITE_NO_LONGER_VALID | The 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
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/notifications/preferences | Auth | List all delivery-channel preferences for the current user |
PATCH | /v1/notifications/preferences/{type} | Auth | Set 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:
notificationPreferencesquery andupdateNotificationPreferencemutation 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:
loginon anauthoror timelineactoris returned only to Ideas staff (ideas:moderate_readon the user or admin role) and to the user themself. Everyone else getsnull, including anonymous callers and other signed-in users. The public pages and the dashboard show onlydisplay_nameandavatar_url.- In the
timelineofGET /v1/ideas/:id, theold_value/new_valueoftitle_changedanddescription_changedentries hold the previous and new text. Only the idea's author and Ideas staff see them. Everyone else getsnullfor both, andactionis still returned.status_changed(status key) andcategory_changed(category label) values are returned to everyone, because the public timeline shows them.
Ideas
| Method | Path | Description | Permission |
|---|---|---|---|
GET | /v1/ideas | List ideas (filter, sort, paginate) | Public |
GET | /v1/ideas/:id | Get idea with timeline | Public |
POST | /v1/ideas | Create idea | ideas:create |
PATCH | /v1/ideas/:id | Update idea | ideas:edit / ideas:moderate_edit |
DELETE | /v1/ideas/:id | Delete idea | ideas: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:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status slug |
category_id | UUID | Filter by category |
tag_ids | string | Comma-separated list of tag UUIDs |
author_id | UUID | Filter by author |
search | string | Full-text search on title and description |
sort | string | newest, most_voted, most_commented, recently_updated |
limit | integer | Page size |
offset | integer | Page offset |
Voting
| Method | Path | Description | Permission |
|---|---|---|---|
POST | /v1/ideas/:id/vote | Vote on an idea | ideas:vote |
DELETE | /v1/ideas/:id/vote | Remove vote | ideas:vote |
POST body: { "vote_type": "up" | "down" }.
Comments
| Method | Path | Description | Permission |
|---|---|---|---|
GET | /v1/ideas/:id/comments | List comments (nested) | Public |
POST | /v1/ideas/:id/comments | Create comment | ideas:comment_create |
PATCH | /v1/ideas/comments/:id | Update comment | ideas:comment_edit |
DELETE | /v1/ideas/comments/:id | Delete comment | ideas:comment_delete / ideas:moderate_comment |
POST | /v1/ideas/comments/:id/vote | Vote on a comment | ideas:comment_vote |
DELETE | /v1/ideas/comments/:id/vote | Remove comment vote | ideas: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
| Method | Path | Description | Permission |
|---|---|---|---|
GET | /v1/ideas/:id/participants | List participants for @mention autocomplete | Signed-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
| Method | Path | Description | Permission |
|---|---|---|---|
GET | /v1/ideas/:id/voters | List voters for an idea | Public |
Categories
| Method | Path | Description | Permission |
|---|---|---|---|
GET | /v1/ideas/categories | List all categories | Public |
POST | /v1/ideas/categories | Create category | Admin ideas:edit |
PATCH | /v1/ideas/categories/:id | Update category | Admin ideas:edit |
DELETE | /v1/ideas/categories/:id | Delete category | Admin ideas:delete |
Tags
| Method | Path | Description | Permission |
|---|---|---|---|
GET | /v1/ideas/tags | List / search tags | Public |
POST | /v1/ideas/tags | Create tag | ideas:create |
DELETE | /v1/ideas/tags/:id | Delete tag | Admin ideas:delete |
GET /v1/ideas/tags supports an optional search query parameter.
Status (Moderation)
| Method | Path | Description | Permission |
|---|---|---|---|
PATCH | /v1/ideas/:id/status | Change idea status | ideas: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:
| Field | Type | Description |
|---|---|---|
id | UUID | Application ID |
user_id | UUID | Applicant's user ID |
application_type | string | "solo" or "team" |
display_name | string | Developer/team display name |
slug | string | URL slug (3-50 chars, lowercase, hyphens) |
description | string | Description of the developer/team |
motivation | string | Why the user wants developer access |
what_to_build | string | What extensions the user plans to build |
github_url | string or null | GitHub profile URL |
website_url | string or null | Website URL |
experience | string or null | Development experience |
avatar_key | string or null | Upload key for avatar image |
status | string | "pending", "approved", or "rejected" |
review_notes | string or null | Notes from the reviewer |
reviewed_by | UUID or null | Reviewer's user ID |
reviewed_at | ISO-8601 or null | Review timestamp |
created_at | ISO-8601 | Submission timestamp |
updated_at | ISO-8601 | Last 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:
| Field | Type | Required | Description |
|---|---|---|---|
application_type | string | Yes | "solo" or "team" |
display_name | string | Yes | Developer/team display name |
slug | string | Yes | URL slug (3-50 chars, starts with lowercase letter, only lowercase letters, digits, hyphens) |
description | string | Yes | Description |
motivation | string | Yes | Why you want developer access |
what_to_build | string | Yes | What you plan to build |
github_url | string | No | GitHub profile URL |
website_url | string | No | Website URL |
experience | string | No | Development 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:
myDeveloperApplicationquery andsubmitDeveloperApplicationmutation 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):
| Field | Type | Description |
|---|---|---|
id | UUID | Verification ID |
developer_id | UUID | The developer profile this record belongs to |
legal_name | string | Legal name |
company_name | string or null | Company name (company type only) |
address_line1 | string | Address line 1 |
address_line2 | string or null | Address line 2 |
postal_code | string | Postal code |
city | string | City |
country | string | ISO-3166-1 alpha-2 country code |
tax_id | string or null | Tax ID (legacy free text) |
tax_number | string or null | Steuernummer |
vat_id | string or null | USt-IdNr. / foreign VAT ID (stored upper-case, without spaces) |
small_business_declared | boolean or null | Small business under § 19 UStG; null = not answered |
tax_details_updated_at | ISO-8601 or null | When the tax details were last entered |
trade_register_id | string or null | Trade register ID (company type only) |
document_key | string or null | Upload key for a supporting document |
status | string | "pending", "verified", or "rejected" |
review_notes | string or null | Notes from the reviewer (e.g. rejection reason) |
reviewed_by | UUID or null | Reviewer's user ID |
reviewed_at | ISO-8601 or null | Review timestamp |
created_at | ISO-8601 | First submission timestamp |
updated_at | ISO-8601 | Last 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:
| Field | Type | Required | Description |
|---|---|---|---|
legal_name | string | Yes | Legal name |
company_name | string | No | Company name - its presence infers the company developer type |
address_line1 | string | Yes | Address line 1 |
address_line2 | string | No | Address line 2 |
postal_code | string | Yes | Postal code |
city | string | Yes | City |
country | string | Yes | ISO-3166-1 alpha-2 country code (exactly 2 letters) |
tax_id | string | No | Tax ID |
trade_register_id | string | No | Trade register ID |
document_key | string | No | Upload key for a supporting document |
tax_number | string | No | Steuernummer (max. 50 characters) |
vat_id | string | No | USt-IdNr. / foreign VAT ID (max. 50 characters) |
small_business_declared | boolean | No | Small 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:
| Developer | Needed | Credit note |
|---|---|---|
| Germany, small business | tax_number or vat_id, small_business_declared: true | No VAT, § 19 UStG note |
| Germany, charges VAT | tax_number or vat_id, small_business_declared: false | 19 % VAT on top of the revenue share |
| Other EU country | vat_id | No 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:
developerVerificationStatusquery and thesubmitDeveloperVerification/updateDeveloperTaxDetailsmutations 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:
| Field | Type | Description |
|---|---|---|
id | UUID | Credit note ID |
kind | string | credit_note or cancellation |
number | string | GS-2026-000001 |
issued_at | ISO-8601 | Date of issue |
payout_id | UUID or null | The payout it documents (credit notes only) |
cancels_id | UUID or null | The cancelled credit note (cancellations only) |
cancelled_at | ISO-8601 or null | When this credit note was cancelled |
cancellation_reason | string or null | payout_failed, developer_objection or correction |
period_start, period_end | ISO-8601 | Service period |
currency | string | EUR |
net_amount, vat_amount, gross_amount | integer | Cents; negative on a cancellation |
vat_rate_bp | integer | VAT rate in basis points (1900 = 19 %) |
vat_treatment | string | domestic_standard, domestic_small_business, reverse_charge_eu or reverse_charge_third_country |
pdf_status | string | pending (being generated) or stored |
download_url | string or null | GET URL of the PDF (null while pending) |
delivered_at | ISO-8601 or null | When 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).
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | Yes | developer_objection or correction |
objection_received_at | ISO-8601 | For an objection | When the objection arrived; not allowed for a correction |
reissue | boolean | No | Issue 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.
| Field | Type | Description |
|---|---|---|
id | UUID | Payout id |
developer_id | UUID | null | null once the profile was deleted |
amount / currency | integer / string | Cents, EUR (the credit note's gross amount) |
method | string | stripe, paypal or bank |
status | string | pending, processing, completed or failed |
stripe_transfer_id / paypal_batch_id | string | null | Provider ids |
payout_reference | string | null | Masked destination |
created_at / completed_at / execution_started_at | ISO-8601 | execution_started_at: when the payout cycle sent it |
needs_operator | boolean | pending 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,completeDeveloperPayoutandfailDeveloperPayoutin 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:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter 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:
| Field | Type | Required | Description |
|---|---|---|---|
notes | string | No | Optional 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:
| Field | Type | Required | Description |
|---|---|---|---|
notes | string | Yes | Rejection 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:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter 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:
| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | "approve" or "reject" |
notes | string | For reject | Review 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:
| Field | Type | Description |
|---|---|---|
max_sounds | integer or null | Max bundled sounds (null to clear) |
max_sound_file_size | integer or null | Max per-file size in bytes (null to clear) |
max_sound_storage_bytes | integer or null | Max 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
| Method | Path | Permission | Description |
|---|---|---|---|
POST | /v1/developer/teams | Auth (developer) + feature:extension_development | Create a new team |
GET | /v1/developer/teams | Auth (developer) | List teams the user belongs to |
GET | /v1/developer/teams/{id} | Team member | Get team details |
PATCH | /v1/developer/teams/{id} | team-settings:edit | Update team name/description/URLs |
DELETE | /v1/developer/teams/{id} | Team owner only | Delete a team and its extensions (uninstalled from every channel; see Accounts) |
Team Members
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/developer/teams/{id}/members | team-members:read | List team members |
PATCH | /v1/developer/teams/{id}/members/{member_id}/role | team-members:edit | Change a member's role |
DELETE | /v1/developer/teams/{id}/members/{member_id} | team-members:remove | Remove a member |
Team Invites
| Method | Path | Permission | Description |
|---|---|---|---|
POST | /v1/developer/teams/{id}/invites | team-members:invite | Create an invite link |
GET | /v1/developer/teams/{id}/invites | team-members:read | List active invites |
DELETE | /v1/developer/teams/{id}/invites/{invite_id} | team-members:invite | Revoke an invite |
GET | /v1/developer/team-invites/{code} | Public | Look up an invite by code |
POST | /v1/developer/team-invites/{code}/accept | Auth + feature:extension_development | Accept 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)
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/developer/teams/{id}/roles | team-members:read | List roles for a team |
POST | /v1/developer/teams/{id}/roles | team-settings:edit | Create a custom role |
PATCH | /v1/developer/teams/{id}/roles/{rid} | team-settings:edit | Update a role |
DELETE | /v1/developer/teams/{id}/roles/{rid} | team-settings:edit | Delete a role |
GET | /v1/developer/teams/permissions | Auth (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:
| Field | Type | Description |
|---|---|---|
id | UUID | Developer profile ID |
display_name | string | Developer display name |
slug | string | URL slug |
description | string or null | Bio/description |
github_url | string or null | GitHub profile URL |
website_url | string or null | Website URL |
avatar_key | string or null | Avatar upload key |
created_at | ISO-8601 | Profile creation date |
stats | object | { extension_count, total_installs, avg_rating } |
extensions | array | List 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:
| Field | Type | Description |
|---|---|---|
id | UUID | Team ID |
name | string | Team name |
slug | string | URL slug |
description | string or null | Team description |
github_url | string or null | GitHub URL |
website_url | string or null | Website URL |
avatar_key | string or null | Avatar upload key |
created_at | ISO-8601 | Team creation date |
stats | object | { extension_count, total_installs, avg_rating } |
members | array | [{ user_id, display_name, avatar_url, role }] |
extensions | array | Published 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.
| Status | When |
|---|---|
404 Extension icon not found | Unknown extension, not store-visible, no icon, a stored key that is not PNG/JPEG/WebP, or the object is missing from storage |
503 ICON_UNAVAILABLE | File 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):
| Field | Type | Description |
|---|---|---|
account_id | UUID | Scope to a single account (incremental account refresh). |
install_id | UUID | Scope to a single install (incremental install/update refresh). |
bundles | by_version | Bundle 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'shandler_codeisnull, andbundlesmaps each install'sversion_idto the compiledserver.jsbundle, or tonullwhen 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_codeis the bundle (one string per install) ornullwhen unresolvable; there is nobundleskey.
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 } }
]
}
| Field | Type | Description |
|---|---|---|
trigger_type | string | command, keyword, pattern, event, timer or moderate |
handler | string | Handler function name (command name, event type, timer name, …), 1-200 characters |
platform | string, optional | Platform the trigger came from |
actions | array | { "action", "params" } items, at most 20; params defaults to {} |
Errors:
| Status | When |
|---|---|
400 | Unknown trigger_type, empty or over-long handler, or more than 20 actions |
404 | Bot 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_code | Meaning |
|---|---|
denied | Refused 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_limited | The install exceeded [extensions] bot_module_action_rate_per_minute (default 120) in the current minute |
rate_limit_unavailable | Redis could not count the action; refused (fail-closed) |
dispatcher codes (bot_not_connected, bot_scope_missing, moderation_forbidden, platform_not_supported, …) or none | The 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:
| Field | Type | Required | Description |
|---|---|---|---|
account_id | UUID | Yes | Must be the caller's active account (403 Cannot control kill switch for another account otherwise) |
killed | boolean | Yes | true 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.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/developer/extensions/\{id\}/secrets | extension-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 foundfor 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(alwaysnull),key,created_at,updated_at. - Audit: set emits
developer:extension_secret_set, a delete that removed a row emitsdeveloper:extension_secret_deleted(user scope, metadataextension_id,key, anddeveloper_key_idfor 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 path | Request | Validation |
|---|---|---|
POST /v1/developer/extensions/\{id\}/versions | JSON version, manifest, optional data_schema | Manifest 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/upload | Multipart files, source, manifest, optional data_schema (JSON), and version metadata | Manifest 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\}/submit | Draft version ID in the path | Stored 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.
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.
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:
| Parameter | Type | Description |
|---|---|---|
limit | integer | Max results (default 50, max 200) |
offset | integer | Pagination offset |
since | ISO-8601 | Only 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:
| Parameter | Type | Description |
|---|---|---|
since | string (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,
"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:
| Parameter | Description |
|---|---|
extension_id | UUID of the extension |
version | Semver version string (e.g. 1.2.0) |
path | File 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.gzis 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_modeor an extension token) or a system key carryingbots:internal:Cache-Control: private, no-storeplusVary: 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:
| Parameter | Description |
|---|---|
id | UUID of the extension |
version | Semver 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:
queryruns aqueryRowsdefinition,mutateaninsertRow/patchRow/deleteRowdefinition. The definition supplies the table, filters (=,!=,>,>=,<,<=,like,in), order (defaultcreated_atascending),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 byparams.identity()is the calling user's id (nullfor token callers). A missingarg()isnullin values and filters, "not set" forlimit/offset, and400 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: trueruns 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 gets403 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) andlimit(newest first).mutate:table,function_type(insert_row|patch_row|delete_row),args(the column values) androw_id(patch/delete). A request withtableis a raw operation andparamsis ignored. Extension, overlay and widget tokens get403 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_atand_updated_at. - Scope: every statement is limited to the install's account; another account's
_idmatches 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 returns404 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-modulectx.db; over the limit429 RATE_LIMIT_EXCEEDED. When the rate-limit store is unreachable, reads run and writes return503 EXTENSION_DATA_UNAVAILABLE(fail-closed). A statement over 2 s returns504 EXTENSION_QUERY_TIMEOUT; no free extensions-database connection within 2 s returns503 EXTENSION_DATA_UNAVAILABLE(db busy). - Callers:
User/ApiKeyof the install's account needextension-data:read(query) orextension-data:edit(mutate). System keys needbots: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) is404 Extension install not foundfor every caller and never reaches the worker.User/ApiKeyneedextension-data:readfor aquery()handler andextension-data:editformutation()/action().editorOnlycomes from the definition; tokens get403 Editor-only function called outside editor. While the account's extension kill switch is on, every call gets403 EXTENSIONS_KILLED. - Handler context:
ctx.dbis the shared data executor on the install's account (same limits as above); aquery()handler is read-only - itsctx.dbwrites,ctx.cachewrites andctx.deferjobs are refused inside the handler, soextension-data:readnever causes a stored write (it can still trigger the handler's ownctx.realtime.pushbroadcasts, under the install's realtime cap);ctx.authis{userId, accountId, role}(userIdisnullfor token callers);ctx.configthe install's config;ctx.cache,ctx.secretsandctx.deferas for bot modules;ctx.fetchrefuses 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), 50mutation()and 20action()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 gets429 RATE_LIMIT_EXCEEDEDat once instead of waiting;server.jsat 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 ofA-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 atctx.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 onlumio:extension_install:{install_id}, which reaches WebSocket subscribers ofextension_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 failedfor 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 nosystem_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:
| Parameter | Type | Description |
|---|---|---|
since | RFC 3339 timestamp | Only entries at or after this time |
level | string | Filter 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 —
nodeConfigkeeps 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>"(nullstaysnull).
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 calldb::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.
| Endpoint | Permission | Result |
|---|---|---|
GET /v1/account-bots | chat-bots:read | Account entries, newest first: platform, platform_user_id, login, display_name, source, added_by, added_by_name, added_at. |
GET /v1/account-bots/auto-detected | chat-bots:read | Read-only badge, curated-list and own-connection entries with platform, source, identity, name. |
POST /v1/account-bots | chat-bots:edit | Body: 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:edit | Returns { 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.
| Endpoint | Result |
|---|---|
GET /v1/history/keywords | keywords (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/test | Body: 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/keywords | Body: 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.
GET /v1/history/reports/\{id\}/shared-links
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):
| Case | Status | error | message |
|---|---|---|---|
| Malformed, unknown, expired or revoked token | 410 | SHARE_UNAVAILABLE | This share link is no longer available |
| Active link with a password; password missing or wrong | 401 | SHARE_PASSWORD_REQUIRED | A 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.
| Route | data 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\}/card | One 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 param | Type | Effect |
|---|---|---|
page | integer | Page number (default 1). |
limit | integer | Page size (default 50, clamped 1..=200). |
type | string | Exact event-type match. |
from | RFC-3339 timestamp | Only events at/after this instant. An unparseable value is ignored. |
to | RFC-3339 timestamp | Only 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:
| Field | Type | Required | Notes |
|---|---|---|---|
user_id | UUID | yes | The subject user the event is about. |
type | string | yes | One 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_address | string | no | The 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_agent | string | no | The end-user's user-agent. |
metadata | object | no | Free-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.