REST API
Lumio exposes a RESTful API. Most resource endpoints wrap their payload in a HATEOAS envelope.
Base URLs
| Environment | URL |
|---|---|
| Production | https://api.lumio.vision/v1 |
| Production Preview | https://lumio.api.prod.zaflun.dev/v1 |
| Staging | https://lumio.api.staging.zaflun.dev/v1 |
Response Format
A single resource is returned in an envelope whose _links.self is a string URL, not a link object:
{
"data": { "id": "…", "name": "…" },
"_links": { "self": "https://api.lumio.vision/v1/overlays/123" }
}
A paginated collection adds _meta and the navigation links, omitting prev/next at the ends of the range:
{
"data": [ … ],
"_links": {
"self": "/v1/events?page=2&limit=25",
"first": "/v1/events?page=1&limit=25",
"prev": "/v1/events?page=1&limit=25",
"next": "/v1/events?page=3&limit=25",
"last": "/v1/events?page=4&limit=25"
},
"_meta": { "total": 100, "page": 2, "limit": 25, "pages": 4 }
}
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.
error | HTTP | Meaning |
|---|---|---|
BAD_REQUEST | 400 | Malformed request |
VALIDATION_ERROR | 400 | Request body failed validation |
UNAUTHORIZED | 401 | No or invalid credential |
FORBIDDEN | 403 | Authenticated but missing the required resource:action |
PLAN_LIMIT_REACHED | 403 | The account's plan quota for this resource is exhausted |
FEATURE_DISABLED | 403 | The feature flag gating this endpoint is off for the account |
NOT_FOUND | 404 | Resource does not exist or is not visible to the caller |
CONFLICT | 409 | Conflicts with current state (duplicate slug, live session, …) |
RATE_LIMIT_EXCEEDED | 429 | Per-auth-context rate limit exceeded — see Authentication |
DATABASE_ERROR, REDIS_ERROR, INTERNAL_SERVER_ERROR | 500 | Server-side failure |
4xx message values are user-facing and returned verbatim. 5xx messages are scrubbed to a generic string (An internal server error occurred, An internal database error occurred, An internal cache error occurred) so no internal detail leaks; the full message is logged server-side only. GraphQL returns the identical error code and message for the same failure.
Interactive Documentation
- Swagger UI —
/v1/swagger-ui/whendocs.swagger_uiis enabled in config - OpenAPI spec —
/v1/api-doc/openapi.json(served with Swagger UI) or/v1/openapi.jsonwhendocs.openapi_jsonis enabled
Both are config-gated so a deployment can lock them down. The generated spec covers the endpoints that carry utoipa annotations.
Spec completeness policy
The spec is hand-enumerated in apps/api/src/openapi.rs, so historically live routes could silently drift out of it. A build-time test (openapi::completeness) now enforces that every route module wired into the live router is either registered in the spec or on a documented exclusion allowlist (SPEC_EXCLUDED_MODULES, with a per-module reason) — so the spec is a trustworthy completeness oracle for everything not on that list. The intentional exclusions are:
internal_extension_handlers— internal service-to-service endpoint (the V8 bot-module/automation workers fetch handler code), not a consumer-facing API operation.webhooks_payments— inbound Stripe Connect / PayPal payout webhooks; a provider-called receiver, not a callable consumer operation./.well-known/security.txt— a static RFC 9116 resource, not a JSON API operation.
The sounds, widgets, and history modules were previously excluded as an annotation gap; their handlers are now annotated and present in the spec. The entire extension / developer-portal / marketplace / payout surface has since graduated into the spec (ZAF-1107): developer_teams, developer_applications, developer_store_profiles, developer_verifications, admin_developer_applications, admin_developer_teams, developer_extensions (Developer Extensions — manage your extensions, versions, secrets, testers, analytics, payouts, and limit requests), extension_actions, extension_purchases, extension_bundles, extension_storage, extension_uploads, extension_access (Extension Access — overlay access grants and shared access links), extension_reviews (Extension Reviews — admin extension review queue and moderation), extension_store (Extension Store — browse, install, review, and serve store assets), extension_functions (Extension Functions — invoke extension query/mutate/execute functions), bot_module_extensions (Bot Module Extensions — bot-module extension configuration and control), automation_nodes, stripe_connect, and marketplace (Marketplace — public extension store browsing, listings, reviews, featured, categories). Only the two internal modules above (internal_extension_handlers, webhooks_payments) remain deliberately excluded, because they are not public consumer operations.
Treat this page and the feature docs as authoritative for the excluded surfaces above.
Endpoints Overview
All paths are under /v1. Request and response bodies use snake_case. Permissions use the resource:action format.
| Resource group | Path prefix | Description |
|---|---|---|
| Auth | /v1/auth | Token exchange, refresh, logout, OAuth link/authorize, popout-token exchange |
| Users | /v1/users/me | Current-user profile, login connections, sessions |
| Accounts | /v1/accounts | Create/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/invites | Team management |
| Roles | /v1/roles | RBAC role CRUD and permission catalog |
| Tokens | /v1/tokens, /v1/api-keys | Popout token and user API key management. POST /v1/tokens requires a first-party session (logged-in user or the user's own API key; ZAF-1017/ZAF-469) — a popout/overlay/widget/extension token is rejected with 403 FORBIDDEN, and an omitted user_id binds to the creator, 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/overlays | Overlay configuration CRUD |
| Sounds | /v1/sounds | Sound library management, upload, playback control, and streaming |
| Widgets | /v1/widgets | Widget instance CRUD, access management, and duplication |
| Uploads | /v1/uploads | File uploads (multipart) and presigned download URLs |
| Chat | /v1/chat/* | History (filter by platform, user, date, keyword search, YouTube live_chat_id), send, moderate, user profiles, user name-completion search, notes, raid/poll/prediction |
| Events | /v1/events | Event history, single event, emit/test |
| Emotes | /v1/emotes | Channel and user emote fetching |
| YouTube Memberships | /v1/youtube/memberships/tiers, /v1/admin/privacy/youtube/member/{id} | Read observed YouTube member-tier badges (account-scoped); GDPR-Art-17 erasure of a member's cached data and their historical chat rows (system_admin only) — see Member Badges |
| Chat Privacy | /v1/admin/privacy/chat/erase | GDPR-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-streams | Active and upcoming YouTube broadcasts (reads from Redis cache written by the YouTube polling worker) |
| Connections | /v1/connections | App credentials and channel OAuth flow |
| Bot Connections | /v1/bot-connections, /v1/bot-status, /v1/bot-toggle, /v1/bot-rejoin | Custom bot identity OAuth and control |
| Bot Commands | /v1/bot-commands | Cross-platform command CRUD and global overrides |
| Bot Modules | /v1/bot-modules | Moderation module configs (link/spam/word/timed), extension bot module kill switch |
| Bot Module Triggers | /v1/internal/bot-modules | Internal trigger resolution + handler-bundle feed for the Bot Module Worker (SystemKey) |
| Extension Handler Feeds | /v1/internal/{bot-modules,automation-nodes}/handlers | Internal handler-code bundle + context feed for the workers' InstallRegistry (SystemKey) |
| Automations | /v1/automations | Visual automation CRUD and manual execution |
| Automation Extension Nodes | /v1/automation/extension-nodes, /v1/automation/webhooks | Extension node listing and webhook receiver |
| Channel Status | /v1/channel-status, /v1/spotify/manual-connect | Live 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/notifications | In-app notifications, actions, and delivery preferences |
| Ideas Hub | /v1/ideas | Community ideas, votes, comments, @mention autocomplete, categories, tags. Moderation actions live on the same paths and are gated on the ideas:moderate_* admin permissions |
| OBS | /v1/integrations/obs, /v1/obs-remote/* | OBS config + remote stream/recording/scene control |
| SE Tokens | /v1/se-tokens | StreamElements JWT token storage |
| Discord Guilds | /v1/discord-guilds/exchange | Discord guild bot install exchange |
| Abuse Reports | /v1/abuse-reports | User-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/playlists | Read-only global safe-song playlists |
| Songs | /v1/songs | Read-only song metadata and copyright status |
| Platforms | /v1/platforms/metadata | Static platform metadata (name, icon, features, scopes) |
| Features | /v1/features/enabled, /v1/providers/enabled | Public feature/provider flag reads |
| Extension Bundles | /v1/extension-bundles | Serve extension bundle files (JS, CSS, assets) |
| Extension Version Files | /v1/extensions/{id}/versions/{version}/files | List files in an extension version bundle |
| Extension Uploads | /v1/extensions/{id}/uploads | Upload/serve files from extension editors (icons, images) |
| Fonts | /v1/fonts/{family}/css, /v1/fonts/{family}/{weight}.woff2 | DSGVO-compliant font proxy (CSS + woff2). Public scope, no auth, permissive CORS |
| Developer Applications | /v1/developer/application | Submit and view developer applications |
| Developer Verification | /v1/developer/verification | Submit and view own KYC verification (self-service) |
| Developer Limits | /v1/developer/limits | View resolved limits and request increases |
| Developer Teams | /v1/developer/teams | Team CRUD, members, invites, roles, permissions |
| Developer Store Profiles | /v1/developer/profiles/{slug}, /v1/developer/teams/{slug}/profile | Public developer and team profile pages |
| Admin Developer Applications | /v1/admin/developer-applications | Review, approve, reject developer applications |
| Admin Limit Requests | /v1/admin/limit-requests, /v1/admin/developers/{id}/limit-requests | Review developer limit increase requests |
| Admin Extension Limits | /v1/admin/extensions/{id}/limits | Set per-extension limit overrides |
| Overlay Folders | /v1/overlay-folders | Overlay folder CRUD and move |
| Overlay Shared Links | /v1/overlay-shared-links | Time-limited shared overlay links (lm_share_*) |
| Channel Connections | /v1/connections/channel, /v1/connections/status, /v1/connections/credential-modes, /v1/connections/oauth/{platform}/exchange, /v1/channel-connections/{id}/reconnect-flag | Channel OAuth, reconnect prompts, the per-platform status overview (/status — health-aware is_connected + reconnect_required + expires_at, plus the acting user's personal login-grant health login_connected + login_reconnect_required reported separately from the channel (ZAF-1045, GraphQL parity connectionStatuses), connections:read), the credential mode per (platform, kind) (/credential-modes — platform, kind "channel"/"bot", mode "system"/"account", system_configured; twin of GraphQL platformCredentialModes, connections:read + feature:connections), and the unified OAuth exchange (/oauth/{platform}/exchange — the single web-host callback shared by the channel, account-bot and admin global-bot system flows; redeems the structured state handle, dispatches on kind, and validates return_to fail-closed against [web, admin]) |
| Channel Info | /v1/channel-info | Live channel metadata for the active account |
| Stream History | /v1/history | Session list, reports, stats, exports, and shared report links |
| Public Stats | /v1/public-stats | Per-channel publicness (opt-out) settings + the unauthenticated public read model (channel + stream stats) |
| Public Stats browse | /v1/public-stats/{browse,games} | Public, unauthenticated cross-channel browse/ranking (metric nav) + Games directory + channel search. No auth; gated by the system:public_stats kill-switch; edge-cacheable |
| Emote Directory | /v1/public-stats/emotes | Public, unauthenticated emote-catalog browse (filter + name search + pagination). No auth; gated by the system:public_stats kill-switch; edge-cacheable |
| Watchlist | /v1/watchlist | Per-user favourite-channel list for the public Stats app. Authenticated viewer (JWT); no account/RBAC permission, no plan gate. GraphQL + REST (no WS) |
| Audit Log | /v1/me/audit-log, /v1/account/audit-log | Self-scope personal-security log and account-scope tenant audit log; the operator audit surface lives under /v1/admin/audit-log |
| Marketplace | /v1/marketplace, /v1/store, /v1/categories, /v1/featured | Public extension store browsing, listings, reviews, icons, screenshots |
| Extension Installs | /v1/extension-installs | Install lifecycle, per-install storage, actions, and query/mutate/execute function calls |
| Extension Purchases | /v1/extension-purchases | Paid-extension checkout |
| Extension Access | /v1/extension-access-invites | Private-extension access invites |
| User Roles | /v1/user-roles | User-scoped role CRUD (admin) |
| Well-Known | /v1/.well-known/* | Service discovery documents |
| Health | /v1, /v1/health | Liveness probes; /v1/health also carries service-dependency status and live WebSocket gauges |
| Metrics | /metrics | Prometheus scrape endpoint (origin root, not under /v1) |
| Admin | /v1/admin/* | System-admin-only endpoints: admin-roles, admin-permissions, feature-flags, plans, coupons, users, accounts, 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.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
lumio_build_info | gauge | version | Constant 1; running build version in the label |
lumio_uptime_seconds | gauge | — | Seconds since the API process started |
lumio_websocket_sessions | gauge | — | Active WebSocket sessions |
lumio_websocket_channels | gauge | — | Active WebSocket broadcast channels |
lumio_db_pool_connections | gauge | pool | Connections owned by the pool (postgres, timescaledb) |
lumio_db_pool_idle | gauge | pool | Idle connections available for checkout |
# HELP lumio_websocket_sessions Active WebSocket sessions.
# TYPE lumio_websocket_sessions gauge
lumio_websocket_sessions{env="prod",service="api"} 128
# HELP lumio_websocket_channels Active WebSocket broadcast channels.
# TYPE lumio_websocket_channels gauge
lumio_websocket_channels{env="prod",service="api"} 342
# HELP lumio_db_pool_connections Connections owned by the pool (idle + in-use).
# TYPE lumio_db_pool_connections gauge
lumio_db_pool_connections{env="prod",pool="postgres",service="api"} 8
lumio_db_pool_connections{env="prod",pool="timescaledb",service="api"} 3
The endpoint carries only aggregate operational counters — no per-user,
per-channel, or per-request detail (the hard cardinality rule) — matching the
websocket, version, and uptime fields also visible as JSON on /v1/health.
Auth
POST /v1/auth/popout/exchange
Exchange a raw popout token (lm_pop_*) for a short-lived popout session — the REST twin of GraphQL exchangePopoutToken. See Authentication → Popout Tokens & Popout Sessions for the full model.
Auth: none — possession of a valid popout token IS the authorization (as ?token= is today).
Request body:
{ "token": "lm_pop_..." }
Response — also sets the session as an httpOnly cookie lumio-popout-token (Path=/, SameSite=Lax, Secure outside development, Max-Age = 15 min):
{
"data": {
"token": "lm_...",
"expires_at": "2026-08-12T12:15:00Z",
"account_id": "…"
}
}
Validates the token (prefix + revoked_at / expires_at) and mints a 15-minute session JWT carrying the token's permission subset. The session resolves to the same popout auth context as the raw token (identical account, permission subset, OBS credential gate) — never a full user session. A revoked, expired, unknown, or non-popout token returns 401 with Invalid or expired popout token.
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_idis bound at token-create time and could be the account owner, is rejected with403 FORBIDDENon both REST and GraphQL. This stops a permission-capped member from listing/minting/renaming/revoking a key bound to the owner (an owner-boundlm_usr_key would otherwise launder past this same gate — e.g.PATCH /v1/users/meemail change → owner takeover).
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/api-keys | apikeys:read | List the caller's own user API keys for the active account |
POST | /v1/api-keys | apikeys:create | Create a user API key; returns the full lm_usr_ key once |
PATCH | /v1/api-keys/\{id\} | apikeys:edit | Rename one of the caller's own keys (label only) |
DELETE | /v1/api-keys/\{id\} | apikeys:delete | Revoke 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.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/service-keys | apikeys:read | List the account's service keys |
POST | /v1/service-keys | apikeys:create | Create a service key; returns the full lm_svc_ key once |
PATCH | /v1/service-keys/\{id\} | apikeys:edit | Rename a service key (label only) |
DELETE | /v1/service-keys/\{id\} | apikeys:delete | Revoke a service key by hard-deleting it |
Create request:
{
"label": "CI/CD deploy",
"permissions": ["events:read", "overlays:read"],
"expires_at": null
}
label is required. expires_at is optional; omit it or send null for a non-expiring key.
Create response:
{
"data": {
"key": "lm_svc_xxxxxxxxxxxx",
"id": "uuid",
"account_id": "uuid",
"created_by": "uuid",
"key_prefix": "lm_svc_ab",
"label": "CI/CD deploy",
"permissions": ["events:read", "overlays:read"],
"created_at": "2026-09-04T10:00:00Z",
"updated_at": "2026-09-04T10:00:00Z",
"expires_at": null,
"last_used_at": null
},
"_links": {
"self": {
"href": "/v1/service-keys/uuid"
}
}
}
created_by records the member who minted the key; it becomes null once that member is removed from the account (the key itself keeps working). Lifecycle changes emit the account-scoped audit events account:service_key_created / account:service_key_updated / account:service_key_revoked. 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:
| Parameter | Type | Description |
|---|---|---|
limit | integer | Page size (default 20, max 100) |
offset | integer | Pagination offset |
search | string | Filter by name (partial match) |
Response — the list also carries the account's resolved sound limits and current usage:
{
"data": {
"sounds": [
{
"id": "uuid",
"account_id": "uuid",
"name": "Tada",
"filename": "tada.mp3",
"content_type": "audio/mpeg",
"size_bytes": 45312,
"duration_ms": 2100,
"waveform": null,
"created_at": "2026-05-28T10:00:00Z",
"source_extension_name": null
}
],
"total_count": 1,
"user_sound_count": 1,
"max_sounds": 50,
"max_sound_file_size": 5242880,
"max_sound_storage_bytes": 104857600,
"used_storage_bytes": 45312
},
"_links": { "self": "https://api.lumio.vision/v1/sounds" }
}
GET /v1/sounds/\{id\}
Get a single sound by ID.
Permission: sounds:read
Response: Sound object (same shape as list items).
POST /v1/sounds
Upload a new sound file.
Permission: sounds:create
Body: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | Audio file (MP3, WAV, OGG, FLAC). Max size set by max_sound_file_size plan limit. |
name | string | No | Display name (defaults to filename without extension) |
Errors:
400BAD_REQUEST/VALIDATION_ERROR— Unsupported file type or file exceeds size limit403PLAN_LIMIT_REACHED— Account has reached themax_soundsor storage plan limit
Response: 201 Created with the new sound object.
PATCH /v1/sounds/\{id\}
Update sound metadata.
Permission: sounds:edit
Body: all fields optional.
| Field | Type | Description |
|---|---|---|
name | string | New display name |
duration_ms | integer | Duration in milliseconds |
waveform | JSON | Precomputed waveform peaks |
Response: Updated sound object.
DELETE /v1/sounds/\{id\}
Delete a sound.
Permission: sounds:delete
Response: 204 No Content.
POST /v1/sounds/\{id\}/play
Trigger playback of a sound in browser sources.
Permission: sounds:play
Body:
| Field | Type | Required | Description |
|---|---|---|---|
volume | float | No | Playback volume 0.0–1.0 (default 1.0) |
target | object | No | \{ "type": "widget" | "overlay", "id": "uuid" \}. Omit to broadcast to all browser sources; any other type is rejected. |
Response: 200 { "success": true } (bare body, no HATEOAS envelope)
POST /v1/sounds/\{id\}/stop
Stop playback of a sound in browser sources.
Permission: sounds:play
Body:
| Field | Type | Required | Description |
|---|---|---|---|
target | object | No | \{ "type": "widget" | "overlay", "id": "uuid" \}. Omit to broadcast to all browser sources; any other type is rejected. |
Response: 200 { "success": true } (bare body, no HATEOAS envelope)
GET /v1/sounds/\{id\}/stream
Stream the audio bytes for a sound. Used by browser sources to load and play the audio.
Permission: sounds:read
Response: Raw audio bytes with the correct Content-Type header (e.g. audio/mpeg) and Cache-Control: private, max-age=86400, immutable. The payload is account-scoped and carries no Vary, so it is marked private — long-lived in the caller's own browser cache but never storable in a shared/CDN cache.
Protocol parity: All endpoints have matching GraphQL queries/mutations — see GraphQL.
Widgets
Access Management
GET /v1/widgets/\{id\}/access
List access entries for a widget.
Permission: widgets:access-read
Response: List of access entries with id, widget_id, user_id, user_name, user_avatar, role, granted_by, and created_at.
GET /v1/widgets/\{id\}/access/candidates
List account members eligible for the widget access dialog.
Permission: widgets:access-read
Response: List of account members with id (the user ID), display_name, and avatar_url.
PUT /v1/widgets/\{id\}/access/\{user_id\}
Set a user's access role on a widget.
Permission: widgets:access-grant
Body:
| Field | Type | Required | Description |
|---|---|---|---|
role | string | Yes | "viewer", "editor", or "none". Any other value is rejected with 400 BAD_REQUEST (Invalid role). |
Response: 200 with the updated access entry.
DELETE /v1/widgets/\{id\}/access/\{user_id\}
Remove a user's access entry from a widget.
Permission: widgets:access-revoke
Response: 204 No Content.
Duplication
POST /v1/widgets/\{id\}/duplicate
Duplicate a widget instance. Creates a copy of the widget with its configuration and returns the new widget with an access token.
Permission: widgets:create
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:
installedAutomationNodesquery andautomationWebhookUrlquery 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:
| Field | Type | Required | Notes |
|---|---|---|---|
login_connection_id | UUID | Yes | ID of the login connection to assign |
provider | string | Yes | Platform slug, e.g. "twitch", "google" |
user_id | UUID | No | Defaults to the authenticated user |
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/notificationsendpoints,POST/GET /developer/application,POST/GET /developer/verification, account create/dissolve/leave, and the JWT-mintingPOST /auth/ws-token) — require a logged-in user session or a user's own API key. A popout/overlay/widget/extension token, whoseuser_idis bound at token-create time and could be set to the account owner, is rejected with403 FORBIDDENon both REST and GraphQL. This closes the profile-edit (email → password-reset) account-takeover path, the owner-bound-JWT minting path, and the broader authorization/disclosure gap. The same gate extends to the extension-developer REST surface keyed on the developer identity (ZAF-471): thedeveloper/profile,developer/revenue,developer/payout-settings,developer/payout-history,developer/limits*reads, thedeveloper/payouts(update) /developer/payouts/request(request) writes, extension create/submit/delete and tester invites, the developer-team endpoints, and extension-access grants/invites — all reject a popout/overlay/widget/extension token with403 FORBIDDEN. Account-scoped developer routes keyed onaccount_id(extension secrets, billing checkout/portal) stay open.
| Field | Type | Description |
|---|---|---|
display_name | string | Update display name (cannot be empty) |
email | string | Update email address |
active_account_id | UUID | Switch active account (must be a member) |
clear_active_account | bool | Clear active account (go to user-only mode) |
streamer_mode | bool | Toggle Streamer Mode on/off |
extension_dev_mode | bool | Toggle 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:shopifyin 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
descriptiontonullto clear it - Permissions are applied as a diff; unknown/legacy permissions are preserved
- Same validation error wording as
POST
DELETE /v1/admin/admin-roles/{id}
Requires admin-roles:delete. Returns 204 on success.
- 400
"Cannot delete system admin role"— ifis_system = true - 404
"Admin role not found"— if not found
GET /v1/admin/admin-roles/{id}/members
Requires admin-roles:read. Returns [AdminRoleMemberResponse] — all users assigned to the role.
PUT /v1/admin/admin-roles/{id}/members/{userId}
Requires admin-roles:edit. Assigns the given user to the role. Idempotent. Returns 204.
- 404
"Admin role not found"— if the role does not exist
DELETE /v1/admin/admin-roles/{id}/members/{userId}
Requires admin-roles:edit. Removes the user's role assignment. Returns 204.
GET /v1/admin/admin-permissions
Requires admin-roles:read. Returns [AdminPermissionInfoResponse] — the full catalog of admin-scope permissions with category labels. Source: lo_auth::rbac::all_admin_permissions().
Request / Response Types
CreateAdminRoleRequest
| Field | Type | Required |
|---|---|---|
name | string | Yes |
description | string | null | No |
permissions | string[] | Yes (may be empty) |
UpdateAdminRoleRequest
| Field | Type | Notes |
|---|---|---|
name | string | Optional; trimmed |
description | string | null | null = clear; omit = leave unchanged |
permissions | string[] | Optional; replaces via diff |
AdminRoleResponse
| Field | Type |
|---|---|
id | UUID |
name | string |
description | string or null |
is_system | boolean |
permissions | string[] |
member_count | integer |
created_at | ISO-8601 string |
updated_at | ISO-8601 string |
AdminRoleMemberResponse
| Field | Type |
|---|---|
user_id | UUID |
display_name | string |
email | string or null |
avatar_url | string or null |
assigned_at | ISO-8601 string |
AdminPermissionInfoResponse
| Field | Type |
|---|---|
permission | string |
category | string |
Protocol parity: All 9 endpoints have matching GraphQL queries/mutations — see GraphQL.
Admin Plan Management
All endpoints below are under the /v1/admin scope and check admin-scope permissions. Request and response bodies use snake_case.
Reading the plan list is GraphQL-only — use the adminPlans query. There is no GET /v1/admin/plans; the REST surface covers create, update, delete, and the feature mapping.
POST /v1/admin/plans
Requires plans:create. Body: CreatePlanRequest. Returns 201 + AdminPlanResponse.
Validation errors (400):
"Invalid slug format"— slug does not match^[a-z0-9]+(?:-[a-z0-9]+)*$or is outside 2–40 chars"Price cannot be negative"— monthly or yearly price is negative"Limit cannot be negative"— a numeric limit is negative
Conflict errors (409):
"Plan slug already in use"— another plan already uses this slug
Auth errors: 401 UNAUTHORIZED for a missing or invalid JWT; 403 FORBIDDEN (Missing permission: plans:create) when the admin role lacks plans:create.
Fields of CreatePlanRequest:
| Field | Type | Required | Notes |
|---|---|---|---|
slug | string | Yes | Regex ^[a-z0-9]+(?:-[a-z0-9]+)*$, length 2–40, immutable after creation |
name | string | Yes | Display name |
description | string | null | No | |
price_monthly | integer | Yes | Cents (or the minor unit of currency) |
price_yearly | integer | Yes | Cents |
currency | string | null | No | ISO-4217 code; defaults to "USD" |
is_public | boolean | Yes | Whether the plan is visible on the public pricing page |
sort_order | integer | Yes | Sort position in pricing pages |
max_overlays | integer | Yes | |
max_storage_bytes | integer | Yes | |
max_upload_size_bytes | integer | Yes | |
max_integrations | integer | Yes | |
chat_retention_days | integer | Yes | 0 = keep forever |
max_sounds | integer | Yes | Maximum number of sounds per account |
max_sound_file_size | integer | Yes | Maximum size per sound file in bytes |
max_sound_storage_bytes | integer | Yes | Total sound storage quota in bytes |
stripe_product_id | string | null | No | Paste from Stripe dashboard |
stripe_monthly_price_id | string | null | No | Paste from Stripe dashboard |
stripe_yearly_price_id | string | null | No | Paste from Stripe dashboard |
PATCH /v1/admin/plans/{id}
Requires plans:edit. Body: UpdatePlanRequest. Returns 200 + AdminPlanResponse.
- The
slugis immutable and is therefore not part ofUpdatePlanRequest. - All fields are required on update — the handler rewrites the full editable field set.
- On success, the feature cache is invalidated for every account currently on the plan.
Errors:
- 400
"Price cannot be negative"/"Limit cannot be negative"— validation failures - 403
FORBIDDEN— missingplans:edit(Missing permission: plans:edit) - 404
"Plan not found"— no plan with that ID
Fields of UpdatePlanRequest:
| Field | Type | Notes |
|---|---|---|
name | string | |
description | string | null | |
price_monthly | integer | |
price_yearly | integer | |
currency | string | |
is_public | boolean | |
sort_order | integer | |
max_overlays | integer | |
max_storage_bytes | integer | |
max_upload_size_bytes | integer | |
max_integrations | integer | |
chat_retention_days | integer | |
max_sounds | integer | |
max_sound_file_size | integer | |
max_sound_storage_bytes | integer | |
stripe_product_id | string | null | |
stripe_monthly_price_id | string | null | |
stripe_yearly_price_id | string | null |
DELETE /v1/admin/plans/{id}
Requires plans:delete. Returns 204 No Content on success.
- 403
FORBIDDEN— missingplans:delete(Missing permission: plans:delete) - 404
"Plan not found"— no plan with that ID - 409
"Cannot delete plan: N account(s) still reference it. Migrate them to a different plan first."— one or more accounts still point at this plan; migrate them before retrying
Deletion cascades to plan_features via the foreign key constraint. No other data is affected.
Request / Response Types
AdminPlanResponse
| Field | Type |
|---|---|
id | UUID |
slug | string |
name | string |
description | string or null |
price_monthly | integer |
price_yearly | integer |
currency | string |
is_public | boolean |
sort_order | integer |
max_overlays | integer |
max_storage_bytes | integer |
max_upload_size_bytes | integer |
max_integrations | integer |
chat_retention_days | integer |
max_sounds | integer |
max_sound_file_size | integer |
max_sound_storage_bytes | integer |
stripe_product_id | string or null |
stripe_monthly_price_id | string or null |
stripe_yearly_price_id | string or null |
features | [AdminPlanFeatureResponse] |
accounts_using | integer |
AdminPlanFeatureResponse
| Field | Type |
|---|---|
feature_id | UUID |
feature_key | string |
label | string |
enabled | boolean |
Protocol parity: The three write endpoints have matching GraphQL mutations, plus
PUT /v1/admin/plans/{id}/featuresfor the feature mapping; the plan list is GraphQL-only (adminPlans) — see GraphQL.
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 parameter | Type | Description |
|---|---|---|
platform | string | Optional platform filter: twitch, youtube, kick, or trovo |
platform_user_id | string | Optional platform-native user filter |
live_chat_id | string | Optional YouTube live-chat/broadcast filter |
from | ISO 8601 timestamp | Optional inclusive lower time bound |
to | ISO 8601 timestamp | Optional inclusive upper time bound |
search | comma-separated string | Optional keyword search over message text, for example ?search=hello,world. Matching is case-insensitive and uses the same validation as GraphQL: at most 20 keywords, each at most 100 characters after trimming and dropping blanks. A keyword search always has a lower time bound: the supplied from, or the last 12 hours when from is omitted. A keyword that itself contains a comma cannot be expressed through REST; use GraphQL ChatFilterInput.search: [String!] for that case. |
page | integer | 1-based page number, default 1 |
limit | integer | Items per page, default 50, max 100 |
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 acceptsChatFilterInput.searchas a real[String!]list; REST accepts the same logical terms through the comma-separatedsearchquery parameter. TheisSharedChat/sourceChannelfields are present on both protocols and on thechat:messageWebSocket payload.
Chat User Search
GET /v1/chat/users/search
Prefix-search the account's known chatters (the platform_users table) by username or display name, for the multichat filter name-completion. Requires chat:userinfo and the feature:multichat feature — without chat:userinfo the search returns nothing, so it never leaks user data. The account is derived server-side from the token (cookie or popout lm_pop_*), never from a client parameter.
| Query parameter | Type | Description |
|---|---|---|
q | string | Case-insensitive prefix over username and display_name. Shorter than 2 characters (after trimming) returns an empty list, not an error. %, _, \ match literally. |
platform | comma-separated string | Optional platform filter, for example ?platform=twitch,youtube. Omit for all platforms. Maps to GraphQL platforms: [String!]. |
limit | integer | Optional, default 10, hard-capped at 25. |
Each result is the slim shape { platform, platform_user_id, username, display_name, avatar_url, last_seen_at, message_count }, ordered last_seen_at DESC, message_count DESC (most-recently-active first).
Protocol parity: mirror at GraphQL
searchPlatformUsers(query, platforms, limit)— same fields, same validation, same empty-below-2-characters behaviour. See GraphQL and Chat.
Chat 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:
| Parameter | Description |
|---|---|
platform | Platform slug: twitch, youtube, kick, or trovo |
platform_user_id | The platform-native user identifier |
Responses:
| Status | Body | Description |
|---|---|---|
200 | Refreshed UnifiedProfile (snake_case) | Enrichment succeeded; profile is updated in DB and Redis |
429 | { "error": "REFRESH_COOLDOWN", "retry_after_seconds": N } | Cooldown active; retry after N seconds (max 600) |
The cooldown is 10 minutes per (account, platform, user) triple, enforced via a Redis SETNX key.
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
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/notifications/preferences | Auth | List all delivery-channel preferences for the current user |
PATCH | /v1/notifications/preferences/{type} | Auth | Set the delivery channel for a notification type |
PATCH body: { "channel": "off" | "in_app" | "email" | "in_app_email" }. Returns 400 for unknown channel values or locked types (e.g. invite).
Protocol parity:
notificationPreferencesquery andupdateNotificationPreferencemutation in GraphQL — see GraphQL.
Ideas Hub
Community idea board with voting, comments, moderation, categories, and tags. All endpoints require the system:ideas_hub feature flag to be enabled. GET endpoints are public (no auth required). Mutation endpoints require authentication and the permissions noted below.
Ideas
| Method | Path | Description | Permission |
|---|---|---|---|
GET | /v1/ideas | List ideas (filter, sort, paginate) | Public |
GET | /v1/ideas/:id | Get idea with timeline | Public |
POST | /v1/ideas | Create idea | ideas:create |
PATCH | /v1/ideas/:id | Update idea | ideas:edit / ideas:moderate_edit |
DELETE | /v1/ideas/:id | Delete idea | ideas:delete / ideas:moderate_delete |
Query parameters for GET /v1/ideas:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status slug |
category_id | UUID | Filter by category |
tag_ids | string | Comma-separated list of tag UUIDs |
author_id | UUID | Filter by author |
search | string | Full-text search on title and description |
sort | string | newest, most_voted, most_commented, recently_updated |
limit | integer | Page size |
offset | integer | Page offset |
Voting
| Method | Path | Description | Permission |
|---|---|---|---|
POST | /v1/ideas/:id/vote | Vote on an idea | ideas:vote |
DELETE | /v1/ideas/:id/vote | Remove vote | ideas:vote |
POST body: { "vote_type": "up" | "down" }.
Comments
| Method | Path | Description | Permission |
|---|---|---|---|
GET | /v1/ideas/:id/comments | List comments (nested) | Public |
POST | /v1/ideas/:id/comments | Create comment | ideas:comment_create |
PATCH | /v1/ideas/comments/:id | Update comment | ideas:comment_edit |
DELETE | /v1/ideas/comments/:id | Delete comment | ideas:comment_delete / ideas:moderate_comment |
POST | /v1/ideas/comments/:id/vote | Vote on a comment | ideas:comment_vote |
DELETE | /v1/ideas/comments/:id/vote | Remove comment vote | ideas:comment_vote |
Comment voting mirrors idea voting exactly: one vote per user per comment, and voting the active direction again removes the vote (toggle-off). POST body: { "vote_type": "up" | "down" }. Both endpoints return the updated comment with recounted vote_count_up / vote_count_down and the caller's my_vote. Comment listings include those three fields per comment and reply.
Comment bodies contain sanitized HTML from a rich text editor. @mentions are stored as <span data-mention-id="UUID" class="mention">@Name</span>.
Participants
| Method | Path | Description | Permission |
|---|---|---|---|
GET | /v1/ideas/:id/participants | List participants for @mention autocomplete | Auth only |
Returns the union of the idea author, voters, and commenters. Supports an optional search query parameter.
Voters
| Method | Path | Description | Permission |
|---|---|---|---|
GET | /v1/ideas/:id/voters | List voters for an idea | Public |
Categories
| Method | Path | Description | Permission |
|---|---|---|---|
GET | /v1/ideas/categories | List all categories | Public |
POST | /v1/ideas/categories | Create category | Admin ideas:edit |
PATCH | /v1/ideas/categories/:id | Update category | Admin ideas:edit |
DELETE | /v1/ideas/categories/:id | Delete category | Admin ideas:delete |
Tags
| Method | Path | Description | Permission |
|---|---|---|---|
GET | /v1/ideas/tags | List / search tags | Public |
POST | /v1/ideas/tags | Create tag | ideas:create |
DELETE | /v1/ideas/tags/:id | Delete tag | Admin ideas:delete |
GET /v1/ideas/tags supports an optional search query parameter.
Status (Moderation)
| Method | Path | Description | Permission |
|---|---|---|---|
PATCH | /v1/ideas/:id/status | Change idea status | ideas:moderate_status |
PATCH body: { "status": "<status_slug>" } (e.g. open, in_progress, done, declined).
Protocol parity: All endpoints above have matching GraphQL queries/mutations — see GraphQL.
Developer Applications
Endpoints for the developer application flow. Users apply to become developers; admins review and approve or reject applications.
GET /v1/developer/application
Returns the authenticated user's latest developer application.
Auth: JWT (user-level, no account context required).
Response:
| Field | Type | Description |
|---|---|---|
id | UUID | Application ID |
user_id | UUID | Applicant's user ID |
application_type | string | "solo" or "team" |
display_name | string | Developer/team display name |
slug | string | URL slug (3-50 chars, lowercase, hyphens) |
description | string | Description of the developer/team |
motivation | string | Why the user wants developer access |
what_to_build | string | What extensions the user plans to build |
github_url | string or null | GitHub profile URL |
website_url | string or null | Website URL |
experience | string or null | Development experience |
avatar_key | string or null | Upload key for avatar image |
status | string | "pending", "approved", or "rejected" |
review_notes | string or null | Notes from the reviewer |
reviewed_by | UUID or null | Reviewer's user ID |
reviewed_at | ISO-8601 or null | Review timestamp |
created_at | ISO-8601 | Submission timestamp |
updated_at | ISO-8601 | Last update timestamp |
Returns 404 "No developer application found" if the user has not submitted an application.
POST /v1/developer/application
Submit a developer application.
Auth: JWT (user-level).
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
application_type | string | Yes | "solo" or "team" |
display_name | string | Yes | Developer/team display name |
slug | string | Yes | URL slug (3-50 chars, starts with lowercase letter, only lowercase letters, digits, hyphens) |
description | string | Yes | Description |
motivation | string | Yes | Why you want developer access |
what_to_build | string | Yes | What you plan to build |
github_url | string | No | GitHub profile URL |
website_url | string | No | Website URL |
experience | string | No | Development experience |
Validation errors (400):
"application_type must be 'solo' or 'team'"— invalid type"Slug must be between 3 and 50 characters"— slug length"Slug must start with a lowercase letter"— slug format"Slug must contain only lowercase letters, digits, and hyphens"— slug characters
Conflict errors (409):
"You already have a developer profile"— user is already a developer"You already have a pending application"— previous application still pending"Slug is already taken"— slug in use
Response: 201 Created with the application object.
Protocol parity:
myDeveloperApplicationquery andsubmitDeveloperApplicationmutation in GraphQL — see GraphQL.
Developer Verification (self-service)
KYC verification an approved developer submits about themselves (individual or company) to unlock paid-extension publishing. This is the developer-facing counterpart to the admin review endpoints under /v1/admin/developer-verifications.
Auth: first-party user session or the user's own API key. A popout/overlay/widget/extension token is rejected with 403 FORBIDDEN (ZAF-469). The caller must be an approved developer (have a developer_profiles row — created when a developer application is approved).
These endpoints are deliberately not gated on
feature:extension_development: that feature is granted by verification approval, so gating submission on it would be circular.
GET /v1/developer/verification
Returns the authenticated developer's own verification record, or null if none has been submitted (or the caller is not yet an approved developer). Returns 200 with a nullable data — not a 404 — mirroring the nullable GraphQL query.
Response data (or null):
| Field | Type | Description |
|---|---|---|
id | UUID | Verification ID |
developer_id | UUID | The developer profile this record belongs to |
legal_name | string | Legal name |
company_name | string or null | Company name (company type only) |
address_line1 | string | Address line 1 |
address_line2 | string or null | Address line 2 |
postal_code | string | Postal code |
city | string | City |
country | string | ISO-3166-1 alpha-2 country code |
tax_id | string or null | Tax ID |
trade_register_id | string or null | Trade register ID (company type only) |
document_key | string or null | Upload key for a supporting document |
status | string | "pending", "verified", or "rejected" |
review_notes | string or null | Notes from the reviewer (e.g. rejection reason) |
reviewed_by | UUID or null | Reviewer's user ID |
reviewed_at | ISO-8601 or null | Review timestamp |
created_at | ISO-8601 | First submission timestamp |
updated_at | ISO-8601 | Last update timestamp |
POST /v1/developer/verification
Submit (or resubmit) the developer's own KYC verification. Idempotent upsert — one record per developer; a resubmit re-enters pending and clears any prior review verdict, so a rejected developer can correct and resubmit. An already-verified record is terminal via self-service and cannot be resubmitted.
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
legal_name | string | Yes | Legal name |
company_name | string | No | Company name — its presence infers the company developer type |
address_line1 | string | Yes | Address line 1 |
address_line2 | string | No | Address line 2 |
postal_code | string | Yes | Postal code |
city | string | Yes | City |
country | string | Yes | ISO-3166-1 alpha-2 country code (exactly 2 letters) |
tax_id | string | No | Tax ID |
trade_register_id | string | No | Trade register ID |
document_key | string | No | Upload key for a supporting document |
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:
developerVerificationStatusquery andsubmitDeveloperVerificationmutation 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:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status: "pending", "approved", "rejected" |
Response: { "items": [...], "total": N } where each item contains id, user_id, application_type, display_name, slug, status, created_at, user_display_name, user_avatar_url.
GET /v1/admin/developer-applications/\{id\}
Get full details of a specific application.
Permission: developer-verifications:read (admin-scope).
Returns 404 "Developer application not found" if not found.
POST /v1/admin/developer-applications/\{id\}/approve
Approve a developer application. Creates the developer profile, seeds default team roles (for team applications), and sends approval notifications (in-app + email).
Permission: developer-verifications:edit (admin-scope).
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
notes | string | No | Optional reviewer notes |
Response: { "success": true, "new_status": "approved" }
POST /v1/admin/developer-applications/\{id\}/reject
Reject a developer application. Sends rejection notifications.
Permission: developer-verifications:edit (admin-scope).
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
notes | string | Yes | Rejection reason (required) |
Response: { "success": true, "new_status": "rejected" }
Protocol parity: All four endpoints have matching GraphQL queries/mutations — see GraphQL.
Admin Limit Requests
Admin endpoints for reviewing developer limit increase requests. All require developer-limits:read or developer-limits:edit admin-scope permissions.
GET /v1/admin/limit-requests
List all limit requests across all developers with optional status filter.
Permission: developer-limits:read (admin-scope).
Query parameters:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status: "open", "in_review", "approved", "rejected" |
Response: { "data": [...] } — list of limit request objects.
GET /v1/admin/developers/\{id\}/limit-requests
List limit requests for a specific developer.
Permission: developer-limits:read (admin-scope).
Response: { "data": [...] } — list of limit request objects for the given developer.
PUT /v1/admin/limit-requests/\{id\}
Review (approve or reject) a limit request. Approving automatically applies the requested limits. Rejecting requires review notes.
Permission: developer-limits:edit (admin-scope).
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | "approve" or "reject" |
notes | string | For reject | Review notes (required when rejecting) |
Response: Updated limit request object.
GET /v1/admin/limit-requests/\{id\}/events
Get timeline events for a limit request.
Permission: developer-limits:read (admin-scope).
Response: { "data": [...] } — list of event objects with event_type, actor_id, details, and created_at.
PATCH /v1/admin/extensions/\{id\}/limits
Set or clear per-extension limit overrides. Replaces the previous /max-sounds endpoint with support for all three sound limits.
Permission: developer-limits:edit (admin-scope).
Request body:
| Field | Type | Description |
|---|---|---|
max_sounds | integer or null | Max bundled sounds (null to clear) |
max_sound_file_size | integer or null | Max per-file size in bytes (null to clear) |
max_sound_storage_bytes | integer or null | Max total sound storage in bytes (null to clear) |
Response: { "data": { "success": true } }
Protocol parity: All endpoints have matching GraphQL queries/mutations — see GraphQL.
Developer Teams
Team management endpoints with team-scoped RBAC. All team operations (except create and list) check require_team_permission() against the caller's team role.
Team CRUD
| Method | Path | Permission | Description |
|---|---|---|---|
POST | /v1/developer/teams | Auth (developer) | Create a new team |
GET | /v1/developer/teams | Auth (developer) | List teams the user belongs to |
GET | /v1/developer/teams/{id} | team-settings:read | Get team details |
PATCH | /v1/developer/teams/{id} | team-settings:edit | Update team name/description/URLs |
DELETE | /v1/developer/teams/{id} | Team owner only | Delete a team |
Team Members
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/developer/teams/{id}/members | team-members:read | List team members |
PATCH | /v1/developer/teams/{id}/members/{member_id}/role | team-members:edit | Change a member's role |
DELETE | /v1/developer/teams/{id}/members/{member_id} | team-members:remove | Remove a member |
Team Invites
| Method | Path | Permission | Description |
|---|---|---|---|
POST | /v1/developer/teams/{id}/invites | team-members:invite | Create an invite link |
GET | /v1/developer/teams/{id}/invites | team-members:invite | List active invites |
DELETE | /v1/developer/teams/{id}/invites/{invite_id} | team-members:invite | Revoke an invite |
GET | /v1/developer/team-invites/{code} | Auth | Look up an invite by code |
POST | /v1/developer/team-invites/{code}/accept | Auth | Accept an invite |
Team Roles (RBAC)
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/developer/teams/{id}/roles | team-members:read | List roles for a team |
POST | /v1/developer/teams/{id}/roles | team-settings:edit | Create a custom role |
PATCH | /v1/developer/teams/{id}/roles/{rid} | team-settings:edit | Update a role |
DELETE | /v1/developer/teams/{id}/roles/{rid} | team-settings:edit | Delete a role |
GET | /v1/developer/teams/permissions | Auth | List all available team permissions |
Protocol parity: All endpoints have matching GraphQL queries/mutations — see GraphQL.
Developer Store Profiles
Public endpoints for developer and team profile pages. No authentication required.
GET /v1/developer/profiles/\{slug\}
Returns a public developer profile by slug, including aggregate stats (extension count, total installs, average rating) and a list of published extensions.
Auth: None (public).
Response:
| Field | Type | Description |
|---|---|---|
id | UUID | Developer profile ID |
display_name | string | Developer display name |
slug | string | URL slug |
description | string or null | Bio/description |
github_url | string or null | GitHub profile URL |
website_url | string or null | Website URL |
avatar_key | string or null | Avatar upload key |
created_at | ISO-8601 | Profile creation date |
stats | object | { extension_count, total_installs, avg_rating } |
extensions | array | List of published extensions with id, short_id, slug, name, description, category, icon_key, pricing_type, pricing_amount, pricing_currency, install_count, rating_avg, rating_count, published_at |
Returns 404 if no developer has the given slug.
GET /v1/developer/teams/\{slug\}/profile
Returns a public team profile by slug, including aggregate stats, team members (with roles), and published extensions.
Auth: None (public).
Response:
| Field | Type | Description |
|---|---|---|
id | UUID | Team ID |
name | string | Team name |
slug | string | URL slug |
description | string or null | Team description |
github_url | string or null | GitHub URL |
website_url | string or null | Website URL |
avatar_key | string or null | Avatar upload key |
created_at | ISO-8601 | Team creation date |
stats | object | { extension_count, total_installs, avg_rating } |
members | array | [{ user_id, display_name, avatar_url, role }] |
extensions | array | Published extensions (same shape as developer profile) |
Returns 404 if no team has the given slug.
Protocol parity: Both endpoints have matching GraphQL queries — see GraphQL.
Extension 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):
| Field | Type | Description |
|---|---|---|
account_id | UUID | Scope to a single account (incremental account refresh). |
install_id | UUID | Scope to a single install (incremental install/update refresh). |
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:
| Field | Type | Required | Description |
|---|---|---|---|
install_id | UUID | Yes | The extension install to disable |
Response: 204 No Content.
Developer Extension Endpoints
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:
| Parameter | Type | Description |
|---|---|---|
limit | integer | Max results (default 50, max 200) |
offset | integer | Pagination offset |
since | ISO-8601 | Only errors after this timestamp |
Response:
{
"data": [
{
"id": "uuid",
"error_type": "handler_timeout",
"message": "Handler exceeded 10s CPU timeout",
"stack_trace": "at handler (server/functions.ts:42:5)",
"occurred_at": "2026-05-20T10:30:00Z",
"install_id": "uuid",
"account_id": "uuid"
}
]
}
GET /v1/developer/extensions/\{id\}/metrics
Returns aggregated invocation metrics for an extension owned by the authenticated developer: total invocations, error count, average and 99th-percentile execution time, a per-handler breakdown, and an hourly time series. Metrics are computed from the extension_install_logs hypertable, so the counts are empty until installs start emitting execution telemetry.
This endpoint is the REST twin of the GraphQL extensionMetrics query — identical fields, permission, and ownership check on both protocols.
Auth: Account auth (JWT or API key). The caller must be the extension owner or a member of the owning developer team.
Permission: extension-dev:read
Feature: feature:extension_development
Path parameter: id — UUID of the extension.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
since | string (RFC 3339) | Optional. Only include data at or after this timestamp. Omit for all-time. |
Response:
{
"data": {
"total_invocations": 12450,
"total_errors": 23,
"avg_execution_ms": 45.2,
"p99_execution_ms": 280.0,
"by_handler": [
{
"handler": "onMessage",
"total_invocations": 9800,
"total_errors": 12,
"avg_execution_ms": 41.7,
"p99_execution_ms": 255.0
}
],
"time_series": [
{
"bucket": "2026-05-20T10:00:00+00:00",
"total_invocations": 512,
"total_errors": 1,
"avg_execution_ms": 44.9
}
]
}
}
by_handler is ordered by invocation count (descending) and capped at 100 handlers; time_series is bucketed to the hour, ordered oldest-first, and capped at 1000 buckets.
GET /v1/developer/limits
Returns the resolved limits for the authenticated developer, including per-category breakdowns with sources and all extension-specific overrides.
Auth: Account auth (JWT or API key). The caller must be a developer.
Permission: extension-dev:read
Feature: feature:extension_development
Response:
{
"data": {
"categories": [
{
"category": "bundled_sounds",
"limits": [
{
"key": "max_sounds",
"label": "Sounds per extension",
"value": 50,
"source": "default",
"default_value": 50,
"configurable": true
}
]
}
],
"extensions": [
{
"extension_id": "uuid",
"extension_name": "My Extension",
"limits": { "max_sounds": 100, "max_sounds_source": "extension_override" }
}
]
}
}
GET /v1/developer/limit-requests/\{id\}/events
Returns the timeline events for a limit request owned by the authenticated developer.
Auth: Account auth (JWT or API key).
Permission: extension-dev:read
Feature: feature:extension_development
Response:
{
"data": [
{
"id": "uuid",
"request_id": "uuid",
"event_type": "submitted",
"actor_id": "uuid",
"details": null,
"created_at": "2026-05-30T10:00:00Z"
}
]
}
GET /v1/developer/extensions/\{id\}/limits
Returns the resolved sound limits for an extension owned by the authenticated developer.
Auth: Account auth (JWT or API key). The caller must be the extension owner or a member of the owning developer team.
Permission: extension-dev:read
Feature: feature:extensions
Response:
{
"data": {
"max_sounds": 50,
"max_sounds_source": "default"
}
}
max_sounds_source is one of: "default" (platform default 50), "developer_override" (admin set on developer), "extension_override" (admin set on this extension).
GET /v1/developer/payout-settings
Returns the authenticated developer's saved payout settings — payout method, Stripe Connect id, PayPal email, and SEPA bank IBAN/BIC/name — for prefilling the payouts form. Companion read to the POST /v1/developer/payouts update (same private DeveloperProfile shape, gated on the payout permission). REST twin of the GraphQL developerPayoutSettings query.
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:
| Parameter | Description |
|---|---|
extension_id | UUID of the extension |
version | Semver version string (e.g. 1.2.0) |
path | File path within the bundle (e.g. layer.js, styles.css, assets/logo-a1b2c3.png) |
Security:
- Path traversal attempts (
.., null bytes, absolute paths) are rejected with 400 source.tar.gzis never served (returns 403)
Response: The raw file bytes with the stored Content-Type header. Cache headers depend on whether the resolved version is published:
- Published — auth-independent bytes, identical for every caller:
Cache-Control: public, max-age=86400, immutable. - Draft / testing — auth-dependent bytes, resolved only for a developer (via
extension_dev_modeor an extension token):Cache-Control: private, no-storeplusVary: Authorization, Cookie. They must never sit in a shared/URL-keyed cache that could serve draft bytes to a non-developer request without re-running the draft-access check.
Legacy compatibility: GET /v1/extension-bundles/{extension_id}/{version}/bundle.js is preserved as a dedicated route and falls back to the bundle_key column for pre-migration versions.
GET /v1/extensions/\{id\}/versions/\{version\}/files
List all files in an extension version's bundle.
Auth: Published versions are publicly accessible. Draft and testing versions require developer authentication.
Path parameters:
| Parameter | Description |
|---|---|
id | UUID of the extension |
version | Semver version string |
Response:
{
"data": [
{
"id": "uuid",
"version_id": "uuid",
"file_path": "layer.js",
"content_type": "application/javascript",
"size_bytes": 312000,
"content_hash": "sha256-abc123...",
"created_at": "2026-05-26T10:00:00Z"
}
]
}
Protocol parity:
extensionVersionFiles(extensionId, version)query in GraphQL -- see GraphQL.
Extension Upload Endpoints
POST /v1/extensions/\{extension_id\}/uploads
Upload a file from an extension editor (icons, images). Used by the SDK's ctx.upload() method inside extension iframes.
Auth: Extension Token (lm_ext_*). The token's extension_id must match the path parameter.
Body: multipart/form-data with a file field.
Limits: Max 256 KB per file. Max 50 uploads per extension per account. Accepted formats: PNG, JPEG, GIF, SVG, WebP, AVIF.
Deduplication: Files are deduplicated by SHA-256 content hash. Uploading an identical file returns the existing URL without re-uploading.
Response:
{
"data": {
"url": "https://api.lumio.vision/v1/extensions/{extension_id}/uploads/{hash}.png"
}
}
GET /v1/extensions/\{extension_id\}/uploads/\{filename\}
Serve an uploaded file. Public access (URLs are unguessable due to content-hash filenames).
Response: Raw file bytes with correct Content-Type and Cache-Control: public, max-age=86400, immutable.
Extension Install Endpoints
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:
| Parameter | Type | Description |
|---|---|---|
limit | integer | Max results (default 50, max 200) |
offset | integer | Pagination offset |
level | string | Filter 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 param | Type | Effect |
|---|---|---|
from | RFC3339 timestamp | Only sessions with started_at >= this instant. |
to | RFC3339 timestamp | Only sessions with started_at <= this instant. |
session_type | single | multi | Only sessions of this type. An unknown value → 400. |
platform | string | Only sessions with a broadcast on this platform. |
search | string | Case-insensitive substring over the session title or any of its stream titles. |
category | string | Session 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 param | Type | Effect |
|---|---|---|
sort_by | started_at | duration_secs | peak_viewers | avg_viewers | total_messages | unique_chatters | new_followers | Column to order by (default started_at). Any other value → 400. |
sort_dir | asc | desc | Direction (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_gifs —
top_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) withsummary.csv(flat key/value,platform_metricsnamespaced — includesemote_countandgif_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), includingschema_versionandplatform_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.
GET /v1/history/reports/\{id\}/shared-links
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 range ∈
DAYS_30 | DAYS_90 | DAYS_180 | DAYS_365 selects the rolling window; an unknown
value → 400. Returns follower_series (dated follower-total growth) and
avg_viewer_series (dated average viewers), each an oldest-first list of day
points: follower_series items are { date, follower_count }, avg_viewer_series
items are { date, avg_viewers }, with date = YYYY-MM-DD (UTC). Follower points
are the last recorded follower total per day (dated crawler snapshots); avg-viewer
points are the exact sample-weighted daily mean from the channel_history_stats_1h
aggregate scoped to the channel's own sessions. Mirrors the GraphQL
publicChannelTrends query exactly. A channel that has opted out or has no
addressable streams → 404; unsupported platform → 400. Either series is an
empty list ([]) when the channel has no history in the window. Aggregate counts
only — no viewer identities. Read via publicGql (SSR); no WebSocket surface.
GET /v1/public-stats/channels/\{platform\}/\{channel\}/panel-history
A channel's ad-panel lifecycle + image-change history (ZAF-892) for the
"Placements" tab — the ad-marked panels grouped by panel, most-recently-active
first. Each entry: panel_id, latest content (title / description /
image_url / link_url), full span (first_seen_at / last_seen_at), lifecycle
(status = "active" / "removed", removed_at), and
images: [{ image_url, first_seen_at, last_seen_at }] — the distinct image
versions over time, oldest-first (the slideshow). Mirrors the GraphQL
publicChannelPanelHistory query exactly. A channel that has opted out or has
no addressable streams → 404; unsupported platform → 400; a channel with no
ad-marked panels → 200 with an empty list. 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 metric → 400.
Response data: { "channels": [ { platform, platform_channel_id, channel_login, channel_display_name, source, last_category, is_live, stream_count, watch_seconds, avg_viewers, peak_viewers, total_messages, follower_gain, last_started_at, broadcaster_type } ], "pagination": { page, limit, total, total_pages } }. 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 param | Type | Effect |
|---|---|---|
page | integer | Page number (default 1). |
limit | integer | Page size (default 50, clamped 1..=200). |
type | string | Exact event-type match. |
from | RFC-3339 timestamp | Only events at/after this instant. An unparseable value is ignored. |
to | RFC-3339 timestamp | Only events at/before this instant. An unparseable value is ignored. |
Response: { "items": [...], "total": N, "page": N, "limit": N, "total_pages": N }.
GET /v1/me/audit-log
The caller's own scope=user rows. Self-only, no RBAC permission — but a
first-party principal is required (a logged-in session or the user's own API
key); a popout/overlay/widget/extension token gets 401. Returns only the
caller's rows. Current writers record successful logins, failed login attempts
tied to a known user, and OAuth grant consent. Mirrors GraphQL myAuditLog.
GET /v1/account/audit-log
The active account's scope=account events. Current emitters write
role/permission, member-role, channel-connection, and account-scoped GDPR
erasure rows. Permission: the account-scope audit-log:read (403
otherwise; 401 if no account is active). Returns only the caller's account's
rows. Mirrors GraphQL accountAuditLog.
POST /v1/internal/audit-ingest
Server-to-server ingest for user-scope login / 2FA / OAuth-grant security
events. These originate in apps/id (NextAuth, TypeScript) at the auth
boundary, but the Rust API is the single audit writer, so apps/id posts them here
after the auth event instead of writing audit_events itself.
Auth: System key only, carrying the narrow audit:ingest grant. Every
non-System principal (session, user API key, popout/overlay/widget/extension
token) is rejected 403, and an anonymous request 401 — no public/user route
can forge audit rows. A System key without audit:ingest (or audit:*) is also
403; a bare *:* does not grant it. Provision apps/id's key with
audit:ingest.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
user_id | UUID | yes | The subject user the event is about. |
type | string | yes | One of user:login, user:login_failed, user:mfa_enabled, user:mfa_disabled, user:oauth_granted. Any other value (including an account:* / system:* type) → 400. |
ip_address | string | no | The end-user's client IP at the auth boundary (this is a server-to-server call, so the request peer is the id service — forward the real client IP here). GeoIP-resolved fail-open into country/city. |
user_agent | string | no | The end-user's user-agent. |
metadata | object | no | Free-form JSON object (defaults to {}; a non-object value → 400). |
The handler forces scope = user, user_id = <subject>, and
account_id = NULL — an ingest caller can never widen scope, attach a tenant, or
write a non-user-scope type. On success returns 201 { "id": "<uuid>" } with the
new row id.