Skip to main content

REST API

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

Base URLs

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

Response Format

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

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

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

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

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.

errorHTTPMeaning
BAD_REQUEST400Malformed request
VALIDATION_ERROR400Request body failed validation
UNAUTHORIZED401No or invalid credential
FORBIDDEN403Authenticated but missing the required resource:action
PLAN_LIMIT_REACHED403The account's plan quota for this resource is exhausted
FEATURE_DISABLED403The feature flag gating this endpoint is off for the account
NOT_FOUND404Resource does not exist or is not visible to the caller
CONFLICT409Conflicts with current state (duplicate slug, live session, …)
RATE_LIMIT_EXCEEDED429Per-auth-context rate limit exceeded — see Authentication
DATABASE_ERROR, REDIS_ERROR, INTERNAL_SERVER_ERROR500Server-side failure

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

Interactive Documentation

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

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

Spec completeness policy

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

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

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

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

Endpoints Overview

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

Resource groupPath prefixDescription
Auth/v1/authToken exchange, refresh, logout, OAuth link/authorize, popout-token exchange
Users/v1/users/meCurrent-user profile, login connections, sessions
Accounts/v1/accountsCreate/dissolve/leave accounts, member invites, login assignments
Login Connections/v1/login-connections/{id}Delete a login connection by UUID; PUT /v1/login-connections/{id}/reconnect-flag clears its reconnect prompt
Members & Invites/v1/accounts/{id}/members, /v1/invitesTeam management
Roles/v1/rolesRBAC role CRUD and permission catalog
Tokens/v1/tokens, /v1/api-keysPopout token and user API key management. POST /v1/tokens requires a first-party session (logged-in user or the user's own API key; ZAF-1017/ZAF-469) — a popout/overlay/widget/extension token is rejected with 403 FORBIDDEN, and an omitted user_id binds to the creator, never the account-owner fallback. 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 reports exactly its own scope and a logged-in user reports their account-role permissions; identical to GraphQL myPermissions, and never the ["*"] wildcard.
Overlays/v1/overlaysOverlay configuration CRUD
Sounds/v1/soundsSound library management, upload, playback control, and streaming
Widgets/v1/widgetsWidget instance CRUD, access management, and duplication
Uploads/v1/uploadsFile uploads (multipart) and presigned download URLs
Chat/v1/chat/*History (filter by platform, user, date, keyword search, YouTube live_chat_id), send, moderate, user profiles, user name-completion search, notes, raid/poll/prediction
Events/v1/eventsEvent history, single event, emit/test
Emotes/v1/emotesChannel and user emote fetching
YouTube Memberships/v1/youtube/memberships/tiers, /v1/admin/privacy/youtube/member/{id}Read observed YouTube member-tier badges (account-scoped); GDPR-Art-17 erasure of a member's cached data and their historical chat rows (system_admin only) — see Member Badges
Chat Privacy/v1/admin/privacy/chat/eraseGDPR-Art-17 erasure of a data subject's platform_chat_messages across all accounts (admin:privacy-erase). Mirrors GraphQL eraseChatSubjectData
YouTube Streams/v1/youtube/active-streamsActive and upcoming YouTube broadcasts (reads from Redis cache written by the YouTube polling worker)
Connections/v1/connectionsApp credentials and channel OAuth flow
Bot Connections/v1/bot-connections, /v1/bot-status, /v1/bot-toggle, /v1/bot-rejoinCustom bot identity OAuth and control
Bot Commands/v1/bot-commandsCross-platform command CRUD and global overrides
Bot Modules/v1/bot-modulesModeration module configs (link/spam/word/timed), extension bot module kill switch
Bot Module Triggers/v1/internal/bot-modulesInternal trigger resolution + handler-bundle feed for the Bot Module Worker (SystemKey)
Extension Handler Feeds/v1/internal/{bot-modules,automation-nodes}/handlersInternal handler-code bundle + context feed for the workers' InstallRegistry (SystemKey)
Automations/v1/automationsVisual automation CRUD and manual execution
Automation Extension Nodes/v1/automation/extension-nodes, /v1/automation/webhooksExtension node listing and webhook receiver
Channel Status/v1/channel-status, /v1/spotify/manual-connectLive status and Spotify manual polling
Spotify/v1/spotify/*State, playback, queue, devices, playlists, search
Copyright/v1/copyright/*Safe/blocked songs, playlist imports, community voting
Notifications/v1/notificationsIn-app notifications, actions, and delivery preferences
Ideas Hub/v1/ideasCommunity ideas, votes, comments, @mention autocomplete, categories, tags. Moderation actions live on the same paths and are gated on the ideas:moderate_* admin permissions
OBS/v1/integrations/obs, /v1/obs-remote/*OBS config + remote stream/recording/scene control
SE Tokens/v1/se-tokensStreamElements JWT token storage
Discord Guilds/v1/discord-guilds/exchangeDiscord guild bot install exchange
Abuse Reports/v1/abuse-reportsUser-submitted abuse reports
Webhooks/v1/webhooks/*Platform webhooks (Twitch, YouTube, Kick, Trovo, Shopify, Stripe)
Billing/v1/billing/*Stripe checkout/portal/status/invoices/coupon
Playlists/v1/playlistsRead-only global safe-song playlists
Songs/v1/songsRead-only song metadata and copyright status
Platforms/v1/platforms/metadataStatic platform metadata (name, icon, features, scopes)
Features/v1/features/enabled, /v1/providers/enabledPublic feature/provider flag reads
Extension Bundles/v1/extension-bundlesServe extension bundle files (JS, CSS, assets)
Extension Version Files/v1/extensions/{id}/versions/{version}/filesList files in an extension version bundle
Extension Uploads/v1/extensions/{id}/uploadsUpload/serve files from extension editors (icons, images)
Fonts/v1/fonts/{family}/css, /v1/fonts/{family}/{weight}.woff2DSGVO-compliant font proxy (CSS + woff2). Public scope, no auth, permissive CORS
Developer Applications/v1/developer/applicationSubmit and view developer applications
Developer Verification/v1/developer/verificationSubmit and view own KYC verification (self-service)
Developer Limits/v1/developer/limitsView resolved limits and request increases
Developer Teams/v1/developer/teamsTeam CRUD, members, invites, roles, permissions
Developer Store Profiles/v1/developer/profiles/{slug}, /v1/developer/teams/{slug}/profilePublic developer and team profile pages
Admin Developer Applications/v1/admin/developer-applicationsReview, approve, reject developer applications
Admin Limit Requests/v1/admin/limit-requests, /v1/admin/developers/{id}/limit-requestsReview developer limit increase requests
Admin Extension Limits/v1/admin/extensions/{id}/limitsSet per-extension limit overrides
Overlay Folders/v1/overlay-foldersOverlay folder CRUD and move
Overlay Shared Links/v1/overlay-shared-linksTime-limited shared overlay links (lm_share_*)
Channel Connections/v1/connections/channel, /v1/connections/status, /v1/connections/credential-modes, /v1/connections/oauth/{platform}/exchange, /v1/channel-connections/{id}/reconnect-flagChannel OAuth, reconnect prompts, the 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-modesplatform, 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, and validates return_to fail-closed against [web, admin])
Channel Info/v1/channel-infoLive channel metadata for the active account
Stream History/v1/historySession list, reports, stats, exports, and shared report links
Public Stats/v1/public-statsPer-channel publicness (opt-out) settings + the unauthenticated public read model (channel + stream stats)
Public Stats browse/v1/public-stats/{browse,games}Public, unauthenticated cross-channel browse/ranking (metric nav) + Games directory + channel search. No auth; gated by the system:public_stats kill-switch; edge-cacheable
Emote Directory/v1/public-stats/emotesPublic, unauthenticated emote-catalog browse (filter + name search + pagination). No auth; gated by the system:public_stats kill-switch; edge-cacheable
Watchlist/v1/watchlistPer-user favourite-channel list for the public Stats app. Authenticated viewer (JWT); no account/RBAC permission, no plan gate. GraphQL + REST (no WS)
Audit Log/v1/me/audit-log, /v1/account/audit-logSelf-scope personal-security log and account-scope tenant audit log; the operator audit surface lives under /v1/admin/audit-log
Marketplace/v1/marketplace, /v1/store, /v1/categories, /v1/featuredPublic extension store browsing, listings, reviews, icons, screenshots
Extension Installs/v1/extension-installsInstall lifecycle, per-install storage, actions, and query/mutate/execute function calls
Extension Purchases/v1/extension-purchasesPaid-extension checkout
Extension Access/v1/extension-access-invitesPrivate-extension access invites
User Roles/v1/user-rolesUser-scoped role CRUD (admin)
Well-Known/v1/.well-known/*Service discovery documents
Health/v1, /v1/healthLiveness probes; /v1/health also carries service-dependency status and live WebSocket gauges
Metrics/metricsPrometheus scrape endpoint (origin root, not under /v1)
Admin/v1/admin/*System-admin-only endpoints: admin-roles, admin-permissions, feature-flags, plans, coupons, users, accounts, 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, use the Swagger UI at /v1/swagger-ui/ or download /v1/api-doc/openapi.json. Feature-specific docs under Features document the most commonly used endpoints with their resource:action permissions.

Health

GET /v1/health

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

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

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

Metrics (Prometheus)

GET /metrics (internal only)

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

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

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

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

Auth

POST /v1/auth/popout/exchange

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

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

Request body:

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

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

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

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

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 first-party gated (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.

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 Dashboard → API Keys; the full key is returned once from the create response and cannot be retrieved later.

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

First-party session required (ZAF-1017 / ZAF-469): personal API keys are a first-party surface — every path below requires a logged-in user session or a user's own API key. A popout/overlay/widget/extension token, whose user_id is bound at token-create time and could be the account owner, is rejected with 403 FORBIDDEN on both REST and GraphQL. This stops a permission-capped member from listing/minting/renaming/revoking a key bound to the owner (an owner-bound lm_usr_ key would otherwise launder past this same gate — e.g. PATCH /v1/users/me email change → owner takeover).

MethodPathPermissionDescription
GET/v1/api-keysapikeys:readList the caller's own user API keys for the active account
POST/v1/api-keysapikeys:createCreate a user API key; returns the full lm_usr_ key once
PATCH/v1/api-keys/\{id\}apikeys:editRename one of the caller's own keys (label only)
DELETE/v1/api-keys/\{id\}apikeys:deleteRevoke one of the caller's own keys by hard-deleting it

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.

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"],
"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"
}
}
}

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.

MethodPathPermissionDescription
GET/v1/service-keysapikeys:readList the account's service keys
POST/v1/service-keysapikeys:createCreate a service key; returns the full lm_svc_ key once
PATCH/v1/service-keys/\{id\}apikeys:editRename a service key (label only)
DELETE/v1/service-keys/\{id\}apikeys:deleteRevoke a service key by hard-deleting it

Create request:

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

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

Create response:

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

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

Sounds

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

GET /v1/sounds

List sounds in the account's library.

Permission: sounds:read

Query parameters:

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

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

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

GET /v1/sounds/\{id\}

Get a single sound by ID.

Permission: sounds:read

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

POST /v1/sounds

Upload a new sound file.

Permission: sounds:create

Body: multipart/form-data

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

Errors:

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

Response: 201 Created with the new sound object.

PATCH /v1/sounds/\{id\}

Update sound metadata.

Permission: sounds:edit

Body: all fields optional.

FieldTypeDescription
namestringNew display name
duration_msintegerDuration in milliseconds
waveformJSONPrecomputed waveform peaks

Response: Updated sound object.

DELETE /v1/sounds/\{id\}

Delete a sound.

Permission: sounds:delete

Response: 204 No Content.

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

Trigger playback of a sound in browser sources.

Permission: sounds:play

Body:

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

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

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

Stop playback of a sound in browser sources.

Permission: sounds:play

Body:

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

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

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

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

Permission: sounds:read

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

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

Widgets

Access Management

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

List access entries for a widget.

Permission: widgets:access-read

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

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

List account members eligible for the widget access dialog.

Permission: widgets:access-read

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

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

Set a user's access role on a widget.

Permission: widgets:access-grant

Body:

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

Response: 200 with the updated access entry.

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

Remove a user's access entry from a widget.

Permission: widgets:access-revoke

Response: 204 No Content.

Duplication

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

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

Permission: widgets:create

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

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

Automation Extension Nodes

GET /v1/automation/extension-nodes

List all installed automation node extensions for the current account.

Permission: automations:read

Response:

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

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

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

Permission: automations:read

Response:

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

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

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

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

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

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

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

Login Assignments

Login assignments link a user's 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.

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

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 the active account.

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

Request body:

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

Response: 201 Created with the new assignment object.

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

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

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

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

Response: 204 No Content.

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.

Response: 204 No Content.

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), 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 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, POST /auth/link (linking/reconnecting an OAuth login provider), PUT /accounts/primary-connection, the /notifications endpoints, POST/GET /developer/application, POST/GET /developer/verification, account create/dissolve/leave, and the JWT-minting POST /auth/ws-token) — require a logged-in user session or a user's own API key. A popout/overlay/widget/extension token, whose user_id is bound at token-create time and could be set to the account owner, is rejected with 403 FORBIDDEN on both REST and GraphQL. This closes the profile-edit (email → password-reset) account-takeover path, the owner-bound-JWT minting path, and the broader authorization/disclosure gap. The same gate extends to the extension-developer REST surface keyed on the developer identity (ZAF-471): the developer/profile, developer/revenue, developer/payout-settings, developer/payout-history, developer/limits* reads, the developer/payouts (update) / developer/payouts/request (request) writes, extension create/submit/delete and tester invites, the developer-team endpoints, and extension-access grants/invites — all reject a popout/overlay/widget/extension token with 403 FORBIDDEN. Account-scoped developer routes keyed on account_id (extension secrets, billing checkout/portal) stay open.

FieldTypeDescription
display_namestringUpdate display name (cannot be empty)
emailstringUpdate email address
active_account_idUUIDSwitch active account (must be a member)
clear_active_accountboolClear active account (go to user-only mode)
streamer_modeboolToggle Streamer Mode on/off
extension_dev_modeboolToggle Extension Developer Mode — loads draft/testing versions in editors

Response: full UserResponse (same shape as GET /users/me). When switching accounts, a token field with a fresh JWT is included.

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.

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

PATCH /v1/admin/users/{id} — account creation override

The request body accepts an optional account_creation_override field:

{ "account_creation_override": "default" }

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

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

Platform-filtered connection lists

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

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

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

Admin Role Management

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

GET /v1/admin/admin-roles

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

POST /v1/admin/admin-roles

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

Validation errors (400):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

GET /v1/admin/admin-permissions

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

Request / Response Types

CreateAdminRoleRequest

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

UpdateAdminRoleRequest

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

AdminRoleResponse

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

AdminRoleMemberResponse

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

AdminPermissionInfoResponse

FieldType
permissionstring
categorystring

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

Admin Plan Management

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

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

POST /v1/admin/plans

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

Validation errors (400):

  • "Invalid slug format" — slug does not match ^[a-z0-9]+(?:-[a-z0-9]+)*$ or is outside 2–40 chars
  • "Price cannot be negative" — monthly or yearly price is negative
  • "Limit cannot be negative" — a numeric limit is negative

Conflict errors (409):

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

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

Fields of CreatePlanRequest:

FieldTypeRequiredNotes
slugstringYesRegex ^[a-z0-9]+(?:-[a-z0-9]+)*$, length 2–40, immutable after creation
namestringYesDisplay name
descriptionstring | nullNo
price_monthlyintegerYesCents (or the minor unit of currency)
price_yearlyintegerYesCents
currencystring | nullNoISO-4217 code; defaults to "USD"
is_publicbooleanYesWhether the plan is visible on the public pricing page
sort_orderintegerYesSort position in pricing pages
max_overlaysintegerYes
max_storage_bytesintegerYes
max_upload_size_bytesintegerYes
max_integrationsintegerYes
chat_retention_daysintegerYes0 = keep forever
max_soundsintegerYesMaximum number of sounds per account
max_sound_file_sizeintegerYesMaximum size per sound file in bytes
max_sound_storage_bytesintegerYesTotal sound storage quota in bytes
stripe_product_idstring | nullNoPaste from Stripe dashboard
stripe_monthly_price_idstring | nullNoPaste from Stripe dashboard
stripe_yearly_price_idstring | nullNoPaste from Stripe dashboard

PATCH /v1/admin/plans/{id}

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

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

Errors:

  • 400 "Price cannot be negative" / "Limit cannot be negative" — validation failures
  • 403 FORBIDDEN — missing plans:edit (Missing permission: plans:edit)
  • 404 "Plan not found" — no plan with that ID

Fields of UpdatePlanRequest:

FieldTypeNotes
namestring
descriptionstring | null
price_monthlyinteger
price_yearlyinteger
currencystring
is_publicboolean
sort_orderinteger
max_overlaysinteger
max_storage_bytesinteger
max_upload_size_bytesinteger
max_integrationsinteger
chat_retention_daysinteger
max_soundsinteger
max_sound_file_sizeinteger
max_sound_storage_bytesinteger
stripe_product_idstring | null
stripe_monthly_price_idstring | null
stripe_yearly_price_idstring | null

DELETE /v1/admin/plans/{id}

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

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

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

Request / Response Types

AdminPlanResponse

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

AdminPlanFeatureResponse

FieldType
feature_idUUID
feature_keystring
labelstring
enabledboolean

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

Chat History

GET /v1/chat/history

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

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

Each returned message is a snake_case chat row. For a Twitch Shared Chat message that originated in a different channel's room, the row additionally carries is_shared_chat: true and source_channel: { platform_channel_id, login, display_name, avatar_url } (the origin channel). For normal and host-origin messages is_shared_chat is false and source_channel is null. Attribution is by channel (public broadcaster identity), never by viewer.

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

GET /v1/chat/users/search

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

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

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

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

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

Chat Moderation

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" | "trovo",
"user_id": "<platform user id>", // required for ban / timeout
"message_id": "<platform message id>", // required for delete
"duration_secs": 300, // optional; default 300, YouTube range 1..86400
"reason": "spam", // optional, written to moderation_log
"live_chat_id": "<id>" // YouTube only, optional — auto-resolved from Redis when omitted
}

Permission required matches the action: chat:ban, chat:timeout, or chat:delete. Failures surface in the standard envelope with data.success = false and data.details.

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

Chat Profile Refresh

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

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

Permission: chat:refresh_user

Path parameters:

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

Responses:

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

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

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 supports action accept_invite for type: "invite" notifications: it adds the addressed user to the account referenced by data.accountId with the invite's role. Action decline_invite deletes the backing account_invites row.

Notification preferences

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

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

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

Ideas Hub

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

Ideas

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

Query parameters for GET /v1/ideas:

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

Voting

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

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

Comments

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

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

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

Participants

MethodPathDescriptionPermission
GET/v1/ideas/:id/participantsList participants for @mention autocompleteAuth only

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

Voters

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

Categories

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

Tags

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

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

Status (Moderation)

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

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

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

Developer Applications

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

GET /v1/developer/application

Returns the authenticated user's latest developer application.

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

Response:

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

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

POST /v1/developer/application

Submit a developer application.

Auth: JWT (user-level).

Request body:

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

Validation errors (400):

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

Conflict errors (409):

  • "You already have a developer profile" — user is already a developer
  • "You already have a pending application" — previous application still pending
  • "Slug is already taken" — slug in use

Response: 201 Created with the application object.

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

Developer Verification (self-service)

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

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

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

GET /v1/developer/verification

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

Response data (or null):

FieldTypeDescription
idUUIDVerification ID
developer_idUUIDThe developer profile this record belongs to
legal_namestringLegal name
company_namestring or nullCompany name (company type only)
address_line1stringAddress line 1
address_line2string or nullAddress line 2
postal_codestringPostal code
citystringCity
countrystringISO-3166-1 alpha-2 country code
tax_idstring or nullTax ID
trade_register_idstring or nullTrade register ID (company type only)
document_keystring or nullUpload key for a supporting document
statusstring"pending", "verified", or "rejected"
review_notesstring or nullNotes from the reviewer (e.g. rejection reason)
reviewed_byUUID or nullReviewer's user ID
reviewed_atISO-8601 or nullReview timestamp
created_atISO-8601First submission timestamp
updated_atISO-8601Last update timestamp

POST /v1/developer/verification

Submit (or resubmit) the developer's own KYC verification. Idempotent upsert — one record per developer; a resubmit re-enters pending and clears any prior review verdict, so a rejected developer can correct and resubmit. An already-verified record is terminal via self-service and cannot be resubmitted.

Request body:

FieldTypeRequiredDescription
legal_namestringYesLegal name
company_namestringNoCompany name — its presence infers the company developer type
address_line1stringYesAddress line 1
address_line2stringNoAddress line 2
postal_codestringYesPostal code
citystringYesCity
countrystringYesISO-3166-1 alpha-2 country code (exactly 2 letters)
tax_idstringNoTax ID
trade_register_idstringNoTrade register ID
document_keystringNoUpload key for a supporting document

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

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

Protocol parity: developerVerificationStatus query and submitDeveloperVerification mutation in GraphQL — see GraphQL.

Admin Developer Applications

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

GET /v1/admin/developer-applications

List developer applications with optional status filter.

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

Query parameters:

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

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

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

Get full details of a specific application.

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

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

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

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

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

Request body:

FieldTypeRequiredDescription
notesstringNoOptional reviewer notes

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

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

Reject a developer application. Sends rejection notifications.

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

Request body:

FieldTypeRequiredDescription
notesstringYesRejection reason (required)

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

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

Admin Limit Requests

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

GET /v1/admin/limit-requests

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

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

Query parameters:

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

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

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

List limit requests for a specific developer.

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

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

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

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

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

Request body:

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

Response: Updated limit request object.

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

Get timeline events for a limit request.

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

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

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

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

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

Request body:

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

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

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

Developer Teams

Team management endpoints with team-scoped RBAC. All team operations (except create and list) check require_team_permission() against the caller's team role.

Team CRUD

MethodPathPermissionDescription
POST/v1/developer/teamsAuth (developer)Create a new team
GET/v1/developer/teamsAuth (developer)List teams the user belongs to
GET/v1/developer/teams/{id}team-settings:readGet team details
PATCH/v1/developer/teams/{id}team-settings:editUpdate team name/description/URLs
DELETE/v1/developer/teams/{id}Team owner onlyDelete a team

Team Members

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

Team Invites

MethodPathPermissionDescription
POST/v1/developer/teams/{id}/invitesteam-members:inviteCreate an invite link
GET/v1/developer/teams/{id}/invitesteam-members:inviteList active invites
DELETE/v1/developer/teams/{id}/invites/{invite_id}team-members:inviteRevoke an invite
GET/v1/developer/team-invites/{code}AuthLook up an invite by code
POST/v1/developer/team-invites/{code}/acceptAuthAccept an invite

Team Roles (RBAC)

MethodPathPermissionDescription
GET/v1/developer/teams/{id}/rolesteam-members:readList roles for a team
POST/v1/developer/teams/{id}/rolesteam-settings:editCreate a custom role
PATCH/v1/developer/teams/{id}/roles/{rid}team-settings:editUpdate a role
DELETE/v1/developer/teams/{id}/roles/{rid}team-settings:editDelete a role
GET/v1/developer/teams/permissionsAuthList all available team permissions

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

Developer Store Profiles

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

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

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

Auth: None (public).

Response:

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

Returns 404 if no developer has the given slug.

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

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

Auth: None (public).

Response:

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

Returns 404 if no team has the given slug.

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

Extension 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: SystemKey only.

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 always false here; the kill switch is delivered live over Redis pub/sub.

{
"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: SystemKey only.

Query parameters (all optional):

FieldTypeDescription
account_idUUIDScope to a single account (incremental account refresh).
install_idUUIDScope to a single install (incremental install/update refresh).

Omitting both 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. handler_code is the single compiled server.js bundle downloaded from storage (one string per install), or null when it cannot be resolved (missing file / storage unavailable). handlers lists the valid function_names the bots dispatch (command name / keyword text / regex source / "moderate" / "timer"); every name maps to the same bundle.

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

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: SystemKey only.

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

Immediately disables an extension bot module for the caller's account. The module's triggers are removed from the active set without requiring a Worker restart. Configuration is preserved for re-enabling later.

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

Permission: bot-modules:edit

Request body:

FieldTypeRequiredDescription
install_idUUIDYesThe extension install to disable

Response: 204 No Content.

Developer Extension Endpoints

Telemetry retention & availability

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

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

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

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

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

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

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

Permission: extensions:read (developer scope)

Path parameter: id — UUID of the extension.

Query parameters:

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

Response:

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

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

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

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

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

Permission: extension-dev:read

Feature: feature:extension_development

Path parameter: id — UUID of the extension.

Query parameters:

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

Response:

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

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

GET /v1/developer/limits

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

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

Permission: extension-dev:read

Feature: feature:extension_development

Response:

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

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

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

Auth: Account auth (JWT or API key).

Permission: extension-dev:read

Feature: feature:extension_development

Response:

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

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

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

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

Permission: extension-dev:read

Feature: feature:extensions

Response:

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

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

GET /v1/developer/payout-settings

Returns the authenticated developer's saved payout settings — payout method, Stripe Connect id, PayPal email, and SEPA bank IBAN/BIC/name — for prefilling the payouts form. Companion read to the POST /v1/developer/payouts update (same private DeveloperProfile shape, gated on the payout permission). REST twin of the GraphQL developerPayoutSettings query.

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

Permission: extension-dev:payouts

Feature: feature:extension_development

Response:

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

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

Extension Bundle Serving

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

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

Auth: Published versions are publicly accessible (no auth required). Draft and testing versions require developer authentication (JWT, API key, extension token, or system key).

Path parameters:

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

Security:

  • Path traversal attempts (.., null bytes, absolute paths) are rejected with 400
  • source.tar.gz is never served (returns 403)

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

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

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

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

List all files in an extension version's bundle.

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

Path parameters:

ParameterDescription
idUUID of the extension
versionSemver version string

Response:

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

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

Extension Upload Endpoints

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

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

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

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

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

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

Response:

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

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

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

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

Extension Install Endpoints

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

Returns recent execution logs for an extension install. Allows the account owner to monitor what the extension is doing in their account.

Auth: Account auth (JWT, API key, or popout token). The caller's active account must own the install.

Permission: bot-modules:read

Path parameter: id — UUID of the extension install.

Query parameters:

ParameterTypeDescription
limitintegerMax results (default 50, max 200)
offsetintegerPagination offset
levelstringFilter by log level: info, warn, error

Response:

{
"data": [
{
"id": "uuid",
"level": "info",
"message": "Command !rank executed for user xyz",
"trigger_type": "command",
"execution_ms": 34,
"created_at": "2026-05-20T10:30:00Z"
}
]
}

Stream History

All endpoints require the feature:stream_history flag. Owner endpoints use the history:* permissions; the public share endpoint is anonymous and IP-rate-limited. These mirror the GraphQL channelHistory* operations exactly (same fields, guards, validation and errors).

GET /v1/history

List the account's stream sessions, newest first. Permission: history:read. Query: page (default 1), per_page (default 25, max 100). Returns session summaries with core aggregates and the platforms involved.

Optional, freely combinable filters (all mirror the GraphQL channelHistory arguments; total counts the filtered set so the page count stays correct):

Query paramTypeEffect
fromRFC3339 timestampOnly sessions with started_at >= this instant.
toRFC3339 timestampOnly sessions with started_at <= this instant.
session_typesingle | multiOnly sessions of this type. An unknown value → 400.
platformstringOnly sessions with a broadcast on this platform.
searchstringCase-insensitive substring over the session title or any of its stream titles.
categorystringSession category or any stream category equals this value.

Sort (both params mirror the GraphQL channelHistory sortBy/sortDir arguments; default started_at descending — the historical order):

Query paramTypeEffect
sort_bystarted_at | duration_secs | peak_viewers | avg_viewers | total_messages | unique_chatters | new_followersColumn to order by (default started_at). Any other value → 400.
sort_dirasc | descDirection (default desc). Any other value → 400.

The three nullable stat columns (duration_secs, peak_viewers, avg_viewers) are ordered NULLS LAST, so a still-live (unfinalized) session — whose finals are not yet computed — never floats to the top. Ties break by started_at descending, keeping pagination stable.

from after to returns 400.

GET /v1/history/filter-options

The distinct platforms and categories that actually occur in the account's history — the values for the filter dropdowns. Permission: history:read. Returns { "platforms": [...], "categories": [...] }, both sorted. Mirrors the GraphQL channelHistoryFilterOptions query.

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

The full report for one owned session: session summary, the per-platform stream table, top chatters/emotes/gifs, and the server-computed chart markers (viewer_markers, follower_markers — per platform and a total entry). Permission: history:read. The summary carries emote_count and gif_count (ZAF-972); the report carries top_emotes and top_gifstop_gifs is [{ id, url, provider, alt, count }], the GIF twin of top_emotes, with the provider URL rendered verbatim, [] when a session predates the metric or used no GIFs. Field set, permission, validation and errors are identical to the GraphQL channelHistoryReport. Each stream-table entry also carries shared_chat_sources (ZAF-819) — Twitch Shared Chat source attribution as [{ platform_channel_id, message_count, display_name, avatar_url }] (display_name / avatar_url are the source channel's identity, ZAF-868, null when uncaptured), null for first-party sessions (only the public-stats crawler populates it today).

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

The raw 60-second time-series samples for a session. Query: optional platform. Permission: history:read.

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

Download a report export as a file attachment (Content-Disposition: attachment, filename stream-report-{date}-{uuid}.{ext}). Permission: history:export. Query: format — one of csv, txt, json, pdf. Every format is rendered from the same report DTO as GET /v1/history/reports/\{id\}, so the numbers match the report page exactly.

  • csv → a ZIP (application/zip) with summary.csv (flat key/value, platform_metrics namespaced — includes emote_count and gif_count), streams.csv, timeseries.csv, top_chatters.csv, top_emotes.csv, top_gifs.csv (ZAF-972).
  • txt → a human-readable summary (text/plain).
  • json → the full report DTO, pretty-printed (application/json), including schema_version and platform_metrics; roundtrips back into the DTO.
  • pdf → a print-themed one-pager (application/pdf): title, summary table and the embedded viewer + follower charts (light theme) with the same peak / median / steepest-rise markers as the report page.

Only finalized sessions can be exported: a still-live session returns 409.

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

Delete a stored session and its data (also revokes its share links). Permission: history:delete. Returns 204.

List the report's share links (secrets excluded). Permission: history:share.

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

Create a share link. Permission: history:share. Body:

{ "duration_secs": 86400, "password": "optional", "allow_export": false }

duration_secs must be one of 3600 (1h), 21600 (6h), 86400 (24h), 604800 (7d), 2592000 (30d). Up to 20 active links per report (else 409). The response includes the plaintext token and url once.

PATCH /v1/history/shared-links/\{id\}

Extend a link's expiry. Permission: history:share. Body: { "duration_secs": … }.

DELETE /v1/history/shared-links/\{id\}

Revoke a link. Permission: history:share. Returns 204.

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

Public, anonymous (RateTier::Anonymous, 120/min, IP-keyed). Resolve a share token to its report. If the link is password-protected, supply the password via the X-Share-Password header; a missing/wrong password, or a revoked/expired/ unknown token, all return 401 with an identical message (no existence leak). The response carries report, its 60s time-series samples (so a public viewer can render the same charts as the owner), and allow_export. This is the data path for the public share page at /share/history/\{token\}.

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

Public, anonymous. Download a shared report's export, in the same four formats and with the same finalized/format rules as the owner export above. Available only when the share link was created with allow_export: true — otherwise 403. Password-protected links require the X-Share-Password header.

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 channel page reads this via publicGql (SSR); 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 rangeDAYS_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. Artwork hotlinked from the Twitch CDN (never re-hosted). The channel endpoint's panels also carries the status / removed_at lifecycle fields. Public data: no §5 minimisation, no retention cap; read via publicGql (SSR), no WebSocket surface.

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. 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 { 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 metric400.

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

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/trovo), provider (the platforms plus 7tv/bttv/ffz), channel_id, animated (bool), search (case-insensitive substring on the emote name), page (1-based, default 1), and limit (clamped to 1..=100, default 50). Empty-string filters are treated as absent.

Response data: { "emotes": [ { id, platform, provider, channel_id, name, url, animated, owner_id, source, first_seen_at, last_seen_at } ], "pagination": { page, limit, total, total_pages } }. channel_id is null for a global (platform-wide) set; source is api_fetch | observed; timestamps are RFC-3339. The internal provider_emote_id is not exposed.

Rate-limiting: the anonymous-tier per-IP limiter that wraps every /v1 request. Caching: Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600 on the 200 — public reference data, safe to edge-cache. When the kill-switch is off the endpoint returns 403 FEATURE_DISABLED with Feature 'system:public_stats' is not available (parity with the GraphQL guard error).

Audit Log

Scoped read surfaces over the audit_events table — one endpoint per scope column value. They mirror the GraphQL myAuditLog / accountAuditLog operations exactly (same fields, permission, validation and errors). The operator audit surface stays at GET /v1/admin/audit-log and can optionally filter by scope.

Both endpoints share the same query parameters and paginated response shape:

Query paramTypeEffect
pageintegerPage number (default 1).
limitintegerPage size (default 50, clamped 1..=200).
typestringExact event-type match.
fromRFC-3339 timestampOnly events at/after this instant. An unparseable value is ignored.
toRFC-3339 timestampOnly events at/before this instant. An unparseable value is ignored.

Response: { "items": [...], "total": N, "page": N, "limit": N, "total_pages": N }.

GET /v1/me/audit-log

The caller's own scope=user rows. Self-only, no RBAC permission — but a first-party principal is required (a logged-in session or the user's own API key); a popout/overlay/widget/extension token gets 401. Returns only the caller's rows. Current writers record successful logins, failed login attempts tied to a known user, and OAuth grant consent. Mirrors GraphQL myAuditLog.

GET /v1/account/audit-log

The active account's scope=account events. Current emitters write role/permission, member-role, channel-connection, and account-scoped GDPR erasure rows. Permission: the account-scope audit-log:read (403 otherwise; 401 if no account is active). Returns only the caller's account's rows. Mirrors GraphQL accountAuditLog.

POST /v1/internal/audit-ingest

Server-to-server ingest for user-scope login / 2FA / OAuth-grant security events. These originate in apps/id (NextAuth, TypeScript) at the auth boundary, but the Rust API is the single audit writer, so apps/id posts them here after the auth event instead of writing audit_events itself.

Auth: System key only, carrying the narrow audit:ingest grant. Every non-System principal (session, user API key, popout/overlay/widget/extension token) is rejected 403, and an anonymous request 401 — no public/user route can forge audit rows. A System key without audit:ingest (or audit:*) is also 403; a bare *:* does not grant it. Provision apps/id's key with audit:ingest.

Request body:

FieldTypeRequiredNotes
user_idUUIDyesThe subject user the event is about.
typestringyesOne of user:login, user:login_failed, user:mfa_enabled, user:mfa_disabled, user:oauth_granted. Any other value (including an account:* / system:* type) → 400.
ip_addressstringnoThe end-user's client IP at the auth boundary (this is a server-to-server call, so the request peer is the id service — forward the real client IP here). GeoIP-resolved fail-open into country/city.
user_agentstringnoThe end-user's user-agent.
metadataobjectnoFree-form JSON object (defaults to {}; a non-object value → 400).

The handler forces scope = user, user_id = <subject>, and account_id = NULL — an ingest caller can never widen scope, attach a tenant, or write a non-user-scope type. On success returns 201 { "id": "<uuid>" } with the new row id.