# Lumio Docs > Documentation for Lumio, a multi-platform streaming management application > for Twitch, YouTube, Kick, Discord and Spotify. Covers the dashboard and > user guide, platform features, the REST / GraphQL / WebSocket API, RBAC > permissions, the admin panel, and self-hosted installation. > > Lumio is a Rust + TypeScript monorepo. The API (Actix Web) exposes every > feature over three protocols at once: GraphQL (POST /v1/gql), REST > (everything under /v1/) and WebSocket (/v1/ws). --- ## Welcome to Lumio Docs Source: https://docs.lumio.vision/intro # Welcome to Lumio Docs Lumio is a multi-platform streaming management application for Twitch, YouTube, Kick, Discord, and Spotify. ## What is Lumio? Lumio gives streamers a single dashboard for overlays, chat, bots, music, events, and automations across every connected platform: - **Multi-platform connections** - Channel connections for Twitch, YouTube, Kick and Spotify; Discord as a login and bot/integration platform - **Overlay editor** - Visual layer-based overlay editor with live preview; every overlay renders as an OBS browser source - **Widgets & extensions** - 12 system extensions (8 widgets + 4 bot modules) plus third-party extensions from the Extension Store - **Event system** - Real-time events (follows, subs, cheers, raids, redemptions, tips) with filtering, alerts and history - **Multichat** - Unified chat across all connected platforms with cross-platform moderation - **Chat bots** - Four bots (Twitch, YouTube, Kick, Discord) sharing one command system - **Spotify integration** - Playback control from the dashboard plus a Now Playing overlay widget - **Automations** - Node-based workflow editor wiring triggers to conditions and actions - **RBAC** - Fine-grained role-based access control for team management ## Platform at a glance Lumio is a Rust + TypeScript monorepo: 34 Rust crates (`lo-*`), 18 apps, 9 internal TypeScript packages (`@lumio/*`), 5 public npm packages (`@zaflun/*`), and 12 system extensions (8 widgets + 4 bot modules). The API is an Actix Web service that exposes every feature over three protocols simultaneously: | Surface | Endpoint | |---------|----------| | GraphQL (primary) | `POST /v1/gql` | | GraphiQL IDE | `GET /v1/graphiql` | | GraphQL SDL | `GET /v1/schema` | | REST | everything under `/v1/` | | WebSocket | `/v1/ws` | Data lives in PostgreSQL (relational data), TimescaleDB (time-series: events, chat, audit logs) and Redis (cache and pub/sub). Production runs on `lumio.vision` (dashboard), `id.lumio.vision` (login), `api.lumio.vision` (API), `admin.lumio.vision` (admin), `overlay.lumio.vision` (overlays) and `widget.lumio.vision` (widgets). ## Quick Links - [Installation](/getting-started/installation) - Set up a local development environment - [OAuth Provider Setup](/getting-started/oauth-setup) - Create the OAuth applications Lumio needs - [Connecting Platforms](/getting-started/connecting-platforms) - Link your streaming accounts - [First Overlay](/getting-started/first-overlay) - Create your first overlay and add it to OBS - [API Reference](/api-reference/rest-api) - REST, GraphQL and WebSocket documentation - [Developer Guide](/developer-guide/bot-modules) - Build custom bot modules ## For AI assistants This site publishes its documentation in the [llms.txt](https://llmstxt.org/) format, so an AI assistant can read it without crawling the HTML: - [llms.txt](pathname:///llms.txt) - structured index of every page with a one-line description - [llms-full.txt](pathname:///llms-full.txt) - the complete documentation in a single file Both files are regenerated on every build, so they never drift from the pages above. Extension developers want the equivalent files on the developer site: [developers.lumio.vision/llms.txt](https://developers.lumio.vision/llms.txt). --- ## Getting Started / Installation Source: https://docs.lumio.vision/getting-started/installation # Installation Set up a local Lumio development environment. ## Hosted Environments Lumio runs three environments per app. Production lives on `lumio.vision`; non-production on `zaflun.dev`. Use these strings when configuring OAuth callbacks, CORS, env-var defaults and CI deploy targets. | App | Production | Production Preview | Staging | |-----|------------|---------------------|---------| | Webapp | `lumio.vision` | `lumio.web.prod.zaflun.dev` | `lumio.web.staging.zaflun.dev` | | ID App | `id.lumio.vision` | `lumio.id.prod.zaflun.dev` | `lumio.id.staging.zaflun.dev` | | Admin App | `admin.lumio.vision` | `lumio.admin.prod.zaflun.dev` | `lumio.admin.staging.zaflun.dev` | | Stats App | `stats.lumio.vision` | `lumio.stats.prod.zaflun.dev` | `lumio.stats.staging.zaflun.dev` | | API | `api.lumio.vision` | `lumio.api.prod.zaflun.dev` | `lumio.api.staging.zaflun.dev` | | Docs | `docs.lumio.vision` | `lumio.docs.prod.zaflun.dev` | `lumio.docs.staging.zaflun.dev` | | Developer Docs | `developers.lumio.vision` | `lumio.dev.docs.prod.zaflun.dev` | `lumio.dev.docs.staging.zaflun.dev` | | Overlay external URL | `overlay.lumio.vision` | _(single per-deployment external host; configurable via `NEXT_PUBLIC_OVERLAY_URL` on the webapp)_ | _(same)_ | | Widget external URL | `widget.lumio.vision` | _(single per-deployment external host; configurable via `LUMIO__WIDGET__PUBLIC_URL` on the API)_ | _(same)_ | **Branch mapping:** `next` → staging · `main` → production preview · tag `20*` → production. This mapping is enforced in CI for the Cloudflare-hosted docs family (`docs`, `developer-docs`, `extension-apps`). The core product containers (API, web, admin, id, workers, bots) are container images promoted by release channel instead - `beta` for a pre-release, `latest` for a full release. The rest of this guide covers a **local** development setup. ## Prerequisites | Tool | Version / notes | |------|-----------------| | Docker + Docker Compose | Runs the local database stack | | Rust toolchain (`rustup`) | Edition 2024. CI builds on `stable`; the Bazel toolchain pins `1.97.1` | | `protoc` | Required - `lo-youtube-api`'s build script compiles the YouTube gRPC protos | | Node.js | `>= 18` | | pnpm | `10.28.0` (see `packageManager` in the root `package.json`) | | [`just`](https://just.systems) | Task runner used by every command in these docs | Bazel is optional locally - it is the CI build system (see [Bazel](#bazel-ci-only) below). ## Dev Stack `just stack-up` starts the local database + broker stack, merging `dev-stack/db.docker-compose.yml` and `dev-stack/broker.docker-compose.yml`: ```bash just stack-up ``` | Service | Image | Host port | Database | |---------|-------|-----------|----------| | `lumio-postgres` | `postgres:18-alpine` | `127.0.0.1:5432` | `lumio` (user/password `lumio`) | | `lumio-tsdb` | `timescale/timescaledb-ha:pg18` | `127.0.0.1:5433` | `lumio_tsdb` (user/password `lumio`) | | `lumio-extensions-postgres` | `postgres:18-alpine` | `127.0.0.1:5434` | `lumio_extensions` (owner `lumio_ext`, DML logins `lumio_ext_api` / `lumio_ext_worker`; password = user name) | | `lumio-redis` | `redis:8-alpine` | `127.0.0.1:6379` | password `lumio` | | `lumio-rabbitmq` | `rabbitmq:4-management-alpine` | `127.0.0.1:5672` (AMQP), `127.0.0.1:15672` (UI) | user/password `lumio` | | `lumio-kafka` | `apache/kafka:3.9.0` | `127.0.0.1:9092` | KRaft mode (no ZooKeeper) | The brokers back the ingest pipeline: **RabbitMQ** is the job/command bus and **Kafka** is the durable ingest firehose (Redis stays cache-only, TimescaleDB is the system of record). Dev and staging run single-node; only production is the multi-node cluster. The extensions PostgreSQL's superuser is `postgres` (password `postgres`), and no app uses it: superusers ignore row-level security. On an empty volume `dev-stack/initdb/extensions/10-roles.sh` creates the DDL owner `lumio_ext`, the `lumio_ext_dml` group and the DML logins (see `developer-guide/data-layer-staging.md`, "Extensions PostgreSQL: roles"). A volume created before this change still has `lumio_ext` as its superuser. Delete that volume once with the stack stopped (find it with `docker volume ls | grep lumio-extensions-pg-data`; compose prefixes the project name) so the script runs, or switch it in place as described there. The databases preload `pg_stat_statements` and log every statement that takes 1 s or longer. Redis has `maxmemory 512mb` with `maxmemory-policy noeviction`: a full Redis rejects writes instead of dropping keys, as on staging (see `developer-guide/data-layer-staging.md`). TimescaleDB keeps its data in the `lumio-tsdb-pgdata` volume. The older `lumio-tsdb-data` volume was mounted at a path the `timescaledb-ha` image does not use, so it holds no data and you can delete it. The stack runs in the foreground, so use a dedicated terminal. Related recipes: | Recipe | What it does | |--------|--------------| | `just stack-down` | Stop the stack (volumes kept) | | `just stack-reset` | Stop the stack, **drop the volumes**, start again | | `just stack-logs` | Tail the stack logs | | `just mail-up` | MailDev on `http://localhost:1080` | | `just minio-up` | MinIO console on `http://localhost:9001` | | `just stack-all` | `stack-up` + `mail-up` + `minio-up` | | `just psql` / `just tsdb-psql` / `just redis-cli` | Open a CLI against the running containers | | `just rabbitmq-ui` | Print the RabbitMQ management UI URL (`http://127.0.0.1:15672`, `lumio`/`lumio`) | | `just kafka-topics` | List Kafka topics on the local KRaft broker | ## Database Migrations Migrations live in `apps/api/migrations/` (main database) and `apps/api/tsdb_migrations/` (TimescaleDB) and are run by the `migrate` binary: ```bash just migrate # run all pending migrations on both databases just migrate-status # show what has been applied just migrate-main # main database only just migrate-tsdb # TimescaleDB only just db-fresh # drop, create, migrate ``` The API also runs pending migrations on startup. > **Migrations are embedded at compile time:** > `sqlx::migrate!()` embeds the `.sql` files into the binary when it is **compiled**. Adding a new migration file therefore requires a rebuild - `cargo build -p lumio-api` (or `just build-api`) - not just a restart. A restart alone will silently run the old migration set. New migrations use the reversible `.up.sql` / `.down.sql` pair format. Legacy migrations from before `20260410` use the simple single-file format and are never renamed. ## Running the Apps Each app has its own recipe and its own port: | Recipe | App | URL | |--------|-----|-----| | `just run-api` (or `just dev-api` for auto-reload, which needs `cargo install cargo-watch`) | Rust API | `http://localhost:3000` | | `just dev-web` | Webapp / dashboard | `http://localhost:4000` | | `just dev-admin` | Admin panel | `http://localhost:4001` | | `just dev-id` | ID / auth app | `http://localhost:4002` | | `just dev-stats` | Public stats app | `http://localhost:4003` | | `pnpm --filter @lumio/shopify dev` | Embedded Shopify admin page (`apps/shopify`); renders only inside the Shopify admin, see [Shopify → Embedded page](../features/shopify.md#embedded-page-appsshopify) | `http://localhost:4004` | | `just dev-docs` | This documentation site | Docusaurus default port - pass `--port` to avoid clashing with the API on `3000` | | `just dev-developer-docs` | Developer documentation | `http://localhost:3004` | | `just dev-supervisor` | Extension supervisor (static) | `http://localhost:3200` | | `just dev-runtime` | Extension runtime (static) | `http://localhost:3201` | ## Bazel (CI Only) Bazel handles CI builds and container image creation. It is not required for local development -- Cargo covers that. Install [Bazelisk](https://github.com/bazelbuild/bazelisk), which automatically downloads the correct Bazel version: ```bash # Install Bazelisk (Bazel version manager) npm install -g @bazel/bazelisk # Or on macOS: brew install bazelisk # Verify bazel --version ``` See the [Bazel Build System](../developer-guide/bazel.md) guide for details on the build architecture, vendored crates, and image targets. ## Environment Configuration ### Sign-in protection and support code The API's `[turnstile]` section controls server-side Cloudflare Turnstile verification. `enabled`, `site_key`, `secret_key`, `id_hostnames`, `web_hostnames`, `verify_url`, and `timeout_ms` can be overridden as `LUMIO__TURNSTILE__ENABLED`, `LUMIO__TURNSTILE__SITE_KEY`, `LUMIO__TURNSTILE__SECRET_KEY`, `LUMIO__TURNSTILE__ID_HOSTNAMES`, `LUMIO__TURNSTILE__WEB_HOSTNAMES`, `LUMIO__TURNSTILE__VERIFY_URL`, and `LUMIO__TURNSTILE__TIMEOUT_MS`. Use exact ID-app hosts for registration, login, reset and resend; exact web-app hosts for email setup and resend and for the contact form (action `contact`). The public site key is supplied to the apps at runtime by `GET /v1/auth/turnstile-config`. Keep the secret in the environment. The default pair is Cloudflare's always-pass test pair, so every protected action passes on a local stack. Cloudflare answers the test secret with hostname `example.com`, no action and `metadata.result_with_testing_key: true`; the API accepts that answer without the action and hostname checks, and only when the environment is neither `staging` nor `production`. In staging and production the test secret fails closed, and a test-key answer with a real secret is rejected, so hosted environments use real keys. To run with no check at all, set `LUMIO__TURNSTILE__ENABLED=false`. The API's `[contact]` section sets where the public contact form (`POST /v1/contact`, GraphQL `submitContactMessage`) sends its messages: `recipient` (default `hey@lumio.vision`, ENV `LUMIO__CONTACT__RECIPIENT`). Each message goes out through the configured `[email]` provider with `Reply-To` set to the sender and is not stored. With `email.enabled = false` the message is only logged as a skipped send. Set `[support].code_secret` through `LUMIO__SUPPORT__CODE_SECRET`. It is the HMAC secret for each profile's hourly support code; rotating it invalidates existing codes. Never expose it to a browser or put it in a repository TOML file. Local development needs no setup: when the secret is empty and `environment` is `development` (or `dev`), the API derives codes from a fixed, public development secret and logs this once at `info` on startup. Every other environment takes no default. Staging or production without the secret still starts, but logs `warn` `support code disabled: LUMIO__SUPPORT__CODE_SECRET is empty`; the support code then stays off (`SUPPORT_CODE_UNAVAILABLE`), so the profile header shows no code and the admin **Support Identity** card cannot reveal or check one. ### File storage for profile pictures Own profile pictures (`POST /v1/users/me/avatar`) are stored through the API's `[storage]` section, the same lo-storage client that channel uploads and GDPR exports use. With `storage.enabled = false` (`LUMIO__STORAGE__ENABLED`) the upload returns `503 AVATAR_UNAVAILABLE`. The files live under the key prefix `users//avatar/` (`-512.webp` and `-128.webp`), and `users.avatar_url` is `storage.public_url` (`LUMIO__STORAGE__PUBLIC_URL`) plus that key. Point `storage.public_url` at the API itself, `/v1/uploads/file` (e.g. `http://localhost:3000/v1/uploads/file`, or your own port): the API serves the current picture under that path (`GET /v1/uploads/file/users//avatar/-512.webp`) from storage, so neither the local directory nor an S3 bucket has to be publicly readable. The API re-derives every stored picture URL from the key at start-up, so changing `storage.public_url` heals existing pictures by itself. The dashboard's `img-src` allows the API origin; a CSP set in front of the web app (such as the production edge) has to allow it too. GDPR export archives use the separate `private/data-exports/` prefix, which must stay private. The 5 MB picture cap is fixed in code and independent of `storage.max_upload_size_bytes`. Uploaded channel pictures (`POST /v1/accounts//avatar`) use the same storage under `accounts//avatar/` and are served at `GET /v1/uploads/file/accounts//avatar/-512.webp`, with the same `503 AVATAR_UNAVAILABLE` when storage is off. ### Billing without Stripe Stripe billing is controlled by the API's `[stripe]` section (`enabled`, `secret_key`, `webhook_secret`, `price_id_pro`, `price_id_enterprise`; ENV `LUMIO__STRIPE__ENABLED`, `LUMIO__STRIPE__SECRET_KEY`, …). It is off in `default.toml`, so a local stack runs without Stripe. Then `billingStatus.billingEnabled` (REST `GET /v1/billing/status` → `billing_enabled`) is `false`, the dashboard's Subscription tab still lists the plans (from the database) and the channel's current plan, with checkout, switching to Free and cancelling disabled; the Invoices tab shows that billing is not set up instead of loading invoices; and every other billing call answers `409 BILLING_DISABLED`. New channels are created on Free either way. Configuration is split by runtime. There is **no repository-root `.env`** - each app is configured on its own. ### Developer payouts and credit notes Every developer payout is documented by a self-billed credit note (Gutschrift). The issuer printed on it comes from the API's `[billing.issuer]` section: `name`, `address` (one line per address line) and `vat_id` (ENV `LUMIO__BILLING__ISSUER__NAME`, `LUMIO__BILLING__ISSUER__ADDRESS`, `LUMIO__BILLING__ISSUER__VAT_ID`). They are empty in `default.toml`: the API then logs a warning at start-up and refuses every payout, so a local stack books none. `[billing.credit_notes]` sets the number prefix (`number_prefix`, default `GS`) and the VAT rate for German standard-rated developers (`domestic_vat_rate_bp`, default `1900`). Storing and mailing the PDFs needs `[storage]` and `[email]`. Details: [Developer credit notes](../features/developer-credit-notes.md#configuration). The payout cycle (`[payments] payout_cycle_enabled`, ENV `LUMIO__PAYMENTS__PAYOUT_CYCLE_ENABLED`, default `true`) pays out developer balances once per month and sends pending Stripe and PayPal payouts every 15 minutes. A provider without credentials (`stripe_secret_key`; `paypal_client_id` + `paypal_client_secret`) is skipped, so a local stack sends nothing. Details: [Developer payouts](../features/developer-payouts.md#configuration). ### Rust services - layered TOML The API and the bots read layered TOML from their own `config/` directory: ``` config/default.toml → config/{env}.toml → config/local.toml → ENV vars ``` Later layers win, so an environment variable always overrides a file. `config/default.toml` is committed and holds every key with a documented default; `config/local.toml` is git-ignored and is where you put local secrets. The `{env}` layer is selected by `APP_ENV` for the API (`development` - the default - `staging`, or `production`), and by `{SERVICE}_RUN_MODE` for the bots (e.g. `TWITCH_BOT_RUN_MODE`). **List values from the environment are comma-separated.** A list key takes one ENV value whose entries are separated by `,`, for example `LUMIO__TURNSTILE__ID_HOSTNAMES=lumio.id.staging.zaflun.dev` or `LUMIO__TURNSTILE__WEB_HOSTNAMES=a.example,b.example`. A single entry needs no comma. Each entry is trimmed and empty entries are dropped, so `" a.example , b.example ,"` loads as `a.example` and `b.example`. Numeric lists work the same way: `CRAWLER_TWITCH__CRAWLER__OWNED_SHARDS=3` loads as `[3]`, and `=1,2` as `[1, 2]`. The list keys are `cors.allowed_origins`, `youtube.innertube_proxy_urls`, `history.command_prefixes`, `webauthn.origins`, `turnstile.id_hostnames`, `turnstile.web_hostnames` (API) and `crawler.owned_shards` (Twitch crawler). Every other key is read as one value, so a comma inside a URL, token or DSN is kept as it is. Create `apps/api/config/local.toml` with at least your OAuth login credentials and a token encryption key (see [OAuth Provider Setup](/getting-started/oauth-setup#2-api-server-appsapiconfiglocaltoml)). The database and Redis defaults in `default.toml` already match the dev stack: | Config key | Default | |------------|---------| | `server.port` | `3000` | | `database.url` | `postgres://lumio:lumio@localhost:5432/lumio` | | `timescale.url` | `postgres://lumio:lumio@localhost:5433/lumio_tsdb` | | `extensions.database.url` | `postgres://lumio_ext_api:lumio_ext_api@localhost:5434/lumio_extensions` | | `extensions.database.ddl_url` | `postgres://lumio_ext:lumio_ext@localhost:5434/lumio_extensions` | | `redis.url` | `redis://:lumio@127.0.0.1:6379` | | `web.public_url` | `http://localhost:4000` | | `admin.public_url` | `http://localhost:4001` | | `overlay.public_url` | `http://localhost:4000/overlay` | | `widget.public_url` | `http://localhost:4000/widget` | The extensions database uses two roles. `extensions.database.url` (`LUMIO__EXTENSIONS__DATABASE__URL`) is the **DML** pool that server functions run on: a login in the `lumio_ext_dml` group that may only read and write rows. `extensions.database.ddl_url` (`LUMIO__EXTENSIONS__DATABASE__DDL_URL`) is the **DDL** pool, at most 2 connections: the schema owner, which runs the extensions migrations, provisioning, the data purges and the storage-quota pass. At startup the API refuses to run if either role is a superuser or has `BYPASSRLS`, or if the DML login has `CREATE` or `TEMPORARY` on the database. Outside `development` it also refuses an empty `ddl_url` or one equal to `url`. In development an empty `ddl_url` falls back to `url`. The dev stack's init script creates both logins; a volume created before it still has `lumio_ext` as its superuser, and the API then refuses to start until the volume is recreated or switched (see the note under the dev-stack table above). ### Required public URLs for hosted API deployments The API uses `[web] public_url` and `[admin] public_url` to mint OAuth redirect URIs and to validate the admin return target for global bot OAuth. It also uses the web URL for developer-team invite emails (`/invite/dev/{code}`) and the admin URL for developer-application review notifications and emails (`/developers/applications`). `apps/api/config/staging.toml` and `apps/api/config/production.toml` do not pin those sections, so hosted deployments must supply them through environment variables: | Environment | `LUMIO__WEB__PUBLIC_URL` | `LUMIO__ADMIN__PUBLIC_URL` | |-------------|--------------------------|----------------------------| | Production | `https://lumio.vision` | `https://admin.lumio.vision` | | Production preview | `https://lumio.web.prod.zaflun.dev` | `https://lumio.admin.prod.zaflun.dev` | | Staging | `https://lumio.web.staging.zaflun.dev` | `https://lumio.admin.staging.zaflun.dev` | If `LUMIO__WEB__PUBLIC_URL` is missing outside local development, system-sourced channel and bot OAuth flows build provider redirect URIs with the default `http://localhost:4000` host. Twitch, Kick and YouTube reject that redirect URI in hosted environments. If `LUMIO__ADMIN__PUBLIC_URL` is missing, the admin global-bot flow stores `http://localhost:4001/providers` as its return target; the unified callback allowlist then cannot return the browser to the hosted admin app. > **`DATABASE_URL` is not an API setting:** > The API reads its connection strings from the TOML layers (or `LUMIO__DATABASE__URL`). The `apps/api` integration-test harness uses `DATABASE_URL` to create an isolated database for each test. Start the [Dev Stack](#dev-stack), set `DATABASE_URL` to its PostgreSQL server using a user that can `CREATE DATABASE`, then run `cargo test -p lumio-api --test `. For a TimescaleDB-gated suite, set `DATABASE_URL` to the Dev Stack's TimescaleDB server and `RUN_TSDB_MIGRATION_TEST=1` before running the same command. Tests that need real Redis can opt in with `TEST_REDIS_URL`; other database tests use the offline Redis fixture. > > Agent runs use a separate host test stack with an injected `DATABASE_URL`. That stack runs on demand: if a test reports connection refused on ports `15432`, `15433`, or `16379`, agents start it with `docker compose -f ~/test-stack/docker-compose.yml up -d`, wait for the services to be healthy, and rerun the test. Agents leave it running; only Raphael stops it. They do not replace the injected URL or switch to the developer stack. ### Mandatory auth secrets Two `[auth]` keys are **required** on any non-`development` deployment. `default.toml` ships them unset/placeholder so the local inner loop just works, but the API **refuses to boot** (`environment` = `staging` or `production`) if either is empty, the shipped placeholder (`CHANGE_ME` / `change-me-in-production`), or shorter than 16 characters: | Key | ENV var | Why it must be a real secret | |-----|---------|------------------------------| | `auth.token_encryption_key` | `LUMIO__AUTH__TOKEN_ENCRYPTION_KEY` | AES-256-GCM key for every at-rest secret (channel + BYO `app_credentials` OAuth tokens, StreamElements JWTs, OBS passwords, Stripe Connect state). An **empty** value derives `SHA-256("")` - a constant any clone of this repo can reproduce - so all ciphertext becomes readable by anyone who has the code. | | `auth.jwt_secret` | `LUMIO__AUTH__JWT_SECRET` | HMAC key for session tokens. A placeholder value lets anyone forge a valid session JWT (auth bypass, no DB access needed). | Generate each with `openssl rand -base64 32`. In `development` the boot only logs an `error!` and continues, so `just run-api` still works against the empty defaults - but data encrypted under the empty key is readable by anyone, so never expose a dev instance. > **Rotating `token_encryption_key` is destructive:** > The ciphertext carries **no key-version tag**, so changing this key makes **every existing row permanently undecryptable** (`aead::Error`) - rows are re-encrypted only on their next write. Set it **once** per deployment and never change it on a live instance. A mid-flight flip between "set" and "unset" is the exact root cause of the ZAF-1196 credential-loss incident. If you must rotate, plan a coordinated re-encrypt of every affected column. ### Next.js apps - `.env.local` `apps/web`, `apps/admin` and `apps/id` each ship a `.env.example`. Copy it next to the app and fill it in: ```bash cp apps/web/.env.example apps/web/.env.local cp apps/admin/.env.example apps/admin/.env.local cp apps/id/.env.example apps/id/.env.local ``` The variables you will actually touch first: | Variable | App | Description | |----------|-----|-------------| | `LUMIO_API_URL` | web, admin, id | Server-side API base - `http://localhost:3000/v1` | | `LUMIO_INTERNAL_TOKEN` | web, admin, id, stats | Shared secret for SSR → API calls; must match `rate_limiting.internal_token` in the API config (`dev-internal-token` locally) | | `LUMIO_AUDIT_INGEST_KEY` | id | System key (`lm_sys_*` carrying the narrow `audit:ingest` grant) that apps/id uses to post user-scope audit events to `POST /v1/internal/audit-ingest`. Leave unset locally - audit emission is then simply skipped and login still works. In staging/production, mint a dedicated key carrying **only** the `audit:ingest` chip from the admin app (**System → System Keys**) and set it here - see [Provisioning the apps/id audit-ingest key](../admin/system-keys.md#provisioning-the-appsid-audit-ingest-key). While it is unset the whole apps/id audit trail is dropped and `GET /api/health` reports `audit_ingest_configured: false` (see [Audit Events → Events that originate outside the API](../developer-guide/audit-events.md#events-that-originate-outside-the-api-appsid-ingest)) | | `AUTH_SECRET` | id | NextAuth session secret | | `AUTH_URL` | id | Public base URL of the ID app - `http://localhost:4002`. NextAuth builds its OAuth callbacks from it, and the ID app's own login redirect, `callbackUrl` and absolute share-image URLs use its origin; behind a reverse proxy the request URL carries the container's bind address (`0.0.0.0:4002`), so this must be the public host | | `AUTH_{PROVIDER}_ID` / `AUTH_{PROVIDER}_SECRET` | id | OAuth login credentials per provider (see [OAuth Provider Setup](/getting-started/oauth-setup)) | | `LUMIO__AUTH__DISCORD_CLIENT_SECRET` | api | Discord login app secret matching `AUTH_DISCORD_SECRET` in the ID app; needed for rotating login-token refresh | | `LUMIO__AUTH__DISCORD_BOT_CLIENT_ID` / `LUMIO__AUTH__DISCORD_BOT_CLIENT_SECRET` | api | Global Discord bot application used for verified server invites; separate from the login pair and the static Bot Token | | `DISCORD_BOT__API__SYSTEM_KEY` | discord-bot | System key created under **Admin → System Keys** in the same environment with `bots:internal`; the API does not provision System keys from config | | `LUMIO_APP_URL` / `NEXT_PUBLIC_APP_URL` | web, admin | Public URL of the app itself; `LUMIO_APP_URL` is read on the server per request and wins. It is the origin of the login `callbackUrl` and of the OAuth connection-callback redirects. On web it also supplies canonical, language-alternate, share-image and sitemap URLs; only `https://lumio.vision` allows indexing. An unset or invalid value yields no canonical or language alternates and blocks indexing. Behind a reverse proxy the request URL carries the container's bind address (`0.0.0.0:`), and the `Host` / `X-Forwarded-Host` headers are never trusted for it | | `LUMIO_STATS_URL` | stats | Public origin of the Stats app, read on the server per request. It supplies the canonical, language-alternate and sitemap URLs. Only `https://stats.lumio.vision` allows indexing: every other value, including the prod origin host `lumio.stats.prod.zaflun.dev`, and an unset or invalid one make every response `noindex, nofollow` (meta tag and `X-Robots-Tag`), `robots.txt` `Disallow: /` and the sitemap routes 404. There is no `NEXT_PUBLIC_*` variant and no localhost fallback | | `LUMIO__PUBLIC_STATS__SITEMAP_PLATFORMS` | api | Comma-separated server-side `[public_stats].sitemap_platforms` allowlist for channel sitemap files. The default is `twitch,kick`; the client cannot expand it. The sitemap includes only allowlisted channels with a login and a stream in the last 12 months. Keep it aligned with the Stats app's indexable channel-platform list. | | `NEXT_PUBLIC_ID_URL` | web | Public URL of the ID app | | `NEXT_PUBLIC_OVERLAY_URL` | web | Public overlay host - `http://localhost:4000/overlay` locally | | `LUMIO_SUPERVISOR_URL` / `LUMIO_EXTENSION_RUNTIME_URL` | web | Extension sandbox hosts - `http://localhost:3200` / `http://localhost:3201` locally. Read on the server per request, so a deployed container takes them from its runtime env. `NEXT_PUBLIC_SUPERVISOR_URL` / `NEXT_PUBLIC_EXTENSION_RUNTIME_URL` are the fallback; they only apply where they were present at `next build` (local checkouts) | | `LUMIO_EXTENSION_RUNTIME_ORIGIN_TEMPLATE` | web | Per-extension runtime origin, e.g. `https://{label}.lumio.ext.staging.zaflun.dev`; `{label}` becomes the extension id as 32 hex characters and must match the template the Supervisor and Runtime were built with. Unset = every extension shares the runtime URL above, so removing the variable and restarting the container is the rollback. Falls back to `NEXT_PUBLIC_EXTENSION_RUNTIME_ORIGIN_TEMPLATE`. Empty values count as unset | | `COOKIE_DOMAIN` | web, admin, id | Only needed for cross-subdomain deployments; leave unset locally. Must be identical across the three apps | ### `LUMIO_WEB_URL` / `NEXT_PUBLIC_WEB_URL` Absolute URL of the `apps/web` app, read by `apps/id`. It is what the ID app redirects to after a successful login (`/dashboard`, `/dashboard/settings/channel/connections`, `/dashboard/settings/channel/subscription`). `LUMIO_WEB_URL` takes precedence; `NEXT_PUBLIC_WEB_URL` is the client-side mirror. - **Default in code:** `http://localhost:4000` - **Staging:** `https://lumio.web.staging.zaflun.dev` - **Production-preview:** `https://lumio.web.prod.zaflun.dev` - **Production:** `https://lumio.vision` `apps/web` itself does NOT need this variable - it uses relative paths because it hosts those pages. ### Environment Variable Naming Every TOML key in `apps/api/config/*.toml` is overridable via an environment variable using the format `LUMIO__
__` - **double underscore between the prefix and the first segment, and double underscore between every nested segment**. Examples: | TOML path | ENV var | |-----------|---------| | `database.url` | `LUMIO__DATABASE__URL` | | `auth.token_encryption_key` | `LUMIO__AUTH__TOKEN_ENCRYPTION_KEY` | | `webhooks.youtube_secret` | `LUMIO__WEBHOOKS__YOUTUBE_SECRET` | | `webhooks.kick_public_key` (optional pin) | `LUMIO__WEBHOOKS__KICK_PUBLIC_KEY` | | `webhooks.kick_public_key_observer.refresh_interval_secs` | `LUMIO__WEBHOOKS__KICK_PUBLIC_KEY_OBSERVER__REFRESH_INTERVAL_SECS` | | `youtube.innertube_observer.api_key_override` | `LUMIO__YOUTUBE__INNERTUBE_OBSERVER__API_KEY_OVERRIDE` | | `kick.realtime.enabled` | `LUMIO__KICK__REALTIME__ENABLED` | The override priority is `default.toml < {env}.toml < local.toml < ENV vars`, so an env-var always wins. Kick event delivery needs no signing-key setup: the API fetches Kick's public RSA key from `https://api.kick.com/public/v1/public-key` itself and refreshes it (`[webhooks.kick_public_key_observer]`, fail-closed). `webhooks.kick_public_key` is only an optional pin. See [Kick webhook setup](/getting-started/oauth-setup#webhooks-event-delivery) and [Kick Webhook Public Key](/developer-guide/kick-webhook-public-key). The **Kick chat-moderation listener** (`[kick.realtime]`) needs no setup either: it picks up messages deleted and users unbanned on kick.com from Kick's unofficial public chat socket, with its public key fetched and refreshed at runtime. `LUMIO__KICK__REALTIME__ENABLED=false` turns it off, `LUMIO__KICK__REALTIME__ALERT_WEBHOOK_URL` routes its two alarms (cold boot, Pusher no longer offered) to Discord, and `LUMIO__KICK__REALTIME__APP_KEY_OVERRIDE` + `LUMIO__KICK__REALTIME__CLUSTER_OVERRIDE` are an emergency pin only. All keys: [Kick Chat-Moderation Listener → Configuration](/developer-guide/kick-chat-listener#configuration). ### 7TV event listener The API listens to the public 7TV EventAPI for changes to the active emote sets of connected Twitch, Kick and YouTube channels. No 7TV key or inbound webhook is needed. `[seventv]` in `apps/api/config/default.toml` accepts these overrides: | Key | Environment variable | Default | |-----|----------------------|---------| | `events_enabled` | `LUMIO__SEVENTV__EVENTS_ENABLED` | `true` | | `ws_url` | `LUMIO__SEVENTV__WS_URL` | `wss://events.7tv.io/v3` | | `max_subscriptions_per_connection` | `LUMIO__SEVENTV__MAX_SUBSCRIPTIONS_PER_CONNECTION` | `450` | | `roster_refresh_secs` | `LUMIO__SEVENTV__ROSTER_REFRESH_SECS` | `600` | `events_enabled = false` stops 7TV event capture and event-driven emote-cache refresh; the normal cache TTL still applies. See the [7TV Event Listener](/developer-guide/seventv-event-listener) for the roster, privacy boundary and cache behaviour. ### Platform Credential Sources Channel and bot OAuth flows choose their OAuth app source from the API config. The flat `[platform_credentials]` table applies to **channel** connections. The nested `[platform_credentials.bot]` table applies to **bot** connections. ```toml # Channel-credential source. The flat form is channel. [platform_credentials] twitch = "system" kick = "system" youtube = "account" spotify = "account" # Bot-credential source. Discord invites use the global bot app; its gateway token is static. [platform_credentials.bot] twitch = "system" kick = "system" youtube = "system" discord = "system" ``` Allowed values are `"system"` and `"account"` for OAuth channel and bot platforms. `system` means the OAuth app pair comes from the global config keys in `[auth]`; `account` means the pair comes from the encrypted per-account `app_credentials` row. Discord bot accepts only `"system"`; an `"account"` override is ignored, and a missing Discord bot app pair makes server invites fail closed. ENV overrides use the same nested naming convention: ```text LUMIO__PLATFORM_CREDENTIALS__TWITCH=system LUMIO__PLATFORM_CREDENTIALS__YOUTUBE=account LUMIO__PLATFORM_CREDENTIALS__BOT__TWITCH=system LUMIO__PLATFORM_CREDENTIALS__BOT__YOUTUBE=system ``` The global system OAuth app pairs live in `[auth]`: | TOML path | ENV var | Notes | |-----------|---------|-------| | `auth.twitch_channel_client_id` | `LUMIO__AUTH__TWITCH_CHANNEL_CLIENT_ID` | Twitch channel/bot system app; falls back to `auth.twitch_client_id` when empty | | `auth.twitch_channel_client_secret` | `LUMIO__AUTH__TWITCH_CHANNEL_CLIENT_SECRET` | Twitch channel/bot system app secret | | `auth.kick_channel_client_id` | `LUMIO__AUTH__KICK_CHANNEL_CLIENT_ID` | Kick channel/bot system app; no login fallback | | `auth.kick_channel_client_secret` | `LUMIO__AUTH__KICK_CHANNEL_CLIENT_SECRET` | Kick channel/bot system app secret | ### YouTube Chat Transport YouTube chat uses InnerTube as the primary transport (zero quota cost). gRPC and REST fallbacks are available but disabled by default: | ENV var | Purpose | Default | |---------|---------|---------| | `LUMIO__YOUTUBE__GRPC_FALLBACK_ENABLED` | Enable gRPC streamList fallback when InnerTube fails | `false` | | `LUMIO__YOUTUBE__REST_FALLBACK_ENABLED` | Enable REST polling fallback when InnerTube and gRPC both fail | `false` | ### YouTube InnerTube Settings The InnerTube chat poller ships with sensible defaults - no configuration is required. For operational overrides, these ENV vars map onto the `[youtube.innertube_observer]` block in `apps/api/config/default.toml`: The InnerTube API key and client version are resolved at runtime in this order: **override (if set) → Redis cache → fresh `youtube.com` scrape → cold-boot constant**. The scraped values are cached in Redis (keys `lumio:yt:innertube_key` / `lumio:yt:innertube_version`, 24 h TTL) and a shared leader-elected refresher re-scrapes every 6 h by default (`REFRESH_INTERVAL_SECS`), so YouTube's periodic rotations are picked up without a restart. The two `*_OVERRIDE` vars below are an **emergency pin only** - leave them empty so auto-rotation stays in charge; a non-empty value wins over every other source, so it must never carry the ordinary current value. If an override *is* set, the API logs a single `WARN` the first time the credential is resolved - `innertube: rotation disabled by pin (client_version_override=…) - unset to resume auto-rotation` - so a forgotten pin never silently disables rotation (the API key value is redacted in that log line). The `*_COLD_BOOT` vars are different: they configure the **last-resort** value used only when override, cache, and scrape have *all* come up empty (Stage 4). Because they are consulted last they can never pin the credential or disable auto-rotation - they are a fallback seed, not an override. `default.toml` ships them **set** to the current cold-boot values so a fresh deployment starts from a known-good credential; the daily drift-check keeps them in lockstep with the compiled constants (`DEFAULT_INNERTUBE_API_KEY` / `DEFAULT_CLIENT_VERSION`), which remain the last-ditch fallback if a field is cleared. Clear one (or set an empty ENV var) to fall back to the compiled constant, or set one to a newer value to give a fresh deployment a better cold-start credential without recompiling. Reaching Stage 4 (cold boot) means Redis was empty **and** the `youtube.com` scrape failed - a fault, not a normal state. Point `COLD_BOOT_ALERT_WEBHOOK_URL` at the **same** Discord channel the InnerTube health CI (`.github/workflows/innertube-health.yml`) already posts to (do not create a second channel) and the refresher fires a **single** alert per incident. `COLD_BOOT_ALERT_AFTER_FAILURES` (default `1` = immediate) debounces it: raise it to require that many consecutive 6 h cycles at cold boot before alerting. Alerting is fail-open - an unset or unreachable webhook only produces a WARN log and never touches the chat path. | ENV var | Purpose | Default | |---------|---------|---------| | `LUMIO__YOUTUBE__INNERTUBE_OBSERVER__API_KEY_OVERRIDE` | Emergency pin for the InnerTube API key. Empty by default; set only if auto-detection fails after a Google rotation | _(empty)_ | | `LUMIO__YOUTUBE__INNERTUBE_OBSERVER__CLIENT_VERSION_OVERRIDE` | Emergency pin for the InnerTube client version. Empty by default; set only if auto-detection fails | _(empty)_ | | `LUMIO__YOUTUBE__INNERTUBE_OBSERVER__API_KEY_COLD_BOOT` | Stage-4 cold-boot API key; used only after override/cache/scrape all miss. Absent/empty → compiled `DEFAULT_INNERTUBE_API_KEY`. Never a pin | _(current key, shipped in `default.toml`)_ | | `LUMIO__YOUTUBE__INNERTUBE_OBSERVER__CLIENT_VERSION_COLD_BOOT` | Stage-4 cold-boot client version; used only after override/cache/scrape all miss. Absent/empty → compiled `DEFAULT_CLIENT_VERSION`. Never a pin | `2.20260731.00.00` | | `LUMIO__YOUTUBE__INNERTUBE_OBSERVER__CACHE_TTL_SECONDS` | Member + tier-badge entry TTL in Redis | `1209600` (14 d) | | `LUMIO__YOUTUBE__INNERTUBE_OBSERVER__REFRESH_INTERVAL_SECS` | How often the shared refresher re-scrapes `youtube.com` and warms Redis | `21600` (6 h) | | `LUMIO__YOUTUBE__INNERTUBE_OBSERVER__COLD_BOOT_ALERT_WEBHOOK_URL` | Discord webhook for the InnerTube health channel; alert on a Stage-4 cold-boot fall-through. Empty → alerting disabled (still WARN-logged). Reuse the CI channel, not a new one | _(empty)_ | | `LUMIO__YOUTUBE__INNERTUBE_OBSERVER__COLD_BOOT_ALERT_AFTER_FAILURES` | Consecutive cold-boot refresh cycles before alerting; `1` = immediate. `0` treated as `1` | `1` | `api_key_override`, `api_key_cold_boot`, and `cold_boot_alert_webhook_url` are all wrapped in a `SensitiveString` and never appear in logs or tracing output. ### WebSocket `[websocket]` configures the `/ws` endpoint (see [WebSocket API](../api-reference/websocket.md)). | Variable | Purpose | Default | | --- | --- | --- | | `LUMIO__WEBSOCKET__HEARTBEAT_INTERVAL` | Seconds between internal heartbeat ticks (credential re-validation and token-expiry checks; the server sends no `Ping`) | `5` | | `LUMIO__WEBSOCKET__CLIENT_TIMEOUT` | Seconds without an inbound frame before the session is reaped; also bounds every outbound send | `30` | | `LUMIO__WEBSOCKET__MAX_MESSAGE_SIZE` | Largest accepted inbound text frame, in bytes | `65536` | | `LUMIO__WEBSOCKET__MAX_CHANNELS_PER_SESSION` | Channels one session may hold at once | `100` | | `LUMIO__WEBSOCKET__SUBSCRIBE_BURST` | `subscribe`/`unsubscribe` frames a session may send back-to-back before `RATE_LIMITED`. A value below `max_channels_per_session` is raised to it at startup, with a warning | `120` | | `LUMIO__WEBSOCKET__SUBSCRIBE_REFILL_PER_SECOND` | Sustained `subscribe`/`unsubscribe` frames per second once the burst is spent. `0` is treated as `1` | `5` | ### ECB exchange rates `[fx]` controls the API's ECB reference-rate loader for Stream History. It fetches the daily file every six hours and imports the historical ZIP when the stored rate history is short. Disabling it leaves amounts in their source currencies and marks conversion unavailable; it does not block history reads. The base `source_url` has `eurofxref-daily.xml` and `eurofxref-hist.zip` appended. Downloads are size-capped (2 MiB for the daily file, 8 MiB for the history ZIP, 32 MiB for the CSV inside it); a larger response is rejected and retried on the next tick. | Variable | Purpose | Default | | --- | --- | --- | | `LUMIO__FX__ENABLED` | Load ECB reference rates | `true` | | `LUMIO__FX__SOURCE_URL` | Base URL of the ECB rate files | `https://www.ecb.europa.eu/stats/eurofxref` | | `LUMIO__FX__REFRESH_INTERVAL_SECS` | Daily-file refresh interval in seconds | `21600` | ### Stream history command prefixes `[history] command_prefixes` sets the prefixes a chat message must start with to count as a command in the Stream History report's command list. The default is `["!", "#"]`: `!` is the convention of third-party bots (Nightbot, StreamElements, Fossabot), `#` counts hashtag-style commands. After the prefix, the name must start with a letter (`[A-Za-z][A-Za-z0-9_-]{0,31}`). The list **must include the `bot.prefix` of Lumio's chat bots**: the bots read their prefix from their own service config, which the API does not see. A multi-character prefix is allowed; the longest match wins. Changing the list affects sessions finalized afterwards and reports built from the chat log; frozen reports keep their list. See [Stream History → Links and commands](../features/stream-history.md#links-and-commands). | Variable | Purpose | Default | | --- | --- | --- | | `LUMIO__HISTORY__COMMAND_PREFIXES` | Comma-separated command prefixes | `!,#` | ### Chat Retention `chat_retention_days` comes from the account's `account_limits` override when set, otherwise its plan (Free 365 days; Pro 1095 days). A background sweep hard-deletes older chat messages and events in TimescaleDB; a separate sweep deletes chatter profiles, notes and moderation records in PostgreSQL after the same effective window, capped at 1095 days. A downgrade shortens the window on the next daily sweep. The sweeps are gated so dev/test/staging environments keep their history (see [Chat → Retention](../features/chat.md#retention) and [Chatter retention](../features/chat.md#chatter-retention)). | Variable | Purpose | Default | | --- | --- | --- | | `LUMIO__CHAT__RETENTION_ENFORCEMENT_ENABLED` | Master on/off switch for the retention sweep. Off in `default.toml`/staging; on in `production.toml` | `false` (dev) · `true` (production) | | `LUMIO__CHAT__RETENTION_SWEEP_INTERVAL_SECS` | How often the sweep runs. Ignored when enforcement is off; a sweep also runs once on startup | `86400` (24 h) | | `LUMIO__CHAT__ORPHAN_SWEEP_ENABLED` | Delete the chatter PII (chat, events, `platform_users`, notes, moderation log) of accounts that no longer exist ([Chat → Erasure](../features/chat.md#erasure-gdpr-art-17)). Only correct when the TimescaleDB belongs to this database alone | `false` (dev) · `true` (production) | | `LUMIO__CHAT__ORPHAN_SWEEP_INTERVAL_SECS` | How often the orphan sweep runs; a sweep also runs once on startup | `86400` (24 h) | | `LUMIO__CHAT__CHATTER_RETENTION_ENABLED` | Delete chatters and moderation records (moderation-state chatters, notes, moderation log) after the account's plan window, at most 1095 days ([Chat → Chatter retention](../features/chat.md#chatter-retention)) | `false` (dev) · `true` (production) | | `LUMIO__CHAT__CHATTER_RETENTION_INTERVAL_SECS` | How often the chatter retention sweep runs; a sweep also runs once on startup. 86400 is the maximum, because a row outlives its window by up to one interval: the API refuses to start with a larger value or `0` | `86400` (24 h) | ### Shopify app The public Lumio Shopify app (`[shopify]`). Setup in the Shopify Dev Dashboard: [OAuth setup → Shopify](./oauth-setup.md#shopify). | Variable | Purpose | Default | | --- | --- | --- | | `LUMIO__SHOPIFY__CLIENT_ID` | Client ID of the app | empty (Shopify not configured) | | `LUMIO__SHOPIFY__CLIENT_SECRET` | Client secret; signs the embedded app's ID tokens, the launch query and every webhook, and authenticates the token exchange. ENV only | empty (Shopify not configured) | | `LUMIO__SHOPIFY__CLIENT_SECRET_PREVIOUS` | The previous client secret while a rotation is in progress; verifiers (ID token, launch query, webhooks) try it after the current one (the metric `shopify_client_secret_matches_total{secret="previous"}` shows when it is no longer used; steps in [Shopify → Client-secret rotation](../features/shopify.md#client-secret-rotation)). Must differ from the client secret, or the API refuses to start. ENV only | empty (no rotation) | | `LUMIO__SHOPIFY__API_VERSION` | GraphQL Admin API version every call pins | `2026-10` | | `LUMIO__SHOPIFY__SCOPES` | Comma-separated scopes the app needs; the token exchange checks Shopify's scope readback against it (the scopes Shopify grants come from `apps/shopify/shopify.app..toml`) | `read_orders,read_products,read_legal_policies` | | `LUMIO__SHOPIFY__APP_STORE_URL` | The App Store listing the connect page links to | empty | The embedded admin page `apps/shopify` (a Next.js app on its own origin) reads these at request time: | Variable | Purpose | Default | | --- | --- | --- | | `LUMIO_API_URL` | API base including `/v1`; the page's route handlers forward to `/v1/shopify/embedded/*` | `http://localhost:3000/v1` | | `LUMIO_WEB_URL` | Lumio web app origin for "In Lumio öffnen" (`NEXT_PUBLIC_WEB_URL` as fallback) | `http://localhost:4000` | | `LUMIO__SHOPIFY__CLIENT_ID` | Same value as the API: the `shopify-api-key` meta tag for App Bridge and the theme editor deep link | empty | | `LUMIO__SHOPIFY__CLIENT_SECRET` / `LUMIO__SHOPIFY__CLIENT_SECRET_PREVIOUS` | Same values as the API: verify the `hmac` of Shopify's launch query, which decides `frame-ancestors`. Without them every page answers `frame-ancestors 'none'` and cannot be embedded. ENV only | empty | The web app (`apps/web`) reads one Shopify value at request time: | Variable | Purpose | Default | | --- | --- | --- | | `LUMIO_SHOPIFY_APP_HANDLE` | The app's `handle` from `apps/shopify/shopify.app..toml` (`lumio-staging` on staging). The Shopify card's "Open in Shopify" opens `https://admin.shopify.com/store//apps/`. Lowercase letters, digits and `-`; anything else reads as the default | `lumio` | ### Abuse report retention Closed copyright [abuse reports](../admin/abuse-reports.md) (status `resolved` or `rejected`) hold the reporter's name, e-mail address and reason. A daily worker deletes them once the case has been closed for longer than the configured period; the privacy policy promises three years (§ 195 BGB). Open (`pending`) reports are never deleted. The worker is always on (a legal maximum has no off switch) and also runs once on startup. | Variable | Purpose | Default | | --- | --- | --- | | `LUMIO__ABUSE_REPORTS__RETENTION_YEARS` | Years a closed report is kept after it was closed (`abuse_reports.retention_years`). Values below 1 count as 1 | `3` | ### Chatter List The [chatter list](../features/chatter-list.md) polls a platform while someone has the panel open (a `chatters:{account_id}` WebSocket subscriber) and — with `live_polling` — while the stream is live for accounts that opened the list in the last 7 days. Cost of live polling: one Twitch Helix list call per 30 s and one Kick web list call per 60 s per live account that used the list in the last 7 days; nothing for accounts that never use it. `[chatters]` holds the global settings; provider tables are `[chatters.twitch]` and `[chatters.kick]`. Kick uses kick.com’s undocumented active-chatters endpoint without an OAuth token or new scope; it lists recent writers, not lurkers. The ordinary chatter group is capped at 100, with the complete role groups and a full platform count. | Variable | Purpose | Default | | --- | --- | --- | | `LUMIO__CHATTERS__ENABLED` | Global kill-switch for every provider. Off: every platform reports `available = false`, `reason = "disabled"`, and no poller starts | `true` | | `LUMIO__CHATTERS__INTEREST_TTL_SECS` | Lifetime of the per-account interest key. Renewed every 5 s while a subscriber is connected; once it lapses the poller stops | `90` | | `LUMIO__CHATTERS__RECENTLY_LEFT_RETENTION_SECS` | How long a chatter who left stays listed as `present = false` (recently left), counted from their `last_seen_at`; then they are dropped | `1800` | | `LUMIO__CHATTERS__WANTED_MARKER_TTL_SECS` | Lifetime of the per-account `lumio:chatters:wanted:{account_id}` marker, renewed on every subscription | `604800` (7 days) | | `LUMIO__CHATTERS__LIVE_POLLING` | Poll marked accounts while their stream is live, even with no panel open. `false` = panel-driven only (kill switch) | `true` | | `LUMIO__CHATTERS__TWITCH__ENABLED` | Twitch provider switch (`reason = "disabled"` when off) | `true` | | `LUMIO__CHATTERS__TWITCH__POLL_INTERVAL_SECS` | Seconds between two Helix snapshots while the list is polled | `30` | | `LUMIO__CHATTERS__TWITCH__MAX_CHATTERS` | Chatters fetched per snapshot (1000 per Helix page); larger chats report `truncated` with Twitch's full `total` | `5000` | | `LUMIO__CHATTERS__KICK__ENABLED` | Kick provider switch; with a Kick channel connection, off reports `reason = "disabled"` | `true` | | `LUMIO__CHATTERS__KICK__POLL_INTERVAL_SECS` | Seconds between kick.com active-chatters reads; the source caches for 60 s | `60` | | `LUMIO__CHATTERS__KICK__MAX_CHATTERS` | Lumio’s upper bound per snapshot. Kick itself lists at most 100 ordinary chatters plus its complete role groups, so the platform’s own cap can set `truncated` first | `5000` | ### Automation Worker The API dispatches extension automation-node handlers to the [Automation Worker](../developer-guide/automation.md#automation-worker) over HTTP. Configured under `[automation_worker]` in `apps/api/config/default.toml`. | Variable | Purpose | Default | | --- | --- | --- | | `LUMIO__AUTOMATION_WORKER__URL` | Base URL of the Automation Worker (`apps/automation-worker`, no trailing slash) | `http://automation-worker:8091` | | `LUMIO__AUTOMATION_WORKER__SYSTEM_KEY` | System key presented as `Authorization: SystemKey `; must match the worker's `worker.system_key`. Empty (the default) disables extension-node dispatch and fails closed | _(empty)_ | ### Bot Module Worker (server functions) The API runs extension handler server functions (`query()` / `mutation()` / `action()`, REST `/execute` and GraphQL `extensionExecuteHandler`) on the Bot Module Worker's V8 executor (`POST {url}/v1/server-functions/execute`). Configured under `[bot_module_worker]` in `apps/api/config/default.toml`. | Variable | Purpose | Default | | --- | --- | --- | | `LUMIO__BOT_MODULE_WORKER__URL` | Base URL of the Bot Module Worker (`apps/bot-module-worker`, no trailing slash) | `http://bot-module-worker:8090` | | `LUMIO__BOT_MODULE_WORKER__SYSTEM_KEY` | System key presented as `Authorization: SystemKey `; must match the worker's `worker.system_key`. Empty (the default) turns handler execution off: `/execute` answers `503 EXECUTOR_UNAVAILABLE` | _(empty)_ | Give the worker a key of its own. Do not reuse an API System key that carries broader grants (for example `*:*`): the worker runs untrusted extension code, and a key it holds should open nothing on the API beyond what the worker needs. ### Automation triggers Chat, keyword, command, event and timer triggers of [automations](../features/automations.md#trigger-path) run only when this switch is on. It is **off** in every shipped config (`default.toml`, `staging.toml`, `production.toml`). With it off, automations run only when executed manually. Turning it on for staging or production is a founder decision. Configured under `[automation]` in `apps/api/config/default.toml`. | Variable | Purpose | Default | | --- | --- | --- | | `LUMIO__AUTOMATION__TRIGGERS_ENABLED` | Starts the leader-elected trigger and timer workers (Redis lease `lumio:automation:triggers:leader`; one replica fires) | `false` | | `LUMIO__AUTOMATION__MAX_CONCURRENT_EXECUTIONS` | Executions of one account that may run at once. A trigger at the cap is dropped and logged | `8` | | `LUMIO__AUTOMATION__EXECUTION_TIMEOUT_SECS` | Wall-clock limit for one triggered execution. Keep it above the 1-hour Delay cap | `3900` | ### Bot outbound throttle Every tenant shares the one Twitch bot connection, so the Twitch bot throttles its outgoing messages with a token bucket and gives each built-in command a per-channel cooldown. Configured under `[bot]` in `apps/twitch-bot/config/default.toml`. A send that would have to wait more than 2 s for a token is dropped and logged as `Outbound rate limit reached`. | Variable | Purpose | Default | | --- | --- | --- | | `TWITCH_BOT__BOT__OUTBOUND_MESSAGES_PER_30S` | Sustained outbound budget in messages per 30 s, across all channels. The default is Twitch's per-30 s limit for an account that is moderator in the channel; raise it only for a Twitch-verified bot account | `100` | | `TWITCH_BOT__BOT__OUTBOUND_BURST` | Messages that may go out back-to-back before the sustained rate applies | `20` | | `TWITCH_BOT__BOT__BUILTIN_COOLDOWN_SECS` | Per-channel cooldown of each built-in command (`!song`, `!np`, `!uptime`, `!commands`); `0` turns it off. The Kick and YouTube bots use a fixed 5 s | `5` | ### Handler-execution telemetry (workers) Both handler-execution workers (`apps/bot-module-worker`, `apps/automation-worker`) optionally record per-execution telemetry into TimescaleDB - one `extension_errors` row on failure and one `extension_install_logs` row on every run (see [Developer Extension Endpoints](../api-reference/rest-api.md#developer-extension-endpoints)). The writer is **fail-open**: it never fails or delays a handler request, and is simply disabled when no TimescaleDB pool is configured. Env vars use each worker's own prefix - `BOT_MODULE_WORKER__…` and `AUTOMATION_WORKER__…`. | Variable | Purpose | Default | | --- | --- | --- | | `…__TIMESCALE__URL` | TimescaleDB connection URL. Omit the whole `[timescale]` section to disable the telemetry writer | _(unset → writer off)_ | | `…__WORKER__TELEMETRY_SALT` | Per-deployment secret salt used to HMAC-anonymize account/install IDs in `extension_errors`. Set a stable value in production; an empty salt still hashes but is weaker | _(empty)_ | ### Worker registry sync Each worker (`apps/bot-module-worker`, `apps/automation-worker`) populates its in-memory `InstallRegistry` from the API's internal handler-bundle feed on startup and re-syncs on a poll - see [Bot Modules → Worker sync-loop](../developer-guide/bot-modules.md#redis-channels). Configured under `[sync]`, with the env prefix `BOT_MODULE_WORKER__SYNC__*` and `AUTOMATION_WORKER__SYNC__*` respectively. When the section is not fully configured the worker starts with an empty registry (every request short-circuits) rather than failing. | Variable (per worker prefix) | Purpose | Default | | --- | --- | --- | | `…__SYNC__ENABLED` | Run the registry sync loop | `true` | | `…__SYNC__API_BASE_URL` | API origin the feed is served from (no trailing slash), e.g. `http://api:8080`. Empty disables the loop | _(empty)_ | | `…__SYNC__SYSTEM_KEY` | System key presented to the feed as `Authorization: Bearer `. Empty disables the loop | _(empty)_ | | `…__SYNC__POLL_INTERVAL_SECS` | Full-snapshot re-sync interval. The Bot Module Worker applies install changes and the kill switch live over `[redis]`, so its poll is a safety net; without `[redis]` it polls at most every 30 s | Bot Module Worker `300`, Automation Worker `30` | ### Bot-module secrets key Bot-module handlers read their extension's secrets with `ctx.secrets.get` (see [Bot Modules → V8 Execution](../developer-guide/bot-modules.md#v8-execution)). The API re-encrypts each value for the Bot Module Worker under a key that only these two services share, so the worker never holds `auth.token_encryption_key`. Set **the same** independent random value (`openssl rand -base64 32`) on both sides: | Variable | Service | Purpose | Default | | --- | --- | --- | --- | | `LUMIO__EXTENSIONS__BOT_MODULE_SECRETS_KEY` | API | Wraps secrets for the worker's internal secrets feed. Empty turns `ctx.secrets` off (the feed answers `503`). A placeholder or a value under 16 characters aborts the boot outside development | _(empty)_ | | `BOT_MODULE_WORKER__SYNC__SECRETS_KEY` | Bot Module Worker | Opens the wrapped values. Also needs `…__SYNC__API_BASE_URL` and `…__SYNC__SYSTEM_KEY`. Empty turns `ctx.secrets` off (calls throw `ctx.secrets is unavailable`); a placeholder or a value under 16 characters aborts the start | _(empty)_ | Never reuse `auth.token_encryption_key` here. To rotate, restart the API and every worker with the new value together; expect up to 60 seconds of `ctx.secrets is unavailable`. Nothing stored is re-encrypted. ### Bot Module Worker `ctx.db` Bot-module handlers read and write their extension's tables through the worker's own pool on the extensions database (the API's `[extensions.database]`, `lumio_extensions`). Each op runs through the same executor as the API's server functions, under the same row-level security; see [Bot Modules](../developer-guide/bot-modules.md). The API applies the extensions-database migrations (`apps/api/migrations/extensions/`) itself at startup, on its DDL pool. The worker only ever gets a DML login (dev stack: `lumio_ext_worker`), never the schema owner. At startup it refuses to run if that login is a superuser, has `BYPASSRLS`, or has `CREATE` or `TEMPORARY` on the database; if the database is unreachable at startup, `ctx.db` stays off until the next restart. ```sh # dev stack BOT_MODULE_WORKER__EXTENSIONS__DATABASE__URL=postgres://lumio_ext_worker:lumio_ext_worker@localhost:5434/lumio_extensions ``` | Variable | Purpose | Default | | --- | --- | --- | | `BOT_MODULE_WORKER__EXTENSIONS__DATABASE__URL` | Extensions database URL as a DML login of the `lumio_ext_dml` group (its own login, not the API's). Empty turns `ctx.db` off (calls throw `ctx.db is unavailable`) | _(empty)_ | | `BOT_MODULE_WORKER__EXTENSIONS__DATABASE__MAX_CONNECTIONS` | Connections per worker replica. An op waits at most 100 ms (sync) / 1 s (async) for one, then throws `ctx.db: db busy`. Keep `20 × API replicas + 5 × worker replicas` plus headroom under the extensions Postgres `max_connections` | `5` | ### Bot Module Worker chat triggers The Bot Module Worker runs extension **keyword** and **pattern** triggers that the bots publish on Redis (`lumio:botmod:msg:*`) and posts each handler's reply back through the bots - see [Bot Modules → Bot Module Worker](../developer-guide/bot-modules.md#bot-module-worker). It needs the same Redis instance as the API and the bots. Without a `[redis]` section the listener does not start and keyword/pattern triggers do nothing; command triggers (HTTP) are unaffected. | Variable | Purpose | Default | | --- | --- | --- | | `BOT_MODULE_WORKER__REDIS__URL` | Redis URL (same instance as the API and the bots). Remove the `[redis]` section to run without chat triggers | `redis://:lumio@127.0.0.1:6379` | | `BOT_MODULE_WORKER__CHAT_TRIGGERS__ENABLED` | Run the keyword/pattern trigger listener | `true` | | `BOT_MODULE_WORKER__CHAT_TRIGGERS__MAX_IN_FLIGHT` | Handlers that may run at the same time; a trigger that arrives while all are busy is dropped with a warning | `32` | ### Bot Module Worker event triggers and timers The Bot Module Worker also runs bot-module **event** handlers for the stream events the API publishes on `lumio:events:*`, and bot-module **timers** - see [Bot Modules → Bot Module Worker](../developer-guide/bot-modules.md#bot-module-worker). Both need `[redis]`; timers also need a configured `[sync]` section, because the timer list comes from the handler feed. Each event and each timer slot is claimed in Redis, so running several worker replicas runs each one once. | Variable | Purpose | Default | | --- | --- | --- | | `BOT_MODULE_WORKER__EVENT_TRIGGERS__ENABLED` | Deliver stream events to bot modules that list them in `triggers.events` | `true` | | `BOT_MODULE_WORKER__EVENT_TRIGGERS__MAX_IN_FLIGHT` | Event handlers that may run at the same time; a delivery that arrives while all are busy is dropped with a warning | `32` | | `BOT_MODULE_WORKER__SERVER_FUNCTIONS__MAX_IN_FLIGHT` | Server-function handlers (posted by the API) that may run at the same time on this replica; a call that finds every slot busy is refused with `RATE_LIMITED` | `16` | | `BOT_MODULE_WORKER__SERVER_FUNCTIONS__MAX_IN_FLIGHT_PER_ACCOUNT` | Of those, the handlers one account may run at the same time | `4` | | `BOT_MODULE_WORKER__TIMERS__ENABLED` | Run bot-module timers (`triggers.timers` / `triggers.timer`) | `true` | | `BOT_MODULE_WORKER__TIMERS__MAX_IN_FLIGHT` | Timer handlers that may run at the same time; a firing while all are busy is skipped | `32` | ### Bot-module handler actions The `actions` a bot-module handler returns are run by the API, not by the worker or the bots: they are posted to the internal `POST /v1/internal/bot-modules/{install_id}/actions` endpoint. The Bot Module Worker posts them with its `[sync] api_base_url` and `system_key` (without both, its actions are dropped with a warning); the bots use their `[api]` settings. The key needs the `bots:internal` grant. | Variable | Service | Purpose | Default | | --- | --- | --- | --- | | `LUMIO__EXTENSIONS__BOT_MODULE_ACTION_RATE_PER_MINUTE` | API | Most actions one bot-module install may run per minute. Over the cap an action is refused and logged once per minute; if Redis cannot count, actions are refused. `0` means the default | `120` | ### GeoIP The optional GeoIP service resolves client IP addresses to a country/city using the free [MaxMind GeoLite2 City](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data) database. It powers the **Location** column of the [Audit Log](../admin/audit-log.md#location-data). It is **disabled by default** - no MaxMind account is needed to run Lumio; leave `enabled = false` and the location fields simply stay empty. When enabled with a license key, the API downloads the `.mmdb` database on startup if it is missing and, when `auto_update` is on, refreshes it every `update_interval_days`. All GeoIP state lives under `[geoip]` in `apps/api/config/default.toml`; every key is overridable via the `LUMIO__GEOIP__*` env vars below. | Variable | Purpose | Default | | --- | --- | --- | | `LUMIO__GEOIP__ENABLED` | Master on/off switch. When `false`, no database is loaded and location fields stay empty | `false` | | `LUMIO__GEOIP__DATABASE_PATH` | Path to the GeoLite2 City `.mmdb` file. The default lives under the container `WORKDIR` (`/app`); the runtime-base image pre-creates `/app/data/geoip` owned by uid 1001, so the file is downloadable and refreshable by the non-root user. Mount a persistent volume there to survive restarts | `/app/data/geoip/GeoLite2-City.mmdb` | | `LUMIO__GEOIP__LICENSE_KEY` | MaxMind license key used to download/update the database. [Get a free key](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data). Without it, an existing database is still read but cannot be auto-downloaded | _(empty)_ | | `LUMIO__GEOIP__ACCOUNT_ID` | MaxMind numeric account ID. Required (with the license key) for the Privacy Exclusions API | _(empty)_ | | `LUMIO__GEOIP__AUTO_UPDATE` | Re-download the database on a schedule when it is older than `update_interval_days` | `false` | | `LUMIO__GEOIP__UPDATE_INTERVAL_DAYS` | Minimum database age before a re-download. MaxMind publishes GeoLite2 weekly | `7` | | `LUMIO__GEOIP__PRIVACY_EXCLUSIONS_ENABLED` | Honour MaxMind [Privacy Exclusions](https://dev.maxmind.com/geoip/privacy-exclusions-api/) (user geolocation opt-outs). Requires `license_key` + `account_id` | `false` | | `LUMIO__GEOIP__PRIVACY_EXCLUSIONS_REFRESH_HOURS` | How often the cached exclusions list is refreshed | `24` | | `LUMIO__GEOIP__PRIVACY_EXCLUSIONS_CACHE_PATH` | Where the exclusions list is cached for persistence across restarts. Lives on the same writable `/app/data/geoip` directory as the database | `/app/data/geoip/geoip-privacy-exclusions.json` | For a one-off database refresh outside the server, run the `geoip-update` maintenance binary: `cargo run -p lumio-api --bin geoip-update -- --help`. > **Privacy.** GeoIP derives only a coarse country/city from the IP - no precise coordinates are stored. Enabling `privacy_exclusions_enabled` makes Lumio respect MaxMind's Privacy Exclusions opt-out list so IPs of users who opted out are not resolved. ### Diagnostics file (Rust services) Every Rust service can write a **selected** subset of its logs to a local rotating file, in addition to stdout. By default the subset is every warning and error of the service (`all_warnings`), plus the `ingest_diag::` capture lines for event types ingest does not understand yet. Payment-webhook logs are always excluded. The file's selection is independent of `[logging] level` and `RUST_LOG`. Details on the selection, redaction and read-back are in [Logging → Rust services: diagnostics file](../developer-guide/logging.md#rust-services-diagnostics-file). Each key below is set under `[logging.diagnostics]` in the service's `config/` or through ENV. Replace `` with `LUMIO` (api), `TWITCH_BOT`, `KICK_BOT`, `YOUTUBE_BOT`, `DISCORD_BOT`, `CRAWLER_TWITCH`, `BOT_MODULE_WORKER` or `AUTOMATION_WORKER`. | Variable | Purpose | Default | | --- | --- | --- | | `__LOGGING__DIAGNOSTICS__ENABLED` | Master switch. While off, no file and no directory is created. On in `staging.toml` for api, twitch-bot, kick-bot and youtube-bot, and in the api's `production.toml` | `false` | | `__LOGGING__DIAGNOSTICS__DIR` | Target directory, relative to the working directory. In containers this is `/app/data/diagnostics`, which the runtime-base image creates (owned by uid 1001). Mount a host volume there | `data/diagnostics` | | `__LOGGING__DIAGNOSTICS__ALL_WARNINGS` | Select every `warn` and `error` line of every component (auth, database, billing, routes, workers, ingest). Off: only the platform target sets below | `true` | | `__LOGGING__DIAGNOSTICS__TWITCH` | Add the Twitch target set (IRC, Helix, EventSub, IRC tags, twitch-bot) | `true` | | `__LOGGING__DIAGNOSTICS__KICK` | Add the Kick target set (API client, `kick*` workers, webhook route and event service, kick-bot) | `true` | | `__LOGGING__DIAGNOSTICS__YOUTUBE` | Add the YouTube target set (InnerTube/gRPC client, YouTube and InnerTube-credential workers, webhook route, youtube-bot) | `true` | | `__LOGGING__DIAGNOSTICS__FILTER` | Extra `EnvFilter` directives, merged after the platform sets. A directive for a target already in a set replaces it, and a bare level (`error`) replaces the `all_warnings` default. One comma-separated string, e.g. `twitch_irc=off,lo_kick_api=debug` | _(empty)_ | | `__LOGGING__DIAGNOSTICS__ROTATION` | `daily` or `hourly`. Boundaries are UTC | `daily` | | `__LOGGING__DIAGNOSTICS__MAX_FILES` | Rotated files kept per service. This is the retention bound: 14 means 14 days with daily rotation. Must be at least 1 | `14` | | `__LOGGING__DIAGNOSTICS__FORMAT` | `json` (one object per line, `.jsonl`) or `compact` (one text line, `.log`) | `json` | | `__LOGGING__DIAGNOSTICS__REDACT_USER_FIELDS` | Replace viewer names and message text with `[redacted]` in the file. Stdout is unaffected | `false` | Files are named `/..jsonl` (`api`, `twitch-bot`, …), so several services can share one directory. A misconfiguration never stops the service: it logs one warning and keeps running on stdout only. **Hosted deployments.** Mount a persistent host directory at `/app/data/diagnostics`; otherwise the file disappears with the container. Create that directory owned by uid 1001 before the first start (`sudo install -d -o 1001 -g 1001 `). The staging compose files mount `${DIAGNOSTICS_DIR:-./data/diagnostics}` (see `dev-stack/staging/README.md`). On the production host, add the same `volumes:` line to each service that enables the file: ```yaml volumes: - ${DIAGNOSTICS_DIR:-./data/diagnostics}:/app/data/diagnostics ``` ### Error tracking (Rust services) Every Rust service sends errors and warnings to Sentry through `lo-telemetry` when `[sentry]` is enabled and has a DSN. Each event carries `release = @`, the `service` and `target` tags, and a fingerprint of target plus message template. Viewer names and message text are removed before sending. Details: [Error Tracking → Rust backend](../developer-guide/error-tracking.md#rust-backend-lo-telemetry). Each key below is set under `[sentry]` in the service's `config/` or through ENV, with the same `` as above. Only these keys enable Sentry: a bare `SENTRY_DSN` is not read by these services. | Variable | Purpose | Default | | --- | --- | --- | | `__SENTRY__ENABLED` | Master switch. Needs a DSN as well | `false` | | `__SENTRY__DSN` | DSN from the Sentry project settings. An unparseable DSN leaves Sentry off | _(empty)_ | | `__SENTRY__ENVIRONMENT` | `environment` of every event | `development` | | `__SENTRY__TRACES_SAMPLE_RATE` | Performance trace sample rate, clamped to 0.0–1.0 | `1.0` (`0.1` for crawler-twitch) | | `__SENTRY__SAMPLE_RATE` | Event sample rate, clamped to 0.0–1.0 | `1.0` | | `__SENTRY__DEBUG` | Sentry SDK debug output | `false` | | `__SENTRY__EVENT_LEVEL` | Lowest log level that becomes an event: `warn` or `error`. Lower levels down to `info` become breadcrumbs. Any other value logs a warning and uses `warn` | `warn` | | `__SENTRY__MAX_EVENTS_PER_FINGERPRINT` | Volume guard: events with the same fingerprint sent per window. The rest is counted and reported as `suppressed_count` on the next event. `0` switches the guard off | `10` | | `__SENTRY__FINGERPRINT_WINDOW_SECS` | Volume guard window in seconds | `60` | `innertube-proxy` has no TOML config. It enables Sentry from `SENTRY_DSN` and `SENTRY_ENVIRONMENT` (default `production`) and installs the same `lo-telemetry` tracing layers as the other services, so its `warn`/`error` logs become Sentry events. See [InnerTube Proxy → Logging and error tracking](../developer-guide/innertube-proxy.md#logging-and-error-tracking). ### Frontend Environment & Logging `apps/web`, `apps/admin`, and `apps/id` share two env-vars that drive the [logger](../developer-guide/logging.md) and the [Sentry integration](../developer-guide/error-tracking.md). Set both the server-side and the `NEXT_PUBLIC_*` mirror so the browser bundle picks them up too. | Variable | Allowed values | Default | Purpose | |----------|---------------|---------|---------| | `LUMIO_ENV` / `NEXT_PUBLIC_LUMIO_ENV` | `development` \| `staging` \| `production` | `development` | Logger threshold default + Sentry environment tag | | `LUMIO_LOG_LEVEL` / `NEXT_PUBLIC_LUMIO_LOG_LEVEL` | `debug` \| `info` \| `warn` \| `error` | per env (debug/info/warn) | Override the auto-threshold | Sentry env-vars per app (`apps/web`, `apps/admin`, `apps/id`, and `apps/shopify` on the server only) - leave blank locally to disable, fill them in the CI build / deployment environment for staging + production: | Variable | Where | Purpose | |----------|-------|---------| | `LUMIO_PUBLIC_SENTRY_DSN` | Run time (browser + server) | Public DSN. Set this on deployed containers: the root layout hands it to the browser SDK per request (web, ID; after Functional consent) | | `NEXT_PUBLIC_SENTRY_DSN` | Build time | Local-dev fallback only - frozen at `next build`, no effect on a prebuilt image | | `SENTRY_DSN` | Server only | Optional override; falls back to `LUMIO_PUBLIC_SENTRY_DSN`, then `NEXT_PUBLIC_SENTRY_DSN`. `apps/admin` and `apps/shopify` run no browser SDK, so only this (or its fallbacks) applies there | | `SENTRY_ORG` | Build (CI) | Sentry org slug for source-map upload | | `SENTRY_PROJECT` | Build (CI) | Sentry project slug | | `SENTRY_AUTH_TOKEN` | Build (CI) | Auth token for source-map upload | Each app also has a `proxy.ts` (Next.js 16's renamed `middleware.ts`) that prints one structured line per HTTP request to the SSR terminal - including the real client IP (`x-real-ip` / `x-forwarded-for` / `cf-connecting-ip`) with IPv4/IPv6 family classification. See [Logging → Per-request access logging](../developer-guide/logging.md#per-request-access-logging-proxyts) for details. ## Verify Installation 1. `just stack-up` - all six containers report healthy. 2. `just migrate-status` - every migration shows as applied. 3. `just run-api` - the API answers on `http://localhost:3000/v1`. `GET /v1/graphiql` serves the GraphiQL IDE and `GET /v1/schema` the GraphQL SDL (both are on by default in `development` and gated by `[graphql].playground` / `[graphql].schema_sdl`). The REST surface is documented at `/v1/swagger-ui/` with the raw spec at `/v1/openapi.json` (both gated by `[docs].swagger_ui` / `[docs].openapi_json`; production turns the Swagger UI off). 4. `just dev-web` - visit `http://localhost:4000` and log in through the ID app on `http://localhost:4002`. ### Before you push | Recipe | What it runs | |--------|--------------| | `just verify-all` | `cargo fmt --check` + clippy (`-D warnings`) + `cargo check` + ESLint + `tsc` - the fast inner loop | | `just lint-all` | clippy + ESLint only | | `just test-all` | `cargo test` (workspace) + the V8 extension tests + Vitest | | `just verify-release` | `verify-all` **plus** regenerating the OpenAPI/GraphQL schemas and the Bazel vendor tree, failing on drift. Run this after a version bump or a `Cargo.lock` change - `verify-all` does not detect that drift | ## Passkey relying party configuration The API's `[webauthn]` settings bind passkeys to the web and ID apps. `rp_id` is the shared relying-party domain, `rp_name` is the name shown by an authenticator, and `origins` is the exact list of browser origins allowed to register or use a passkey. The default local values are: ```toml [webauthn] rp_id = "localhost" rp_name = "Lumio" origins = ["http://localhost:4000", "http://localhost:4002"] ``` Staging uses `rp_id = "staging.zaflun.dev"` with `https://lumio.web.staging.zaflun.dev` and `https://lumio.id.staging.zaflun.dev`. Production uses `rp_id = "lumio.vision"` with `https://lumio.vision` and `https://id.lumio.vision`. The API checks origins exactly: do not include overlay, widget, admin or extension hosts. Environment overrides are `LUMIO__WEBAUTHN__RP_ID` and `LUMIO__WEBAUTHN__ORIGINS` (comma-separated, e.g. `https://lumio.vision,https://id.lumio.vision`); `rp_name` is also a TOML setting. **Never change `rp_id` after the first real passkey exists.** Existing passkeys are bound to that ID and would stop working. --- ## Getting Started / OAuth Provider Setup Source: https://docs.lumio.vision/getting-started/oauth-setup # OAuth Provider Setup ## Sign-in The ID app offers Twitch, Kick, Google, Discord and verified email/password sign-in. Registration uses email and password; an address is usable only after the person clicks **Confirm** on the emailed link. The link does not sign anyone in. A matching email claim from a platform does not automatically link profiles. If 2FA is enabled, the ID app offers a passkey, authenticator app code or recovery code after the password or platform callback and before the API creates a session or PKCE authorization code. A trusted device can bypass this prompt for 30 days. Native desktop and mobile flows finish on `/login/done`, which offers **Back to the app** if the browser did not open the app automatically. The callback is allowlisted and the app name comes from a fixed list. Adding a platform login connection from **Security → Sign-in** requires a fresh confirmation on the existing session. Reconnecting the same provider account is exempt. The ID app returns a limited `link_result` value to the dashboard, which displays the outcome and clears the URL parameters. This guide walks you through creating OAuth applications for each platform. Lumio uses distinct credential sources for login, channel, and bot flows. They are stored in different places and are not interchangeable: | | Login credentials | System channel credentials | System bot credentials | Account channel/bot credentials | |---|---|---|---|---| | **Purpose** | User authentication ("Sign in with Twitch") | Lumio-managed channel OAuth apps | Lumio-managed bot OAuth apps | Per-account platform API access (chat, events, moderation) | | **Stored in** | `apps/id/.env.local` **and** `apps/api/config/local.toml` | `[auth]` `*_channel_client_id` / `*_channel_client_secret` keys in the API | `[auth]` `*_bot_client_id` / `*_bot_client_secret` keys in the API | The `app_credentials` database table (AES-256-GCM encrypted) | | **Entered by** | The operator, once, in config files | The operator, once per environment / provider app | The operator, once per environment / provider app | Each account, in **Dashboard → Connections → the platform tab → App** | | **Scopes** | User-level, defined in `apps/id/src/lib/providers.ts` | Channel scopes, defined in `apps/api/src/platforms.rs` | Bot scopes, defined in `apps/api/src/platforms.rs` | Channel/bot scopes, defined in `apps/api/src/platforms.rs` | | **Resulting record** | `login_connections` | `channel_connections`, pinned `credential_source = "system"` | `bot_connections`, pinned `credential_source = "system"` | `channel_connections` or `bot_connections`, pinned `credential_source = "account"` | > **Caution:** > Login credentials are not channel credentials, and channel credentials are not bot credentials. System channel and bot credentials are the global Lumio OAuth apps used only by connections pinned to `system`; account credentials are used only by connections pinned to `account`. The login scopes in `apps/id/src/lib/providers.ts` are **user-level** - they are not a subset of the channel scopes and must not be stripped on the assumption that they belong on the channel side. A third surface - **bot connections** - is covered in [Bot Connections](#bot-connections) at the end of this guide. ## Redirect URI Registration Matrix System-sourced channel connections, account bot connections, and admin global-bot connections use the unified web-host callback: ```text {web.public_url}/api/connections/oauth/{platform}/callback ``` The callback must be served by the **same web host the user is signed in to**: for channel and account-bot connects the API completes the exchange only for the browser session that started the connect (the web callback forwards that session), so a callback on another host - without the session cookie - fails with `401`. The admin global-bot flow needs no session on the web host. For production Twitch/Kick system apps, register: ```text https://lumio.vision/api/connections/oauth/{platform}/callback ``` For staging, register: ```text https://lumio.web.staging.zaflun.dev/api/connections/oauth/{platform}/callback ``` For local development, register: ```text http://localhost:4000/api/connections/oauth/{platform}/callback ``` The server dispatches the callback by the OAuth `state` handle's `kind` (`channel`, `bot` or `global_bot`; the [Shopify app](#shopify) uses no OAuth callback) and `return_to`. Account/BYO channel connections and degraded bot flows still use source-specific legacy callback URLs because their authorize path emits the legacy state format. ### Login apps Login OAuth belongs to the ID app. These apps are separate from system channel/bot apps. | Platform | Production redirect URI | Staging redirect URI | Local redirect URI | Notes | |----------|--------------------------|----------------------|--------------------|-------| | Twitch login | `https://id.lumio.vision/api/auth/callback/twitch` | `https://lumio.id.staging.zaflun.dev/api/auth/callback/twitch` | `http://localhost:4002/api/auth/callback/twitch` | Twitch allows multiple redirect URIs per app, so the login pair can also serve as the Twitch channel fallback when the channel pair is unset and the app also registers the unified web callback. | | YouTube / Google login | `https://id.lumio.vision/api/auth/callback/google` | `https://lumio.id.staging.zaflun.dev/api/auth/callback/google` | `http://localhost:4002/api/auth/callback/google` | The ID app provider slug is `google`; channel connections use the Lumio platform slug `youtube`. | | Kick login | `https://id.lumio.vision/api/auth/callback/kick` | `https://lumio.id.staging.zaflun.dev/api/auth/callback/kick` | `http://localhost:4002/api/auth/callback/kick` | Kick accepts up to 10 redirect URIs per app, so one login app can register production, staging and local login callbacks. Keep the login app separate from the system channel/bot apps by design — it lives in the ID-app config with user-level scopes. | ### System channel and bot apps System OAuth app pairs live in the API `[auth]` config. Channel apps use `*_channel_client_id` / `*_channel_client_secret`; dedicated bot apps use `*_bot_client_id` / `*_bot_client_secret`. The Channel app and Bot app for the same provider register the same callback in a given environment; the encrypted `state` handle selects whether Lumio redeems the code with the channel, account-bot, or admin global-bot secret pair. | Platform / app | Production redirect URI | Staging redirect URI | Local redirect URI | Notes | |----------------|--------------------------|----------------------|--------------------|-------| | Twitch Channel app | `https://lumio.vision/api/connections/oauth/twitch/callback` | `https://lumio.web.staging.zaflun.dev/api/connections/oauth/twitch/callback` | `http://localhost:4000/api/connections/oauth/twitch/callback` | Twitch supports multiple redirect URIs on one app. A single Twitch Channel app can carry production, staging and local callbacks when that matches the operator model. | | Twitch Bot app | `https://lumio.vision/api/connections/oauth/twitch/callback` | `https://lumio.web.staging.zaflun.dev/api/connections/oauth/twitch/callback` | `http://localhost:4000/api/connections/oauth/twitch/callback` | Dedicated bot keys are preferred. If absent, bot connects fall back to the Twitch channel pair, then to the Twitch login pair. | | Kick Channel app | `https://lumio.vision/api/connections/oauth/kick/callback` | `https://lumio.web.staging.zaflun.dev/api/connections/oauth/kick/callback` | `http://localhost:4000/api/connections/oauth/kick/callback` | Kick accepts up to 10 redirect URIs per app. A single Kick Channel app can carry production, staging and local callbacks when that matches the operator model. | | Kick Bot app | `https://lumio.vision/api/connections/oauth/kick/callback` | `https://lumio.web.staging.zaflun.dev/api/connections/oauth/kick/callback` | `http://localhost:4000/api/connections/oauth/kick/callback` | Keep the Bot app on a separate `client_id` from the Channel app for quota/suspension isolation, not because of any redirect-URI limit. Both apps register the same environment callback. | | YouTube Bot app | `https://lumio.vision/api/connections/oauth/youtube/callback` | `https://lumio.web.staging.zaflun.dev/api/connections/oauth/youtube/callback` | `http://localhost:4000/api/connections/oauth/youtube/callback` | YouTube channel connections remain BYO/account. The system callback is only for an operator-managed YouTube bot app. | ### Account/BYO callbacks Account-owned credentials are entered per account in **Dashboard → Connections → the platform tab → App** and are stored in the database. These callbacks are legacy/BYO callbacks: register them only for account-managed connections, for local setups intentionally using account credentials, or for platforms that have degraded from `system` to `account` because no system pair is configured. | Platform / flow | Production redirect URI | Staging redirect URI | Local redirect URI | Notes | |-----------------|--------------------------|----------------------|--------------------|-------| | Twitch BYO channel | `https://lumio.vision/api/connections/channel/twitch/callback` | `https://lumio.web.staging.zaflun.dev/api/connections/channel/twitch/callback` | `http://localhost:4000/api/connections/channel/twitch/callback` | Needed only when Twitch channel connects use account credentials. | | YouTube BYO channel | `https://lumio.vision/api/connections/channel/youtube/callback` | `https://lumio.web.staging.zaflun.dev/api/connections/channel/youtube/callback` | `http://localhost:4000/api/connections/channel/youtube/callback` | YouTube channel connections are BYO/account; the platform slug is `youtube`, not `google`. | | Kick BYO channel | `https://lumio.vision/api/connections/channel/kick/callback` | `https://lumio.web.staging.zaflun.dev/api/connections/channel/kick/callback` | `http://localhost:4000/api/connections/channel/kick/callback` | Needed only when Kick channel connects use account credentials. | | Spotify BYO channel | `https://lumio.vision/api/connections/channel/spotify/callback` | `https://lumio.web.staging.zaflun.dev/api/connections/channel/spotify/callback` | `http://127.0.0.1:4000/api/connections/channel/spotify/callback` | Spotify rejects `localhost`; Lumio rewrites `localhost` to `127.0.0.1` only for Spotify local callbacks. | | Account bot legacy callback | `https://lumio.vision/api/connections/bot/{platform}/callback` | `https://lumio.web.staging.zaflun.dev/api/connections/bot/{platform}/callback` | `http://localhost:4000/api/connections/bot/{platform}/callback` | Used only when an account bot flow degrades to account credentials instead of using a system bot/channel pair. | | Admin global-bot legacy callback | `https://admin.lumio.vision/api/bot-connections/{platform}/callback` | `https://lumio.admin.staging.zaflun.dev/api/bot-connections/{platform}/callback` | `http://localhost:4001/api/bot-connections/{platform}/callback` | Kept for legacy/in-flight global bot authorizations. Current system global bots return through the web-host unified callback and then redirect back to Admin via `return_to`. | Production needs four system apps for the Twitch and Kick system-managed paths: one Channel app and one Bot app per platform. Because both Twitch **and** Kick accept multiple redirect URIs per app, those same four apps also cover staging and local — register each environment's callback on the corresponding app. Add apps only if you deliberately want separate `client_id`s per environment for blast-radius isolation. | Platform | Production system apps | Staging system apps | System callback | |----------|------------------------|---------------------|-----------------| | Twitch | 2 apps: Channel + Bot | same 2 apps | `https://lumio.vision/api/connections/oauth/twitch/callback` and `https://lumio.web.staging.zaflun.dev/api/connections/oauth/twitch/callback` | | Kick | 2 apps: Channel + Bot | same 2 apps | one callback per environment, all registered on the same two apps: `.../api/connections/oauth/kick/callback` | Kick accepts **up to 10 redirect URIs per OAuth app** (verified in the Kick developer dashboard, 2026-09-23; Kick's public documentation does not state the limit). One Kick app can therefore carry production, staging and local callbacks (three of the ten slots). Its Channel app and Bot app each register the same environment callback, for example `https://lumio.vision/api/connections/oauth/kick/callback`; the `state.kind` value selects whether Lumio uses the channel secret pair or the bot secret pair. Keeping Channel and Bot on separate apps is a quota/suspension-isolation choice, not a redirect-URI requirement. The login app is kept separate by design (it lives in the ID-app config with user-level scopes), but when no dedicated Kick Channel app is configured, Kick channel connects fall back to the login pair (`kick_client_id` / `kick_client_secret`) with a one-time warning (ZAF-1493) — the login app can register the channel callback in one of its ten redirect-URI slots. YouTube bot credentials have dedicated `[auth]` key slots too (`youtube_bot_client_id` / `youtube_bot_client_secret`), but YouTube channel connections stay BYO/account and there is no YouTube channel system app to fall back to. Without the YouTube bot keys, the YouTube bot flow degrades to `account`. --- ## Twitch ### Developer Console https://dev.twitch.tv/console ### Login Credentials (ID App) 1. Go to **Applications** → **Register Your Application** 2. Set **Name** to something like `Lumio Login` 3. Set **OAuth Redirect URL** to: `{ID_APP_URL}/api/auth/callback/twitch` - Local: `http://localhost:4002/api/auth/callback/twitch` 4. Set **Category** to `Website Integration` 5. Copy **Client ID** and generate a **Client Secret** 6. Add to `apps/id/.env.local`: ```env AUTH_TWITCH_ID=your_client_id AUTH_TWITCH_SECRET=your_client_secret ``` **Login Scopes** (requested automatically): | Scope | Purpose | |-------|---------| | `openid` | OpenID Connect authentication | | `user:read:email` | Read user email | | `user:read:emotes` | Read user emotes | | `chat:read` | Read chat messages | | `chat:edit` | Send chat messages | | `user:write:chat` | Write chat messages | | `user:read:subscriptions` | Read user subscriptions | | `moderator:read:chatters` | Read chatter list | | `moderator:manage:chat_messages` | Delete chat messages | | `moderator:manage:banned_users` | Ban/unban users | **Username resolution.** Twitch's OIDC claim set carries **no** `display_name` — only `preferred_username`, the lowercase login. Right after the OAuth handshake the ID App's Twitch `profile()` therefore calls Helix `GET /users` with the user's fresh access token to recover the real (possibly localized) display name. `name` then carries the display name and `preferred_username` the login, so the login connection stores them in the correct `display_name` / `username` columns and the `users` row gets a distinct `display_name` and `login`. Without this lookup both would be the lowercase login, and every Twitch user's Lumio display name (sidebar, account switcher, profile) would show as their login. If the Helix call fails we fall back to the OIDC login for both. ### Channel Credentials Twitch channel connections default to `system`. Configure a global Twitch channel app in the API `[auth]` config (`twitch_channel_client_id` / `twitch_channel_client_secret`) and register the unified callback: ```text {web.public_url}/api/connections/oauth/twitch/callback ``` Twitch allows multiple redirect URIs on one app, so if the channel-specific pair is empty, Lumio may use the Twitch login pair as the system pair. If no global pair is available, new Twitch channel connects degrade to the account-credential path and use the legacy account callback: ```text {WEB_URL}/api/connections/channel/twitch/callback ``` **Channel Scopes** (33 scopes, requested automatically): | Scope | Purpose | |-------|---------| | `channel:read:subscriptions` | Read subscriber list and events | | `channel:read:redemptions` | Read channel point redemptions | | `channel:manage:redemptions` | Manage channel point redemptions | | `channel:read:hype_train` | Read hype train events | | `channel:read:polls` | Read poll data | | `channel:manage:polls` | Create and manage polls | | `channel:read:predictions` | Read prediction data | | `channel:manage:predictions` | Create and manage predictions | | `channel:read:goals` | Read channel goals | | `bits:read` | Read cheer/bits events | | `moderator:read:followers` | Read follower list | | `moderator:read:suspicious_users` | Read suspicious user flags | | `moderator:manage:suspicious_users` | Update suspicious user treatment | | `moderator:manage:banned_users` | Ban and unban users | | `channel:bot` | Send chat messages as the channel bot | | `user:read:chat` | Read chat messages | | `channel:read:ads` | Read ad schedule | | `channel:manage:raids` | Cancel raids (Lumio does not start raids) | | `channel:moderate` | Moderate chat | | `moderator:read:blocked_terms` | Read blocked terms | | `moderator:read:chat_settings` | Read chat settings | | `moderator:read:unban_requests` | Read unban requests | | `moderator:read:banned_users` | Read banned users list | | `moderator:read:chat_messages` | Read chat messages as moderator | | `moderator:read:warnings` | Read user warnings | | `moderator:read:moderators` | Read moderator list | | `moderator:read:vips` | Read VIP list | | `moderator:read:chatters` | Read the connected chatters (Helix `GET /chat/chatters`) for the [chatter list](../features/chatter-list.md) | | `moderation:read` | Read the moderator list (Helix Get Moderators) — the chatter list's moderator group | | `channel:read:vips` | Read the VIP list (Helix Get VIPs) — the chatter list's VIP group | | `moderator:manage:automod` | Receive held AutoMod messages and decisions | | `moderator:manage:blocked_terms` | Requested for later blocked-term management; currently unused | | `moderator:manage:automod_settings` | Requested for later AutoMod-level management; currently unused | Reconnect an existing Twitch channel once to grant the AutoMod scopes. Lumio requests all three together; held messages need `moderator:manage:automod`, while the two management scopes are not used yet. ### Bot Credentials Twitch bot connections default to `system`. Configure a separate Twitch bot app in `[auth]` with `twitch_bot_client_id` / `twitch_bot_client_secret` and register the same unified callback as the channel app: ```text {web.public_url}/api/connections/oauth/twitch/callback ``` If the Twitch bot pair is empty, the bot flow falls back to the Twitch channel pair, then to the Twitch login pair, and logs a one-time warning. If neither bot nor channel/login keys are available, the bot flow degrades to `account`. --- ## YouTube / Google ### Developer Console https://console.cloud.google.com/apis/credentials ### Prerequisites 1. Create a **Google Cloud project** (or use an existing one) 2. Enable **YouTube Data API v3** under **APIs & Services > Library** 3. Configure **OAuth consent screen** under **APIs & Services > OAuth consent screen** - User Type: **External** (or Internal for Google Workspace) - Add the scopes listed below - Add test users if app is in "Testing" status ### Login Credentials (ID App) 1. Go to **APIs & Services > Credentials** → **Create Credentials** → **OAuth 2.0 Client ID** 2. Application type: **Web application** 3. Set **Authorized redirect URI** to: `{ID_APP_URL}/api/auth/callback/google` - Local: `http://localhost:4002/api/auth/callback/google` 4. Copy **Client ID** and **Client Secret** 5. Add to `apps/id/.env.local`: ```env AUTH_GOOGLE_ID=your_client_id AUTH_GOOGLE_SECRET=your_client_secret ``` **Login Scopes:** | Scope | Purpose | |-------|---------| | `openid` | OpenID Connect authentication | | `email` | User email address | | `profile` | User name and profile picture | | `youtube.readonly` | Read YouTube channel data - also used at login to fetch `snippet.customUrl` for the username | | `youtube.force-ssl` | YouTube Data API access (live chat) | **Username resolution.** Right after the OAuth handshake the ID App calls `youtube/v3/channels?part=snippet&mine=true` with the user's fresh access token and stores `snippet.customUrl` (e.g. `cruex_de`) as the login connection's `username`. Without this lookup Google's OIDC profile only exposes `email` (typically a `…@pages.plusgoogle.com` pseudo-email), which would never match the YouTube display name a viewer actually `@`-mentions in chat. If the YouTube call fails (no channel attached to the Google account, scope revoked) we fall back to `email`. The handle drives both the multichat `@`-mention highlighter and the broadcaster-status fallback that unlocks moderation buttons before the broadcaster's first chat message. ### Channel Credentials YouTube channel connections are BYO/account. Each account supplies its own Google Cloud OAuth client. 1. Create a **separate** OAuth 2.0 Client ID in the same Google Cloud project 2. Set **Authorized redirect URI** to: `{WEB_URL}/api/connections/channel/youtube/callback` - Local: `http://localhost:4000/api/connections/channel/youtube/callback` - The channel-connection platform slug is `youtube`, **not** `google` - only the ID App's login callback uses `google`. 3. Users enter their own Client ID + Secret in **Dashboard → Connections → the platform tab → App** **Channel Scopes** (2 required scopes, 1 optional scope requested on every connection): | Scope | Purpose | |-------|---------| | `youtube.readonly` (required) | Read channel, video, and live chat data | | `youtube.force-ssl` (required) | Full YouTube Data API access (chat, comments) | | `yt-analytics-monetary.readonly` (optional) | Read the channel's estimated revenue from YouTube Analytics | ### YouTube Analytics API {#youtube-analytics-api} To show YouTube Analytics revenue in [Stream History](/user-guide/stream-history), prepare the Google Cloud project that holds the channel connection's OAuth client: 1. Enable **YouTube Analytics API** under **APIs & Services → Library**, alongside **YouTube Data API v3**. 2. On the OAuth consent screen, add `https://www.googleapis.com/auth/yt-analytics-monetary.readonly` under **Data Access**. The App Credentials wizard marks this scope **optional** and provides the full name to copy. 3. If the app is in **Testing**, add the channel owner as a test user. For an external app in production, Google may show an **unverified app** warning until its sensitive scopes are verified. Google's Testing refresh tokens can expire after seven days; publish the app and reconnect to obtain a production grant. 4. Connect or reconnect YouTube in **Manage → Connections**. Leave Google's checkbox for viewing revenue selected. Lumio requests the optional scope on every connect, but Google lets you decline it. The connect dialog explains that members with the **View revenue** permission (`history:revenue-read`) can see the YouTube amounts. YouTube reports revenue only for channels in the **YouTube Partner Program**. Figures normally arrive **2–3 days after a stream** and may be corrected before they become final; until they arrive, the report shows the Lumio estimate. If the scope was declined, the channel is outside the Partner Program, or the API is disabled, the revenue area explains the next step. The optional scope does not create a general connection warning. The Analytics requests use the quota of **your own Google app**; Lumio makes at most one bundled revenue request per channel per day, usually none. > **Info:** > Both login and channel credentials request `access_type=offline` and `prompt=consent` to ensure refresh tokens are always issued. ### Bot Credentials YouTube bot connections have a `system` credential mode but no channel-system app to fall back to. Configure `youtube_bot_client_id` / `youtube_bot_client_secret` in `[auth]` and register the unified callback when the deployment uses an operator-managed YouTube bot app: ```text {web.public_url}/api/connections/oauth/youtube/callback ``` If the YouTube bot pair is empty, the YouTube bot flow degrades to `account`. --- ## Discord ### Developer Console https://discord.com/developers/applications ### Login Credentials Discord has **no channel connection**. It is a sign-in-only provider for users and a separate server-invite platform for Lumio's global bot. A Discord login cannot be assigned to a channel. The login and bot applications use separate credentials; the bot itself authenticates with a static token saved in Admin → Providers. 1. Go to **Applications** → **New Application** 2. Under **OAuth2**, copy **Client ID** and **Client Secret** 3. Add **Redirect URL**: `{ID_APP_URL}/api/auth/callback/discord` - Local: `http://localhost:4002/api/auth/callback/discord` 4. Add to `apps/id/.env.local`: ```env AUTH_DISCORD_ID=your_client_id AUTH_DISCORD_SECRET=your_client_secret ``` **Login Scopes:** | Scope | Purpose | |-------|---------| | `identify` | Read user identity | | `email` | Read user email | The API needs the same Discord login Client ID and Secret under `[auth].discord_client_id` and `[auth].discord_client_secret` to validate the token audience and refresh login tokens. Discord refresh tokens rotate; keep the secret in the API environment as well as the ID app. ### Discord Bot App Use a Discord application for the one global Lumio bot. Set `[auth].discord_bot_client_id` and `[auth].discord_bot_client_secret` in the API environment, then save its Bot Token under **Admin → Providers → Discord**. The Application ID and Secret are not entered in the admin form. Register these bot-app OAuth redirects in the Discord Developer Portal: ```text https://lumio.vision/api/connections/oauth/discord/callback https://lumio.web.staging.zaflun.dev/api/connections/oauth/discord/callback http://localhost:4000/api/connections/oauth/discord/callback ``` Enable **Requires OAuth2 Code Grant** for the bot application and set its **Install Link** to **None**. Lumio builds the invite itself with `bot applications.commands identify`, `integration_type=0`, and permissions `2147576832` (View Channels, Send Messages, Manage Messages, Embed Links, Read Message History, Use Application Commands). Never grant Administrator (`permissions=8`). `identify` makes Discord return a code and the confirmed server in the token response; Lumio revokes that user token after verification. The inviter needs **Manage Server** on the target server. If connecting a server ends with *Discord did not confirm the server invitation*, the API log names the cause. `Discord rejected the guild invite code exchange` carries Discord's `error`: `invalid_client` means `[auth].discord_bot_client_secret` is not the **Client Secret** of the app in `discord_bot_client_id` (the Bot Token is a different value), and `invalid_grant` means the redirect URL does not match or the code was already used. The bot itself authenticates with its own `DISCORD_BOT__BOT__TOKEN`; a gateway close `4004 Authentication failed` means that token is wrong or was reset in the portal. Enable the privileged **Server Members Intent** for member events and **Message Content Intent** for message-based features in the Bot settings. Discord requires verification once the bot reaches 100 servers. If either privileged intent is off, the bot reports `DisallowedGatewayIntents` on startup. ```toml [auth] discord_bot_client_id = "..." discord_bot_client_secret = "..." ``` **Issuer:** the Discord provider in `apps/id/src/lib/providers.ts` sets `issuer: "https://discord.com"`. Discord appends the RFC 9207 `iss=https://discord.com` parameter to the OAuth callback, and Auth.js rejects any `iss` that differs from the provider's issuer — without the explicit value it expects its fallback `https://authjs.dev`. The setting triggers no OIDC discovery, because the authorization, token and userinfo endpoints are explicit URLs. Kick sets no `issuer`: it sends no `iss` today. ### Troubleshooting `unexpected "iss"` If Discord sign-in **and** linking Discord from *Security → Sign-in* both end on `/login?error=Configuration`, and the ID app log shows a `CallbackRouteError` with `unexpected "iss" (issuer) response parameter value` (`expected: https://authjs.dev`), the Discord provider is running without its `issuer`. The callback carries `iss=https://discord.com` and fails before the sign-in or link step runs. Restore `issuer: "https://discord.com"` on the Discord provider in `apps/id/src/lib/providers.ts`; the regression test `apps/id/__tests__/lib/providers.test.ts` fails when it is missing. The Discord Developer Portal has no setting for this. --- ## Kick Kick runs **OAuth 2.1**. Its `/oauth/authorize` mandates **both PKCE (`code_challenge` + `code_challenge_method=S256`) and a `state` parameter**, even for confidential clients that also send a `client_secret`; omitting **either** makes `id.kick.com` answer with a `404` (not a `400`), which reads like a broken URL rather than a protocol error. Its `/oauth/token` expects the client credentials as **body parameters** (`client_secret_post`), not an `Authorization: Basic` header — the same auth style our Rust token-refresh uses for Kick. This applies to every Kick app you create below. Lumio sends `code_challenge` + `code_challenge_method=S256` and `state` on the authorize URL and the matching `code_verifier` on the token exchange for **all** Kick flows (login, channel, bot, and the admin global bot). For the API-driven channel/bot flows PKCE is generated server-side per authorize request; the login flow (`apps/id`, NextAuth) pins `checks: ["pkce", "state"]` on the Kick provider so no `@auth/core` default change can silently drop `state`. No extra configuration is required. ### Developer Console https://kick.com/settings/developer ### Login Credentials (ID App) 1. Create a new application in the Kick Developer settings 2. Set **Redirect URL** to: `{ID_APP_URL}/api/auth/callback/kick` - Local: `http://localhost:4002/api/auth/callback/kick` 3. Copy **Client ID** and **Client Secret** 4. Add to `apps/id/.env.local`: ```env AUTH_KICK_ID=your_client_id AUTH_KICK_SECRET=your_client_secret ``` 5. Set the **same** `client_id` as `kick_client_id` in the API `[auth]` config (see [API Server](#2-api-server-appsapiconfiglocaltoml) below). Kick is a first-class login provider on every token-exchange path (`POST /v1/auth/token`, `POST /v1/auth/authorize`, GraphQL `exchangeToken`, `POST /v1/auth/link`), and each validates the token audience against `kick_client_id`. If it is unset, Kick **login** — not just linking — fails closed (see the mandatory audience-validation note in the [API Server](#2-api-server-appsapiconfiglocaltoml) section). **Login Scopes:** | Scope | Purpose | |-------|---------| | `user:read` | Read user profile | | `chat:write` | Send chat messages | | `moderation:ban` | Ban, time out and unban users in the multichat and on the user info card | | `moderation:chat_message:manage` | Delete chat messages | Kick chat moderation (delete, timeout, ban) runs on the acting user's personal **Kick login** token, the same way Twitch moderation runs on the Twitch login token, so the moderation scopes sit on the login app. The API checks the stored login scopes before it calls Kick; a missing scope comes back as `login_scope_missing` and the dashboard shows "log in with Kick again". Kick only grants new scopes on a fresh authorize: a user who logged in with Kick before the moderation scopes were requested must **log in with Kick once more** (or use **Reconnect** on the Kick login connection under **Account → Profile**) to grant them. No migration is needed. **User profile lookup:** after the token exchange the ID app reads the signed-in user from Kick's official user route, `GET https://api.kick.com/public/v1/users` (no `id` query = the token owner). That response is an envelope wrapping an array — `{ "data": [ { "user_id", "name", "email", "profile_picture" } ], "message" }` — so the provider unwraps `data[0]` and maps `user_id` → account id (stable, required), `name` → display name and login name, `email`, and `profile_picture` → avatar. The legacy unofficial `https://api.kick.com/api/v1/user` route returns `404` and must not be used. ### Channel Credentials Kick channel connections default to `system`. Create a Kick Channel app and register the unified callback for each environment it serves — one app can carry production, staging and local, because Kick accepts up to 10 redirect URIs per app: ```text {web.public_url}/api/connections/oauth/kick/callback ``` Set `kick_channel_client_id` and `kick_channel_client_secret` in the API `[auth]` config to give Kick channel connects a dedicated `client_id`. When those are unset, Kick channel connects **fall back to the Kick login pair** (`kick_client_id` / `kick_client_secret`), mirroring Twitch — Kick accepts up to 10 redirect URIs per app, so the login app can register the channel callback above alongside the login callback. The fallback logs a one-time warning so you register a dedicated channel app (every provider rate limit and subscription cap is scoped to the `client_id`). Only when **neither** the channel pair nor the login pair is configured do new connects degrade to the account-credential path and use the legacy account callback: ```text {WEB_URL}/api/connections/channel/kick/callback ``` **Channel Scopes** (6 scopes): | Scope | Purpose | |-------|---------| | `user:read` | Read user profile information | | `channel:read` | Read channel information | | `events:read` | Read channel events | | `events:subscribe` | Subscribe to real-time events | | `chat:write` | Send chat messages | | `moderation:chat_message:manage` | Delete chat messages | ### Bot Credentials Kick bot connections default to `system`. Create a Kick Bot app — kept on a separate `client_id` from the Channel app for quota/suspension isolation, not because of any redirect-URI limit — register the same unified callback as the Kick Channel app, and set `kick_bot_client_id` / `kick_bot_client_secret` in the API `[auth]` config. Both the Channel app and the Bot app register the same callback: ```text {web.public_url}/api/connections/oauth/kick/callback ``` If the Kick bot pair is empty, the bot flow falls back to the Kick channel pair and logs a one-time warning. If neither pair is available, the bot flow degrades to `account`. **Bot Scopes** (2 scopes): | Scope | Purpose | |-------|---------| | `user:read` | Read the bot account's own profile — used right after the exchange to resolve the bot's display name via `GET /public/v1/users`. Required: without it the identity lookup returns `403` and the bot saves without a name. | | `chat:write` | Send chat messages as the bot | > **Note:** > Adding `user:read` to the Kick bot scope set (ZAF-1510) **re-consents existing Kick bot connections**: Kick only grants the wider scope on a fresh authorize, so a bot connected before this change keeps its old grant until it is reconnected. No migration is needed — the next bot connect picks up `user:read` automatically. ### Troubleshooting a rejected token exchange A **`401` at `https://id.kick.com/oauth/token`** with `error: invalid_client` means the `client_id` / `client_secret` pair Lumio presented is **rejected by Kick** — the secret is wrong or was rotated. It is **not** a `redirect_uri`, PKCE, or expired-code problem (those return `400 invalid_grant` / `invalid_request`). The fix is a setup action: regenerate the secret in the Kick Developer console and update the matching `[auth]` key, or confirm you pasted the *right* app's pair when running separate Channel, Bot, and Login apps. Surrounding whitespace is **not** a cause: Lumio strips leading and trailing whitespace from every configured `client_id` / `client_secret` — the system `[auth]` keys and the per-account BYO `app_credentials` alike — on load, so a copy-pasted trailing newline or stray space can no longer produce this silent `401`. When it trims a system key it emits exactly one startup `warn` naming the config key (never the value), e.g. `OAuth credential carried surrounding whitespace and was trimmed on load, config_key=auth.kick_channel_client_secret`; re-set that key cleanly to silence it. Lumio surfaces this without leaking secrets. The dashboard shows `Kick rejected the token exchange: invalid_client` (the OAuth code, never the raw upstream body), and the API log emits one `warn` naming which app was used: ```text platform=kick credential_source=system client_id_hint=...6789 status=401 Unauthorized oauth_error=invalid_client oauth_error_description="Client authentication failed" ``` `credential_source` (`system` / `account`) plus the last-4 `client_id_hint` identify the app **per failing request**, so you never have to grep the boot log for the one-time channel-uses-login-pair fallback warning: a channel connect that fell back to the login pair (see above) is still `system`, but its `client_id_hint` is the login app's id, not the channel app's. To confirm a pair without waiting for a connect attempt, POST the `client_credentials` grant to the same endpoint with the pair in question: ```sh curl -s -i -X POST https://id.kick.com/oauth/token \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=client_credentials' \ --data-urlencode 'client_id=…' --data-urlencode 'client_secret=…' ``` `200` with an `access_token` means the pair is valid; `401 invalid_client` means it is not. ### Webhooks (event delivery) The Kick **channel** app requests `user:read`, `channel:read`, `events:subscribe`, `chat:write`, `moderation:chat_message:manage`, `kicks:read`, `channel:rewards:read` and `ads:read`. The last three support KICKs, channel-point redemptions and ad-break status respectively. A connection lacking any of these scopes lists them in `missing_scopes` until the owner reconnects; no migration is needed. Ad-break status is polled only while a stream is live and once at finalization, not delivered by webhook. Full list: [Channel Connections & OAuth Scopes](../developer-guide/channel-connections-oauth-scopes.md#kick-8-scopes). Real-time events (follows, subs, resubs, gift subs, KICKs, channel-points redemptions, bans, livestream status and metadata) reach Lumio via Kick **webhooks**. Lumio subscribes to the events automatically (`POST /public/v1/events/subscriptions`, using the `events:subscribe` channel scope above), but **Kick has no API to set the delivery URL** — it is a manual per-app setting: 1. Open **kick.com/settings/developer → Edit app** for the Kick **Channel** app. 2. Turn on the **"Enable Webhooks"** toggle. 3. Set **"Webhook URL"** to your API's public receiver: - Production: `https://api.lumio.vision/v1/webhooks/kick` - Otherwise: `{server.public_url}/v1/webhooks/kick` (the exact value is logged once by the Kick worker at API start). 4. **No signing-key setup.** The API fetches Kick's public RSA key itself from `https://api.kick.com/public/v1/public-key` (unauthenticated; the same key for every Kick app), caches it, and refreshes it every 6 h. The API host needs outbound HTTPS to `api.kick.com`. The check is fail-closed: if no key can be fetched and none is cached, every delivery returns `401` and the API logs an `ERROR`. Leave `webhooks.kick_public_key` empty. It is only an optional pin, for example for a host without egress, and when it is set, automatic rotation is off. Leave `webhooks.kick_secret` empty too. Kick does not issue a webhook shared secret, and the legacy HMAC value is not the OAuth Client Secret. Details: [Kick Webhook Public Key](../developer-guide/kick-webhook-public-key.md). Because the URL is a single per-app field, **one Kick app serves one environment** — a shared receiver for all environments is not possible with a single app. Without this step the OAuth flow still succeeds and subscriptions register, but **no event is ever delivered** (chat still works — the Kick bot reads chat over Kick's Pusher WebSocket, which needs no webhook). ## Spotify ### Developer Console https://developer.spotify.com/dashboard ### Channel Credentials Only Spotify is **channel connection only** - there is no Spotify login. 1. Go to **Dashboard** → **Create App** 2. Set **Redirect URI** to: `{WEB_URL}/api/connections/channel/spotify/callback` - Local: `http://127.0.0.1:4000/api/connections/channel/spotify/callback` - Spotify rejects `localhost` redirect URIs, so Lumio rewrites `localhost` → `127.0.0.1` for Spotify (and only Spotify) when building the callback URL. Register the `127.0.0.1` form. 3. Under **"Which API/SDKs are you planning to use?"**, select both **Web API** and **Web Playback SDK** 4. Users enter their own Client ID + Secret in **Dashboard → Connections → the platform tab → App** **Channel Scopes** (7 scopes): | Scope | Purpose | |-------|---------| | `user-read-playback-state` | Read current playback state | | `user-modify-playback-state` | Control playback (play, pause, skip) | | `user-read-currently-playing` | Read currently playing track | | `playlist-read-private` | Read private playlists | | `playlist-read-collaborative` | Read collaborative playlists | | `playlist-modify-public` | Modify public playlists | | `playlist-modify-private` | Modify private playlists | > **Spotify Auth Style:** > Spotify uses `Authorization: Basic base64(client_id:client_secret)` for token refresh, unlike other platforms which send credentials in the POST body. ## Shopify Lumio is **one public app in the Shopify App Store**, a system app like the Twitch/Kick channel apps. Its credentials live only in the API config `[shopify]`, never in `app_credentials`, and are never mixed with a login app. There is no Shopify login and no per-account Shopify app. How install and link work end to end: [Shopify](../features/shopify.md#install-and-link). ### Dev Dashboard https://dev.shopify.com/dashboard — create the app under the zaflun organization with **public** distribution. - **Embedded, Shopify-managed install.** The app runs inside the Shopify admin (App Bridge ID tokens + token exchange; Shopify: *Authenticate an embedded app without a template*). The app URL (the embedded app `apps/shopify` on its own origin), the redirect URL Shopify requires, the scopes and the webhook subscriptions are declared in `apps/shopify/shopify.app..toml` and deployed with `shopify app deploy`; Lumio requests no scopes and has no OAuth callback. - **Scopes:** `read_orders,read_products,read_legal_policies`. The token exchange refuses a token whose scope readback lacks one of `[shopify] scopes`. - **Protected customer data.** Order webhooks are protected customer data, so request **Level 1** access in the Partner Dashboard (*API access requests → Protected customer data*). Add the **Name** field (Level 2, with a data protection review) only if merchants should be able to show a customer label (`customer_display`); without it Shopify redacts the name and every label is empty. Development stores work without a review once the data and fields are selected. - **Webhooks** are app-level subscriptions in `shopify.app..toml` (`{server.public_url}/v1/webhooks/shopify`), signed with the app's client secret; every installed store sends them, and a store that is not linked to a Lumio account is dropped on arrival. See [Webhooks → Shopify](../features/webhooks.md#shopify). - **Compliance webhooks** are app configuration, not a per-shop subscription. Set them in each environment file, `apps/shopify/shopify.app.staging.toml` and `apps/shopify/shopify.app.production.toml`, before the App Store review: ```toml [[webhooks.subscriptions]] compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"] uri = "https://api.lumio.vision/v1/webhooks/shopify/compliance" ``` The example uses production. Staging uses `https://lumio.api.staging.zaflun.dev/v1/webhooks/shopify/compliance`. Keep the app URL, `client_id`, scopes and all webhook URLs aligned with that environment, then run `shopify app deploy --config staging` or `shopify app deploy --config production` from `apps/shopify`. This releases Shopify's app configuration and extensions; the embedded web app is deployed separately. See [Shopify → Compliance webhooks](../features/shopify.md#compliance-webhooks). ### API config ```toml [shopify] client_id = "…" # LUMIO__SHOPIFY__CLIENT_ID # client_secret: ENV only # LUMIO__SHOPIFY__CLIENT_SECRET # client_secret_previous: ENV only, during a rotation # LUMIO__SHOPIFY__CLIENT_SECRET_PREVIOUS api_version = "2026-10" # LUMIO__SHOPIFY__API_VERSION scopes = "read_orders,read_products,read_legal_policies" app_store_url = "" # the App Store listing, once published ``` With `client_id` or `client_secret` empty every Shopify surface answers `Shopify is not configured…`, and with `webhooks.require_signatures = true` every Shopify webhook is refused. To rotate the client secret, set the new one as `client_secret` and the old one as `client_secret_previous` until Shopify no longer signs with the old one. The steps are in [Shopify → Client-secret rotation](../features/shopify.md#client-secret-rotation). --- ## Where to Configure Login credentials must be set in **two places** - the ID App's `.env.local` and the API's `config/local.toml`. The API needs them for login token refresh and Twitch chat API calls. ### 1. ID App (`apps/id/.env.local`) Used by NextAuth for the OAuth login flow: ```env # NextAuth AUTH_SECRET=generate-a-random-secret-here AUTH_URL=http://localhost:4002 # Twitch AUTH_TWITCH_ID= AUTH_TWITCH_SECRET= # Discord AUTH_DISCORD_ID= AUTH_DISCORD_SECRET= # Google / YouTube AUTH_GOOGLE_ID= AUTH_GOOGLE_SECRET= # Kick AUTH_KICK_ID= AUTH_KICK_SECRET= ``` ### 2. API Server (`apps/api/config/local.toml`) `local.toml` is git-ignored and is the last file layer before environment variables (`default.toml → {env}.toml → local.toml → ENV`). The API uses these for login token refresh and Twitch chat operations. **They must match the ID App's credentials:** ```toml [auth] # Same credentials as AUTH_TWITCH_ID / AUTH_TWITCH_SECRET in ID App twitch_client_id = "..." twitch_client_secret = "..." # Same credentials as AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET in ID App google_client_id = "..." google_client_secret = "..." # Same credentials as AUTH_DISCORD_ID / AUTH_DISCORD_SECRET in ID App. discord_client_id = "..." discord_client_secret = "..." # Same credentials as AUTH_KICK_ID / AUTH_KICK_SECRET in ID App kick_client_id = "..." kick_client_secret = "..." # Global system Channel apps. Both Twitch and Kick fall back to the login pair # above when their channel pair is unset (ZAF-1493); Kick accepts up to 10 redirect # URIs per app, so the login app can also register the channel callback. twitch_channel_client_id = "..." twitch_channel_client_secret = "..." kick_channel_client_id = "..." kick_channel_client_secret = "..." # Dedicated global system Bot apps. If one of these pairs is empty, that bot # flow falls back to the platform's channel pair and logs a warning. If neither # bot nor channel keys are configured, the bot flow degrades to account/BYO. twitch_bot_client_id = "..." twitch_bot_client_secret = "..." kick_bot_client_id = "..." kick_bot_client_secret = "..." youtube_bot_client_id = "..." youtube_bot_client_secret = "..." discord_bot_client_id = "..." discord_bot_client_secret = "..." # AES-256-GCM key used to encrypt OAuth tokens and credentials at rest. # MANDATORY outside development — the API refuses to boot in staging/production if # this is empty/placeholder/< 16 chars, and CHANGING it makes every existing # ciphertext permanently undecryptable. See installation.md → "Mandatory auth secrets". # Generate with: openssl rand -base64 32 token_encryption_key = "..." ``` Every one of these keys can also be supplied as an environment variable, which wins over the file: `LUMIO__AUTH__TWITCH_CLIENT_ID`, `LUMIO__AUTH__TWITCH_CHANNEL_CLIENT_ID`, `LUMIO__AUTH__TWITCH_BOT_CLIENT_ID`, `LUMIO__AUTH__DISCORD_CLIENT_ID`, `LUMIO__AUTH__DISCORD_CLIENT_SECRET`, `LUMIO__AUTH__DISCORD_BOT_CLIENT_ID`, `LUMIO__AUTH__DISCORD_BOT_CLIENT_SECRET`, `LUMIO__AUTH__TOKEN_ENCRYPTION_KEY`, and so on. > **Warning:** > The credentials in `apps/api/config/local.toml` must be **identical** to those in `apps/id/.env.local`. The API uses them to refresh login tokens - if they don't match, token refresh will fail silently and users will be logged out. `[auth].discord_client_id` validates the Discord login token audience. The matching `discord_client_secret` lets the API refresh that user's rotating login token. The separate `discord_bot_client_id` / `discord_bot_client_secret` pair signs the verified server invite; neither is an account-owned app credential. > **Audience validation is mandatory (fail-closed):** > On every token exchange (`POST /v1/auth/token`, `POST /v1/auth/authorize`, GraphQL `exchangeToken`, `POST /v1/auth/link`) the API validates both the token's **subject** (the claimed user) and its **audience** - the OAuth client the token was minted for. The audience each provider reports must match the configured client id: > > | Provider | Audience source | Subject source | Must match | > |---|---|---|---| > | Twitch | `client_id` from `GET /oauth2/validate` | same response | `twitch_client_id` | > | Google | `aud` from `/tokeninfo` | same response | `google_client_id` | > | Discord | `application.id` from `/oauth2/@me` | same response | `discord_client_id` | > | Kick | `client_id` from `POST /public/v1/token/introspect` | `user_id` from `GET /public/v1/users` | `kick_client_id` | > > Kick is the only provider needing **two** requests: no single Kick endpoint carries both the subject and the audience. Its introspect route is flagged deprecated in Kick's OpenAPI but has no announced successor - if Kick removes it, Kick logins fail closed rather than skipping the check. > > This blocks a confused-deputy attack where an attacker registers their own OAuth app, gets a victim to authorize it, and replays the victim-subject token against Lumio (ZAF-1015). The check is **fail-closed**: if a provider's client id is unset, token exchange for that provider **fails** (HTTP 500 / `INTERNAL_SERVER_ERROR`) rather than skipping the check. A foreign-audience token is rejected as an invalid credential (HTTP 401, audited as `user:login_failed`), identically to a subject mismatch. Set all four client ids in any deployment that serves logins for that provider. #### System app environment variables | Config key | Environment variable | |------------|----------------------| | `twitch_channel_client_id` | `LUMIO__AUTH__TWITCH_CHANNEL_CLIENT_ID` | | `twitch_channel_client_secret` | `LUMIO__AUTH__TWITCH_CHANNEL_CLIENT_SECRET` | | `kick_channel_client_id` | `LUMIO__AUTH__KICK_CHANNEL_CLIENT_ID` | | `kick_channel_client_secret` | `LUMIO__AUTH__KICK_CHANNEL_CLIENT_SECRET` | | `twitch_bot_client_id` | `LUMIO__AUTH__TWITCH_BOT_CLIENT_ID` | | `twitch_bot_client_secret` | `LUMIO__AUTH__TWITCH_BOT_CLIENT_SECRET` | | `kick_bot_client_id` | `LUMIO__AUTH__KICK_BOT_CLIENT_ID` | | `kick_bot_client_secret` | `LUMIO__AUTH__KICK_BOT_CLIENT_SECRET` | | `youtube_bot_client_id` | `LUMIO__AUTH__YOUTUBE_BOT_CLIENT_ID` | | `youtube_bot_client_secret` | `LUMIO__AUTH__YOUTUBE_BOT_CLIENT_SECRET` | ### 3. Account Credentials (Database) Account channel/bot credentials are **not** configured in files. Each user enters their own Client ID + Secret via **Dashboard → Connections → the platform tab → App**. They are stored AES-256-GCM encrypted in the `app_credentials` table, one row per `(account, platform)`. The resulting access/refresh tokens land in `channel_connections` or `bot_connections`, also encrypted. --- ## Bot Connections Bot connections let Lumio post to chat with a bot identity instead of the channel identity. Twitch, YouTube, and Kick use OAuth bot tokens; Spotify has no bot connection. Discord uses a static global bot token plus a separate OAuth app for verified server invites. OAuth bot connections default to the `system` credential source and use dedicated bot app keys from `[auth]`: `twitch_bot_client_id`, `kick_bot_client_id`, or `youtube_bot_client_id` plus the matching secret. Register the unified web-host callback for system bot flows: ```text {web.public_url}/api/connections/oauth/{platform}/callback ``` The admin global-bot flow starts in Admin → Providers, returns through the web host, and redirects back to the admin Providers page through the state handle's `return_to`. For Discord, save the static global bot token in Admin → Providers and configure the invite app pair under `[auth]` as described above. When a deployment has no global key pair for a bot platform, the account bot authorize path degrades to the legacy account source and uses: ```text {WEB_URL}/api/connections/bot/{platform}/callback ``` | Platform | Bot scopes | |----------|-----------| | Twitch | `chat:read`, `chat:edit`, `user:write:chat`, `user:bot`, `user:read:chat` | | YouTube | `youtube.force-ssl` | | Kick | `chat:write` | | Discord server invite | `bot`, `applications.commands`, `identify` | If a Twitch or Kick dedicated bot pair is unset but its channel pair exists, the bot flow uses the channel keys and logs a warning. For those OAuth bots, missing global keys can fall back to `account`, which is the normal local development path. Discord has no per-account bot app: an unset `discord_bot_client_*` pair makes server invites fail closed. Kick production verification now applies to both production Kick apps. The Bot app is the one whose `chat.message.sent` subscription cap matters for chat sending: unverified apps are capped at 1,000 subscriptions for that event type, verified apps at 10,000. See [Bot Connections](/features/bot-connections) for the full flow. ## See Also - [OAuth Credentials Architecture](/developer-guide/oauth-credentials-architecture) - Dual-credential design, encryption, token refresh - [Channel Connections & Scopes](/developer-guide/channel-connections-oauth-scopes) - Scope details, connection flow, storage - [Token Refresh](/features/token-refresh) - Automatic token refresh worker --- ## Getting Started / Create Your First Overlay Source: https://docs.lumio.vision/getting-started/first-overlay # Create Your First Overlay Build an overlay in Lumio and add it to OBS as a browser source. ## Create the Overlay 1. Go to **Dashboard → Overlays** (`/dashboard/overlays`). 2. Click **New Overlay**. 3. Give it a name, pick a resolution preset - `1920×1080 (Landscape)`, `1080×1920 (Portrait / TikTok)`, `1280×720 (720p)`, `2560×1440 (1440p)`, or a custom size - and choose a background. Leave the background **Transparent** unless you specifically want an opaque one; OBS composites the overlay over your scene. 4. Click **Create**. The dialog immediately shows the overlay's URL with a **Copy** button. ## Add Layers Open the overlay's **Editor** (`/dashboard/overlays/{id}/editor`) and add layers - alerts, chat, music, widgets, custom HTML/CSS/JS, or installed extension widgets. Save with `Ctrl/Cmd + S`. While the editor is open, `/overlay/{key}/preview` mirrors the canvas live, so you can keep a preview window next to the editor and watch changes land as you make them. ## The Overlay URL An overlay URL has three parts: ``` https://overlay.lumio.vision/?token=lm_overlay_xxxxxxxx ``` - The **host** is the overlay host - `overlay.lumio.vision` in production, `http://localhost:4000/overlay` in local development. It is configured per deployment (`NEXT_PUBLIC_OVERLAY_URL` on the webapp, `LUMIO__OVERLAY__PUBLIC_URL` on the API). - The **key** is the overlay's own non-enumerable identifier. It never expires. - The **token** authenticates the request. Loading `/overlay/{key}` **without** `?token=` renders `403 - Access token required`. > **Copy URL rotates the token:** > **Copy URL** on an overlay card mints a **fresh** overlay token (`lm_overlay_*`) and immediately invalidates the previous one. Any browser source still holding the old URL stops working. Copy the URL once, paste it into OBS, and only copy again when you intend to revoke the old link. Three token types authenticate an overlay URL: | Token | Prefix | Use | |-------|--------|-----| | Overlay token | `lm_overlay_` | The overlay's own token, handed out by **Copy URL**. One live token per overlay | | Shared overlay link | `lm_share_` | A time-limited link for someone else. Choose 1 h, 3 h, 6 h, 12 h or 24 h; up to 20 active links per overlay; revocable at any time. Requires the overlay-sharing feature on your plan | | Popout token | `lm_pop_` | A permission-scoped token you generate in **Dashboard → Tokens**. Works on overlay pages as well as the popout views | ## Add It to OBS 1. In OBS, add a **Browser Source**. 2. Paste the overlay URL. 3. Set **Width** and **Height** to the overlay's canvas resolution (e.g. `1920` × `1080`) - browser sources do not resample cleanly, so match the numbers exactly. 4. Click **OK**. The overlay renders over your scene and updates in real time over the WebSocket connection - no refresh needed. If the source is blank, open the same URL in a normal browser tab. A `403 - Access token required` there means the `?token=` part is missing or was rotated away by a later **Copy URL**. ## Permissions | Action | Permission | |--------|-----------| | View the overlay list and open the editor | `overlays:read` | | Create an overlay | `overlays:create` | | Edit layers, save, copy the URL, create a shared link | `overlays:edit` | | Delete an overlay | `overlays:delete` | Overlays additionally have per-overlay access levels, so a team member can be granted Editor rights on one overlay without holding them account-wide. ## Next Steps - [Overlays (user guide)](/user-guide/overlays) - the editor, layers, presets and troubleshooting in depth - [Tokens](/user-guide/tokens) - popout tokens for the overlay and popout pages - [Connecting Platforms](/getting-started/connecting-platforms) - so your alerts have events to fire on --- ## Getting Started / Connecting Platforms Source: https://docs.lumio.vision/getting-started/connecting-platforms # Connecting Platforms Connect your streaming platforms and services to Lumio from **Settings → Connections** in the dashboard (`/dashboard/settings/channel/connections`). ## What Can Be Connected Different platforms plug into Lumio in different ways. Only the first group uses a **channel connection** - the OAuth link that lets Lumio read chat and events for your channel. | Platform | Channel connection | Bot | Login | Other | |----------|--------------------|-----|-------|-------| | **Twitch** | Yes - chat, events, moderation, rewards | Yes | Yes | - | | **YouTube** | Yes - chat, super chats, memberships | Yes | Yes | - | | **Kick** | Yes - chat, events, moderation | Yes | Yes | - | | **Spotify** | Yes - playback, playlists | - | - | - | | **Discord** | - | Global bot invited to servers | Yes, sign-in only | Server integration | | **Shopify** | - | - | - | Integration | The **Services** area holds **OBS** (WebSocket), **StreamElements** tokens for tip ingestion, and **Shopify**. Shopify connects through its embedded app in the Shopify admin, then asks you to confirm the link in Lumio; it is not a channel OAuth connection. Streamer.bot is listed as a later service. See [Shopify setup](/user-guide/connections#shopify). ## Before You Connect YouTube and Spotify channel connections use your account's **own OAuth application**. Twitch and Kick normally use Lumio's app; if its system credentials are unavailable, they can use an account-owned app: 1. Create the OAuth app on the platform's developer console and register Lumio's callback URL - see [OAuth Provider Setup](/getting-started/oauth-setup). 2. In Lumio, open that platform's area and choose **Set up app** in its **App** section. The guide saves the **Client ID** and **Client Secret** encrypted. Spotify's guide has five steps; YouTube's has six. Until an account-owned app is configured, its platform area asks you to set it up before connecting the channel. ## How to Connect For Twitch, YouTube, Kick or Spotify channel connections: 1. Open **Settings → Connections** in the dashboard and choose the platform. 2. Click **Connect** in its Channel section. 3. Authorize the requested scopes on the platform's consent screen. 4. You return to the same platform area, which shows the result. You only enter an own app's credentials once per platform. **Reconnect** re-runs OAuth for the same platform account when a required scope is missing or a grant cannot be refreshed. Spotify also needs reconnecting after its 180-day authorization period; the hub warns you during the last 14 days. ## One Connection Per Platform Each account holds **at most one channel connection per platform** - the `channel_connections` table is unique on `(account, platform)`. Connecting a second Twitch channel to the same account replaces the first. Use separate Lumio accounts for separate channels. Your **login** connections are tracked separately, so signing in with Twitch and also connecting a Twitch channel are two independent records. Discord sign-in cannot be assigned to a channel. Manage its login connection under **Security → Sign-in**. To invite the Lumio bot to a Discord server, open the **Discord** area; this is separate from the personal login connection. ## Permissions | Action | Permission | |--------|-----------| | See a channel connection and its status | `connections:read` | | Save an own app, start channel OAuth | `connections:create` | | Disconnect a platform | `connections:delete` | The hub shows only tabs whose providers, features and read permissions are available to you. OBS and StreamElements each have their own read permission. ## Next Steps - [Connections (user guide)](/user-guide/connections) - every panel on the page, in detail - [Bot Connections](/features/bot-connections) - run chat under your own bot identity - [Create Your First Overlay](/getting-started/first-overlay) --- ## User Guide / Dashboard Source: https://docs.lumio.vision/user-guide/dashboard # Dashboard The dashboard home brings your connected channels, stream metrics, calendar and activity into one view. It adapts when a channel goes live. The sidebar takes you to Chat, Events, History and the other account features. ## Where to find it - URL: `/dashboard` - Shown automatically right after you sign in - every sign-in lands here, including your first one. - The sidebar on the left is always available while you are inside the app. ## First sign-in The very first time you sign in you do not have an account yet, so the dashboard shows a short **account setup** step instead of the channel cards: - **Create a Free account** - start on the Free plan right away (paid plans show an "upgrade later" hint; you upgrade from **Settings → Channel → Subscription** once the account exists). - **Join with an invite code** - if a team owner invited you, paste their code here to join their account as a member instead of creating your own. Once an account exists, `/dashboard` shows the normal home page described below. You are never sent to the Subscription page before you have an account. ## Quick start 1. Sign in via the **Login** button - pick Twitch, YouTube, Kick, or Discord. 2. If you do not have a streaming platform linked yet, the connection notices at the top show **Connect a streaming platform** - click the platform you want to add. 3. Open **Connections** in the sidebar and connect at least one channel so Lumio can read your chat and events. 4. Back on the dashboard, check the status strip for your channel. While you stream, it becomes a live strip with a link to the broadcast. 5. Use the sidebar to create your first overlay, automation, or command. ## Header controls The top-right corner of the dashboard header contains two icon buttons: - **Shield** - Toggles [Streamer Mode](/user-guide/streamer-mode) on/off. Highlighted when active. Masks sensitive data (IDs, tokens, credentials) across the entire dashboard, replacing it with dots - ideal when your dashboard is visible on stream. - **Bell** - Opens the notification dropdown. ## Layout at a glance The home page groups its content into a header, status strip, metrics and timeline, a 12-month calendar, and activity. When the body content area is at least about 72 rem wide, activity sits beside the metrics, timeline and calendar. With less room, the page stacks metrics, activity, timeline and calendar in that order. This follows the space available beside the sidebar, so collapsing it can change the arrangement without changing the window size. The status strip above the body follows the window width. Each group needs its own feature and permission. If your role lacks one, that group is omitted and the remaining layout closes the gap. A failed data request stays in its group with a retry action. ### Upgrade banner If you are the account **owner** on the free plan, a slim upgrade card appears above the status strip. You can dismiss it; that choice is saved. ### Connection notices Above the page content (on the dashboard and every other non-fullscreen page) a single card collects every connection notice: an expired login, channel or bot connection (red), a login that is not assigned to this channel, missing permissions and the streaming-platform onboarding (amber). It shows one notice at a time - use **‹ ›** (or **←/→**) to page through them, and **✕** to hide one until you reload the page. Your own (user) problems come first, then the channel's. Each notice carries its repair button; channel and bot reconnect buttons appear only if you may reconnect channels (`connections:create`). See [Connections → Connection notices](../features/connections.md#connection-notices). ### Streaming-platform onboarding If the only login you have is Discord (no streaming platform linked), the connection notices include **Connect a streaming platform** - on every page with notices, the Chat and Events pages and the popouts included. It offers a button each for **Twitch**, **YouTube** and **Kick** (none is preferred). Click one to start the OAuth link flow. After authorizing, you are redirected back and the notice disappears. ### Header and popouts The page header greets you and shows the date, or how long the channel has been live (“You have been live since 18:00” for the owner, “EL_CAPiTANO1988 has been live since 18:00” for everyone else). **Open chat** opens Chat. **Popouts ▾** offers Chat, Events, Music and Test-Events when their features and permissions allow them. Music also needs a Spotify connection. Each entry can open a window or copy a token-free page URL; the menu never displays a token URL. To use a popout in OBS, create a [popout token](./tokens.md) with the needed permissions. ### Weekly review Sentences about streaming, going live and gaining followers belong to the channel account. The account's owner reads them addressed to themselves (“Quiet week. Your last stream was 21 days ago.”). Everyone else — for example a moderator, who does not stream on that channel — reads the name of the active account as shown in the account switcher (“Quiet week. EL_CAPiTANO1988’s last stream was 21 days ago.”); if the account has no name, the sentence says “the channel”. The same applies to the live line in the header, the live switch announced to screen readers and the Music entry in **Popouts ▾**. The greeting and your role always address you. While the channel is offline, with `feature:stream_history` and `history:read`, the header becomes a weekly review card. The greeting and date appear at its top, beside the header buttons when the card is at least about 42 rem wide; in a narrower card, the buttons sit below them. Below is a verdict, one sentence and a bar chart of hours streamed per day: this week next to the same weekday of the week before. The bars sit beside **History** and **Watch last stream** when the card has that width, and above those links when it does not. The links sit beside each other when the card is at least about 24 rem wide; in a narrower card, they stack. **History** opens the stream history and **Watch last stream** opens the report of the channel’s most recent finished stream. The review always covers the last 7 days on every platform. The range and platform filter of the metrics strip do not change it. The verdict follows these rules, comparing this week with the 7 days before: | Verdict | When | | --- | --- | | **Quiet week.** | No stream this week. The sentence says how many days ago the account’s last stream was. | | **Good week.** | The account streamed this week but not the week before. The sentence shows days and hours, without a comparison. | | **Strong week.** | Stream time **or** new followers rose by at least 10 %, and neither fell by 10 % or more. | | **Calmer week.** | Stream time **and** new followers both fell by at least 10 %. | | **Solid week.** | Every other case. | A number the week before was zero for (for example no new followers) gives no percentage, so it counts as neither up nor down. An account that has never streamed sees only the greeting. While the channel is live, the review and the **Up next** card disappear and the header shows how long the channel has been live. When the stream ends, both reload once. Without `history:read` the header stays a plain greeting. ### Up next With `feature:connections` and `connections:read`, **Up next** shows the channel’s next planned stream within the next 7 days, from any platform's stream plan (YouTube scheduled broadcasts, the Twitch schedule and the Kick schedule): platform, title, day and time, a **Scheduled** badge and a link to the livestream page. The card sits beside the weekly review when the header content area is at least about 72 rem wide; with less space, it spans the full width below the review. In that full-width card, the stream title stays on the left and the countdown or start time sits on the right when the card is at least about 36 rem wide. In a narrower card, they stack. The livestream link stays below. Under 24 hours it counts down to the start; further ahead it shows the date and time. Screen readers hear one announcement when the start is less than an hour away, not every minute. Without a planned stream in the next 7 days there is no card and the weekly review takes the full width. The planned streams for the next 30 days also appear in the timeline (see [Planned streams in the timeline](#planned-streams-in-the-timeline)), and the status strip keeps its link to the scheduled broadcast. These layouts follow the space beside the sidebar, so collapsing it can change the arrangement without changing the window size. ### Status and readiness With `feature:connections` and `connections:read`, the offline strip shows each connected channel, its last live time, follower count (approximately rounded subscribers on YouTube), and a link to the channel. With `history:read` as well, a second line under the followers shows the channel's current paid subs ("412 subs") on Twitch and Kick; Twitch counts gifted subs too and needs the channel permission to read subscriptions, so an older connection may need a reconnect before the line appears. YouTube shows no such line, because channel memberships cannot be read. When a count is not available the line is left out rather than shown as a dash. The strip shows this line only while you are offline. A scheduled YouTube broadcast links directly to its watch page. Without a connected channel, the page points you to **Connections**. While live, the strip shows each live platform's title, category, viewers and direct broadcast link; YouTube also shows likes and views when available. Platforms sharing a title are grouped into small platform cards. With only one live platform, its viewer count sits next to the platform and the other channels keep their scheduled or offline status beside it. When several platforms are live, wide screens show the total viewers with platform shares on the right side; on narrower screens the total sits below the stream list. The strip updates without reloading. The **Readiness** ring gives a text count such as “4 of 5 ready”. It has one segment per check, ready ones first clockwise from the top, with an even gap between segments; a single check is a closed ring. Its segments appear only when you have the feature and read permission: connections (`connections:read`), bots (`bot-connections:read`), OBS (`obs:read`), overlays (`overlays:read`) and Music (`spotify:read`, with Spotify connected). **Open notice** selects the matching connection notice in the AlertDeck above the page, even if you dismissed that notice during this visit. Other segments link to their feature pages. ### Metrics and timeline With `feature:stream_history` and `history:read`, the offline metrics strip shows average viewers, stream time, new followers, chat messages, and Subs & Members. Choose **Today**, **7 days** (default) or **30 days** to compare each metric with the preceding period and see a small trend. Each trend line covers the whole selected range, the same window as the number above it. Only days with a stream are points: a day without a stream is skipped (the line bridges it), not drawn as zero, so all five lines share the same days. With fewer than two stream days in the range, for example **Today**, no line is shown. An account without streams shows an empty state rather than an infinite percentage. The five metric tiles share one card: they sit in one row when the main column is at least about 42 rem wide, or in rows of three and two when it is narrower. On a phone, the tiles scroll sideways. Inside a stream card, the viewer curve sits beside the figures when the card is at least about 42 rem wide and above them otherwise. Live figures use four cells across when their row is at least about 36 rem wide, or two rows of two below that width. Compact stream rows show record badges from about 36 rem of row width and the viewer trend from about 48 rem; narrower rows keep the stream details without those extras. The selected range applies to the metrics **and** the timeline's past: both always show the same window. The timeline shows your planned streams at the top (below), then **Today** with today's planned streams from every platform, then the finished streams that started in the range, grouped day by day in your time zone (a stream that runs past midnight belongs to the day it started). The newest stream gets the large card with its viewer curve and figures; the others are compact rows. The curve shows each platform as its own line at its real viewer count — lines are never stacked on top of each other. With two or more platforms, a filled **Total** curve sits behind them: the combined viewers of the selected platforms, counted minute by minute, so it can be lower than the platforms' peaks added up. Hover or tap the curve for the time, **Total** first and then each shown platform. The timeline shows at most 12 streams; if the range holds more, **{n} more streams in this range** below the last day opens [Stream History](./stream-history.md) filtered to that range, and so does **View full history**. A range without streams says so (“No stream today yet”, “No streams in the last 7 days” or “… 30 days”). While you are live the metrics strip is hidden; the timeline then shows **Now** and, below it, the streams of the range you last selected. While a new range loads, the current streams stay in place, dimmed. Milestones include viewer and Sub/Member records, the biggest raid, and follower marks. A first stream or first raid does not yet beat a previous record; equal values are not records. Records cover your whole stream history, including streams from before milestones were introduced. Follower marks start with your second stream after the update, because older streams have no saved follower count. While live, the metrics strip gives way to the **Now** card: a viewer line per platform with the **Total** curve behind them (as in the stream card), Community totals (new followers, paid Subs & Members with the gifted Subs as a "+ N gifted" line below, chat messages per minute, and peak viewers; the paid figure is the one the stream's history row and report show), and separate Support figures for Twitch Bits, Kick KICKs, YouTube Super Chats and Stickers, and StreamElements tips. Figures and their sublines update on the history sampler's roughly 60-second rhythm without reloading the page. The follower rate appears after 15 minutes; the platform subline labels Kick and Twitch counts, while YouTube subscribers say **after the stream**. The gifted Subs line, message total, Super Chat and Sticker counts, tip count and sample time update with their headline figures. Chat messages can trail the newest chat by up to another 30 seconds. The card shows only applicable platforms and figures; it does not infer an active-chatter count from chat messages. During a Twitch Shared Chat, the **This stream** card has a **My channel** / **Whole session** switch. My channel is the default and keeps your own viewer curve, messages and Chat/min. Whole session shows a **Sum of all channels** area and one line per channel in host → own → partner order, with a legend. A partner who leaves keeps the earlier part of their line; an unknown count is a gap, not zero. The chat cell leads with session messages and shows your own count beneath. Its Chat/min headline uses the whole session's rate and shows **—** until two samples exist; My channel uses your own rate. Support and revenue always come only from your channel. A Shared Chat start, join, leave or end refreshes the card without reloading the page. #### Planned streams in the timeline With `connections:read` as well, the timeline starts with your stream plan for the next 30 days, so it reads top to bottom as one time line: the latest planned stream at the top, the next one directly above **Today**, then the past days with the newest first. The plan comes from each platform: - **YouTube**: scheduled broadcasts and premieres of your channel. - **Twitch**: your channel's stream schedule (cancelled slots and vacations are left out). - **Kick**: the **Schedule** tab of your Kick channel. Kick has no official API for it, so Lumio reads the same data the kick.com page shows; if Kick changes it, the Kick plan stays empty until Lumio is updated. Planned streams are grouped by day in your time zone (**Tomorrow**, **Sat, Oct 11**, …). Each entry shows the platform logo, the title (or “Untitled stream”), **Scheduled on Twitch · 20:30** — or **20:30–23:00** when the platform has an end time — and the category. A recurring slot carries a small repeat icon; hover or focus it for **Recurring slot**. On the same day, a badge counts down to the start (“in 15 min”). **Livestream page ↗** (YouTube) or **Channel ↗** (Twitch, Kick) opens the stream on the platform. A simulcast on two platforms at the same time shows as two entries, one below the other. When a planned-stream card is narrower than about 32 rem, the countdown and livestream link wrap below the title; both remain available. The timeline shows at most 5 planned streams, the nearest ones. If the next 30 days hold more, **+ {n} more scheduled** above them opens the plan on the platform of the next hidden entry: the Twitch schedule, the Kick channel page, or YouTube Studio. Today's planned streams sit under **Today** instead and do not count towards the 5. A planned stream stays listed until its end time passes, or, without an end time, for 30 minutes after its start. The platform filter applies to the plan as well. The range (**Today / 7 days / 30 days**) does not: the plan always looks 30 days ahead. While you are live, **Now** replaces **Today**, the slot you are streaming in disappears, and only planned streams that start after now stay above **Now**. The plan reloads when you open the page and whenever a channel goes online or offline — never on a timer. Without planned streams there is no plan section. If the plan cannot be loaded, the timeline shows a notice with a retry action instead of an empty plan. #### Platform filter When your account has two or more streaming platforms (connected, or with streams in your history), platform chips with logo and name sit next to **Today / 7 days / 30 days**. On a phone they sit below the range and show only the logo. All platforms are on by default. Select a chip to switch that platform off or back on. At least one platform always stays on: the last active chip cannot be switched off, and its tooltip says so. Your choice lasts until you reload the page, like the range. The filter applies to the whole page below it: - **Metrics and trend lines** count only the selected platforms' share of each stream. For a simulcast with only YouTube selected, for example, stream time is YouTube's live time, and followers, chat messages and Subs & Members are YouTube's. Average and peak viewers use YouTube's audience only. - **Timeline** lists only streams that ran on a selected platform, and only planned streams of a selected platform. In each stream card, the viewer curve, the figures and the compact row's peak show only the selected platforms. A platform you switched off stays in the card's legend, greyed out. - **Stream calendar** days and their tooltip values count the same share. Follower marks show only for selected platforms; viewer, Sub/Member and raid records always show. - **View full history** and **{n} more streams in this range** open [Stream History](./stream-history.md) filtered to that platform when exactly one is selected. With two or more selected, they open the unfiltered list, because the history list filters by one platform at a time. The legend chips in the stream cards switch the same filter: switching off Twitch in a card switches it off in the header and everywhere else. A change loads the metrics, timeline and calendar together in one update; until it arrives, the current values stay in place, dimmed. While you are live, the metrics strip and with it the platform filter are hidden, and the page shows all platforms. The legend in the **Now** card then switches only that card's platform lines; the **Total** curve keeps counting every live platform. Your filter choice applies again when the stream ends. ### Calendar and activity The calendar fills the main content column and spans 12 months in your timezone. Hover, focus or tap a day to see its stream count and duration, each stream's platforms, title, time, peak and new followers, plus milestones. **Open day in History** filters the history list to that day. Keyboard users can move through days with arrow keys and open one with Enter. A day without a stream says so in the tooltip. With `feature:events` and `events:read`, the activity column mixes follows, subscriptions, resubs and memberships, sub upgrades (Prime and gifted to paid), gifts and paid-forward gifts, Bits, KICKs, Super Chats and Stickers, tips, and raids across platforms. Twitch reports a resub only when the viewer shares the resub message in chat; a resub that is not shared never reaches Lumio. New entries arrive at the top; **All** opens the [Events page](./events.md). The emotes in an event's message, such as the sub emotes in a resub message or the cheermotes in a cheer, render as images, as on the Events page. With `events:userinfo`, a click on a name opens the same user info card as on the Events page; without it, names are plain text. A thin line separates every two rows. Highlighted events such as Super Stickers or Bits keep their tinted background, and every row's background (highlight, hover, test border) keeps a little space to the lines. A name you can click turns to the accent colour on hover. A test event has a **TEST** badge and dashed border here, but disappears after a reload. Its name does not open a user info card. The Events page and Events popout do not show tests. At about 72 rem of body width or more, activity sits beside the main content and shows up to 14 of the newest rows. Below that width it sits under the metrics and above the timeline, showing the five newest rows. **All** opens the full feed in either layout. ### Test events With `feature:events` and `events:create`, **Test event** in the header opens a side sheet (full screen on a phone). Search the catalog, switch between platform tabs and **More**, send a row with default values, or expand it to edit values and choose random values. The sheet opens on the tab you used last in this session while that platform is still offered, otherwise on the first offered platform; until your connections have loaded, the tab row and the rows show placeholders. The catalog offers only events you could actually receive. An event source appears in the tabs, **More**, the search and **Just sent** only when all of these hold: | Source | Connection | Features | Permission | |--------|------------|----------|------------| | Twitch, YouTube, Kick | A channel connection for the platform (also one that needs a reconnect) | `platform:twitch` / `platform:youtube` / `platform:kick` | — | | Spotify | A Spotify connection | `platform:spotify` and `feature:music` | — | | StreamElements | A stored StreamElements token | `feature:streamelements` | — | | Discord | — | `platform:discord` | — | | Shopify | — | `integration:shopify` | — | | Copyright | — | `feature:copyright_detection` | `copyright:read` | | 7TV | At least one Twitch, YouTube or Kick channel connection | `feature:events` | — | Your connected stream platforms get their own tabs and every other offered source sits under **More**; with no stream platform offered, the first other sources take the tab slots. If the connections or the StreamElements token cannot be read (for example without `connections:read` or `se-tokens:read`), those sources stay listed rather than hidden. If nothing is offered, the sheet says so and links to **Connect a channel**. Entries in **Just sent** for a source that is no longer offered are hidden, not deleted. The sheet stays open after sending; **Just sent** keeps this session's tests with **Again** actions. The confirmation also offers **Again**. Tests broadcast to the account's overlays and dashboard activity. They are not stored, so they do not enter history, metrics, milestones, the Events page or the Events popout. The separate [Test-Events popout](./tokens.md#where-popout-tokens-work) uses the same catalog and can run as a narrow OBS dock. Its popout token must explicitly include `events:create`; without that right it shows a missing-permission notice instead of the catalog. The popout applies the same filter with the token's own rights, so Copyright appears there only when the token also includes `copyright:read`. In the popout, **Connect a channel** opens a new window; inside OBS it also offers **Copy link**, because a browser source blocks new windows. While it loads, the window shows placeholder rows in the catalog's layout. ### Streamer Mode and loading Streamer Mode masks money, Bits and KICKs in activity and the **Now** card, including test rows, as well as the current paid subs in the status strip; use the eye control to reveal a value. The same masking applies in the Events feed and Events popout, and to the money amounts on event lines in the chat (Super Chats, tips, Shopify order totals and item prices). Each home group loads in a matching skeleton, including on mobile; with Twitch or YouTube connected, the timeline skeleton includes a planned-stream row. Live state changes are announced once to assistive technology; rapidly changing count ticks are not. ## Sidebar sections Below the account switcher, the sidebar groups features into collapsible sections. Entries are filtered automatically based on your role permissions and the features enabled for your plan - you may not see every item listed below, and a section disappears entirely when none of its entries are visible to you. Clicking a section header collapses or expands it, and your choice is remembered in that browser. The **Dashboard** entry sits on its own above the first section. You can collapse the whole sidebar down to icons with the round arrow handle on the sidebar's right edge, level with the account switcher. The same handle expands it again, and the choice is remembered in that browser. ### Stream - **Overlays** - Browse, create, rename, organise into folders, and delete overlay configurations. Each overlay has its own URL for browser sources. See the [Overlays guide](./overlays.md). - **Chat** - Full-page multichat across every connected platform. See the [Chat guide](./chat.md). - **Events** - Full-page live event feed. See the [Events guide](./events.md). - **Music** - Spotify now-playing state and playback controls. See the [Music guide](./music.md). - **Rewards** - Twitch channel-point redemption configuration. See [Rewards](../features/rewards.md). - **OBS** - OBS WebSocket remote control. See [OBS](../features/obs.md). - **History** - Past stream sessions with metrics, charts, and reports. See [Stream History](./stream-history.md). ### Widgets - **Widgets** - Widget instances you place into overlays, each with its own designer. Extension widgets belong to an extension installed on the active account. Widget access settings also control the list: a widget assigned `none` is hidden, while widgets with no access entry or with `viewer` or `editor` access are visible. Copying a widget requires Editor access to the original and permission to create widgets. Members allowed to view all widget access entries can see every widget; the ability to grant widget access also bypasses the Editor check when copying. ### Hub - **Ideas** - The cross-account [Ideas Hub](./ideas-hub.md) for feature requests and voting. ### Store - **Browse Store** - The [extension store](../features/extensions.md). - **Installed** - Extensions installed on this account. ### Tools - **Automations** - Visual flow builder that reacts to events (follow, raid, superchat) and triggers actions (play sound, change scene, send chat, fire webhook). See [Automation Templates](./automation-templates.md). - **Bot Modules** - Link protection, spam protection, word filter, and timed messages. See the [Bot Modules guide](./bot-modules.md). - **Bot Control** - Pause, resume, or reconnect your chat bots per platform. See [Bot Control](../features/bot-control.md). - **Commands** - Chat commands with templates, platform overrides, and Discord slash-command registration. See [Bot Commands](../features/bot-commands.md). - **Copyright** - Copyright-strike detection on your VODs. See [Copyright](../features/copyright.md). ### Manage - **Connections** - Platform OAuth connections, integrations, StreamElements tokens, and bot connections. See the [Connections guide](./connections.md). - **Channel settings** - Settings for the whole channel: revenue assumptions and the danger zone. Shown as soon as you may open one of its tabs. See [Channel settings](./channel-settings.md). - **Members** - Team members and invitations. See the [Members guide](./members.md). - **Roles** - Define roles with granular permissions. See [Roles and permissions](./roles-and-permissions.md). - **Audit Log** - Account-wide role, member, connection, API-key, and erasure audit rows. Requires `audit-log:read`; see [Audit Log](./audit-log.md). - **Uploads** - Sound and image assets used by overlays and automations. See [Uploads](../features/uploads.md). - **Tokens** - Popout tokens for OBS browser-source docks. See the [Tokens guide](./tokens.md). ### Extensions Only shown to approved extension developers, and only while no account is selected in the account switcher: **My Extensions**, **New Extension**, **Team**, **Team Members**, **Verification**, and **Payouts**. Opening an extension from **My Extensions** (or from a developer e-mail or notification) leads to its overview page at `/dashboard/extensions/`. The top card shows the icon, name, review status, slug, description, latest version, category, rating, installs and whether it is a personal or a team extension. Below it, one entry per tool, grouped as: | Group | Entries | Shown with | |-------|---------|------------| | **Build** | Editor · Testing Phase · Secrets | Editor: `extension-dev:edit`; the rest: `extension-dev:read` | | **Publish** | Submit for review · Submission history · Access Management | Submit for review: `extension-dev:publish`; the rest: `extension-dev:read` | | **Monitor** | Analytics · Metrics · Errors · Logs | Analytics: `extension-dev:analytics`; the rest: `extension-dev:read` | An entry you lack the permission for is hidden, and a group without entries is left out. Secrets of a team extension additionally need the team permission `team-secrets:read`; without it the Secrets page itself reports the missing access. Every tool page except the editor has a back link to the overview. An unknown slug shows the not-found page. ### Account - **Settings** - Your profile, appearance ([Region & Language](./preferences.md), [seasonal look](./preferences.md#seasonal-look), Streamer Mode), notifications, data & privacy, and your channel memberships. See [Settings](./settings.md). - **Security** - Sign-in methods, authenticator apps and passkeys for 2FA, sessions and your activity. See [Account security](./account-security.md). - **Developer** - The developer application and developer profile. Only shown while no account is selected in the account switcher. - **Subscription** (owner only) - Plan, renewal, payment method and invoices, as the two tabs **Subscription** and **Invoices**. The entry stays highlighted on both tabs. ## Common scenarios ### First-time setup 1. Sign in with your main streaming identity. 2. Go to **Connections** and connect every platform you stream on. 3. Go to **Roles** and review the three defaults (Owner, Administrator, Moderator). 4. Go to **Members** and invite your moderators with the **Moderator** role. 5. Go to **Overlays** and create your first overlay. 6. Open the overlay URL as a browser source in OBS. 7. Open **Test event** in the dashboard header and send a sample event to confirm your overlays receive it. ### Adding a moderator quickly 1. From the sidebar, open **Members**. 2. Click **Invite**, choose **Invite Link**, select the **Moderator** role, pick a validity, and click **Create Invite**. 3. Copy the link and send it to your moderator. ### Account switching The account picker at the top of the sidebar lets you switch between your **personal profile** and any channel account you own or belong to. It appears as soon as you have at least one channel account - including on the free plan, where you own a single channel - so you can always move back and forth between that channel and your personal profile. Switching to a channel reloads all data for the new account, including events, overlays, and permissions. What happens to the page you are on: - **Most pages reload** with the new account's data. - **A page that shows one item of the old account** - a history report, an overlay or automation editor, a widget designer, a bot module's details, logs or errors - cannot show that item for the new account. Lumio opens the matching list instead: a history report goes to **History**, an overlay editor to **Overlays**, and so on. - **Switching to your personal profile** always opens the dashboard home. - **Other open dashboard tabs follow.** The active account applies to your whole sign-in session, so every other dashboard tab in the same browser moves to the new account the same way. While your **personal profile** is selected there is no channel to manage, so the dashboard home shows the getting-started view instead: the plan cards for creating a new channel account and a **Join with an invite code** field for joining a team you were invited to. ## Permissions Most sidebar entries are gated by a `resource:action` permission **and** by a feature being enabled for your plan - both must be satisfied for the entry to appear. The account **Owner** role holds every permission. | Sidebar entry | Required permission | | -------------------------- | ------------------------- | | Dashboard | (implicit - any member) | | Overlays | `overlays:read` | | Chat | `chat:read` | | Events | `events:read` | | Music | `spotify:read` | | Rewards | `rewards:read` | | OBS | `obs:read` | | History | `history:read` | | Widgets | `widgets:read` | | Ideas | (no permission - feature only) | | Browse Store / Installed | `extension-store:read` | | Automations | `automations:read` | | Bot Modules | `bot-modules:read` | | Bot Control | `bot-connections:read` | | Commands | `bot-commands:read` | | Copyright | `copyright:read` | | Connections | `connections:read` | | Members | `members:read` | | Roles | `roles:read` | | Audit Log | `audit-log:read` | | Uploads | `uploads:read` | | Tokens | `tokens:read` | | API Keys | `apikeys:read` + `feature:apikeys` | | Subscription | Owner only | If an entry is missing from your sidebar, either your role does not grant the corresponding `:read` permission or the feature is not part of your plan. Ask your account owner to adjust your role, or open **Roles** yourself if you have `roles:edit`. ## Tips and best practices - **Pin the Chat and Events pages** as browser tabs while you stream - they are the fastest way to react to your audience. - **Send a test event** before every stream to confirm your overlays respond after a config change. - Dashboard data is loaded server-side on first render (SSR) and then kept live via WebSocket. If you suddenly see a static view, it usually means the WebSocket dropped - refresh the page. - Dismissing the upgrade card is saved for your account. - If you manage multiple brands, create a separate Lumio account per brand and use account switching - do **not** try to multiplex channels into one account. ## Troubleshooting **The dashboard is stuck on "Connect a streaming platform" even though I added Twitch.** The onboarding notice only disappears once you finish the OAuth linking flow. If you closed the tab mid-flow, click a platform button again - it will not create a duplicate. **I see "No events yet" on the Events page but I know events are coming in.** Three things to check: (1) your plan actually includes the Events feature, (2) the active account is the correct one (account picker in the sidebar), and (3) the channel connection is healthy - open **Connections** and look for an expired-token warning. **My status strip does not show LIVE but my stream is live.** Live status polling depends on the platform's own API. Twitch usually refreshes within 1 minute, YouTube within 2 minutes. If it stays offline, reconnect the channel in **Connections**. **Sidebar entries are missing.** Your role does not include the `:read` permission for that section. Go to **Roles** (if you can see it) or ask your account owner. **Real-time updates stop after my laptop wakes from sleep.** Lumio reconnects automatically with exponential backoff, up to a 30 s ceiling. If updates do not resume within 30–60 s, refresh the page. ## Related - [Connections](./connections.md) - link streaming platforms and integrations. - [Members](./members.md) - invite team members and manage invitations. - [Audit Log](./audit-log.md) - review personal and account audit events. - [Roles and permissions](../features/roles.md) - fine-tune what each role can see and do. - [Overlays](../features/overlays.md) - design and publish overlays. --- ## User Guide / Settings Source: https://docs.lumio.vision/user-guide/settings # Settings Open **Settings** at the bottom of the dashboard sidebar, or press **Ctrl + ,** (**⌘ + ,** on a Mac). The settings window opens over your current page. Use the selector at its top left to choose **You** or one of your channels; the list below it shows the areas for that choice. Your personal settings apply across all your channels. Channel settings apply to the selected channel and its members. Choose an area from the list or search for a setting with **/** while the window is open. Use ↑ and ↓ to select a result and Enter to open it. A result in the other side of the selector switches sides for you. The window highlights the matching control. Close the window with ×, Escape, or a click outside it. On a phone or narrow window, the area list fills the screen; tap an area to open it and use the back arrow beside **Settings** to return to the list. Each area has a shareable address on the page you were using, such as `/dashboard#settings/appearance`. The short links in the table open the same area over the dashboard home and survive a sign-in round trip. **Settings** without an area opens the personal overview; **Channel** in the selector opens the channel overview. | Area | Short link | What you can do | |---|---|---| | **Profile** | `/dashboard/settings/profile` | Choose your displayed name and image source, inspect your profile email and channels, and copy your Lumio ID. | | **Appearance** | `/dashboard/settings/appearance` | Choose the color scheme, Streamer Mode, language and region, [motion](/user-guide/preferences#animations-in-lumio), and [seasonal look](/user-guide/preferences#seasonal-look). | | **Notifications** | `/dashboard/settings/notifications` | Choose in-app and email delivery for each configurable notice. | | **Data & privacy** | `/dashboard/settings/privacy` | Request and download your data, read the export log, and review privacy controls. | | **Account & channels** | `/dashboard/settings/account` | See your channel memberships, leave a channel, open the danger zone of a channel you own, or request profile deletion. | The same window also contains [Security](/user-guide/account-security) and [Developer limits](/user-guide/developer-limits) on the **You** side. The **Channel** side contains [Channel settings](/user-guide/channel-settings), [Connections](/user-guide/connections), [API keys](/user-guide/api-keys), [Popout tokens](/user-guide/tokens), and [Subscription and invoices](/user-guide/billing). The channel selector changes the active channel in the dashboard; settings for a different channel load for that channel. Areas that your role or plan does not allow are absent from the list, and a direct link still runs the same access check. The personal overview shows your avatar, display name, Lumio ID, and support code, each with its label on the left. The copy button next to a value turns into a green check for a moment once the value is copied. In Streamer Mode the ID and code are covered with dots in their own shape and a line says that copying still works; the eye reveals a value. On narrow screens the ID block moves onto its own line below the status chips, and a long ID is shortened in the middle (`57755ce9…c51e39123`); copying always copies the full ID. On phones the overview head shows only the title and the 2FA and Streamer Mode chips. The support code rotates hourly; **new in … min** next to it shows when the next one comes. When the server cannot issue support codes, the row says **Not available right now** instead. Give it to Lumio support only when you contact them; it does not sign anyone in. The overview also links to your [sign-in methods and sessions](/user-guide/account-security). The 2FA chip summarizes whether your profile has an authenticator app or passkey; manage the methods under **Security → Sign-in**. Your displayed name and picture come either from one of your platform sign-ins or from **your own details**. Choose under **Name & picture from**; every sign-in is listed with its platform icon (for example *Twitch · cruex*), and **Own details** sits below a divider. The head of the Profile area shows *@login · Member since March 2026*, or *Own details · Member since …*. With a platform sign-in as the source, Lumio copies its name and picture again at every sign-in. When you switch to your own details, your current name and picture stay as the starting point. You can then: - change the name: 2 to 32 characters, leading and trailing spaces are removed, invisible characters are not allowed; - upload a picture: PNG, JPEG or WebP up to 5 MB and 16 megapixels. Drop it into the dialog or choose it, then move it with the mouse, a finger or the arrow keys, zoom with the slider or **+** / **−**, and **Rotate** in 90° steps; three previews show the round result. Lumio uploads the visible square, crops it to a square again, stores it at 512 × 512 and 128 × 128 pixels, and removes all embedded metadata such as location data. You can upload up to 10 pictures per hour; - remove the picture, so your initials are shown instead. Signing in through a platform no longer changes your own details. Switching back to a platform sign-in asks first, then shows its name and picture again right away and deletes the picture you uploaded. An uploaded picture is part of your [data export](/user-guide/privacy-and-export) and is deleted together with your profile. E-mail-only profiles always use their own details. Your profile email is set in one place only: **Security → Sign-in → Email & password**. With an email sign-in, your profile email is always its confirmed address, and it follows when you change that address. Without one, a platform can fill an empty profile email when you sign in with it. When you remove the email sign-in, the platform address it replaced comes back; if there was none, you have no profile email. The **Email** row only shows the address: where it came from (a shield and "confirmed on …" in the accent color for your email sign-in, the platform's logo and "not confirmed" for a platform address) and a link to **Security → Sign-in → Email & password** — **Change** with an email sign-in, **Set with email & password** without one. An address taken from a platform also has **Remove**, which asks first; the address of your email sign-in is changed or removed with the email sign-in. Without any address the row says **No email address**. In Streamer Mode the address is covered. Old `/account/profile`, `/account/sessions`, `/account/activity`, and `/account/accounts/*` addresses redirect to the matching settings areas. Use the short links above when sharing an area outside the dashboard. Settings for the whole channel (its name and picture, revenue assumptions, dissolving the channel) are in [Channel settings](/user-guide/channel-settings). In **Settings → You → Account**, the link **Channel settings › Danger zone** next to a channel you own opens its danger zone there and switches to that channel first if it is not the active one. **My channels** on the Profile area lists each channel with its picture, the icons of its connected platforms and your role; when the channel's name and picture come from a platform, its icon sits as a badge on the picture. See [Preferences](/user-guide/preferences), [Notifications](/user-guide/notifications), and [Data & privacy](/user-guide/privacy-and-export) for the controls in each area. --- ## User Guide / Preferences Source: https://docs.lumio.vision/user-guide/preferences # Preferences Preferences control how Lumio renders language, time zones, date/time values, currency, and motion for your own user profile. They are personal settings: they follow you across devices, browsers, and every channel account you can access. They do not belong to the selected account, so changing them in one account changes the same values everywhere you sign in. ## Where to find them Open **Settings → You → Appearance** (`/dashboard/settings/appearance`) and use the **Region & Language** section. The section is available in the dashboard account context and in your personal profile context. No account role or RBAC permission is required because the values are stored on your user, alongside personal settings such as Streamer Mode and notification preferences. Settings that belong to a channel rather than to you, such as the revenue assumptions or dissolving a channel, are in [Channel settings](./channel-settings.md). ### A second entry point in the Multichat, Events and Music settings The language, timezone, date and time controls also appear as a **Region & language** block in the settings panel of the Multichat, Events and Music views (open the settings gear, on the **General** tab). It is the same global preference, not a second value: changing it there writes your user profile exactly like the settings page, so both entry points always agree. The block carries a hint that it applies everywhere you sign in, because every other setting in those panels is stored per channel account. In an **OBS popout that authenticates with a popout token** the block is **read-only** — it shows your current language, time zone and date/time examples and points you to the dashboard settings page — because a token-authenticated popout has no signed-in user to write the profile to. A popout you open while signed in with your normal session shows the editable controls. ## Language The **Language** control offers: | Value | Meaning | |---|---| | Automatic | Use the request-time fallback for the app language. | | English | Render the web app in English. | | Deutsch | Render the web app in German. | Choosing English or German saves the value to your user profile and mirrors it into the `locale` preference cookie so the next server render uses the same language. Choosing **Automatic** clears that cookie mirror, so Lumio falls back to the default language when no other valid language cookie is present. The language switcher in the sidebar writes the same user setting. It is just the quick-access control for the same preference, so switching language there also follows you to other devices after you sign in. You can also open a Lumio web or sign-in link with `?lang=de` or `?lang=en`, for example `https://lumio.vision/features?lang=de`. The page renders in that language for this request without redirecting. A valid link language takes precedence over the language cookie for that request. If your browser has no `locale` cookie yet, Lumio stores the link language in one; if a cookie already exists, the link leaves it alone. On a later visit without `?lang=`, your saved language cookie applies again. Other `lang` values are ignored. ## Timezone The **Timezone** control lets you choose **Automatic** or any specific IANA timezone such as `Europe/Berlin`. Every IANA zone your browser knows is selectable — plus `UTC` — so there is no short list to fall off the end of. The control is a searchable dropdown: open it and type to filter. The search box stays pinned at the top of the open list while you scroll, and filtering hides whole groups — a country header only appears while at least one of its zones still matches, so you never have to scroll past empty headings to reach a result. Zones are grouped **by country** — each group header is the country name in your interface language — and every entry shows its current UTC offset (for example `UTC+02:00`). The offset is the value in effect right now, so a zone that observes daylight saving time reads `UTC+02:00` in summer and `UTC+01:00` in winter. The search matches the city name (`New York`), the raw IANA id (`America/New_York`), the country name and code (`Germany` / `DE`), the offset (`+02` / `UTC+02` / `GMT+2`), and the zone's abbreviations and full names — in both your interface language and their international forms, for both daylight-saving phases. Typing `MEZ` or `MESZ`, `CET` or `CEST` finds `Europe/Berlin`; `EST` or `EDT` finds `America/New_York`; `GMT+2` finds every zone currently two hours ahead of UTC. Because both phases are indexed, a summer abbreviation still finds its zone in winter and vice versa. If you already have a timezone saved that your current browser does not list — the same zone can carry different alias names across browsers — it still appears under **Other** so the dropdown reflects what is stored. Search terms that are not spelled out in an entry's label — the raw id, the country code, the unpadded offset, the abbreviations from other languages — are matched but never displayed: once a zone is selected the closed control reads it back as its country, city and, when the zone has a named abbreviation in your interface language, that abbreviation before the current offset (`Germany, Berlin — CEST · UTC+02:00`; `United Kingdom, London — UTC+01:00` when the language has no distinct abbreviation for it); a fixed-offset entry reads back as its offset (`UTC+05:00`). Where the control is too narrow — on a phone, for example — it wraps onto a second line between place and zone instead of cutting the text off. The country groups, the offsets and the abbreviations appear once the page has loaded in your browser; until then the control shows the plain IANA id (`Europe/Berlin`). Below the country groups there is a **Fixed offset** group: `UTC` and the whole `UTC−12:00` … `UTC+14:00` range. These entries are pure offsets that never shift for daylight saving time, which is why the country groups come first — pick a country zone if you want your local time to follow its seasonal changes. | Value | Meaning | |---|---| | Automatic | Detect the browser's timezone on the client — shown with its country, city and abbreviation (`Automatic — Germany, Berlin · CEST`), or the offset where your interface language has no named abbreviation — and mirror it into `lumio-tz` for server-rendered pages. | | IANA timezone | Use the selected timezone everywhere Lumio formats your times. | The **Automatic** entry always describes the zone your browser reports right now, not the zone you currently have saved — so you can see what picking it would give you before you pick it. With `America/New_York` saved on a machine in Germany, the entry still reads `Automatic — Germany, Berlin · CEST`. The live preview in the setting shows the effective zone — the one Lumio is formatting your times in today, which is your saved zone while a specific one is selected. Automatic detection and the per-entry offsets are computed after the page mounts, so the browser's timezone is never read during server render; until the page has mounted the entry reads plainly as **Automatic**, without a detected zone. ## Date Format Date and time format share one row, **Date & time format**, with a preview of today's date and time in the chosen formats and timezone. The **Date format** control changes numeric dates. The setting affects field order and separators only; month names and other text always follow the language setting. | Value | Example | |---|---| | Automatic | Uses the language locale's normal numeric date format. | | `dmy_dot` | `31.12.2026` | | `dmy_slash` | `31/12/2026` | | `mdy_slash` | `12/31/2026` | | `ymd_dash` | `2026-12-31` | ## Time Format The **Time format** control changes numeric times. | Value | Example | |---|---| | Automatic | Uses the selected numeric format locale's default hour cycle. | | `h24` | `23:30` | | `h12` | `11:30 PM` | ## Display Currency Under **Region & Language → Currency**, choose **Automatic** or an ECB reference currency. Automatic resolves to EUR for German and USD for other languages. Your choice follows your user profile across devices. Stream History uses it for estimated revenue, converted donations and Super Chats, and report exports; the report also offers a temporary quick switch. Historical conversions use the ECB rate from the stream date, and amounts without a recent rate stay in their original currency. See [Stream History](./stream-history.md#estimated-revenue-and-ads). ## How the Values Are Applied These values are saved on your user profile through `PATCH /api/users/me`, then the dashboard refreshes so server-rendered pages and client components resolve the same settings. Only language and timezone need cookies for server-rendered pages: | Cookie | Purpose | |---|---| | `locale` | Mirrors an explicit language choice. | | `lumio-tz` | Mirrors the resolved timezone. | Date and time format presets are read from your user profile by the dashboard shell and provided to client components. Numeric date/time output uses the format preset, while textual output such as month names, weekday names, and relative time remains in the language locale. ## Animations in Lumio Under **Settings → You → Appearance**, **Animations in Lumio** controls motion across the signed-in dashboard, Chat, Events, Music and their popouts (including OBS). It is saved on your user profile and follows you across devices and accounts. | Choice | Result | |---|---| | **System** (default) | Normal animation, or Reduced when your operating system requests reduced motion. | | **Reduced** | No movement, scaling or repeating decoration. Short fades remain. | | **Off** | App animations and transitions happen instantly. Loading still has a visible signal. | Your operating system's reduced-motion request always applies; neither the global choice nor a per-view choice can turn it back on. Animated emotes and GIFs in Chat keep playing because they are viewer content. Chat, Events and Music each have their own **Animations** row in the settings gear: **As global**, **Reduced**, or **Off**. This choice applies only to that view, is stored per browser and per user/account pair, and works on its dashboard page and popout. It can only be stricter than the effective global value; when global motion is Off, Reduced is unavailable. An OBS dock can set it in the view or use `?motion=reduced` or `?motion=off` on its popout URL. The URL choice affects that popout only and is not saved. A popout can read your global preference but cannot change it; use dashboard Settings for that. The **Seasonal animations: Automatic / Off** row below is separate: it controls seasonal decoration only. The stricter of your system, **Animations in Lumio**, and the seasonal row wins for that decoration. At global Reduced or Off, seasonal decoration stands still; at global Off, the seasonal row is disabled with an explanation. The seasonal row cannot make other app animations move. ## Seasonal Look During certain weeks of the year the dashboard can wear a seasonal look. The available looks are **Halloween, 1–31 October**, and **St. Nicholas, 4–8 December**. St. Nicholas takes precedence during its window even when the Christmas calendar window overlaps it; Christmas itself has no look yet. Season windows are counted in your effective timezone (the [Timezone](#timezone) preference, or your browser's zone on **Automatic**), so a season starts and ends at your local midnight. Outside a season nothing changes. Configure it under **Settings → You → Appearance** (`/dashboard/settings/appearance/seasonal-look`): | Control | Default | Options | |---|---|---| | **Show seasonal look** | On | On / Off. It starts automatically during the season. One switch for every season — switched off, you get no seasonal look at any time of the year. | | **Strength** | Full | **Subtle** — seasonal colours, the seasonal logo and the seasonal icon on the Chat emote button (see **Halloween emote button** below). **Full** — Subtle plus a dismissible banner, the season's header scenery, footer and input-bar strips, seasonal loading indicator, saved-message accent and seasonal empty states in Events and Chat. | | **Seasonal animations** | Automatic | **Automatic** — the decorations move unless your operating system or browser asks for reduced motion. **Off** — the decorations stay but never move (the seasonal loading indicator fades in and out instead, so you can still tell something is loading). There is no "always on": Lumio never overrides a reduced-motion setting. Only available at Full (Subtle has no animations). | | **Creatures** | Per animal (see below) | One row names a few animals and how many are on (for example *7 of 8 on*); it opens the animals of every season, grouped by season. The switches are available when the look is on and Strength is Full. | The seasonal look sits on top of your light or dark theme and works with both. Chart and platform colours never change. Decorative motion follows your animation settings. Halloween logo blinks are limited to 2.5 times a second; St. Nicholas stars twinkle without flashing. **St. Nicholas look.** From 4 to 8 December, the Subtle look uses cream and cocoa tones, carmine and gold, a still boot-shaped Lumio logo, and a Nicholas-head emote icon. At Full, the banner, golden-star loader, boot empty states and chocolate-coin saved-message accent appear too. The page-header scenery shows snow, stars, an arched window, a boot and a robin. The window sits below the top edge of the scenery; on wider screens it stays to the left of the header buttons and above the first card row. The boot can react to a shooting star and the robin can fly before returning to its perch. The footer and the Chat, Events and Music bars show snowy roofs on five complete houses per tile, with filled snow caps and lit windows; a small Nicholas figure occasionally peeks from behind a roof. Individual roof windows occasionally go dark and light up again. With motion Off or Reduced, every roof window stays lit and the other decorations stand still while the loading stars continue to pulse. Scheduled motion pauses while the browser tab is hidden. On the full-height Chat and Events pages and in popouts, the compact header shows a star garland and a hanging boot. The robin may fly across the header, passing behind its buttons and tab text. The artwork remains behind the controls and never blocks a click. The **Robin** switch controls the bird in both header sizes. The banner can be dismissed for the current St. Nicholas season; **Switch off** disables every seasonal look. **Halloween scenery.** At Full, the top of every dashboard page shows a scenery behind the content: a crescent moon with a soft, warm halo in the top-right corner, a spider web in the corner and three bats beside the page title. In the dark theme the moon is pale yellow with a faint glow and the bats are pale; in the light theme the moon is golden with a golden glow and the bats are dark violet, so they stand out against the light background. The bats appear on screens from tablet width upwards. The Chat and Events pages fill the whole window, so they show the smaller popout scenery behind their tab bar instead: the corner spider web (with its spider if **Spiders** is on), two bats and, about every one to two minutes, a small bat flying across the tab bar — without the moon. | Situation | Scenery | |---|---| | Idle | The bats glide slowly on the spot. Now and then — every 4–10 seconds, each bat on its own timer, so they never move together — a bat beats its wings two or three times, then glides again. | | About every one to two minutes | One bat flies across the top of the page: it comes in from one side, flies a gentle arc and leaves on the other side within a few seconds, flapping as it goes. Never more than one at a time. It stays behind the page content and dialogs and never blocks a click. | | **Animations: Off**, or reduced motion requested by your system | The moon, web and bats stay where they are and nothing moves: no gliding, no wing beats, no fly-by. | | Browser tab in the background | Wing beats and fly-bys pause; nothing is caught up when you come back. | **Halloween loading indicator.** At Full, wherever the dashboard shows a loading indicator for a page, a section, a panel, a list or a dialog — for example the Events list — three small ghosts take the place of the spinning circle. They bob up one after another, like typing dots. With **Animations: Off**, or reduced motion requested by your system, they stay in place and fade in and out one after another instead, so the dashboard still shows that it is loading. Small spinners inside buttons and fields (such as "Saving…") stay the regular spinner. Screen readers announce "Loading…". **Creatures.** Some people are afraid of certain animals, so you can switch them off before their season starts. The settings list the animals of every season that has them, all year round. Each group shows the season's name and window. The season running now appears first with a **Running now** label; the others follow in order of their next start date. Each switch shows its default. The switches stay visible but are unavailable at Subtle strength or when the seasonal look is off. Like the rest of **Appearance**, your choices are saved on your profile. If an animal appears in more than one season, its switch controls that animal in every season. **Halloween · 1–31 October** | Animal | Default | What it covers | |---|---|---| | **Bats** | On | Every bat in the look: the gliding bats with their wing beats, the bat flying across the page and the bats on the "saved" message. Off — no bat anywhere. | | **Spiders** | Off | A small spider in the corner web. Off — the web stays empty. | **St. Nicholas · 4–8 December** | Animal | Default | What it covers | |---|---|---| | **Robin** | On | The robin beside the boot in the page-header window and on the compact garland, including its flights. Off — no robin is shown. | When **Spiders** is on, the spider sits on a strand of the corner web most of the time. Every 20–40 seconds it either crawls a short way along the strand and stops there, or — less often — lets itself down a little on a thread, hangs for a moment and climbs back up. It moves its legs as it walks, stays behind the page content and dialogs, and never blocks a click. With **Animations: Off**, with reduced motion requested by your system, or while the browser tab is in the background, it sits still in the web. **Halloween logo.** At Subtle, the Lumio logo becomes a still carved pumpkin. At Full, the logo is a lit pumpkin in the expanded and the collapsed desktop sidebar, in the mobile header and in the page footer: the L and the triangle are cut out of it, a warm candle light shines in both cut-outs, and a soft glow ring surrounds it. What it does: | Situation | Logo | |---|---| | Idle | Every 6–15 seconds the candle flickers in a short burst of 3–6 blinks. Each blink puts out the light behind the L or the triangle, picked at random, for about a fifth of a second, so you look through the hole at the page behind it. About every third blink also tips that cut-out slightly before it settles back. | | Mouse over the logo | Blinking stops at once, both cut-outs light up brighter and the pumpkin wobbles once. In the expanded sidebar, the dot of the "i" in "Lumio" also turns into a witch on a broom and flies off to the right in a flat arc over the top of the page, fading out before it reaches the edge of the window; it never blocks a click. The collapsed sidebar, the mobile header and the footer have no witch. When you move the mouse away, the dot comes back and the idle blinking resumes. | | Touch screens | A tap is not a hover: no wobble and no witch. The idle blinking still runs. | | **Animations: Off**, or reduced motion requested by your system | The pumpkin glows and both cut-outs stay lit, but nothing blinks, tips, wobbles or flies. Hovering only makes the light brighter. | | Browser tab in the background | Blinking pauses, and no blinks are caught up when you come back. | Screen readers always read the logo as "Lumio". **Halloween footer.** At Full, a small graveyard stands on the top edge of the footer at the bottom of every dashboard page: tombstones, a cross, a crooked stone, a little fence, a bare tree and small pumpkins. It is a faint silhouette — pale in the dark theme, violet-grey in the light theme — with the pumpkins in orange. It sits above the footer card, never covers the links or the version line, and never blocks a click. The footer logo is the lit pumpkin described above (no witch). The footer links — Privacy, Terms, Imprint, Cookies — never move or change. | Situation | Footer | |---|---| | Footer visible on screen | About once a minute (every 45–75 seconds) a small ghost rises from behind one of the visible tombstones, the cross, the fence or the tree, looks around for about a second and sinks back — about two and a half seconds in all. Each time it picks a spot at random, never the same one twice in a row. | | Footer scrolled out of view, or browser tab in the background | No ghost, and nothing is caught up when the footer comes back into view. A ghost that is peeking when the footer leaves the screen disappears. | | **Animations: Off**, or reduced motion requested by your system | The graveyard stays, the ghost never appears and the logo stands still. | | Subtle | Only the seasonal colours: no graveyard, no ghost. | **Halloween search and input bars.** At Full, the same graveyard also stands on the top edge of the **search bar at the bottom of Events** and of the **message input bar in Chat**, on the dashboard pages and in the chat and events popouts, and of the **search bar of the Music popout**, including popouts inside OBS. Here it is a narrow band, about 16 pixels high, that belongs to the bar itself: the tombstones stand on the bar's dividing line, just like on the footer, and the graveyard runs the full width of the bar, from edge to edge. The bar grows by that much, so the band never covers the last message or event row above it. While you reply to a message in Chat, the graveyard and its line sit on top of the **Replying to** box, which is part of the input bar, so nothing comes between the reply box and the message field. Without the graveyard (Subtle, season off) the line is the top edge of the bar, as usual. An open reply box does not keep the ghost away. It also never covers the search or message field, its placeholder, the platform toggles, the emote button, the send button, the clear-search button or the popout settings. The mention list and the emote library open above the bar, on top of the band (in windows up to 480 pixels wide the emote library's bottom sheet ends right at the band's top edge, so the graveyard stays visible), and the band never blocks a click. | Situation | Search bar / input bar | |---|---| | Bar visible and idle: the field is empty, nothing was typed for 4 seconds, and the emote library and the popout settings are closed (the field may still have focus, for example right after sending a message) | Every 15–30 seconds a ghost rises out of the ground at one of the tombstones, the cross, the fence or the tree until it is fully visible inside the band, looks left, looks right and sinks back — about three and a half seconds in all. In about one peek in three it glides instead to another stone 40–80 pixels along the bar before it sinks. | | Field contains text, you typed in the last 4 seconds, the mention list or the emote library is open, or the popout settings are open | No ghost. A ghost that is peeking when you start typing sinks back right away. | | Bar scrolled out of view, or browser tab in the background | No ghost. | | **Animations: Off**, or reduced motion requested by your system | The graveyard stays and the ghost never appears. | | Subtle, or the seasonal look switched off | No band at all, and the bar has its regular height. | Never more than one ghost is on screen at a time on a page, however many places show a graveyard. The footer, the Events search bar and the Chat input bar share one ghost. It picks a random spot among all graveyards that may show it right now, never the same spot twice in a row — every 15–30 seconds while a search or input bar may show it, otherwise (only the footer) every 45–75 seconds. On the Chat page, for example, it sometimes peeks at the footer and sometimes at the input bar, but never at both at once. The [URL parameters for popouts](#popouts-and-obs) apply to the band as well: `seasonStrength=subtle` removes it and `seasonMotion=off` keeps the ghost away. **Halloween emote button.** In the Chat message input bar — on the dashboard Chat page and in the chat popout, including inside OBS — the emote button shows a small ghost instead of the smiley face, at the same size and in the same colour as the button's other icons. The button works exactly as before: one click opens or closes the emote library. Screen readers announce it as "Emote picker". | Situation | What the emote-button ghost does | |---|---| | Full, **Animations: Automatic**, browser tab in front | When you point at the button or move keyboard focus to it, the ghost looks left, looks right and turns back — about two seconds. Now and then it also does this on its own while the input bar is idle (the same rule as the graveyard ghost: empty field, nothing typed for 4 seconds, emote library and popout settings closed). | | A graveyard ghost is peeking somewhere on the page | The emote-button ghost stays still, also when you point at the button. It counts as the page's one moving ghost, so the graveyard ghost waits while it glances. | | The emote library is open (Full, **Animations: Automatic**, browser tab in front) | The ghost sways calmly left and right, over and over, until you close the library. Then it eases back to its resting position. While it sways it is the page's one moving ghost: a graveyard ghost that is peeking sinks away, and no other ghost appears until the library is closed. | | The popout settings are open | The ghost stays still. | | Subtle, **Animations: Off**, or reduced motion requested by your system | The ghost is shown but never moves. | | Outside the season, or the seasonal look switched off | The regular smiley face. | **Follows your account.** Like the other settings on this page, **Appearance** is saved on your user profile, so it applies on every device and in every browser you sign in with. A change takes effect immediately in the tab you made it in and in your other open tabs of the same browser; another device picks it up the next time it loads the dashboard or comes back to the foreground (at most every five minutes). To paint the right look before the page has loaded, the browser keeps a copy in the `lumio-season` preference cookie (see [Cookie Preferences](/user-guide/cookie-preferences)); clearing it is harmless — the next dashboard load restores it from your profile. Settings you chose in a browser before they moved to the profile are carried over once, the first time that browser opens the dashboard, provided you have not chosen anything on your profile yet. **Banner.** At Full, a short banner sits at the top of every dashboard page while a season runs. The Chat and Events pages show it as a full-width strip directly under their tab bar, above any connection notices; it shortens the message or event list, and the chat input and the events search stay in view. The fullscreen overlay and widget editors never show it. It has two buttons: | Button | What it does | |---|---| | **×** | Hides the banner for the rest of that season — on the dashboard, in every popout and on all your devices. The seasonal look itself stays. The next season (or the same season next year) shows its banner again. | | **Switch off** | Switches the seasonal look off right away — the same **Seasonal look** switch as above, so it is off for every season, not just the current one — and hides the banner. It takes effect at once in this tab and in your other open tabs and popouts of this browser; another device picks it up the next time it syncs. A message confirms it and links to **Settings**, where you can switch it back on; it also offers **Undo** for a few seconds. If saving fails, the look comes back and an error message says so. | **Preview for admins.** Lumio staff who can sign in to the admin app and hold the `devtools:read` permission see a small wrench icon directly left of **Logout** at the bottom of the sidebar (expanded and collapsed sidebar, and the mobile menu), and can also open it with **Ctrl + Alt + Shift + D** (Mac: **⌘ + ⌥ + ⇧ + D**; ignored while typing in a text field). It opens the **Dev & Admin** menu, whose **Season preview** has three modes, one at a time: | Mode | What you see | |---|---| | **Automatic** (default) | The calendar decides, exactly as every other user sees it. | | **Force season** | Pick Halloween, Christmas, St. Nicholas, New Year's Eve, Easter or **None**. Seasons marked **No look yet** have no look, so nothing seasonal is shown for them. | | **Simulate date** | Pick a day; the calendar decides which season would run then (including overlaps such as St. Nicholas inside Christmas, and the movable Advent and Easter dates). The menu names the season for that day. | The preview shows the look even if you switched **Seasonal look** off above. It applies only to you, only in this browser: it is kept in the browser's local storage, never saved to your profile, and nobody else sees it. While it is active, a small **Preview: … · only you** badge stays at the bottom of the screen; **End** returns to the calendar. Users without the permission see no icon, and the shortcut does nothing for them. The preview also applies to the chat, events, music and test-events popouts you open **in the same browser** — whether you are signed in there directly or the popout uses a [popout token](/user-guide/tokens), as long as you are signed in to Lumio in that browser with the permission. A small **Preview: … · only you** strip under the popout header shows it, with **End** to end the preview for the whole browser, dashboard included. Starting, changing or ending the preview updates open popouts and dashboard tabs right away, without a reload. A popout URL with `?season=off` shows no seasonal look even during a preview. An OBS dock or browser source has its own browser and never shows your preview. ### Popouts and OBS The seasonal look also appears in the five popouts — `/popout/chat`, `/popout/events`, `/popout/music`, `/popout/test-events` and `/popout/obs` — wherever you open them: in a browser window, as an OBS custom browser dock, or as an OBS browser source. A popout follows the **Appearance** settings of the user it shows: your own when you are signed in, or those of the member a [popout token](/user-guide/tokens) is bound to. Without a session it uses the defaults (on, Full, Animations Automatic, bats and robin on, spiders off). A popout only reads these settings; the only things it can change are the two banner buttons below, and it can only switch the look **off**, never on. Because a popout is narrow, it shows seasonal colours, loading and empty states, the saved-message accent, the season's Chat emote icon, and at Full a strip along the Chat input, Events search or Music search bar. The header uses compact scenery: Halloween has a corner web and bats without the moon; St. Nicholas has a star garland, hanging boot and robin. The St. Nicholas robin may fly behind the header buttons and tab text. Both looks stay behind content and controls and never block a click. **Animations: Off** and your system's reduced-motion setting work as on the dashboard. **Banner in popouts.** At Full, the chat, events, music and test-events popouts show a compact version of the [banner](#seasonal-look) — one thin strip directly under the popout header (at the top of the music popout) with the same information in short form, for example "Happy Halloween! Halloween look until 31 Oct. Overlays unchanged." It sits above the messages, events, test catalog or player and moves them down; it never covers them. In a narrow popout it wraps onto two or three lines rather than cutting text off. It has the same two buttons as on the dashboard: - **×** hides the banner for this season everywhere — in every popout, on the dashboard and on all your devices. - **Switch off** switches the seasonal look off for every season, just like the setting. A message names **Settings** as the place to switch it back on; a popout has no **Undo**, because a popout can only switch the look off. This also applies inside OBS: a chat, events or music popout added as an OBS browser source shows the banner on stream until someone closes it. Close it once (in any popout or on the dashboard) or add `season=off` to that URL (see below). The OBS control popout (`/popout/obs`) has no banner. Subtle and `season=off` hide it, exactly as on the dashboard. To change the look of one popout without touching your settings — for example a dock inside OBS, where you cannot open the settings page — add a URL parameter. It applies to that URL only and is never saved: | Parameter | Effect | |---|---| | `season=off` | No seasonal look on this popout. | | `season=on` | Seasonal look on this popout even when **Seasonal look** is off in your settings. | | `seasonStrength=subtle` | Colours only (no decoration, regular loading indicator). | | `seasonStrength=full` | Full, even when your setting is Subtle. | | `seasonMotion=off` | The decoration stays still (the seasonal loading indicator fades instead of moving). | | `seasonMotion=auto` | Use the seasonal **Automatic** setting; global motion and the OS can still keep decoration still. | | `motion=reduced` | Reduce app animation in this popout only, if its global value is less strict. | | `motion=off` | Turn app animation off in this popout only. | Append with `?` when the URL has no parameters yet, otherwise with `&` — for example `https://lumio.vision/popout/chat?token=lm_pop_…&seasonStrength=subtle`. Unknown values are ignored. The last screen of the popout-token wizard lists these parameters next to the token. ### Overlays stay unchanged Overlays, widgets, extensions, the fullscreen overlay/widget editors, the landing and legal pages, the login pages, the admin app and the public stats site always look the same — what you show on stream through an overlay or widget does not change. ## Related - [Dashboard](/user-guide/dashboard) - sidebar, account switching, and dashboard navigation. - [Cookie Preferences](/user-guide/cookie-preferences) - consent categories and preference-cookie management. - [REST API](/api-reference/rest-api#get-usersme) - `GET` / `PATCH /v1/users/me` preference fields. - [GraphQL](/api-reference/graphql) - `me` and `updateMe` preference fields. --- ## User Guide / Account Security Source: https://docs.lumio.vision/user-guide/account-security # Account Security Open **Settings** in the dashboard, choose **You** at the top left, then choose an area under **Security**: **Overview** (`/dashboard/settings/security`), **Sign-in & 2FA** (`/dashboard/settings/security/login`), **Sessions** (`/dashboard/settings/security/sessions`), or **Activity** (`/dashboard/settings/security/activity`). These areas belong to your Lumio profile, even when no channel is selected. The links open the settings window over the dashboard home; while you work in the dashboard, the window stays over your current page and its address ends in `#settings/security/…`. **Overview** shows a ring with how many of four checks are done and one tile per check: sign-in methods, two-factor authentication, active sessions, and failed sign-ins of the last 30 days. The sessions tile names the device and place of the session unused for the longest time when one has been idle for more than five days; the failed-sign-in tile names the date, place and device of the latest attempt. Tiles that still need attention carry an arrow to their tab. In [Streamer Mode](./streamer-mode.md) the place in both sentences appears as `••••••••` with an **Eye**; the Eye reveals only the place without opening the tile's area, and the rest of the sentence stays. ## Sign-in methods You can sign in with Twitch, Kick, Google, Discord, or a verified email address and password. **Google** is the sign-in method; a YouTube channel connection is a separate connection. For Google, the ID shown after `#` is your Google account ID, not a YouTube channel ID. The Sign-in & 2FA area's **Login connections** card groups platform connections by provider and ends with the **Email & password** row and shows the channels assigned to each connection. A connection that has to be reconnected gets a red frame and a **Reconnect** button. Reconnecting opens the sign-in page for exactly that connection: it shows the account in one row - provider logo, name · platform ID and its state (**disconnected**, **rights missing** or **connected**) - and asks you to sign in to the provider as that account. A different provider account is turned down without changing anything; the page then names the account you were signed in with and the one that is needed, and suggests signing out at the provider or using a private window. You can add a channel or confirm replacement of an existing assignment. Each channel chip has an **×** that removes the assignment. A sign-in does not decide a channel's name or picture; that is set in the [channel settings](./channel-settings.md#name-and-picture). With `account:edit` in the active channel, the top of the card says so and links there. To add a platform sign-in, choose **Connect** in its group. The dialog explains the provider hand-off and lets you select up to 25 channels to assign. Confirm **Is it really you?** first: with 2FA enabled, confirm with a passkey, authenticator app code or recovery code; otherwise confirm with an existing sign-in method. The dialog lists them under **Confirm with**, each with when it was last used or connected; the one you last signed in with is pre-selected, and one that must be reconnected cannot be chosen. The browser then goes to the provider and returns to the dashboard with a success or error notice. Reconnecting an existing link to the same provider account does not require this extra confirmation. Removing a login method requires it and is refused if it would leave you with no sign-in method. ## Email and password To set up email sign-in on an existing profile, choose **Set up** in the **Email & password** row, the last row of the **Login connections** card. The row shows the state: not set up, a pending link (with when it was sent), or active (the address, when it was confirmed, and when the password was last changed). Enter the address, pass the **Is it really you?** check, then open the confirmation link from your inbox. The check slides into the same dialog like the other steps, which stays on the **Address** step while it asks; it is skipped if you confirmed in the last 10 minutes. The link is valid for 24 hours, and until you finish nothing changes about how you sign in. The waiting dialog advances even if you open the link on another device. The link's page asks for a deliberate **Confirm** click; merely loading the page does not confirm it. Return to the dialog, choose a password (the eye button shows what you typed; a check mark appears once both entries match), and review the finished state. From then on the address is also your Lumio email: notifications and security emails go there. The progress bar at the top of the dialog shows where you are: **Address**, **Confirm**, **Password**. An unconfirmed request does not reserve the address. If the address already belongs to another Lumio profile, it gets a notice instead of a link, saying that you can sign in with it or remove it there first under **Security › Sign-in**; nothing changes. Once the password is set, Lumio sends a security email to your previous profile address and to the new one. It names the new address, the date and time (in your timezone), the browser and operating system and, when it can be determined, the country — never the IP address. If that wasn't you, open **Security** and remove the method. In [Streamer Mode](./streamer-mode.md) every address in this row and in the setup dialog, including the address field, appears as `••••••••` with an **Eye** to reveal it. The same section lets you change the sign-in address, resend or cancel a pending link, change the password, or remove email sign-in. The old address remains active until its replacement is confirmed. A password reset starts from **Forgot password?** on the ID-app sign-in page; the response does not disclose whether an address has a Lumio account. Completing the reset ends existing sessions, while 2FA stays enabled. Email confirmation by itself never signs you in. After a wrong password, the entered password stays in the field, marked red, so you can correct it. From the second wrong password on, Cloudflare Turnstile checks in the background that a person is signing in; if Cloudflare is unsure, a checkbox appears across the card, the red notice asks you to confirm you are human, and **Sign in** stays locked until you tick it. Registration, **Forgot password?** and resending a link run the same check, and lock their button the same way while the checkbox waits. After five wrong passwords for an address, password sign-in pauses for 15 minutes. The sign-in page then shows **Short break** with the remaining minutes, **Reset password**, and the platform buttons, which keep working. If Lumio cannot be reached, the page says so, keeps its buttons visible but locked, and tries again on its own every few seconds. Your email sign-in also sets your profile email, the address Lumio sends mail to, invoices and notices included (the setup dialog says so too): while you have an email sign-in, the profile email is always its confirmed address and follows when you change it. This section is the only place where you set an address; [Settings → Profile](/user-guide/settings) shows the profile email and its source and links here. When you remove the email sign-in, the profile email goes back to the address a platform sign-in gave you before, or is empty. A platform's unverified email claim is not proof that it owns your email sign-in, and Lumio does not automatically link profiles by matching email text. ## Two-factor authentication and recovery Open **Security → Sign-in** to manage your profile's authenticator apps and passkeys. You can keep up to **five authenticator apps** and **ten passkeys** together. Your first method turns 2FA on; turning 2FA off removes every method. The Security overview shows the number of each. Each method has its own name (up to 40 characters), added date and last-used date; you can rename or remove it. Choose names without personal details. Only you see these names, not administrators, audit entries or security notices. Choose **Set up** or **Add a method**, then choose **Passkey** or **Authenticator app**. Both require a recent **Is it really you?** confirmation. To add an app, scan its QR code or enter the shown key, then enter its six-digit code. Adding another app does not replace an existing one. For your first method, save the ten recovery codes and finish setup; adding further methods does not issue another set. A recovery code works once. **Generate new codes** replaces all unused codes immediately. Keep a copy somewhere private. A passkey lives on a device, security key or password manager. It combines possession with unlocking that device **or password manager**; it does not demand a fingerprint at every sign-in. Choose **Add passkey**, complete your browser or password manager prompt, save the ten recovery codes if this is your first method, and name the passkey. The registration challenge lasts five minutes. If it expires, restart setup and discard the codes shown in that attempt. The list shows your chosen name and, when the browser supplies a known AAGUID, the provider or YubiKey model. Where Lumio has that provider's logo, it appears next to the name; otherwise the tile shows a generic passkey symbol. Some browsers suppress the AAGUID when Lumio requests no attestation; the entry then says **Passkey** with the generic symbol and can be renamed. A synced passkey and a device-only passkey are labelled accordingly. When at least one logo appears, the list shows this notice: **Provider logos are trademarks of their respective owners. Lumio is not affiliated with these providers.** Add a second passkey on a different device or password manager so losing one does not leave you relying only on recovery codes. ### Save a passkey in Bitwarden or 1Password To save one in Bitwarden, open its browser extension and turn on **Settings → Notifications → Ask to save and use passkeys**. Choose **Add passkey** in Lumio and accept Bitwarden's save prompt. To use your device or a hardware key instead, choose that option in Bitwarden's prompt. Turn the same extension setting off if you do not want Bitwarden to offer to save or use passkeys; this also stops prompts for passkeys already in your vault. You can also exclude an individual domain. See [Bitwarden's passkey guide](https://bitwarden.com/help/storing-passkeys/). For 1Password, unlock the vault and choose it when the browser offers a place to save the passkey. Your browser and installed extensions determine which providers appear. Passkey prompts need a supported system browser. **OBS and Streamlabs browser docks are unsupported**; open Lumio in a regular browser to create or use a passkey. Embedded in-app browsers such as Twitch or Discord on iOS and Android may not support them either. On Firefox for Linux, use a USB security key or passkey extension; phone QR hand-off is unavailable. Bitwarden's Firefox extension may not offer inline autofill; the **Sign in with passkey** button can still open it. If a passkey prompt fails, use an authenticator app or recovery code and try a supported browser. When 2FA is on, every new email or platform sign-in asks for an app code, passkey or recovery code before a session begins. Signing in **with** a passkey already completes that second step. A trusted device can skip the second step for 30 days; revoke one or all trusted devices in the same section. Sensitive actions, including removing a method, turning 2FA off and generating new recovery codes, accept a passkey, an app code or a recovery code. Removing an individual method asks for confirmation; use **Turn 2FA off** to remove the last one. If you remove a passkey in Lumio, delete its entry from your device or password manager yourself; supported browsers may hide the unusable entry automatically. Removing the last method also clears recovery codes and trusted devices. If you lose all methods and recovery codes, contact support for a manual identity check and 2FA reset; the operator must record a reason. If your account has admin access to Lumio and no 2FA method yet, the dashboard asks you to set one up: a dialog explains that admin access stays locked until you add a passkey or an authenticator app. **Set up two-factor** opens **Security → Sign-in** and starts the choice of method. **Later** hides the dialog until you close the browser; everything except admin access keeps working. The admin app sends you to the same dialog. Once your first method is added, the dialog no longer appears. Lumio receives a passkey's public credential and signature, never biometric data or its private key. Registration sends your **display name**, never your email address, to the provider. Lumio requests `attestation: none` and does not use FIDO Metadata Service (MDS) to identify the device; provider names come from a local AAGUID list, with no runtime lookup. ## Sessions and activity **Sessions** (**Active sessions**) shows device, sign-in method, whether 2FA was verified, approximate location, IP address, and last activity. Location can be absent when it cannot be resolved. The current session is marked **This device** and says in its row that it cannot be signed out here. The top of the table counts the sessions and the ones unused for more than five days (marked yellow), and holds **Export**, **Sign out unused (N)** while at least one session is unused, and **Sign out all others**; a note below the table says how many other devices **Sign out all others** signs out. **Sign out unused** asks for confirmation and then signs out exactly the sessions marked yellow, never this device. Sign out another session with its **Sign out** button, or tick several and use **Sign out selected** in the band that appears above the table. You can export all sessions or the selection as CSV or JSON. Streamer Mode covers the IP address and the location on screen; the export contains them in full. Popout tokens are separate credentials and do not appear as sessions here. Revoking a browser session does not by itself revoke a popout token; manage those under [Tokens](/user-guide/tokens). **Activity** (**My activity**) records your personal security events. Where the event carries it, the text names the context, for example **Sign-in via Twitch**, **Identity confirmed via authenticator app** or **Login connection Kick added**. Platforms appear under their brand name; a confirmation with an app code, a recovery code or the password is named as such. Filter by event or date (an empty end date means "until today"), expand a row for metadata and device details, and export the current filter or a selection as CSV or JSON. The export dialog shows the file name and its approximate size before the download; **Details (JSON)** starts unticked because the raw metadata is large. In Streamer Mode the IP address, location and user agent are masked per row, as in the [Audit Log](/user-guide/audit-log). The account-wide audit log has its own permission and page; see [Audit Log](/user-guide/audit-log). --- ## User Guide / Data & Privacy Source: https://docs.lumio.vision/user-guide/privacy-and-export # Data & Privacy Open **Settings → You → Data & privacy** (`/dashboard/settings/privacy`) for self-service data exports and their request history. ## Export your data Under **Data access & export**, pick one of two cards: **User data only** (for every profile) or **User + channel data** (only for channels you own; the card names them). Each card lists what the export contains. For the second model, select the owned channels to include; the optional chat data depends on the channel's plan and retention period. Then choose **Request export**. Lumio processes the request, notifies you when it is ready, and lists it in the export log. You can request one export per model at a time, with a 24-hour interval between requests of the same model. Each log row shows the model, when you requested it, its state and what happens next: a progress bar with a percentage while it is created, **Ready** with the file size and the days left until it expires, **Expired** once the file is deleted, or **Cancelled**. To stop it, choose **Cancel** next to it. Lumio then stops building the file and keeps nothing; the entry stays in the log as cancelled. A cancelled export does not count towards the 24-hour interval as long as Lumio had not started building it yet, so you can request a new one right away. Once building had started, the cancelled export counts like any other. A finished export can no longer be cancelled. When the ZIP is ready, choose **Download** and confirm **Is it really you?**. The resulting download link works for five minutes and for that export only. The export is encrypted at rest and removed after seven days. The ZIP includes a README explaining its files. If you uploaded your own profile picture, the export contains the picture file itself (`profile_picture.webp`). It does not contain passwords, API tokens, or other members' private identifiers. Its 2FA section lists `factors` with `kind`, `label`, `created_at`, and `last_used_at`. A passkey factor also has `provider` and `backup_state` (whether it is synced). The export excludes authenticator secrets, passkey keys, credential IDs, signature counters, the passkey user handle, and recovery codes. For example: ```json { "factors": [ {"kind": "totp", "label": "Phone app", "created_at": "2026-10-15T12:00:00Z", "last_used_at": null}, {"kind": "passkey", "label": "Laptop", "created_at": "2026-10-15T12:05:00Z", "last_used_at": null, "provider": "Passkey", "backup_state": false} ] } ``` The passkey's provider is a name looked up locally from the AAGUID supplied by the device; an unknown or withheld AAGUID is shown as `Passkey`. Both models also contain what you did on Lumio as a person, outside any channel: | File | Contents | |------|----------| | `ideas.json` | `ideas` you posted on the ideas board, your `comments` and replies, your `idea_votes` and `comment_votes`, and the idea `history` entries you caused. Other people's comments under your ideas and who voted on them are not included. A comment of yours under an idea that was deleted carries `idea_deleted: true` and an empty `idea_title`. | | `developer.json` | Your developer `profile` with payout details (PayPal address and bank details in readable form; a value that can no longer be decrypted is `null`), `verifications`, developer `applications`, `limit_requests`, `payouts`, your self-billed `credit_notes` (number, dates, amounts, VAT treatment and the supplier details printed on them; the PDFs are in the developer settings under Payouts), `settled_released_balances` (a balance Lumio could not pay out when an earlier developer profile of yours was deleted and later credited to this profile with your claim code: amount, case, reason, release, claim and settlement dates), the developer `teams` you belong to with your role (no other members), and the `extensions` of your personal developer profile with their versions (metadata only, no bundles or sources). | | `extension_reviews.json` | The marketplace reviews you wrote, with the extension name, rating and text. | | `lumio_activity.json` | Your Stats `stats_watchlist`, `overlay_access` and `widget_access` shared with you, `extension_testing` invitations with your feedback, `channel_invites` addressed to you, your `channel_sign_in_assignments`, your `roles` (admin roles, user roles, permission overrides), and the `copyright_reports` and `copyright_confirmations` you submitted. Invite codes and who granted or assigned something are not included. | Each section is an empty list (or `null` for the developer profile) when you have no such data. `sign_in_methods.json` also lists `known_sign_in_devices` with when each device was first and last seen (the device fingerprint itself is a hash and is not included). The **Security → Activity** page and the account [Audit Log](/user-guide/audit-log) offer a separate CSV/JSON export of audit rows. The account log requires `audit-log:read`. ## AutoMod data and access requests For Twitch messages held by AutoMod, Lumio keeps an AutoMod log with the sender, their last-known avatar, chat color and badges at hold time when available, the hold time, reason, outcome and moderator decision. The held text, flagged spans, blocked-term text, emotes and Cheers are kept with the log entry and deleted together with it once the hold is older than the account's chat retention window (Free 365 days, Pro 1,095 days), for **every** outcome, including denied, expired and allowed messages. An allowed message is also published as a new chat message and follows normal chat-message retention. The log appears in the signed-in Stream History AutoMod tab for team members with both `history:read` and `chat:automod-read`, and as held-message rows in the chat history of Multichat and chat popouts for team members with `chat:read` and `chat:automod-read` (the last 24 hours without `history:read`). It is absent from shared reports, report exports and the self-service data export on this page. A chat participant can request access under Art. 15 GDPR at [hey@lumio.vision](mailto:hey@lumio.vision); the support response includes any AutoMod log rows Lumio still holds for that person. A participant can also object or request erasure there. The AutoMod log belongs to the channel: Lumio deletes a person's rows in a channel on that channel's instruction, in that channel only, and an objection does not stop new rows from being logged. ## Delete your profile Open **Settings → Account** (`/dashboard/settings/account`). Three steps explain the way: **Export** (optional, a copy of your data first), **Confirm** (sign in again and hold), and **Choose the period**. Choose when the profile is deleted on one of three cards, each naming its date; **In 30 days** is preselected: - **Now** - the profile is deleted right away, with no waiting period and no way back. You are signed out and the sign-in page says **Your profile was deleted**. - **In 7 days** or **In 30 days** - Lumio ends your sessions and schedules the deletion for that date. The confirmation mail names it. Then hold the red button for two seconds - it is named **Delete permanently now**, **Delete in 7 days** or **Delete in 30 days** - (or type your profile name when animations are Off) and pass a fresh **Is it really you?** check, which repeats the date. The check is needed for every choice. If you own a channel that is not scheduled for dissolution, a yellow box names it and the button stays locked until you transfer, dissolve or schedule the channel for dissolution (see [Channel settings → Danger zone](./channel-settings.md)). Once every channel you own is scheduled, you can delete your profile with a period that ends no earlier than the last of them: the other cards are greyed out with the reason. **Now** needs you to own no channel at all. When both dates arrive, Lumio first dissolves the channels and then deletes the profile. If you are an extension developer and Lumio still owes you money, the deletion pays it out first, and the section shows what applies above the step tiles: - **A balance that can be paid out** — a blue box names the amount. With a waiting period of 7 or 30 days it is paid out with your deletion request as the closing payout, also below the minimum payout amount; **Now** is greyed out until then. The confirmation names the amount paid out. - **A payout in progress** — a yellow box names it, and the button stays locked until the payout has completed. - **Incomplete tax details** — a yellow box links to the tax details in the developer verification: the closing payout needs them for its credit note, and the button stays locked until they are complete. - **No payout method or no verified developer identity** — a yellow box links to the payout settings (**Extensions › Payouts**) or the verification, and the button stays locked until the balance can be paid out. If it cannot be paid out, write to [hey@lumio.vision](mailto:hey@lumio.vision). The box needs the channel you have selected to let you manage developer payouts. Without it the section shows no box, and the delete button answers with **You still have a developer balance with Lumio or a payout in progress. You can delete your profile once it has been paid out. If it cannot be paid out, for example because it is below the minimum payout amount, write to us at hey@lumio.vision.** The address in the message opens your mail client. During the waiting period the deletion page shows your developer balance too. Revenue that arrives after the deletion request is paid out with **Request closing payout**, also below the minimum. While a payout is in progress the page says that the deletion runs once it has completed: when the waiting period ends, the deletion waits for the payout to finish. Your payout records are kept for 8 years after the year of the payout, as tax law requires, also after the profile is deleted. During the waiting period, signing in leads to `/account/deletion`, which names the date. Hold **Cancel deletion** for two seconds to keep your profile (letting go early cancels; a plain button with animations Off), or sign out and leave it scheduled. Cancelling works until the date; once it has passed the deletion is already running and can no longer be cancelled. A reminder mail is sent seven days before the end of a 30-day period and one day before the end of a 7-day period. For the privacy policy and retention details, see [Legal pages](/user-guide/legal-pages). --- ## User Guide / Overlays Source: https://docs.lumio.vision/user-guide/overlays # Overlays Overlays are the visual layers you composite on top of your stream in OBS (or any browser-source-capable encoder). Lumio ships a visual, layer-based editor for building them, a live preview that mirrors your canvas, and stable URLs you can drop into OBS as browser sources. This guide walks you through creating, editing, and shipping overlays end-to-end. ## Where to find it Open the dashboard sidebar and go to **Stream → Overlays** (`/dashboard/overlays`). You'll see a grid of every overlay on your account with its canvas resolution, plus a **Search Overlays** box and folders for grouping. The **New Overlay** button in the top right also shows how many overlays you have against your plan limit (for example `New Overlay 2/5`). Each overlay card has an **Edit** button, a **Copy Link** button, and an overflow menu with **Insert Overlay**, **Rename**, **Move overlay**, **Overlay access**, **Revoke token**, and **Delete**. ## Quick start 1. Go to **Stream → Overlays** and click **New Overlay**. 2. Give the overlay a name (e.g. `Main Scene`), pick a resolution preset (1920×1080 is the default), and choose a background - **Transparent** is what you want for OBS in almost every case. 3. Click **Create**. Lumio immediately offers the finished browser-source URL - copy it right there. 4. Click **Edit** to open the visual editor. 5. Click the **+** button (bottom-left of the canvas) and pick a widget to add your first layer. 6. Drag it on the canvas, tune it in the right-hand properties panel, then press **Ctrl/Cmd + S** (or the **Save** button) to persist. 7. Paste the copied URL into OBS as a **Browser Source**. ### Resolution presets **1920×1080 (Landscape)**, **1080×1920 (Portrait / TikTok)**, **1280×720 (720p)**, **2560×1440 (1440p)**, or **Custom** with your own width and height. The background is either **Transparent** or a custom colour. ## Folders At the root of the overlays grid you can create folders to group overlays: - **Create folder** makes a new folder; folder names must be unique. - Open a folder to see the overlays inside it. Only root shows folders. - The folder's own menu offers **Rename folder** and **Delete folder** - deleting a folder moves the overlays inside it back to root, it does not delete them. - **Move overlay** on an overlay card lets you pick a target folder (or **Root**). The search box filters both overlays and, at root, folders. ## The editor in depth The editor fills the full window, follows your light or dark theme, and has four regions: - **Toolbar (top):** panel toggles, **Snap**, zoom out / zoom in with the current zoom level, **Save** (a dot marks unsaved changes) and close. `Ctrl/Cmd + S` saves; `Ctrl/Cmd + \` or the fullscreen button hides both side panels. - **Layer panel (left):** every layer on the canvas, stacked top-down in render order. Click a layer to select it. Each row has toggles for **visible** (eye icon) and **locked** (padlock). Drag rows to reorder, or focus a row's grip and use Space and the arrow keys. Locked layers can't be moved or resized on the canvas - handy once a layer is positioned correctly. - **Canvas (center):** the WYSIWYG surface. Snap-to-grid helps with alignment. Use the zoom buttons in the toolbar; when the zoomed canvas is larger than the window, scroll to reach the rest. The canvas is always rendered at the overlay's native resolution, so what you see maps 1:1 to what OBS will see. - **Properties panel (right):** every setting for the selected layer - position, size, rotation, opacity, and widget-specific options (text content, alert templates, event filters, Spotify theme, etc.). On a narrow window (below 768 px) both side panels start closed and open over the canvas; use **Layers** and **Properties** in the toolbar to show them. Below the canvas you have: - A **+** button that opens the **Widget Picker**. - A **Test Events** panel for firing synthetic follows, subs, cheers, etc. into the preview without waiting for a real viewer. - A **Live preview** indicator plus an **Open preview** button that launches the full-resolution preview in a new tab. ### Layer types The widget picker shows all widget extensions installed on the account. Built-in system widgets are pre-installed; additional widgets can be found in the Extension Store. | Built-in Widget | What it does | |---|---| | Alert Box | Animated popups for follows/subs/cheers/raids/redemptions. | | Chat Box | Live chat feed with platform badges. | | Event List | Scrolling list of recent follows, subs, cheers, raids, etc. Filter per-platform. | | Spotify Now Playing | Now-playing card (cover art, title, artist, progress). Requires a Spotify connection. | | Text | Static or dynamic labels - stream title, clock, custom string. | | Image | Upload or link to an image/PNG/GIF - logos, frames, decorations. | | Timer | Countdown or stopwatch. Great for intermission screens and sub-goals. | | Browser Source | Headless layer that talks to the OBS Browser Source API for scene control. | The number of overlays and of standalone widget instances is limited by plan (overlays - Free: 1, Pro: 5; widgets - Free: 3, Pro: 20). Lumio admins can override these limits per account. ### Editing a layer Select a layer and use the properties panel: - **Position (x/y)** and **size (width/height)** in pixels, relative to the canvas origin. - **Rotation** in degrees. - **Opacity** from 0 (invisible) to 1 (fully opaque). - **Widget settings** - specific to the type (e.g. Alerts has templates per event type; Event List has platform filters and direction). Tips: - Click and drag directly on the canvas for fast positioning. - Shift-drag constrains movement. - Hit **Delete** with a layer selected to remove it. - Unsaved changes are marked in the toolbar; closing the editor or refreshing prompts you to **Save & Leave** or **Discard**. ### Preview: live vs production Every overlay has two URLs: | URL | What it's for | |---|---| | `/overlay/{key}/preview` | Live preview. Mirrors the editor canvas in real time - any change you make in the editor appears instantly. Safe to use during editing only. | | `/overlay/{key}?token=…` | Production. Stable, cached, token-authenticated. This is what you paste into OBS. | The editor opens the preview in a hidden iframe so you see changes instantly without switching windows. Use **Open preview** in the bottom bar to pop it out into its own tab (useful for a second monitor while editing). ### The overlay token The production URL carries the overlay's **own** access token - you do **not** create a popout token for it, and you never assemble the URL by hand. **Copy Link** on the overlay card (or **Copy URL** in the Insert Overlay dialog) hands you the complete, ready-to-paste URL. Two things follow from that: 1. Anyone holding that URL can render your overlay. Treat it like a password - never show it on stream. 2. **Revoke token** (in the overlay's menu) invalidates the current URL immediately. The confirmation says it plainly: your OBS browser source stops working until you click **Copy Link** again and update OBS. Use it if a laptop is lost or a collaborator leaves. Popout tokens are a different credential entirely, used for the `/popout/*` dashboard views - see [Tokens](./tokens.md). ## Browser source setup 1. In **Stream → Overlays**, open the overlay's menu and click **Insert Overlay**. The dialog shows the exact canvas size and two ways to get the overlay into OBS. 2. Either **drag and drop** the entry straight into OBS, or click **Copy URL** and paste it into a new browser source. 3. In OBS: **Sources > + > Browser**. Paste the URL. 4. Set **Width** and **Height** to match the overlay's canvas (e.g. 1920 and 1080). 5. Leave **Custom CSS** empty unless you know you need it. 6. Enable **Control audio via OBS** and **Shutdown source when not visible**, as the dialog recommends. 7. Click **OK**. The overlay should render against a transparent background over your scene. If nothing shows up: right-click the source > **Interact** and check for errors - usually a revoked token, meaning you need to copy the URL again. ## Sharing an overlay temporarily The Insert Overlay dialog also has a **Share with Others** section for handing an overlay to a collaborator or a backup streaming PC without giving away your permanent URL: - Pick an expiry - **1h**, **3h**, **6h**, **12h**, or **24h** - and click **Create Shared Link**. The dialog shows exactly when the link will expire. - **Active Links** lists every link you created with its **Active** / **Expired** status, when it was created, and when it expires. You can copy it, extend it, or revoke it. ## Per-overlay access **Overlay access** in the overlay's menu controls who on your account can reach that specific overlay: - Search for a person and grant them **Viewer** or **Editor**, or set **No access**. - Channel admins always have access. - **No access** blocks a user even if their channel-wide role would otherwise let them in; **Viewer** / **Editor** grants access to users who have no channel role at all. Managing this needs the per-overlay access permissions - `overlays:access-read` (view access entries and candidates), `overlays:access-grant` (set a user's access), and `overlays:access-revoke` (remove a user's access) - which Owner and Administrator hold by default. ## Common scenarios **"I want the same overlay on two scenes."** Add the browser source to both scenes; OBS will share the same URL. Or duplicate the source. Both are fine; no need to create a second overlay. **"I want different overlays per scene."** Create one overlay per scene (e.g. `Main`, `BRB`, `Ending`) and add each as a separate browser source to its scene. **"I want to share an overlay with my editor."** Open **Insert Overlay → Share with Others** and create a shared link with the shortest expiry that covers the session. Revoke it from **Active Links** when they're done. For a permanent collaborator on your account, use **Overlay access** and give them **Editor** on just that overlay instead. **"I want to test alerts before going live."** Open the editor, add an Alerts layer, and use the **Test Events** panel in the bottom bar to fire a fake follow/sub/cheer. The preview animates the alert so you can tune timing, text, and sound before a real viewer triggers it. **"My overlay needs to portrait for TikTok."** Pick the **1080×1920 (Portrait / TikTok)** preset when creating the overlay. ## Permissions | Action | Permission | |---|---| | See the overlays list and open the editor | `overlays:read` | | Create a new overlay | `overlays:create` | | Edit layers, save changes, manage folders | `overlays:edit` | | Delete an overlay | `overlays:delete` | | View per-overlay access entries and candidates | `overlays:access-read` | | Grant a user per-overlay access | `overlays:access-grant` | | Revoke a user's per-overlay access | `overlays:access-revoke` | The UI hides buttons you don't have permission for, and the server rejects the request anyway - both sides enforce the same permissions. ## Tips & best practices - **Design at your output resolution.** If you stream at 1920×1080, build the overlay at 1920×1080 - not 1280×720 scaled up. Browser sources don't resample gracefully. - **Use the Transparent background** unless you have a specific reason not to. OBS composites the overlay on top of your scene. - **Lock layers once positioned.** Prevents accidental drag during a quick edit. - **Name your layers.** `alerts-follow`, `timer-countdown`, `spotify-top-right` - future-you will thank you when an overlay has 20 layers. - **Test with the test events panel** before going live. Catches z-ordering and animation bugs that won't surface until a real viewer triggers them. - **Use folders once you pass a handful of overlays** - one per scene set (`Live`, `BRB`, `Events`) keeps the grid readable. - **Use a shared link, not your real URL,** whenever someone else needs the overlay temporarily. ## Troubleshooting **Empty/blank overlay in OBS.** Usually the overlay token was revoked. Open the URL in a regular browser tab first - you should see the overlay render. If not, click **Copy Link** on the overlay card for a fresh URL and update OBS. **Alerts never fire in production.** Check that the Alerts layer is visible (eye icon) and not locked under another layer. Then verify on **Stream → Events** that Lumio is actually receiving events from your platform (if the event list is empty, the platform connection is the problem, not the overlay). **Preview shows changes but OBS doesn't update.** OBS aggressively caches browser sources. Right-click the source > **Refresh cache of current page**, or right-click and **Properties > OK** to force a reload. Enabling **Refresh browser when scene becomes active** prevents this going forward. **"403 - Access token required" error.** You loaded `/overlay/{key}` without the `?token=…` part. Use the full URL from **Copy Link** rather than typing the path by hand. **Layer moves off-canvas and I can't grab it.** Select it in the layer panel (left) and reset x/y in the properties panel. **Save button stays disabled.** Nothing to save - the editor only enables Save when there are unsaved changes (dirty state). **Unsaved changes dialog keeps appearing.** Click **Save & Leave** if you want to keep your edits, or **Discard** to drop them. Closing the browser tab with unsaved changes triggers the browser's native "leave?" prompt too. ## Related docs - [Tokens](/user-guide/tokens) - generating and managing popout tokens for OBS. - [Events](/user-guide/events) - the event stream that feeds the Event List and Alerts widgets. - [Music](/user-guide/music) - the Spotify integration that powers the Spotify widget. - [Channel Status](/user-guide/channel-status) - what controls "am I live" detection. - [Roles and Permissions](/user-guide/roles-and-permissions) - how `overlays:*` permissions interact with roles. --- ## User Guide / Connections Source: https://docs.lumio.vision/user-guide/connections # Connections Connections are the **Connections** group on the channel side of the [settings window](/user-guide/settings). Open **Settings** in the dashboard sidebar, select the channel at the top left, and choose an area under **Connections**. The short link `/dashboard/settings/channel/connections` opens the Overview; each platform has its own area, for example `/dashboard/settings/channel/connections/twitch`. The window opens over the page you are on. Areas appear only for the platforms, features and permissions available to your account. Existing bookmarks to the former Connections hub redirect permanently to the matching short link and keep their query. ## Find your connection | Area | Short link | What you can manage | |-----|---------|---------------------| | Overview | `/dashboard/settings/channel/connections` | Connections needing attention, platform status and connected services | | Twitch, YouTube, Kick | `/dashboard/settings/channel/connections/twitch`, `…/youtube`, `…/kick` | The channel, its permissions, its chat bot and its OAuth app | | Spotify | `/dashboard/settings/channel/connections/spotify` | Music connection, permissions and your Spotify app | | Discord | `/dashboard/settings/channel/connections/discord` | Servers using the Lumio bot | | Services | `/dashboard/settings/channel/connections/services` | OBS, StreamElements and Shopify | The Overview shows **Needs attention** when a channel or bot needs reconnecting, a required permission is missing, an own app cannot be read, a Discord server needs attention or Spotify authorization is nearing its end. Choose the action on that row to fix it or to open the platform's area. In the window's list, the Overview entry counts every outstanding item and each platform entry counts its own: red when something is disconnected or broken, amber for a warning or a missing permission. While anything needs attention, **Settings** in the dashboard sidebar carries a dot, and **Reconnect** in a dashboard alert opens the platform's area in the window. Login connections used to sign in to Lumio remain under [Security → Sign-in](/user-guide/account-security). Above the table a legend says what the columns Channel, Bot, App and Permissions mean. The table lists each platform with its channel, bot, app and permission state and the action its state needs. It fits the window and never scrolls sideways. Where the space is tighter, it becomes more compact: the YouTube badge shows only its flask icon, and **Open** and **Rejoin all bots** show only their icons, with the full name on hover and for screen readers. Where the space is narrower still, as on a phone, every platform becomes a card with the same states as labelled lines and the action button underneath. A value too long for its column ends in **…**; hover over it to see the full text. You can open the group if at least one area is available to you. A platform's channel sections need `connections:read` and the Connections feature; its chat bot needs `bot-connections:read` and the Bots feature. OBS needs `obs:read`, StreamElements needs `se-tokens:read`, and Shopify needs `connections:read`, each with its corresponding feature (Shopify: the Shopify integration on your plan). A direct link to an area you cannot access shows no access instead of its data. Under **What Lumio may do**, every permission the platform granted is listed with one sentence on what Lumio uses it for. A missing permission is marked **missing**. Before a Twitch or Kick channel is connected, **What Lumio can do with it** sums up what connecting gives you instead. The channel connection reads the chat, events and moderation actions; writing and moderating in the Lumio chat use each member's own Twitch, Kick or Google sign-in. The connections page says so under **What Lumio may do** (or under **What Lumio can do with it** before connecting), and the connect dialog points it out too. For Twitch, **AutoMod held messages** says Lumio can show messages held for review in the chat; allowing and denying use the acting member's own Twitch sign-in. **Blocked terms and AutoMod level** says Lumio requests these rights together so you need only one reconnect, but does not use them yet. Manage terms and the level on twitch.tv. If the channel was connected before these rights were requested, use **Grant permission** to reconnect it once. ## Connect a channel 1. Open the platform's area and check the **Channel** and **App** sections. Twitch and Kick normally use Lumio's app. YouTube and Spotify require your account's own OAuth app. 2. If **App** asks for setup, select **Set up app** and finish the guided steps below. Your client ID and secret are stored encrypted for this Lumio account. 3. Select **Connect** in the Channel section. Review the permissions in the dialog, then authorize on the platform's site. 4. You return to the same platform area. A toast reports the result and the channel row shows its connection state. While YouTube or Spotify is not connected, its area shows these as two steps. Step 1 states whether an own app is stored: - **No app stored:** Step 1 offers **Set up now**. **Connect** in step 2 stays disabled and shows **Step 1 first**. - **App stored:** Step 1 is ticked as **Own … app set up** and shows the client ID's last four characters and the date it was saved. **Change** opens the guide to replace an app that does not fit. **Connect** is enabled. Removing the app is available in the App section after connecting. - **Stored app can no longer be read:** Step 1 is highlighted as a warning with **Re-enter**. **Connect** stays disabled until you have entered the keys again. A channel reconnect must authorize the same platform account. If you sign in as a different one, Lumio keeps the existing channel and shows which account is required. When a required scope is missing, **Grant permission** starts the same connect flow. You do not need to disconnect first. YouTube's optional Analytics revenue scope can be declined while the channel remains connected; it never creates a standing missing-permission warning. Your personal Twitch sign-in can also lack a right Lumio asks for, such as `moderator:manage:automod` for Allow and Deny. If you hold an affected chat permission, an amber **Twitch sign-in needs a new permission** notice in Chat and the dashboard offers **Sign in with Twitch again**; the Chat page also shows it above the input. The notice does not appear when Lumio does not know the old grant's scopes or in a popout, and a disconnected sign-in shows its disconnect notice instead. Members with `history:revenue-read` can see YouTube amounts in [Stream History](/features/stream-history#revenue-and-currency). ### Set up your own Spotify app The Spotify area opens a five-step full-screen guide. Each step shows where to work in Spotify's developer dashboard: 1. **Account & Premium:** check that the Spotify account you intend to connect has Premium. 2. **Create the app:** register Lumio's callback address and select the Web API and Web Playback SDK. 3. **Allow your account:** add the connecting account's email under **User Management** while the app is in Development Mode. Spotify allows up to five users there. If the account is missing, Lumio saves no connection and its message points you to this step. 4. **Copy the keys:** copy the client ID and client secret from the app. 5. **Enter the keys:** save them in Lumio, then connect with the same Spotify account. Spotify authorization ends **180 days after consent**, even if access tokens refresh in between. The Spotify area shows the authorization end date; the Overview asks you to reconnect during the last 14 days. **Reconnect** starts a new 180-day period and keeps your app and keys. ### Set up your own YouTube app The YouTube area opens a six-step full-screen guide for a Google Cloud web application: 1. **Project & API:** create a project, enable YouTube Data API v3 and, if you want YouTube revenue figures, enable YouTube Analytics API. 2. **Create the app:** configure Google Auth Platform with an external audience. 3. **Branding:** enter the website, privacy and terms addresses shown by Lumio. 4. **Scopes & publish:** register the two required YouTube scopes and the optional Analytics monetary scope, then move the app to **In production**. An app left in Google's Testing status can issue refresh tokens that end after seven days. 5. **OAuth client:** create a web client, register the callback address and download its client JSON. 6. **Enter the keys:** drop the JSON file into Lumio. Lumio checks the web-client type and callback address in your browser before saving the ID and secret; manual entry is also available. The callback address and scopes shown in each guide come from the current connection settings. Copy those values into the provider console exactly. See [OAuth provider setup](/getting-started/oauth-setup) for provider details. On a phone, each guide keeps **Back** and the step's button in one bar at the bottom, long addresses wrap instead of running off the screen, and the console sketch opens with **Show sketch**. Twitch asks for `channel:manage:raids` so a viewer with the Lumio `chat:raid` permission can cancel a running raid from the card above chat, including in a popout. Lumio does not start raids. The connection explains this under **Cancel running raids**. ## Reconnect or disconnect A short-lived **access-token expiry** is informational because Lumio refreshes access tokens automatically. A **Reconnect required** state means the provider has rejected the grant. Reconnect from the relevant platform area. For Google Testing and Spotify's 180-day limit, the area can explain the specific cause. Open the Channel row's menu and choose **Disconnect** to see what will be removed. Confirming stops that platform's worker and removes the connection with its tokens. Disconnecting YouTube also removes its associated revenue data, so only YouTube's dialog lists something under **Will be deleted**; Twitch, Kick and Spotify list only what stays. Your channel's data stays: chatter profiles, moderator notes, the moderation and AutoMod logs, chat history and events remain and are deleted only when their retention period ends or when you [dissolve the channel](./channel-settings.md#danger-zone--dissolve-the-channel). Stream reports have no retention period: they stay until you dissolve the channel. If you connect the platform again, you see them again. Your own OAuth app and its saved credentials remain, so you can connect again. Deleting an own app can also disconnect a channel that depends on those credentials. ## Chat bots and Discord The **Chat bot** section on each Twitch, YouTube and Kick area shows whether the bot is in chat, paused, offline or disconnected. You can pause or resume it, ask it to re-enter chat, and connect your own bot identity when that feature is available. **Re-enter chat** asks a working bot to join again; **Reconnect bot** renews an OAuth grant. Use a bot account distinct from the channel account. Disconnecting an own bot returns the platform to its Lumio bot where available. Bot actions require `bot-connections:create`; removing an own bot requires `bot-connections:delete`. In the [Discord area](/features/bot-connections#discord-servers), **Add server** invites the Lumio bot. You need **Manage Server** in Discord. Each linked server shows **Connected**, **Bot removed** or **Permissions missing**. A server with live notifications switched on also names the channel they post to. **Reconnect** repeats the verified invite; **Disconnect** removes the server mapping and its settings and makes the bot leave. Discord sign-in belongs under **Security → Sign-in** and cannot be assigned as a channel connection. ## Services The **Services** area shows OBS and StreamElements independently, according to your permissions and plan. OBS can be configured for local or remote WebSocket access; **Test** checks the connection and reports the result on the row. StreamElements holds encrypted tokens for tip ingestion, with a platform and optional label for each token. Streamer.bot appears as later-service information rather than a setup control. ### Shopify Lumio receives your Shopify store's orders as events. You cannot type a store address into Lumio: the connection always starts in your Shopify admin. 1. In **Services › Shopify**, select **Shopify App Store** (when the server has no App Store listing yet, the row says so) and install Lumio on your store. 2. In your Shopify admin, open **Apps › Lumio** and select **Connect Lumio account**. Shopify opens a Lumio tab. 3. The Lumio tab names the store by its `myshopify.com` address and shows the Lumio account it will go to. The warning above **Confirm connection** uses that address only, not a store display name. Pick another account in the list if needed; accounts where you may not create connections are shown but cannot be picked. Confirm only if you started the connection yourself in your Shopify admin. You need `connections:create`, and Shopify must be part of the account's plan. If you are not signed in, Lumio asks you to sign in first and then shows the same page again. 4. Switch back to Shopify. It asks once more whether the store should be connected to that Lumio account; confirm there to finish. A link from Shopify is valid for 10 minutes and only once. An expired or used link says so; start again in your Shopify admin. One store belongs to one Lumio account, and one account connects one store; if the store or the account is already taken, the confirm page says so and what to do. Once connected, the row shows the store name, its `myshopify.com` address and the date it was connected. **Open in Shopify** opens Lumio in your store's Shopify admin. **Expired** means Shopify no longer accepts Lumio's access; open Lumio in your Shopify admin (**Open in Shopify**) and Shopify renews it. **Buyer name on stream** sets how order events name the buyer: no name (default), initial (`A.`), first name (`Alex`), first name and initial (`Alex M.`), or the buyer's own name (a name the buyer types in the cart for the stream). A name appears only when the buyer agreed in the cart or checkout or typed a name; you set up that block in Shopify (**Open in Shopify › Setup**). For the three real-name forms, Lumio also needs Shopify's approval for protected customer data (level 2). It applies to orders that arrive afterwards. Changing it needs `connections:edit`. Place **Name on livestream** on the cart page of an Online Store 2.0 theme. Shopify Plus stores can also place Lumio's checkout block. The real-name forms show an unticked checkbox; **Your own name** shows an optional text field. If a buyer uses accelerated checkout without seeing an eligible block, no buyer name reaches Lumio. The embedded Shopify page shows setup steps and the shop's privacy-policy status. **Order details on stream** has two switches: **Show items** (item name and quantity, on by default) and **Show price** (order total with currency and, with items shown, each item's price; off by default, hidden in streamer mode). Whatever is switched off, Lumio does not store at all. They apply to new orders and need `connections:edit`. Automations can read `{{event.raw.line_items}}` only when **Show items** was on for that order, and `{{event.raw.total_price}}` only when **Show price** was on. Older events keep the details they had when received. **Disconnect** (in the row's **⋯** menu, `connections:delete`) asks for confirmation, then stops accepting the orders and deletes Lumio's access. Shopify keeps sending orders until you remove Lumio in your Shopify admin; Lumio discards them unread. In the **Events** feed an order shows the buyer name (as plain text, never a user card), up to three items (`2× Hoodie`, then `+2 more`) and the total, as far as your settings allow. **Hide name** (the eye icon on an order with a name, `events:edit`) removes the name from that order and from its **Order Paid** event after a confirmation. What was already shown on stream is not recalled. The chat shows the same order line without the action. ## See also - [Connection architecture](/features/connections) - [Shopify](/features/shopify) - [Bot connections](/features/bot-connections) - [Bot control](/features/bot-control) --- ## User Guide / Channel settings Source: https://docs.lumio.vision/user-guide/channel-settings # Channel settings Channel settings apply to the **active channel** and to every member of it. Your personal settings (profile, appearance, notifications) stay under [Settings](./settings.md). Open **Settings** at the bottom of the dashboard sidebar and select your channel at the top left of the window. The channel overview opens first; choose an area in the list to its left. A direct short link such as `/dashboard/settings/channel/members` opens that area over the dashboard home. In a narrow window, tap an area in the full-screen list, then use the back control to return to the list. In your personal profile the selector lists all your channels with your role; picking one switches the dashboard to that channel and opens its overview. With no channel, the selector offers **Create channel**. Members, roles and the audit log are separate areas in the window. They have no sidebar entries of their own. Existing bookmarks to the former channel-settings pages redirect to the matching short links, including query parameters. | Area | Short link | Who sees it | What you can do | |---|---|---|---| | **General** | `/dashboard/settings/channel/general` | Every member | See the channel's name, picture and ID; change the name and picture with `account:edit`. | | **Members** | `/dashboard/settings/channel/members` | `members:read` | See the members and invitations, invite with `members:create`, change roles with `members:edit`. See [Members](./members.md). | | **Roles** | `/dashboard/settings/channel/roles` | `roles:read` | See the roles; create and change them with `roles:edit`, delete them with `roles:delete`. See [Roles and permissions](./roles-and-permissions.md). | | **Revenue assumptions** | `/dashboard/settings/channel/history` | `history:revenue-read`, with stream history on your plan | Read the revenue assumptions; change them with `history:revenue-edit`. | | **Bots** | `/dashboard/settings/channel/bots` | `chat-bots:read` | See automatically recognised bots and your channel's bot list; add or remove entries with `chat-bots:edit`. | | **Keywords** | `/dashboard/settings/channel/keywords` | `history:read`, with stream history on your plan | See and test tracked keywords; add, change or remove them with `history:keywords-edit`. | | **Audit log** | `/dashboard/settings/channel/audit-log` | `audit-log:read` | Read and export the channel's audit log. See [Audit log](./audit-log.md). | | **Danger zone** | `/dashboard/settings/channel/danger` | The channel owner (`account:delete`); while the channel waits to be dissolved, every member | Dissolve the channel now, in 7 days or in 30 days, or cancel a scheduled dissolution. | Each area has its own address, and a link to an area you may not open shows **No access**. While the channel waits to be dissolved, the channel list shows **Danger zone**; keys, tokens and billing remain locked. ## Header The channel overview shows the channel picture and name, and chips for: - the channel's plan; - the connected platforms (with `connections:read`; links to **Connections**); - the number of members (with `members:read`; links to the **Members** area); - your role in the channel. It also shows the **Channel ID** with a copy button; after copying, the button shows a green check for a moment. In [Streamer Mode](./streamer-mode.md) the ID is covered with dots in the shape of the ID, the header shows a **Streamer mode** chip, and a line says that copying still works; the eye reveals the ID. On narrow screens the ID moves onto its own line and a long ID is shortened in the middle; copying always copies the full ID. **Go to connections** opens the connections page when you may read it. ## Name and picture The channel's name and picture appear in the account switcher, the header above, **Settings → Profile → My channels**, the channel chips under **Security → Sign-in**, member lists and mails. When they come from a platform, that platform's icon sits as a small badge on the picture in the channel overview, in this area and under **My channels**. The **Name & picture** area shows one card, built like the profile card in [Settings → Profile](./settings.md): the picture, the name and where they come from (for example *Twitch · @cruex*, or *Own details*). Changing them needs `account:edit` (owner and administrator by default) and a signed-in browser session, not an API key or a popout token. Without `account:edit` the card is read only: it shows the source without a choice and says who may change it. Choose under **Name & picture from**. The list shows each of the channel's Twitch, YouTube and Kick connections with its platform icon (for example *Twitch · cruex*); **Own details** - *Set name and picture yourself* - sits below a divider. The connections are listed for members who may also read the connections (`connections:read`); otherwise the list shows the current source and **Own details** only. - **A connected channel** - one of the channel's Twitch, YouTube or Kick connections (Spotify cannot be chosen). The name is that channel's name on the platform, the picture its profile picture there. Both follow the platform: they are refreshed when you connect or reconnect and when Lumio starts. If the platform picture cannot be shown, the first letter of the name appears instead. - **Your own details** - a name you type and, optionally, a picture you upload. When you switch to your own details, the current name is kept as a starting point and the picture starts as the first letter of the name. - The name needs 2 to 50 characters; spaces at the start and end are removed, and invisible or control characters are not allowed. - The picture is a PNG, JPEG or WebP file of at most 5 MB and 16 megapixels. Lumio re-encodes it and removes all metadata (such as location). You can upload up to 10 pictures per hour. Remove the picture to show the first letter of the name again. With own details the card adds two rows: **Channel name** with a field and **Save** (Enter saves too; a name outside the rules shows why under the label), and **Channel picture** with **Upload picture** and, once there is a picture, **Remove**. **Upload picture** opens the same dialog as the profile picture: drop a file or choose one (a file of the wrong type or over 5 MB is refused right there), then crop it round. Drag the picture or use the arrow keys to move it, the slider, the mouse wheel or + and − to zoom, and **Rotate** to turn it by 90°. Three previews show the result; **Apply** uploads it. Switching back to a connected channel asks first and deletes an uploaded picture. If you disconnect the connection that supplies the name and picture, the channel switches to your own details with that channel's last name and the first letter as picture, and the owner gets a notification that links here. New channels start with your own details. Every change is recorded in the [audit log](./audit-log.md) as `account:identity_changed`. While the channel waits to be dissolved, nothing here can be changed. ## History · revenue assumptions Lumio estimates the revenue of each stream in the [stream history](./stream-history.md) from its events and the values in this area. The Twitch and Kick sub prices, Prime, Bits and KICKs appear as **fixed USD values with a lock and source**. They cannot be edited here and are converted at the rate of the stream day. KICKs carry a warning because their $0.01 value is not published by Kick. The YouTube section also shows the channel's Analytics revenue status when YouTube is connected: **on** after the Partner Program check succeeds, **unlocked** while that check is pending, or a hint to reconnect with the revenue permission, join the Partner Program, or enable the YouTube Analytics API in your Google app. The hints appear here and on a report's revenue card, not in general connection notices. You need `history:revenue-read` to see this area and YouTube's figures; every member with that permission can see them. YouTube values normally arrive 2–3 days after the stream. Until then the report shows the Lumio estimate. See [YouTube Analytics API setup](/getting-started/oauth-setup#youtube-analytics-api). | Locked value | USD amount | |---|---:| | Twitch Tier 1 / 2 / 3 sub (also used for gifts) | $4.99 / $9.99 / $24.99 | | Twitch Prime | $4.99 | | Twitch Bit | $0.01 | | Kick sub (also used for gifts) | $4.99 | | Kick KICK | $0.01 (check against your payout) | | Row | Fields | |---|---| | Twitch · your share | 50 % standard, 60 % Plus Program, 70 % legacy contract, or any value | | Twitch · ads | CPM (empty = no amount), ad share, ad-free viewers | | Kick · subs and gifts | Your share; the USD $4.99 sub value is locked | | Kick · ads | CPM (empty = no amount), ad share, ad-free viewers; reconnect Kick if `ads:read` is missing | | YouTube · ads | CPM (empty = no amount), mid-rolls per hour (empty = no ad line), ad share and ad-free viewers; pre-rolls are excluded | | YouTube · Super Chats, Super Stickers and memberships | A separate creator share for each, 70 % by default | | YouTube · membership levels | Up to six level names with a price in the payout currency; drag to reorder or use the keyboard | | Payout currency | Currency for the editable money fields; fixed USD values are converted at the stream-day ECB rate | The level list shows how often Lumio saw each name in stored YouTube membership events. Names seen but not yet priced appear as one-click suggestions; **Add level** lets you enter a name manually. A membership with an unknown or unpriced level uses the cheapest entered level and is marked as an assumption in the report. Without a level price, memberships remain unpriced. Membership milestones count as paid months. **Save** writes the editable assumptions; **Reset** discards your unsaved changes. Both stay off until you change something. The change applies to new and past reports and is recorded in the [audit log](./audit-log.md) as `account:history_revenue_settings_updated`. Empty ad CPM fields leave their ad lines without an amount. An empty YouTube mid-roll rate leaves out the YouTube ad line entirely. Without `history:revenue-edit` the tab is read only: a note at the top says who may change the values, every field is locked, and there are no level suggestions, reorder handles or save bar. In Streamer Mode the money fields are covered until you reveal them. ## Bots The **Bots** area shows bots Lumio recognises automatically from platform bot badges, its curated list and your connected Twitch or Kick bot account. It also shows the bot accounts your channel has marked. The account list supports Twitch, Kick and YouTube; entries are tied to a stable platform user ID or YouTube channel ID, never just a display name. The [Viewer List](./viewer-list.md) puts these accounts in **Bots** even when they are moderators or VIPs. [Stream History](./stream-history.md) leaves their messages out of chat totals and top lists, but still shows their link and keyword posts with a bot label. With `chat-bots:edit`, choose a platform and enter a name or `@handle` to mark an account, or use the action on a chat user's card. Lumio looks up the platform account before saving; a name that cannot be resolved adds nothing. An existing entry is not duplicated. Remove a mark from the account list when you no longer want it. Automatically recognised bots are read only. Without edit permission, you can inspect both lists but cannot change them. Marking a bot starts a background recalculation of retained stream sessions whose chat logs are still complete. Their bot-filtered figures can decrease. Removing a mark stops that mark from classifying future messages; badge, curated-list or own-connection recognition may still apply. Already recalculated session figures are not restored. Real list changes appear in the [audit log](./audit-log.md) as `account:bot_list_updated`. ## Keywords The **Keywords** area tracks up to **50** account-wide chat terms for [Stream History → Chat insights](./stream-history.md). Each row shows the term, match mode, case setting and its count in the latest finalized stream when known. Choose **Whole word**, **Contains** or **Regex**. Whole word needs word boundaries on both sides; Contains finds the text anywhere; Regex uses Rust regex syntax. Matching ignores case by default, and you can change that per entry. A whole-word or contains term may have at most 100 characters; a regex may have at most 200, may not match empty text and must compile within the 1 MiB size limit. Regex look-around and backreferences are unavailable. **Test pattern** checks a term against a sample of up to 500 characters using the same server matcher without saving it. It shows every matching span or the validation error and stays available with read-only access. The test permits 30 calls per minute. Saving, changing match or case, and removing an entry require `history:keywords-edit`; `history:read` is enough to open the tab and test. A duplicate term with the same mode is not added twice. One message counts once for each keyword it matches, even when the text appears more than once. Bot posts count and are labelled. When a leading `#ad` or similar token matches a tracked keyword, the keyword wins and that message is not also counted as a command. A list change recounts finalized sessions in the background **only while their full chat log is retained**; older sessions without a complete log keep their frozen figures. Real changes appear in the [audit log](./audit-log.md) as `account:history_keywords_updated`. ## Danger zone · dissolve the channel Only the channel owner sees this area (while the channel waits to be dissolved, every member sees it, see below). Dissolving deletes the channel in Lumio with its overlays, automations and memberships, and with them the channel's chat history, events, the stream reports in [Stream History](./stream-history.md), chatter profiles, moderator notes and the moderation and AutoMod logs. The channel's platform connections (Twitch, YouTube, Kick, Discord, Spotify) are deleted with their access tokens; for YouTube, Lumio also revokes its Google access. Your Lumio sign-in with Twitch, YouTube or Kick belongs to your profile, not to the channel, and stays until you [delete your profile](./privacy-and-export.md#delete-your-profile). Your accounts at those platforms themselves are not affected. With **In 7 days** or **In 30 days**, nothing is deleted until the date; until then you can cancel. Dissolving works like deleting your profile: 1. **Choose when** on one of three cards: **Now** (final, no way back), **In 7 days** or **In 30 days**. Each card names its date; **In 30 days** is preselected. The sentence under the cards says what the choice means. 2. **Type the channel name** exactly. A check mark shows when it matches. 3. **Hold the button for 2 seconds.** It is named after your choice: **Dissolve permanently now**, **Dissolve in 7 days** or **Dissolve in 30 days**. Letting go early cancels. With **Animations in Lumio** set to Off there is no holding; the button works with a click once the name matches. 4. **Sign in again** in the **Is it really you?** dialog, as described in [Account security](./account-security.md). The dialog repeats the date you chose, or that the channel is dissolved right away. If you confirmed in the last 10 minutes, this step is skipped. With **Now** the channel is dissolved and the dashboard opens without a channel selected. - While a paid plan runs, the button is locked. The note names the date the paid period ends; cancel the plan first under [Subscription & billing](./billing.md). - Dissolving works only in a signed-in browser session, not with an API key or a popout token. ### While the channel waits to be dissolved With **In 7 days** or **In 30 days** the channel is suspended until the date: - Every member gets a mail naming the channel and the date. - While the channel is selected, a red banner sits fixed at the top of every dashboard page and the fullscreen editors: **This channel will be dissolved on … Bots, overlays, widgets and API keys are off until then.** It cannot be closed and takes its own space, so nothing lies under it, on phones too. The owner sees **Cancel dissolving ›** in it, which opens this area. Popouts and OBS show no banner; they are switched off. - The account switcher and **Settings → Account** label the channel **being dissolved**. - Members can still open their profile, security and developer settings, create or switch to another channel, or leave this one, but they cannot use the channel: its pages show **Channel is being dissolved** with the date instead of their content. - This tab shows **Will be dissolved on …** to every member. Only the owner gets the button to cancel. - API keys, service keys, popout links, overlay and widget links and extensions of the channel stop working. They are not deleted; after a cancel they work again. - Bots leave the channel's chats and Discord servers. Automations, bot modules and incoming platform events stop. No new subscription can be bought. - You get a reminder mail seven days before the end of a 30-day period and one day before the end of a 7-day period. On the date Lumio dissolves the channel. The owner can **cancel the dissolution** at any time before the date: hold **Cancel dissolving** on this area for 2 seconds (the banner link leads here). Letting go early cancels; with **Animations in Lumio** set to Off it is a plain button. No new **Is it really you?** check is needed. Everything works again right away, and bots rejoin by themselves. Members are told by mail. If your own profile is scheduled for deletion too, a note asks you to cancel the profile deletion first and links to it. Once the date has passed the dissolution is already running and can no longer be cancelled. You can also reach this area from **Settings → Account**: the link **Channel settings › Danger zone** next to a channel you own opens it. If that channel is not the active one, Lumio switches to it first. --- ## User Guide / Events Source: https://docs.lumio.vision/user-guide/events # Events Events are the heartbeat of your stream. Every follow, sub, cheer, raid, channel-point redemption, super chat, tip, and Spotify track change flows through Lumio's event pipeline in real time. The Dashboard gives you a live feed of what is happening on every connected platform, and the same stream powers your overlays, automations, and bot reactions. Lumio surfaces the same live feed in two places: the dedicated **Events** page in the sidebar and the `/popout/events` window for OBS and secondary monitors. This guide covers both, plus filtering and searching, sending test events, the channel-status indicator, and every supported event type. ## Where to find it - **Events page** (`/dashboard/events`): a dedicated, full-height feed in the sidebar under **Stream → Events**, right below Chat. This is the primary place to watch and filter events. It requires the `events:read` permission and the `feature:events` plan feature. - **Popout window** (`/popout/events`): a borderless copy of the same feed for a second monitor or OBS browser source. While Events opens on the dashboard or in a popout, its header, tabs and search bar keep their places and the feed shows loading rows. A hidden header or footer stays hidden during loading. Live events that arrive while the first history read is pending remain in the feed when that read finishes. Both surfaces share the same feed component, so filters and display settings you pick carry across them, and each updates automatically as long as the WebSocket is connected. The Dashboard home does not embed the live feed - it carries the **Test Events** panel and a compact stream-history overview (the last few sessions) instead. ## Quick start 1. Open **Stream → Events** in the sidebar for the full feed. 2. Connect at least one streaming platform under **Manage → Connections** so events can flow in. 3. Watch the feed. New events push in from the top. 4. If nothing arrives, go to the Dashboard home and click the **Test Events** header to expand the panel and fire a fake follow, sub, or cheer to verify your overlays and automations. 5. For a dedicated monitor, open the popout with the external-link icon (on the Events page or the home panel) or load `/popout/events` directly. ## The event feed ### Panel layout Every surface shares the same controls: - **Tab toggle** (`Events` / `Filters`) - switches between the event list and the filter controls. - **Search box** - filter by any text in the event (username, message, reward title, track name). Placeholder reads `Search events...`. - **Settings gear** - on both the **Events** page and the popout, a gear opens the display settings (font size, emote size, timestamps, badges, avatars, viewer count, and the header/footer toggles) shared with the chat popout. The font-size slider scales the row text and its emotes together; the emote-size slider (0.5×–2.0×) tunes the emotes on top of that. The General tab also carries a **Region & language** block (language, time zone, date format, and time format) - a second entry point to the same global user preference as [Dashboard → Settings](/user-guide/preferences), marked as applying everywhere you sign in; it is read-only in a token-authenticated popout. Both surfaces load older events automatically as you scroll (infinite scroll) - there is no page-by-page counter. Each row shows the platform icon, a colored type badge (e.g. `Follow`, `Sub`, `Bits`, `Raid`), the username, the timestamp, and an event-specific payload (message, amount, reward title, etc.). Headline numbers sit as small chips right beside the type badge rather than under the name - a raid's viewer count (incoming, outgoing and completed), cheered bits, KICKs sent on Kick (`500 KICKs`), a resub's month count (`×3`), or an ad break's length in seconds next to its `System` badge. The Bits and KICKs chips carry a small graphic in front of the amount - the cheer's tier graphic or the Kick gift's sticker - and cheermotes in a cheer's message (`Cheer100`) render as graphic plus amount, as in the chat. A KICKs row also names the gift after the sender (`trainwreck_fan · Rage Quit`). The avatar keeps its place on the left; when no avatar is shown (avatars turned off, or an anonymous sender) the graphic takes that spot instead. Without a graphic (a Kick gift Lumio does not know yet, or an older event) the chip is text only. The graphics follow the font-size slider everywhere; cheermotes in the message box also follow the emote-size slider, while the chip graphic and the large graphic in the avatar spot follow only the font size. Twitch's larger cheermote image is loaded on high-resolution screens and for large graphics, so they stay sharp. With animations off or your system's reduced-motion setting on, the graphics show their still image, and where the platform sent no still image, the plain text instead of an animation. Clicking the **username** - or the small **ⓘ** button beside it - opens the **User Info Card** (see below). The username click target and the button appear together and only where a real platform user backs the row (not on channel-wide events like raids out or hype trains, the anonymous gifter, or Spotify). The header also shows the total event count - _"No events yet"_, _"1 event total"_, or _"42 events total"_ - and, while any channel is live, a small viewer-count pill per online platform. ### Twitch Shared Chat session events The feed shows **Shared Chat started**, **channel joined**, **channel left** and **Shared Chat ended** as Twitch events at the time they happened. Start and end show the host-first channel stack and partner details; join and leave show the affected channel. A join reconstructed from a later check says **Detected later**. An unavailable category, viewer or follower value is simply omitted. Use the **Shared Chat** chip to see these session events, or switch individual types on and off in the existing **Twitch** filter group. They are on by default and do not open a chat alert. The same session lines appear in the chat's **All** and **Twitch** tabs, with start/end details collapsed there. ### Twitch Shared Chat partner events During a [Twitch Shared Chat](./chat.md#twitch-shared-chat) session, subs, resubs, gift subs, upgrades, pay-it-forward, raids into a partner and Bits that happen in a **partner** channel appear in the feed as well. Their name line starts with a **channel chip**: the partner channel's channel-coloured avatar plus its name in a grey pill (a long name is shortened with `…`; hover: **"Happened in {channel}'s channel (Shared Chat)"**), so they never read as your own events. The chip shows in both the full and the compact feed, also with the type badge or avatars switched off. The stream report's announcement list names the partner channel next to its avatar the same way. Partner events use the same filters as your own events of that type and do not count in your stream history. To hide all of them, switch off **Events from Shared Chat partners** in the feed's display settings (gear icon, on by default). The four Shared Chat session events always stay; the switch changes only what the feed shows. ### Real-time updates The dashboard maintains a WebSocket connection to the API and subscribes to the `events:{account_id}` and `chat:{account_id}` channels. Events flow through Redis pub/sub → API → WsServer → browser. When the connection drops, Lumio reconnects with exponential backoff (1s, 2s, 4s … capped at 30s) and you will see the `connectionLost` banner briefly. ### Reconnect required The per-platform stats row in the header (viewers / likes / views) doubles as a health indicator. If a platform's OAuth connection expires and needs reconnecting, that platform shows a **reconnect-required error pill** - and it stays visible even while the channel is offline (a dead connection is exactly when nothing is live, so the pill must not disappear). This is separate from the transient `connectionLost` WebSocket notice above. Members who may reconnect channels (`connections:create`) also get a red entry in the [connection notices](../features/connections.md#connection-notices) under the header, with a **Reconnect {platform}** button (e.g. **Reconnect Twitch**) that starts the platform's OAuth flow in place. Otherwise reconnect from **Account → Profile** or **Connections**; see [Connections → Reconnect flags](../features/connections.md#reconnect-flags). ### New permissions available When Lumio starts requesting a scope that an existing connection was granted without, the connection keeps working but some events cannot arrive until you reconnect it. The Events page (and the [Chat](./chat.md#new-permissions-available) page) then shows an amber **needs new permissions** entry in the [connection notices](../features/connections.md#connection-notices) under the header, one per affected platform - for example _"Reconnect Kick to receive KICKs and channel points."_ Its **Grant {platform} permission** button starts the same OAuth flow as the Connections page; from a popout window it opens in a new tab. Nothing needs to be disconnected first. The notice is only shown to members who may reconnect channels (`connections:create`), and a connection that already needs a reconnect (above) shows only that entry. See [Connections → Missing scopes](../features/connections.md#missing-scopes). ### Filtering Switch to the **Filters** tab to narrow the feed: - **Platform** - toggle Twitch, YouTube, Kick, Spotify, StreamElements, Discord, Shopify, 7TV, Copyright, System. Twitch, YouTube, Kick, and Spotify appear when the account has the corresponding channel connection; StreamElements appears when a StreamElements token is stored; 7TV appears when at least one Twitch, YouTube or Kick channel is connected. Discord, Shopify, Copyright, and System do not depend on those connections. If Lumio cannot read a connection or token status, its platform remains listed (including StreamElements for a moderator without `se-tokens:read`). Turning a platform off hides its events from the feed. - **Type** - fine-grained toggles for every event type that has appeared for your account (follower, subscribe, resub, cheer, raid, reward, poll, prediction, hype train, superchat, track, tip, and so on). Filters persist locally, so they stick across reloads. The feed re-queries and the infinite-scroll list resets to match the filtered result set. Disconnecting a platform removes its controls from the Filters tab, but does not erase saved filter choices or older events from the feed history. Those events remain visible when your saved filters and access settings allow them. ### Search The search box performs a substring match on the rendered row. Useful examples: - Type a username to find every action by that viewer. - Type a reward title to audit channel-point redemptions. - Type `@yourname` to find messages that tagged you. ### User Info Card Clicking a username opens the user's card as a **full-page view** over the Events page - tinted in the platform's colours, with the page behind faintly visible. It is the same card the chat opens (see [User Info Card](./chat.md#user-info-card)): the header shows the avatar, name, badges, `@username`, platform ID and an **Open Profile** link; on a wide screen the profile facts and moderation actions sit in a left column next to the tabs, on a narrow screen and in the events popout everything stacks into one column. Its tabs: - **Messages** - the user's recent chat history on that platform. Links in these messages are clickable and open through the `External Link` warning (the same one the chat feed uses), so nothing leaves Lumio until you confirm; the warning appears on top of the card and `Esc` closes only the warning. This works on the Events page and in the events popout. - **Mod Notes** - private notes shared with your moderators (requires `chat:notes`). - **Moderation History** - bans, timeouts, deletions and treatment changes for this user. - **Events** - every event that user has triggered (follows, subs, cheers, KICKs, redemptions, raids). Cheers and KICKs show their graphic in front of the amount, and the emotes in an event's message (for example a resub message's sub emotes, or the cheermotes in a cheer) render as images. The card also shows account-created date, the user's own follower count (Twitch only), follow status and date, and the user's treatment status. Close it with `X` or `Esc`. Requires `events:userinfo` (or `chat:userinfo` when opened from the chat view). The chat-only **Highlight** / **Hide** buttons do not appear here. ## Test events On the [Dashboard home](./dashboard.md#test-events), **Test Event** opens a side sheet; on a phone it fills the screen. The button appears with `feature:events` and `events:create`. The same catalog is available in the [Test-Events popout](./tokens.md#where-popout-tokens-work), including as an OBS dock. ### What it does Sending broadcasts an event marked `isTest: true` to the account's overlays and dashboard activity. The activity row carries a **TEST** badge and dashed border. The Events page and Events popout filter test broadcasts out. Tests are not stored and do not affect metrics, the live **Now** card, milestones or history; the activity row disappears after a reload. ### How to fire one 1. Open **Test Event** from the dashboard header. 2. Search the catalog or choose a platform tab; **More** contains other event sources. The catalog lists only sources that are connected, whose features are on and that your role may see — see [which events are offered](./dashboard.md#test-events). 3. Choose **Send** on a row to use its defaults, or expand the row to change values or pick random values before sending. 4. Check the confirmation and **Just sent** list. **Again** repeats a test. The sheet stays open for further sends. Use this to: - Verify a new overlay animation before going live. - Check several overlay alerts without waiting for real audience activity. ## Popout window Open `/popout/events` (or click the external-link icon on the **Events** page) to get a borderless, chrome-free event list suitable for a second monitor, a stream deck browser, or a browser source for your producer view. Two authentication modes: 1. **Cookie auth** - if you are already logged in to Lumio in the same browser, the popout uses your session cookie automatically. 2. **Popout token** - append `?token=YOUR_TOKEN` to the URL (e.g. `https://lumio.vision/popout/events?token=lm_pop_…`). Use this when loading the popout inside OBS, where cookies are unavailable. Create popout tokens under **Settings → Channel → Access → Popout Tokens**, see [Tokens](./tokens.md). The popout respects the permissions granted to the token - a scoped token may hide moderation-only event types or the User Info Card. The [seasonal look](/user-guide/preferences#popouts-and-obs) also shows in this popout; append `&season=off`, `&seasonStrength=subtle` or `&seasonMotion=off` to the URL to change it for this popout only. ### Animations for this view Open the settings gear and choose **Animations → As global, Reduced, or Off**. The choice affects this view on the dashboard and in its popout, and is stored per browser and per user/account pair. It can only reduce motion further than your [global Animations in Lumio preference](/user-guide/preferences#animations-in-lumio); an unavailable looser choice is disabled. For an OBS popout, append `&motion=reduced` or `&motion=off` (use `?` if it is the first URL parameter) to apply a stricter choice to that URL without saving it. The global preference is read-only in a token popout. ## Where these settings apply **These settings apply to you on this account.** Events settings are stored in your browser (there is no server copy) and keyed to the pair *(you, the channel account you are viewing)*. This covers **both** the event-feed display options and the **event-feed filter** (the platform and type toggles under the Filters tab): switch to a second channel account, or sign in as a different team member of the same account, and they start from defaults — a filter you set while viewing one channel is not applied to another, and never leaks between team members. You set them once per browser context (the dashboard, and again in an OBS popout, which is separate). To reuse them elsewhere, move them deliberately with Export / Import or a preset. ## Save and transfer your settings Your events display options and event filters are stored in the browser, so they only exist where you set them - a fresh OBS browser source, a second computer, or a browser with cleared site data starts from defaults. **Transfer settings** lets you save them and re-import them anywhere, with no server involved. Open the **Settings** cog, go to the **Presets** tab, and use the **Export** / **Import** buttons below the preset cards (the same controls are in the dashboard and the popout). On a plan without the presets feature there is no Presets tab, so Export / Import appears at the bottom of the **General** tab instead. - **Export** - tick the sections you want (grouped *Chat / Events / Music*; your Events sections are pre-selected here); sections with nothing saved are greyed out. Take them with you by **Copy code** (a single `LS1…` line to send over a messenger), **Copy link** (a share link that carries the same code and opens the events settings with the import preview directly when clicked on any signed-in device - both update live as you change the selection), **Copy JSON**, or **Download** a file. Very long links can be wrapped by some messengers - for those, use the code or the file. - **Import** - pick a file, drop it onto the dialog, or paste **a file, an `LS1…` code, or a share link** into the one input (detected automatically). A **preview** shows what each section will replace before anything is written; **import replaces a section wholesale, it does not merge**. The page reloads afterwards. Event filters record which platforms and event types you show. Newly added event types are enabled automatically on the next load, so an older file never silently hides a brand-new event type. Importing something from a newer version of Lumio, an incomplete code, or anything that is not a valid Lumio settings file or code, is refused with a clear message. See the [Multichat guide](./chat.md#save-and-transfer-your-settings) for the full list of what is and is not included in a settings file. ## Presets The Events settings panel also has a **Presets** tab - named snapshots of your Events display and filter settings, saved to your account so you can switch layouts and reuse them across devices. (Because the Events panel used to be a single column, the Presets tab is what gives it a tab bar.) **New preset** opens an assistant to choose what to store and name it; each saved preset card offers **Load**, share, overwrite, rename and delete, with a quota line showing how many you have used. **Share** copies a code/link for the preset and can make it visible to your whole account (with the `presets:share` permission); presets teammates share into your account appear in a **Shared from the channel** section you can load or save as your own. In a popout you can load a preset but not change or share one. See the [Setting Presets](../features/setting-presets.md) feature page for details. ## Channel-status indicator While a channel is live, the Events header shows a small pill per online platform with that platform's icon and viewer count (and, on YouTube, optional like and total-view counts you can switch on in the display settings). When every channel is offline, no pill is shown. The Dashboard home shows the same state as a **LIVE** badge on each channel card. The Spotify worker and some event pollers only run while at least one channel is live (see the [Channel Status](./channel-status.md) guide). This saves API calls and avoids logging Spotify tracks when you are not streaming. If you need to bring the Spotify worker up manually, use the **Connect** button on the Music page - it starts the worker for 30 minutes. ## Supported event types Event type strings follow the `platform:action` convention (e.g. `twitch:follower`, `youtube:superchat`). The full set currently recognised: ### Twitch `twitch:follower`, `twitch:subscribe`, `twitch:resub`, `twitch:prime_upgrade`, `twitch:gift_upgrade`, `twitch:gift`, `twitch:pay_it_forward`, `twitch:bits_badge`, `twitch:cheer`, `twitch:raid`, `twitch:raid_out`, `twitch:unraid`, `twitch:raid_done`, `twitch:reward`, `twitch:reward_status`, `twitch:hype_train`, `twitch:hype_train_end`, `twitch:poll`, `twitch:poll_end`, `twitch:poll_delete`, `twitch:prediction`, `twitch:prediction_lock`, `twitch:prediction_end`, `twitch:goal`, `twitch:goal_end`, `twitch:ad_break`, `twitch:stream_online`, `twitch:stream_offline`, `twitch:stream_update`. Twitch also reports `twitch:mod_added`, `twitch:mod_removed`, `twitch:vip_added` and `twitch:vip_removed`. The feed labels them **New Mod**, **Mod removed**, **New VIP** and **VIP removed**. In chat they are centred event rows with the platform icon first when that setting is on: grants use the role colour, while removals use a grey dashed treatment. The fallback VIP symbol is a diamond. Each type has its own chat Events switch, on by default. The row always says **Moderator** for a mod, including a Lead Moderator, because Twitch does not report that distinction. These role rows exist only for Twitch; Kick does not report its mod, VIP or OG commands to Lumio, and YouTube reports bans only. The three Twitch pin types are `twitch:pin_added`, `twitch:pin_updated` and `twitch:pin_removed`. The feed calls them **Message pinned**, **Pin duration changed** and **Pin removed**; the icon at the start identifies the platform. Each type can be filtered independently and is on by default. A **Pin removed** row also says how long the pin stood (or that it expired): in seconds or minutes under an hour, in hours and minutes from one hour, and in days and hours from one day, rounded down. A goal row shows the goal text as its title and the translated goal type underneath, with the progress as a chip in your language's number format (e.g. `13.284/14.000`). Its badge reads **Goal Started** when the goal is created and **Goal Reached** or **Goal Missed** when it ends (**Goal Ended** if Twitch did not say). Progress updates in between are not listed. **Stream Offline** shows the stream's length as a clock chip, e.g. `3h 12m`. A Twitch prediction's end badge reads **Prediction Resolved** (**Vorhersage aufgelöst** in German) when Twitch reports it as resolved, or **Prediction Canceled** (**Vorhersage abgebrochen**) when it was canceled. If Twitch sends no status, the badge reads **Prediction End** (**Vorhersage Ende**). The prediction title and outcome breakdown stay visible in the event row. A channel-point redemption shows the reward title, its cost in points and the viewer's input. When you complete or refund a redemption in Twitch's reward queue, that status change is its own `twitch:reward_status` row that reads _completed_ or _refunded_; it is on by default and has its own toggle under the **Twitch** heading of the type filter. Creating, editing or deleting a reward is not listed in the feed — those changes reach widgets and extensions live only. ### YouTube `youtube:subscribe`, `youtube:member`, `youtube:superchat`, `youtube:supersticker`, `youtube:gift_membership`, `youtube:gift_membership_received`, `youtube:poll`, `youtube:poll_end`, `youtube:stream_online`, `youtube:stream_offline`. A YouTube poll is one channel-event row titled with the poll question. Below it, each option shows its bar and its vote count, or its share of the votes when YouTube sends no count. The row updates in place on every vote, and once the poll is closed the leading option gets the trophy. Polls are not stored, so the row is only there while the page is open. Stream start and stream end render as channel events with a `System` badge, like Twitch and Kick: **Stream Online** shows the broadcast's title underneath (YouTube has no stream category, so none is shown), **Stream Offline** shows how long the stream ran as a clock chip (e.g. `3h 12m`) when the start time is known, otherwise the label only. ### Kick `kick:follower`, `kick:subscribe`, `kick:resub`, `kick:gift`, `kick:kicks`, `kick:reward`, `kick:reward_status`, `kick:stream_online`, `kick:stream_offline`, `kick:stream_update`. `kick:pin_added` and `kick:pin_removed` use the same **Message pinned** and **Pin removed** labels, with the Kick platform icon. Pin rows have the ordinary event layout. The large head avatar belongs to the person who pinned the message on **Message pinned** and to the sender on **Pin duration changed** or **Pin removed**. The head name has no badges. Below it, the message's sender appears with avatar, badges, text and the time they wrote the message when known. The planned duration appears as a chip by the type label. The moderator who pinned and a replaced sender show avatar and name without badges. Names open the User Info Card when the viewer has `chat:userinfo`; otherwise they are plain text. The feed row has no jump link to the message. Kick rows look like their Twitch counterparts: the viewer's name and avatar (click it for the User Info Card), a months chip on a resub, the gift count and every recipient on a gift (an anonymous gifter shows a `?` avatar and no clickable name), a gold **KICKs** row with the amount chip and the sender's message, and a channel-point redemption with the reward title, its cost in points and the viewer's input. A redemption's later status change is its own `kick:reward_status` row that reads _accepted_ or _rejected_. Stream start, stream end and stream-info changes render as channel events with a `System` badge and the stream title or the changed fields (see [Stream updates](#stream-updates)), not as a user row. Every Kick type is on by default and has its own toggle under the **Kick** heading of the type filter; a filter saved before a type existed turns the new type on automatically. Kick events need the `platform:kick` plan feature. ### Stream updates A **Stream Update** row (`twitch:stream_update`, `kick:stream_update`) names only the fields that actually changed, each with its own label: | Label | Shows | Platforms | |-------|-------|-----------| | **Title** | The new title | Twitch, Kick | | **Category** | `old → new` category (game); only the new one when the previous is unknown | Twitch, Kick | | **Language** | `old → new` broadcast language, as a language name in your interface language (e.g. _English → German_); the raw code when there is no name for it (Twitch's `other`) | Twitch, Kick | | **Tags** | The new tag list, comma-separated; `—` when every tag was removed | Twitch | An unchanged field is never repeated. The title shows only the new value so the line stays readable. The same line appears on the Events page, in the chat inline event and on the **Events** tab of the User Info Card. Stream updates recorded before Lumio tracked language and tags show title and category only. On Twitch, a tag-only edit is reported together with the next title, category or language change — Twitch sends no update for tags alone (details: [Stream update payloads](/features/events#stream-update-payloads-twitchstream_update-kickstream_update)). ### Discord `discord:member_join`, `discord:member_leave`, `discord:boost`. ### Spotify, StreamElements, Shopify, Copyright, System `spotify:track`, `spotify:play`, `spotify:pause`, `spotify:skip`, `spotify:previous`, `spotify:seek`, `spotify:shuffle`, `spotify:repeat`, `spotify:volume`, `spotify:device`, `spotify:queue_add`, `spotify:playlist_create`, `spotify:playlist_edit`, `spotify:playlist_delete`, `spotify:playlist_add`, `spotify:playlist_remove`, `streamelements:tip`, `shopify:order`, `shopify:order_paid`, `shopify:product`, `copyright:warning`, `copyright:blocked`, `copyright:clear`, `system:test`. ## Permissions | Permission | What it unlocks | |-------------------|--------------------------------------------------------| | `events:read` | View the Events page and the popout | | `events:create` | Fire a test event | | `events:userinfo` | Open the User Info Card on an event row | ## Tips & best practices - **Use test events liberally before going live.** Fire `twitch:raid`, `twitch:cheer`, and `youtube:superchat` to make sure overlays, alerts, and automations all behave. - **Pin a popout to your control monitor.** `/popout/events` in a narrow window makes a great always-visible activity feed next to your streaming software. - **Generate a scoped token** for browser sources under **Settings → Channel → Access → Popout Tokens** so a compromised stream PC does not expose your full Lumio session. - **Filter aggressively during raids.** Hide `spotify:*` and `chat:*` noise so you can focus on follow/sub/cheer incoming during the chaos. ## Troubleshooting ### No events arrive 1. Check the channel-status pill - if all channels are offline, most platforms only push events for live streams. 2. Open **Manage → Connections** and confirm every platform shows a green **Connected** badge. Re-authorize anything showing **Not connected**. 3. Fire a test event. If it shows up but real events do not, the issue is with your platform connection or that platform's event subscription scopes. 4. Reload the page to force a WebSocket reconnect. ### `Connection lost - reconnecting…` banner persists The browser cannot reach the API's `/v1/ws` endpoint. Causes: your auth token expired (log out and back in), a network blocker (VPN, corporate firewall), or the API is genuinely down. Check **Account → Sessions** to confirm your session is still valid. ### Test events do not fire You need the `events:create` permission. Ask your account owner to grant it on your role under **Settings → Channel → Roles**. ### Popout shows `Invalid or expired token` The token was revoked, expired, or scoped too narrowly. Create a new one under **Settings → Channel → Access → Popout Tokens** and update the URL in OBS / your second monitor. ### Popout shows `No authentication token provided` You loaded `/popout/events` outside a browser session. Append `?token=YOUR_TOKEN` to the URL, or load it inside the same browser where you are logged in. ## Related - [Channel Status](./channel-status.md) - how online/offline state gates workers and pollers. - [Overlays](./overlays.md) - how events drive on-stream widgets and alerts. - [Automation Templates](./automation-templates.md) - react to any event with the visual builder. - [Chat](./chat.md) - chat messages and moderation share the same WebSocket channel. --- ## User Guide / Music (Spotify) Source: https://docs.lumio.vision/user-guide/music # Music (Spotify) Lumio integrates with Spotify to show and control the music playing on your stream. It surfaces the currently playing track, full playback controls, device selection, your playlists, and a recently-played log - all from the Dashboard, and all available as an OBS-friendly popout. Track changes emit `spotify:track` events so overlays and automations can react in real time. This guide covers connecting Spotify (including the manual workaround for free accounts), the Music page layout, playback controls, popout configuration, and troubleshooting. ## Where to find it - **Sidebar**: Stream → Music (`/dashboard/music`). - **Popout window**: `/popout/music`. - Requires `spotify:read` and the Music feature on your plan. ## Quick start 1. Open **Manage → Connections** and connect **Spotify**. Authorize every requested scope - Lumio needs read and playback access to show the current track and control playback. 2. Start playback on **any** Spotify device (desktop app, phone, Sonos, Alexa, browser web player). Spotify's API cannot cold-start a device - it can only transfer an already-active session. 3. Open **Stream → Music**. You should see the current track, album art, and playback controls. 4. If you are not live right now, click **Connect** in the banner at the top to wake the Spotify worker for 30 minutes. 5. Optional: open `/popout/music?token=YOUR_TOKEN` in OBS as a browser source to show a chrome-free player on your producer view. ## Connecting Spotify Go to **Manage → Connections** and find the **Spotify** card. Click `Connect`, log in to Spotify, and approve the scopes. On success the card shows `Connected as {name}` with an auto-refresh note - Lumio keeps the OAuth token fresh via the Token Refresh Worker, so you never re-authorize manually. If the Music page shows `Connect Spotify to control your music`, Spotify is not linked yet - the button `Go to Connections` takes you straight to the setup. ### Spotify Free accounts The Spotify Web API refuses playback control for Free accounts for long stretches of time, but **read access (current track, recently played) still works**. Lumio shows the current track and emits `spotify:track` events even on Free; you just cannot hit Play/Pause/Skip from the Music page without a Premium account. ## The Music page ### Now-playing header At the top of the page: - **Album art** - click to copy the Spotify track URL (`Copy URL` label). - **Song title and artist** - links out to Spotify's web player. - **Progress bar** - current position / duration in `mm:ss`. - **Playback controls** - `Previous`, `Play/Pause`, `Next`, `Shuffle`, `Repeat` (with a `Repeat 1` state). Shuffle can display `Off`, `On`, or `Smart`. Smart Shuffle is a read-only state in Lumio: activate it in the Spotify app, because Spotify's public Web API only lets Lumio switch regular shuffle on or off. `Mute`/`Unmute` and the volume slider are a separate block that needs `spotify:volume`. - **Popout button** - the external-link icon at the top right opens the `/popout/music` window (380×700 by default). The page heading and popout button stay in place while Spotify status loads. If a player is expected, loading rows occupy its space; a temporary loading state does not show a connection error. Within the Music page, the loading player and an unreachable-player message occupy the same space, keeping the recently played section in place when the player read finishes. If Spotify is connected but nothing is playing, you will see `Paused` or `No active playback` with the hint `Open Spotify on any device to start playback`. Once at least one device and one playlist are visible, a small starter row appears with a **Device** dropdown, a **Playlist** dropdown, and a `Start Playback` button that begins that playlist on that device. It requires `spotify:playback`. ### Connect banner (channel-status gating) To save API quota, Lumio only keeps the Spotify worker running while at least one of your channels (Twitch, YouTube, Kick) is live. When you are offline you will see a `Connect` banner: - **Connect** - wakes the worker manually for 30 minutes so you can test overlays or listen on camera-off streams. Requires `spotify:worker`. Once any channel goes live, the worker starts automatically and the banner disappears. See [Channel Status](./channel-status.md) for details. ### Reconnect required If your Spotify connection expires and cannot be refreshed, the player stays on the page but turns **unavailable**: dimmed, with the last known track (or the empty state) and every control disabled - separate from the "connect Spotify" hint you see when nothing is linked yet. The red **Spotify channel disconnected** entry of the [connection notices](../features/connections.md#connection-notices) above it says why. On the dashboard its **Reconnect Spotify** button starts the Spotify OAuth flow; in the popout the entry is read-only - a link that opens Connections in a new tab (a delegated popout shows the text only). See [Connections → Reconnect flags](../features/connections.md#reconnect-flags). ### Spotify rate limit Spotify can temporarily throttle the account's app. When a Spotify request gets a 429, the Music player shows a warning banner with the seconds until it will read again. The same banner appears in the dashboard and the popout, including an OBS popout. The last known track stays visible; a rate limit does not mean the connection has expired or the player is unreachable. If the first read is throttled, the player still appears with the banner while it waits for its first full state. During the wait, the player pauses its regular 60-second poll and skips device and playlist refreshes. It reads the full state once when the countdown ends, then resumes regular polling. You do not need to reconnect or reload the page. The banner's × hides it for the current wait; a later rate limit can show it again. It has no action button in either view. ### Tabs Three tabs sit below the player: #### Search Search Spotify's catalogue. Typing runs a debounced search once you have entered at least two characters. While a search is running the panel shows `Searching…`. If Spotify is unreachable, the panel shows `Search failed` with a **Retry** link instead of an empty result. A rate-limited search shows the player-wide [Spotify rate-limit banner](#spotify-rate-limit); while that banner is visible, the panel does not repeat the rate-limit message. If you dismiss the banner, the panel can show `Too many requests — please wait a moment` with **Retry**. Results show cover, title, artist, and two actions: - **Add to queue** - queue the track on the active device. Requires `spotify:queue`. - **Add to playlist** - drop it into one of your Lumio-managed playlists. Requires `spotify:playlist`. #### Playlists Your Spotify playlists, with create / rename / delete on the ones you own: - The `New playlist name` input plus the `+` button creates a new playlist. - Each playlist row shows its cover, name and track count. **Click the row** to start playing it. - The pencil icon (only on playlists you own) opens a rename dialog, which also holds the delete action - it asks `Delete this playlist?` before removing it. - The refresh icon (`Refresh playlists`) forces a resync if Spotify changes are slow to appear. The whole tab requires `spotify:playlist`. #### Devices Lists every Spotify Connect device currently visible to your account (desktop, mobile, browser, speakers, consoles). Each row shows the device name and `Active` badge if it is the current playback target. Click any device to transfer playback to it. `Refresh devices` forces a fresh poll. The list scrolls inside the tab, so a long device list never spills out of the card; once you have more than five devices a filter field appears above it so you can narrow the list by name or type (with a `No matching devices` line when nothing matches). Requires `spotify:device`. ### Queue panel The queue panel sits next to the tabs and lists upcoming tracks (from Spotify's read-only queue) plus anything you add via Lumio's `Add to queue` action. Click a queue row to skip straight to that track. Once the queue has more than three entries, a small search input appears in the panel header so you can find a specific track quickly. ### Recently played Below the player, the `Recently Played` section lists the last 7 days of tracks (up to 50). Each row shows the cover art, song, artist, how long ago it played, and a status badge (safe / blocked / reported, from the copyright database). Small icon buttons at the end of each row let you copy the Spotify link, copy the track ID, and - if you have the copyright feature and the matching permission - report or recommend the song. The same report and recommend actions appear on the inline `Now Playing` event line in Multichat: on a Spotify track row the flag and plus buttons sit **always visible** next to the copy-URL action - no hover needed, so they are reachable on touch and in the pop-out just as they are on the dashboard. They show only if you have the copyright feature and the `copyright:report` or `copyright:recommend` permission; each button appears only when you hold that specific permission and the track has both a song name and artist. A playlist line carries only the copy-URL action. ## Popout (OBS and secondary monitors) Open `/popout/music` or click the external-link icon on the Music page. Inside OBS or a browser without your cookies, append `?token=YOUR_TOKEN` (popout tokens are created under **Settings → Channel → Access → Popout Tokens**, see [Tokens](./tokens.md)). The [seasonal look](/user-guide/preferences#popouts-and-obs) also shows in this popout; append `&season=off`, `&seasonStrength=subtle` or `&seasonMotion=off` to the URL to change it for this popout only. The popout is a compact, chrome-free player with: - The same now-playing header, playback controls, queue, search, playlists, and devices - in a single-column layout. - A `Settings` cog that opens a **full-screen settings panel** - the same presentation as the Chat and Events popouts - with a **Font Size** slider, a **Streamer Mode** toggle, a **Theme** picker, and a **Region & language** block (language, time zone, date format, and time format). Close it with the `×` in its header. (The per-item display toggles you see in the Chat and Events popouts do not apply here.) Once the view settings have loaded, the cog and wake-lock indicator remain in the bottom bar whether Spotify is disconnected, needs reconnecting, is waiting for manual connection, is playing, or is unreachable. The search field appears with the player; its space remains reserved in the other states. The saved font size applies from the first frame. Permissions granted to the token determine which buttons appear - a token with only `spotify:read` will show the player but hide Play/Pause, Skip, queue, and device controls. ### Animations for this view Open the settings gear and choose **Animations → As global, Reduced, or Off**. The choice affects this view on the dashboard and in its popout, and is stored per browser and per user/account pair. It can only reduce motion further than your [global Animations in Lumio preference](/user-guide/preferences#animations-in-lumio); an unavailable looser choice is disabled. For an OBS popout, append `&motion=reduced` or `&motion=off` (use `?` if it is the first URL parameter) to apply a stricter choice to that URL without saving it. The global preference is read-only in a token popout. ## Where these settings apply **Most of these settings apply to you on this account.** Your Music **display settings** (font size, theme and the layout options in the settings panel) are stored in your browser and keyed to the pair *(you, the channel account you are viewing)* — switch to a second channel account, or sign in as a different team member of the same account, and they start from defaults. You set them once per browser context (the dashboard, and again in an OBS popout, which is separate). **Streamer Mode, the Region & language block, and Animations in Lumio are global user settings.** Streamer Mode, your language, time zone, date/time format and global motion preference are stored on your user profile, so they follow you across devices and every channel account — the Region & language block here is a second entry point to the same values as [Dashboard → Settings](/user-guide/preferences), not a per-account copy. Which **Spotify account** is connected is a separate matter: that is a per-channel **connection**, managed on the [Connections](./connections.md) page, not a view setting stored here. ## Presets The music settings panel has a **Presets** tab - named snapshots of your Music display settings saved to your account, so you can switch layouts and reuse them across devices. **New preset** opens an assistant to choose what to store and name it; each preset card offers **Load**, share, overwrite, rename and delete, with a quota line for your plan's limit. **Share** copies a code/link for the preset and can make it visible to your whole account (with the `presets:share` permission); presets teammates share into your account show up in a **Shared from the channel** section you can load or save as your own. The **Export** / **Import** buttons below the cards move settings to a file, code or link (on a plan without presets they sit in the General tab instead). In a popout you can load a preset but not change or share one. See the [Setting Presets](../features/setting-presets.md) feature page for details. ## Permissions | Permission | What it unlocks | |--------------------|----------------------------------------------------------| | `spotify:read` | See the current track, queue, recently played | | `spotify:playback` | Play / pause / skip / previous / shuffle / repeat / start a playlist | | `spotify:volume` | Mute / unmute and the volume slider | | `spotify:queue` | Add tracks to the playback queue | | `spotify:playlist` | Create, edit, delete, and play your playlists | | `spotify:device` | List and switch Spotify Connect devices | | `spotify:worker` | Manually wake the Spotify worker when offline | The Music page itself requires `spotify:read` (the `PermissionErrorBoundary` enforces it). Without `spotify:read` the page shows a graceful access-denied state instead of an empty player. ## Tips & best practices - **Always start playback on a real device before the stream goes live.** Spotify cannot cold-start a device from the Web API; transfer a playing session. - **Use playlists for stream music.** Curate copyright-safe playlists and swap between them from the Playlists tab during breaks. - **Turn off private session on Spotify.** Private sessions hide the current track from the API - you will see `No active playback` even while music plays. - **Pin the popout in OBS** with a token restricted to `spotify:read` if the popout will be visible on stream, so a viewer cannot trick an overlay into skipping a track. - **Let the worker auto-start.** The 30-minute manual window is designed for setup and testing; under normal use the worker follows your live state. ## Troubleshooting ### `Connect Spotify to control your music` Spotify is not connected at all. Go to **Manage → Connections**, find the **Spotify** card, and authorize. ### Spotify rate-limit banner Spotify is temporarily throttling requests for the connected app. Keep the player open; its countdown shows when it will read again and it resumes automatically. See [Spotify rate limit](#spotify-rate-limit). ### `No active playback` but music is playing - Open Spotify on any device and press Play again to force a Connect handshake. - Check that Spotify is not running in a private session (Settings → Social → `Private session` off). - Free accounts cannot be remote-controlled but can still be read; you may need to control playback from the Spotify app itself. ### `Open Spotify on any device to start playback` No device has been active recently. Spotify Connect devices go to sleep after inactivity; open the Spotify desktop or mobile app to wake one up, then hit `Start Playback` again. ### Playback buttons are disabled Either the Spotify worker is not active (channels offline and no manual connect), you do not have the relevant permission (`spotify:playback`, `spotify:volume`, `spotify:queue`, `spotify:device`), or you are on a Free account. The banner at the top of the page explains which case you are hitting. ### `Connection lost - reconnecting…` The Spotify worker WebSocket dropped. Lumio reconnects automatically. If it persists, your session token may have expired - log out and back in. ### Devices list is empty Open any Spotify client (desktop, mobile, browser) so it registers as a Connect device, then click `Refresh devices`. ### Track art never updates The Spotify worker is probably stopped (no live channels). Click `Connect` in the banner to wake it for 30 minutes, or go live on any connected platform. ## Related - [Events](./events.md) - Spotify changes emit `spotify:track` events your overlays and automations can react to. - [Channel Status](./channel-status.md) - controls when the Spotify worker runs automatically. - [Overlays](./overlays.md) - add the Spotify Now Playing widget to your overlays. - [Automation Templates](./automation-templates.md) - trigger flows on track changes, play, pause, skip, and playlist edits. --- ## User Guide / Multichat Source: https://docs.lumio.vision/user-guide/chat # Multichat Multichat is Lumio's unified chat experience. It merges messages from every connected platform - Twitch, YouTube, and Kick - into one stream with full emote rendering, moderation tools, reply threading, and a rich user-info card. It also shows live overlays for polls, predictions, hype trains, raids, ad breaks, and every other mod-facing event so you can react without ever leaving the chat view. This guide walks through the Chat page, every moderation action, the user info popup and mod notes, the `/popout/chat` window for OBS, and how to send messages from Lumio directly. ## Where to find it - **Sidebar**: Stream → Chat (`/dashboard/chat`). - **Popout window**: `/popout/chat`. - Requires `chat:read` and the Multichat feature on your plan. ## Quick start 1. Connect at least one streaming platform under **Manage → Connections**. 2. Open **Stream → Chat**. Messages start appearing as soon as the WebSocket connects - you will see the `Connected` indicator in the header. 3. Pick the platforms you want to see using the platform toggles at the top of the feed (Twitch / YouTube / Kick). Toggles let you read, send, or both per platform. 4. Type a message in the footer input, press `Enter` to send. Use the emote picker (the `:)` icon) to insert emotes from any of your connected sets. 5. Click any username to open the **User Info Card** for quick moderation context. ## The Multichat feed When you open Chat on the dashboard or in a popout, its header, input bar and optional viewer-list area keep their places while message rows load. The viewer list shows loading rows immediately when enabled. If you have hidden the header, input bar or viewer list, its loading frame respects that setting. When you switch to a platform or YouTube broadcast tab, the feed shows loading rows in place. **No messages found.** appears only once that load has finished and there really is nothing to show (or every row is filtered out). A live message that arrives during the load shows at once. With the Halloween look at **Full** strength the empty feed shows a floating ghost instead (see [Preferences](./preferences.md)). ### Message rows Every row shows: - **Platform icon** (Twitch purple, YouTube red, Kick green). - **User avatar** (squared, next to the platform icon). - **Source-channel avatar** (Twitch Shared Chat only) - a square, rounded-corner avatar with a channel-coloured ring channel avatar directly *before* the badges when the message crossed in from another channel's room during a shared-chat session, as on Twitch (see [Twitch Shared Chat](#twitch-shared-chat)). - **Badges** (broadcaster, moderator, VIP, subscriber, founder, artist, cheer). Twitch and YouTube member badges use the image the platform sends. Where a platform sends none, the badge is drawn in that platform's own artwork where Lumio has it - a Kick channel owner shows Kick's pink-to-purple microphone **host badge**, a Kick moderator shows Kick's blue sword **moderator badge** and a Kick VIP shows Kick's gold crown **VIP badge** - and otherwise falls back to the generic icon. On **Kick**, a subscriber shows the channel's own sub badge for their tier, and a chatter who turned on their Kick **level badge** shows Kick's level image. The level badge can be missing on a chatter's first message and appears from the next one. OG, Verified, Staff, Sidekick, Bot and Sub Gifter show Lumio's own Kick-style badges, as does a subscriber or level badge whose image is not known yet (the level plaque shows the number). Hover a badge to see what it is, with its tier where the platform sends one - `Subscriber · 12 months`, `Sub Gifter · 50`, `Level 7`. - **Display name** in the user's Twitch/YouTube color (falls back to a platform default). On **Twitch**, a viewer whose display name is written in a non-Latin script that differs from their login - for example `こさむん` with the login `kosamun` - is shown as **`こさむん (kosamun)`**, so you still see the handle to moderate or search by. An ordinary name is shown on its own, never in brackets: a display name that differs from the login only by capitalization (`Cruex` / `cruex`) or an accented Latin name such as `Ömer` gets no bracket. This is Twitch-only - Kick and YouTube names never get it. Because the rule is applied as the chat renders, the bracket also appears on older messages in the history, and it shows the same way in the reply-preview header and the delete/moderation confirmation dialogs. - **Message body** with inline emotes rendered from Twitch, YouTube, Kick, 7TV, BTTV, and FFZ. - **Bits** in a Twitch message (`Cheer100`, or a channel's own cheermote such as `xqcCheer500`) render the way Twitch shows them: the cheermote graphic followed by the amount in the tier colour (grey 1, purple 100, teal 1000, blue 5000, red 10000), kept together on one line. The graphic is the same size as an emote and follows your **Emote Size** setting. Hover it to see the original word. If Lumio has no cheermote list for the channel, the word stays plain text. - **GIFs** posted with the Twitch GIF keyboard render inline as images, loaded directly from the GIF provider (GIPHY) at their original URL. Click a GIF to open it in a lightbox - a larger preview over a blurred backdrop showing the GIF's name, ID, and URL (the ID and URL each with a copy button). Close the lightbox with the `✕` button, the `Esc` key, or a click outside it. GIFs never appear in a moderation placeholder or a deleted message. The **Show GIFs** setting (on by default) controls this: with it off, GIFs are not rendered and nothing is loaded from GIPHY. The setting is stored per browser. - **Timestamp** (localized). - **Action row** on hover: `Reply`, `Copy`, `Delete`, and the moderation dropdown. Platform icons, avatars, badges, and timestamps are individually toggleable in the chat settings (gear icon). ### Announcements and spotlight rows Twitch announcements appear in the message feed in five colours: primary (Lumio's fixed teal for `/announce`), blue, green, orange and purple. They keep their colour, emotes, links and Shared Chat origin after a reload. Lumio reads these messages but does not send announcements. Twitch does not let Lumio delete an announcement, so its row has no Delete control. If its author is timed out or banned, the row can still appear as deleted through the usual chat moderation state. Twitch announcements cannot be pinned, so they have no Pin control. A first-time chatter and a Twitch watch streak use spotlight rows in both full and compact chat density. The full row carries the person's message and its emotes and links; a watch streak also shows the streak count. Compact density keeps an icon with a tooltip and omits the label. A bare domain such as `example.com` is a clickable link in ordinary messages, announcements and spotlight rows, subject to the same external-link warning as other links. Announcements count as ordinary chat messages for keyword tabs and chat counts. ### Pinned messages The card above chat shows the current Twitch and Kick pins. The sender has an avatar and badges; **Pinned by** shows the moderator's avatar and name without badges. **Sent at** is when the sender wrote the message, and is omitted when that time is unknown. With `chat:userinfo`, names in the card and pin event rows open the same User Info Card as other chat names; without it they are plain text. Members with `chat:pin` can pin an ordinary Twitch message, unpin it or set its duration from 30 seconds to 30 minutes or until stream end. Twitch uses that member's own moderator login for the action. Kick pins are visible but read-only in Lumio and stay pinned until Kick removes them or the channel moves from live to offline. A Twitch pin made on twitch.tv, even while the channel is offline, appears after the next poll: within 10 seconds while live or 30 seconds while offline. At stream end Lumio checks Twitch; a timed pin may remain. An action refused as **already pinned** refreshes the card. You can collapse the card or hide a pin locally without changing it on the platform. Pin changes also appear as chat event rows, including pins made on twitch.tv without a stream. These rows show avatar and name without badges. The sentence itself is not a link; use **↓ To message** at its right edge to jump to the pinned message. Pin event types have individual switches in Chat's Events settings and appear in the Twitch or Kick platform tab. ### Emote and badge details Hover an inline emote or badge to see a larger image and its details. On a phone, tap it to open a bottom sheet; tap outside, swipe down, use Back, or press Escape to close. A touch tap on a wider tablet opens a card beside the item. Tapping a user's name still opens the User Info Card. An emote card shows its provider and, when known, whether it is animated or static, its creator, and whether it belongs to this channel, another channel, or a global set. An unknown animation state has no chip. A linked other-channel name can open Twitch or YouTube; 7TV, BTTV, and FFZ emotes can link to their provider page. Kick may identify another channel without a usable channel link. Use **Copy name** for chat text (Kick uses `[emote:ID:NAME]` when the ID is numeric) or **Copy ID** when an ID is available. A badge card shows the badge title, tier or months when known, and platform, without copy buttons. These cards also work in the [chat popout](#popout-chat-for-obs); in an OBS browser source, use **Copy link** if OBS blocks opening an external link. Reply chains are shown inline with a small `↳ Replying to {name}` marker. Mentions of your own username are highlighted; the highlight style is configurable in the popout settings (`Off`, `Border`, `Background`, `Border + Background`). A **keyword rule** can carry its own highlight color: matching messages glow in that color across the platform tabs (a rule's own keyword tab does not re-color its own rows, since every row there already matches). When several colored rules match one message, the first rule in your ordered list wins. A rule color outranks the automatic role coloring: on a moderator's, VIP's, or self-mention row a matching rule paints **both** the border and the background in the rule color, while the mod/VIP **badge stays visible**. Rule colors honor the same highlight-style setting above and are automatically adjusted for contrast in both light and dark themes. Moderation always takes precedence: a message you have set to `Monitor` or `Restrict` keeps its amber / red treatment marking regardless of any rule color, and a rule with no color assigned highlights nothing. A rule matches a message in one of two ways, combined with **or**: a **keyword** matches when the message *contains* it (anywhere, or on whole-word boundaries - set by the rule's matching mode), and a **prefix** matches when the trimmed message *begins with* it, case-insensitively. Prefixes are made for command tabs: a rule with the prefix `!frage` collects every message that *starts* with `!frage` but not one that merely mentions it mid-sentence. A rule can mix both - for example prefix `!frage` **or** keyword `umfrage` - and either one alone is enough: a rule needs at least one keyword or one prefix, not both. The two fields are **never joined into a single term**: a keyword `test` together with a prefix `#` matches messages that contain `test` **or** that start with `#` - not the one string `#test`. To match `#test`, enter `#test` itself, either as a prefix (begins-with) or as a keyword (contains). Prefixes ignore the whole-word setting (a prefix is always a begins-with test). Because a prefix rule is exactly what you reach for alongside the global *Hide commands* switch, its matches stay visible even when that switch would otherwise blank a `!` message. ### Inline stream events Some stream events appear **inline in the feed**, between the message rows, styled to match the Events feed but at chat-line density - not as an overlay card (that is the separate [Alerts overlay](#alerts-overlay)). - **Watch streak** (Twitch) renders as a **spotlight card**, not a plain event line - see [Spotlight cards](#spotlight-cards-first-time-chatters-and-watch-streaks) below. - **Subs and resubs read in your language.** A sub, resub, gift sub, Prime upgrade or bits badge shows its summary - the **tier** ("Stufe 1", "Prime") and, for a resub, the **months** ("30 Monate") - **translated by Lumio**, not the English sentence Twitch sends (e.g. "subscribed at Tier 1. They've subscribed for 30 months!"). Month counts are pluralised correctly in each language ("1 Monat" vs "30 Monate"). - **Events carry the viewer's own message in a grey box below the line.** For the message-bearing types - resubs, cheers, tips, and YouTube SuperChats (and any other event the viewer can attach text to) - the event summary itself (name, and the translated platform recap such as "Stufe 1 · 30 Monate") stays **on the event line**, and the viewer's **own written message** drops onto **its own line below**, set apart in a **grey box with a small message icon** - the same way it appears on the Events page - so written text stands out from the automatic recap. Emotes in the message render as images and follow your **font size** and **emote size** settings, exactly like a normal chat message. A long message **wraps within the box** and is shown in full, exactly like a chat message - the box is never clipped with an ellipsis. Events with no written message (a follow) show no box. - **Channel-point redemptions** (Twitch and Kick) name **what was redeemed and its price** on the line: after the redeemer's name comes the reward's title (a muted suffix) and its cost as a small accent plaque, e.g. "500 Points". If the reward has a message prompt, the viewer's own text follows in the same grey box **below the line** - the reward and its cost read on the line, and the viewer's text sits in the box beneath, wrapping when long. On Kick a redemption can change status after it was redeemed; that change is its own **Reward Status** line, which adds _accepted_ or _rejected_ after the reward title. - **Cheers** (Twitch Bits) and **KICKs** (Kick's Bits) show the sender's name, the amount and the sender's own message in the grey box, highlighted in gold. The amount is a token like in the platform's own chat: the cheer's tier graphic or the Kick gift's sticker, then the number in the tier or gift colour (e.g. KICKs · trainwreck_fan · Rage Quit [sticker] 500). A KICKs line also names the gift. Cheermotes inside the cheer's message render as graphics too. - **Twitch goals** name the goal, its type and how far it got: the goal text you set on Twitch, the translated goal type (Followers, Sub points, Subs, New sub points, New subs, Bits or Cheerers) and the progress as a plaque in your language's number format, e.g. "Goal Started · Road to 14k · Followers [13,284/14,000]". When the goal ends, the line is labelled **Goal Reached** or **Goal Missed** (a goal you end early on Twitch counts as missed - Twitch sends no separate cancel event). Progress in between is not shown as a line, so a busy goal never floods the chat. A part Twitch did not send is left out. - **Twitch polls and predictions** show their title after the row label when Twitch supplies one. Poll start, prediction start, prediction lock and poll delete rows show the label and title only. A poll end with votes and a named winner adds a plaque with the trophy, winning option and vote share, e.g. 🏆 `Elden Ring · 62 %`. If the leading options tie, the plaque reads **Tie** (**Unentschieden** in German) without a trophy; with no votes, there is no result plaque. A prediction end reads **Prediction Resolved** (**Vorhersage aufgelöst**) with a trophy and the winning outcome, or **Prediction Canceled** (**Vorhersage abgebrochen**) when canceled. Without a status from Twitch, its label remains **Prediction End** (**Vorhersage Ende**). The line does not name who started or ended a poll or prediction: Twitch sends no creator for these events, and Lumio does not start them. The full breakdown of options, bars, channel points and Bits stays on the Events page and in the chat alert box. - **YouTube polls** show the poll question after the row label, as one line per poll: vote updates do not add lines. The live share per option is in the chat alert box and on the Events page. - **Stream Offline** shows how long the stream ran as a clock plaque, e.g. "Stream Offline [3h 12m]", on Twitch, YouTube and Kick. When the start time is unknown (for example an older Twitch or YouTube event) the line shows the label only. Hover the token for the full amount, e.g. "Rage Quit · 500 KICKs". When no graphic is available (a Kick gift Lumio does not know yet, or an older line), the line shows the coin or bolt icon with a text plaque such as "500 KICKs" or "500 Bits" instead. "KICKs" is Kick's product name and is not translated. - **Bits and KICKs graphics** follow the theme (Twitch's light images in the light theme) and your **font size**; in a chat message and on the inline event line they also follow the **Emote Size** slider, exactly like an emote. Lumio loads Twitch's larger cheermote image on high-resolution screens and whenever the graphic is drawn large, so it stays sharp at big font or emote sizes. The graphics move only when animations are allowed: with **Animations in Lumio** off, or your system's reduced-motion setting on, they show their still image - and where the platform sent no still image, the plain text (`Cheer100`) instead of an animation. In the light theme the amount colour is slightly darker so it stays readable. - **Resubs** (Twitch and Kick) name the months, e.g. "3 months". - **Gift subs** (Twitch, Kick, and YouTube gift memberships) name **how many were gifted and who received them**: after the gifter's name comes the count as a small accent plaque, e.g. "×5", and then the recipients as a muted suffix, e.g. "to Ann, Bob, Cara, Dan, Eve". **Every** recipient is named, however large the bundle - the list is never shortened to a "+N" and never clipped; the segment simply **wraps** onto the next lines. A single targeted gift shows the one recipient's name. If the recipient list has not arrived yet (a large bundle is announced the instant it starts, its recipients a moment later), only the count shows and the "to …" part is left off - no empty separator. The recipient names are plain text, not clickable: the clickable name on the line is the gifter, matching who the User Info Card would open. - **Hype Train** (Twitch) shows its **level** and running **total**: the line reads "Hype Train · Level 3" with the total as an accent plaque, e.g. "4,200 Points". The end-of-train line reads the same way. Hype Train has no single viewer, so its name is not clickable. - **Raids** (incoming, Twitch) name **who raided and what they were streaming**: after the raider's name comes the raided stream's **title** and **category** as muted suffixes, and the **viewer count** as a plaque with a people icon, e.g. "150". If any of those details is missing it is simply left off - no empty separator. - **First message** (a viewer's first-ever message in the channel) does **not** get a separate line - for a first message the event *is* the message. Instead the message itself becomes a **spotlight card** - see [Spotlight cards](#spotlight-cards-first-time-chatters-and-watch-streaks) below. - **Click the name on an event line** to open the same **User Info Card** a message name opens - the quick moderation and profile context for that viewer. This needs the *View user info* permission (the same one a message-name click needs); without it the name is plain text. The name is only clickable where a real platform user backs the line: events with no viewer (a hype train, a Spotify track, a stream going live), the anonymous gifter (Twitch or Kick), and any event whose payload carries no user ID show no click target, so a click never opens the wrong card or nothing at all. Kick events carry the viewer's Kick user ID, so a Kick follower, subscriber, gifter, KICKs sender or redeemer is clickable like a Twitch viewer, and the card's **Open Profile** link goes to their Kick channel. Beyond the watch streak, the inline catalog is the **same list as the Events feed** - every event type the feed can show can appear inline too. That covers the high-value stream events across platforms (subs, resubs, gifts, cheers, KICKs, tips, superchats, raids, the hype-train lifecycle), follows, channel-point reward redemptions and their Kick status changes, the Spotify now-playing family, **and** the stream-lifecycle events (stream online / offline / updated on Twitch, YouTube and Kick, and Twitch ad breaks), Shopify orders, Discord member events, and copyright notices. **Every event type is shown by default.** Tune which types appear, and how each line looks, on the **Events** tab of the chat settings (see below); the choice is per browser. **7TV emote changes** also appear as chat lines: an emote is added, removed or renamed, or the channel switches its active 7TV set. The line shows the 7TV editor's name as plain text, the emote image and name (struck through when removed), an `old → new` name for a rename, or the set name and an emote count when known for a switch. In chat, consecutive additions or removals by the same editor in the same set combine when each is within 30 seconds of the last and no other line intervenes. Up to 12 emote images appear, followed by `+N more` for the rest. Renames and set switches stay separate. The Events page and stored history keep each change as its own event. A newly available emote normally reaches the picker and autocomplete after the live event, with a short refresh delay. Events from before the listener ran or during a full outage cannot be recovered. A stream-lifecycle line (for example **Stream Online**) has no viewer behind it, so it renders its label and the stream details - the title on go-live, the changed title or category on **Stream Update** - with no avatar and no name. The **All** chat tab keeps the mixed feed: all chat messages and all visible inline event lines. Platform tabs narrow both parts of the feed: the Twitch tab shows Twitch messages and `twitch:*` event lines, the YouTube tab shows YouTube messages and `youtube:*` event lines, and so on. A 7TV line also appears in every platform tab whose channel uses that set, including older history, when the event lists those platforms. If the platform list is missing, the line appears only in **All**. At the top of the **Events** tab a **Show Event Lines** master switch turns the inline event lines off at once. It is a display switch only: turning it off leaves the per-type selection below untouched (dimmed so you can see it resting intact), and turning it back on restores exactly the choice you had. Two lines are **always** kept: the **first-message marking** and the **Watch Streak** stay visible even with the switch off, because both carry the viewer's own chat message - so they have no per-type toggle either, just a note in the master switch's description. The count badge only ever counts the toggleable types, so with the switch off it reads 0. The display options under the master switch (avatar, platform icon, timestamp, event label) also carry **Events from Shared Chat partners** (on by default). Off hides every event that happened in a [Twitch Shared Chat](#twitch-shared-chat) partner channel; the four Shared Chat session lines (start, join, leave, end) always stay. Below the master switch, the event types are grouped into ten named groups - Twitch, YouTube, Kick, **7TV**, Spotify, StreamElements, Discord, Shopify, Copyright, and **System** (only the test event). A stream going online/offline, a title change and an ad break belong to their platform's group; the **Kick** group lists every Kick type - follow, sub, resub, gift sub, KICKs, reward, reward status, and stream online/offline/update. Each group is **collapsible and starts collapsed**, with a "*n* of *m* on" counter on the heading so a closed group still tells you how many of its types are enabled; click the heading to expand it. The **7TV** group has four switches for added, removed, renamed and set-switched events, all on by default. The Events page has a 7TV platform filter and type filters, and the test-event panel can preview all four types. The Twitch group also has individual, default-on switches for **Message pinned**, **Pin duration changed**, **Pin removed**, **New Mod**, **Mod removed**, **New VIP** and **VIP removed**. Kick has **Message pinned** and **Pin removed**. The pin type labels do not repeat the platform name; the icon at the start of the row identifies it. Moderator/VIP rows are available only for Twitch. A **Pin removed** or **Pin expired** line says how long the pin stood: in seconds or minutes under an hour (`45 sec`, `12:04 min`), in hours and minutes from one hour (`1 hr 8 min`), and in days and hours from one day (`2 days 20 hr`), rounded down. **Each group heading is also an on/off switch** - a separate toggle beside the collapse control. Switch *Twitch* off and every Twitch event line disappears at once - handy when you follow one platform's chat but not another's - while the per-type selection underneath stays exactly as you left it (it just dims), so switching the group back on restores your choices. Switching *Kick* off silences every Kick line, including types added after you saved your settings. Turning a group off leaves the master switch (which is the "all groups at once" lever) untouched, and the always-on Watch Streak line still shows even with its group switched off. Newly added groups start on, and so do event types added after you last saved: a type your saved selection does not mention yet is shown by default. (This is separate from the platform **tabs** at the top of the chat, which still narrow the feed by the event's platform prefix.) Event lines follow the same **Highlight Events** setting as the rest of the chat (the one under *General*): `Off` paints neither the accent border nor the tint, `Border` the left accent border only, `Background` the tint only, and `Border + Background` both. The setting applies on both the compact and full line densities. **Density** itself is a *General*-tab control and is global - it applies to every message and every event line alike. `Full` (the default) keeps the standard spacing and your font size. `Compact` tightens the spacing and makes the **whole chat slightly smaller** - messages, event lines, avatars and emotes shrink together, still following your **Font Size** and **Emote Size** settings. Density never changes the highlight. The event's icon and label keep their accent colour so the line stays recognizable even with the highlight off. Event lines always use the **same text size as chat messages** and follow the **Font Size** slider exactly like messages do. On both kinds of line the time, platform icon, avatar, badges and event icon sit centred on the first line of text at every font size. These event lines are **loaded back with the message history**, at the same moments the messages are: when you open the chat, when you scroll up to reveal older messages, and after a reconnect. So after a reload the feed shows past event lines interleaved with the restored messages - not just new events as they happen - and the **first-time chatter** and **watch streak** spotlight cards return where they belong (without replaying their arrival animation). On open, reload, or navigation the feed **lands at the newest line** and stays there until you scroll, even when the most recent lines are event lines rather than chat messages - the later history and event waves, and any avatars or embeds that finish loading afterwards, keep the view pinned to the end instead of leaving it above them. The dashboard chat and the pop-out behave identically. Messages and event lines are loaded over the **same time window**. Each time you open the chat or scroll up, the feed pulls back one shared window - up to a full page of messages *or* about a day of history, whichever comes first - and fetches the event lines for exactly that step's span, from the oldest message the step revealed up to the top of the same window. Because each step's events stop at the top of that step, a **quiet gap above a batch of messages stays event-free** - scrolling up no longer drops you into days of event lines skimmed off the top of the empty stretch between two sparse bursts of chat. On open, the event history stops at your most recent chat message rather than reaching to the present, so a channel that has been quiet for a while (its last chat is old but its events - Spotify now-playing, follows - kept streaming) no longer opens on a wall of message-less event lines with the chat far above them. On a busy channel a page fills in minutes, so a step loads the same amount of messages it always did; on a quiet channel the step is bounded by time instead, so you no longer scroll through days of event lines to find the next batch of messages. **The number of event lines a step brings back is tied to the messages that step revealed** - a quiet window that holds only a handful of messages pulls just a few events, so a chat-still but event-busy channel (Spotify now-playing, follows) no longer buries each scroll-up under a wall of message-less event lines; a full page of messages still pulls a full page of events. When a window holds more events than that budget or than the feed keeps, the **event lines** are trimmed - the chat messages are never dropped and no gap appears in the message history. **Whenever you are reading history** - the moment you scroll up off the bottom - the event lines *older* than the oldest message you have loaded are held back, so events walk back in step with the messages instead of a multi-day wall of message-less event lines running ahead of them. This holds on a chat-quiet channel too, where the feed always technically still holds the newest message; you no longer scroll up into a multi-day stretch of event lines with no chat between them. **While you are reading history the inline event lines are also thinned per gap:** at most a few event lines appear between any two chat messages (the ones closest in time to the message below them), so however many events a window minted, scrolling up reads like a chat with the occasional event line rather than an event log. The thinned-out events are still available in the **Events** feed. This thinning applies only while you browse history - at the live tail every event line shows as before. The **newest** event lines, by contrast, are never removed while you browse: on a chat-quiet, event-busy channel the buffer keeps holding the newest message the whole time, so the most recent event lines simply stay parked at the tail below your view and are **revealed again - never re-fetched or re-assembled - when you scroll back down**, so nothing flickers or reloads at the bottom. (Only on a busy channel, where a long scroll-up actually pushes the newest messages out of the in-memory buffer, are those newest lines dropped until you jump back to the present.) **Any upward scroll lets go of the live end** - even a single mouse-wheel notch, a short touch swipe or *Arrow Up*: the feed stays exactly where you left it, and new messages, event lines or late-loading avatars and GIFs no longer pull you back down. The chat **follows the newest messages again only once you are back at the very bottom** - by scrolling all the way down, with the jump-to-bottom button, or with *Page Down*. When you scroll up and older content loads, the row you are reading **stays where it is**: the newly loaded messages appear above it, so they are easy to find. Scrolling up loads the next window **before you reach the very top**, so the trigger fires even when the lines above you are mostly events. **Scrolling back down never fetches anything on its own** - it simply reveals the messages you already loaded, so returning through your history is smooth and never re-pulls lines that are already there. Coming back to the present is a deliberate step: the **jump-to-bottom button** or *Page Down* **reloads the newest messages**, so after a long scroll back through history you land on the current tail rather than a stale one, without reloading the page. If a long scroll-up pushed the newest messages out of the in-memory buffer, the jump-to-bottom button **stays available even once you reach the bottom** of what is loaded, so the live tail is always one tap away. (Only events kept in the server's event history come back; broadcaster-only alert frames such as same-level hype-train, poll, prediction, and goal *progress* ticks are never stored and so cannot be restored. Hype-train milestones - the start, each level-up, and the end - **are** stored and do come back.) Restored event lines are treated as history: they never move the unread counter or the "new messages" divider and never play the notification sound. Inline events arrive on the dashboard chat and on a logged-in pop-out; a delegated or share-token pop-out receives them (live and restored alike) only when its token was granted `events:read`. An event line stays visible even when it is **older than every chat message currently in the feed**. This is the normal case for Spotify track lines: while you play music before going live, or after the stream ends, the chat is quiet, so those lines predate the first message that later arrives. While the feed is following live, such lines are pinned to the **top** of the feed rather than dropped - they only leave when you close or reload the chat. Scrolling up into history is different: there the feed shows the events that belong to the message window you have scrolled to, so an event line settles back between the messages it happened around. The dashboard chat and the pop-out behave identically. ### Spotlight cards (first-time chatters and watch streaks) These captures show the current chat in dark, light and compact density.
First-time chatter and watch streak in dark chat
First-time chatter and watch streak in light chat
Compact first-time chatter and watch streak in dark chat
Two moments have a **spotlight row** with a coloured edge and soft tint. In full density, the type and hint sit above the message; the platform icon, time, avatar, badges and name follow the normal chat settings. - **First-time chatter** (Twitch) - a blue edge and **First chat** heading, followed by the viewer's message and a **Greet** button when you can write. Replies, `/me` messages and deleted messages keep working in the row. - **Watch streak** (Twitch) - a warm edge, **Watch Streak** heading, streak count and the viewer's own message when present. Badges, name colour and avatar come with the event; older streak events from before this change simply show no badges, the default name colour and an initial instead of the avatar. **Greet a first-time chatter.** The **Greet** button on a first-time-chatter card does two things: it sets that message as your **reply target** (on Twitch and Kick a native reply, so the viewer is notified; on YouTube the usual `@Name` is added when you send) and fills the chat box with a greeting, cursor at the end. **It never sends on its own** - you can edit the text and send it with Enter. If you already typed something, your text stays and only the reply target is set. The button only appears when you may write in that chat. The greeting is set in the chat settings, **Events** tab, field **Greeting text**: - **Untouched**, it is the default in your **Lumio language** ("Welcome to the chat, {name}! Glad you're here" / "Willkommen im Chat, {name}! Schön, dass du da bist") and follows a language switch. - **Your own text** is used exactly as written, whatever the language. `{name}` becomes the viewer's display name. No `@` is added - the native reply already notifies them. - **Empty** means Greet only sets the reply target. - Up to 300 characters; **Reset to default** goes back to the language default. The field travels with presets and settings transfer like every other chat setting. **Hover actions.** Both cards carry the same hover bar as a normal message, also reachable with the keyboard (Tab into the card). On a first-time-chatter card it is exactly the message bar: reply, copy, delete and the moderation menu. On a watch-streak card it offers **copy** (when there is a message) and the **moderation menu** (timeout and ban, monitor, restrict), under the same permissions as on messages. A watch-streak card has **no delete**: Twitch documents deleting only for chat messages, not for streak notices - a short timeout clears the viewer's messages instead. Older streak events without badge data offer no moderation at all, so a moderator is never shown a timeout button against another moderator. **Moderated viewers are not celebrated.** If the viewer is **restricted** or **monitored**, the first-time-chatter message stays a normal row with the red/amber moderation styling and only a small **First chat** pill; the watch-streak card swaps its frame for the red/amber edge and its flame stands still. **Motion.** A card arriving live enters once, quietly (a short fade-in, a soft glow and three small sparks around the pill; the streak count counts up). Cards loaded from history, after a reconnect, or when you scroll, filter or reload never replay that entry. The streak flame keeps a gentle flicker and pauses while scrolled out of view. With **Reduced** motion, the cards use a brief fade; at **Off**, they show their final look immediately. The seasonal "animations off" switch only affects the seasonal decoration, not these cards. **Density and width.** With the global density **Compact** the card becomes a **spotlight row**: built like a normal chat line (time, platform icon, small avatar, badges, name, icon, message), only the frame makes it 2 px taller. The spotlight type is an icon with a tooltip, without a text label; Greet becomes an icon button. In a very narrow column the full card drops the hint text and Greet becomes an icon button. These cards follow the **Badges**, **Platform Icon** and **Timestamp** settings like the rows around them. "Highlight Events" and the event-line **Avatar**/**Label** options do not apply to them - frame, avatar and spotlight mark are part of the card. ### Creating and editing a rule Rules are built in a **full-screen, three-step assistant**. Open it from the **Filters** tab of the chat settings (the *New* button) or from the **+** at the end of the chat's tab bar - both open the same assistant and produce identical rules. The assistant fills the whole chat window, so it works inside a pop-out too. The three steps follow *what does the rule match?* → *where does it apply?* → *how is it shown?*: 1. **Rule** - the rule's name plus its **keywords** and **prefixes** (both chip fields: type a value and press `Enter` or `,`). You cannot leave this step until the rule has a name and at least one keyword or one prefix. 2. **Scope** - the matching mode (anywhere vs. whole word) and the platform the rule applies to. Both have defaults, so you can pass straight through. 3. **Appearance** - show the rule as its own tab, give it a highlight colour, and pick a notification sound (with a preview button). These come last on purpose: for a quick command tab you just press *Next* twice. When you **create** a rule the assistant is linear - *Back* and *Next*, with the finish button only on the last step. When you **edit** an existing rule the same assistant opens with every step clickable in the progress bar and **Save** active on every step, so you can jump straight to *Appearance*, change a colour, and save without touching the other steps. Closing the assistant (the **×**, *Cancel*, or `Escape`) discards everything - a half-filled assistant never leaves a rule behind. ### Keyword tabs and history A keyword rule can also appear as its own **tab** above the feed - a filtered view that shows only the messages matching that rule. Keyword tabs behave like the main feed, with three additions: - **History back-fill.** A keyword tab starts from what is still in the in-browser buffer, then pulls older matches from the server as you scroll up - the same infinite-scroll as the main feed, but querying the keyword search over the last 12 hours. Live and back-loaded messages are judged by the same matcher, so a message never shows in one place and not the other. If a fetched page happens to contain no visible match, the tab loads on automatically - capped, so a keyword with no recent hits never loops. - **History timestamps.** Rows pulled from history always show their timestamp (even when timestamps are switched off globally), because for a keyword tab *when* something was asked is the point. A single divider separates the back-loaded history above from the live buffer below. - **A named end.** When there are no older matches left in the window, the feed shows `No older matches in the last 12 hours` instead of the scroll indicator simply vanishing. Because a keyword search is time-bounded and retention trims old data, reaching this end is normal. Each tab keeps its own scroll position, unread divider, and jump-to-latest counter: switching tabs and back returns you to exactly where you were reading, and one tab's new-message counter never leaks into another. Entering a keyword tab that has unread matches draws a `new messages` line at your read boundary and lands you there, so the matches the badge counted are the first thing you see. **Leaving that tab counts as read:** as long as you did not scroll while it was open, the line does not come back when you return - unless new matches have arrived since, which draw a fresh line. The preserved read position of the other views is unaffected: scroll up to read history in `All` (or a platform tab), switch away and back, and you return to exactly that spot with its divider intact, because the messages below it there are genuinely still unread. ### The tab bar Platform tabs and keyword tabs share **one row** at the top of the chat. When there are more tabs than fit - many rules, or a narrow pop-out - the row **scrolls sideways** instead of wrapping onto a second line, so the chat never loses height to the tab bar. The **+** (new rule) and the **gear** (settings) sit outside the scrolling area and stay in place; everything else scrolls. - **On a mouse/desktop**, a left and right **arrow** appear at the edges of the row only while there is more to reach in that direction, and a soft fade marks the scrollable edge. Click an arrow to scroll the row. - **On touch**, there are no arrows - **swipe** the row sideways instead. - The **active tab always scrolls into view** when you select it, and again after a reload restores your last tab, so the tab you are reading is never hidden off-screen. - On narrow widths an inactive keyword tab collapses to just its **colour dot and unread count** to save room; the active tab keeps its label, and hovering a collapsed tab shows its name. **Reordering.** On a mouse you can **drag a keyword tab** left or right to reorder it - a click still just selects the tab; only a small drag starts a move. Touch keeps the **up/down arrows** in the **Filters** settings list for reordering. Both write the same order, and it survives a reload. ### Twitch Shared Chat [Twitch Shared Chat](https://help.twitch.tv/s/article/shared-chat) lets several channels run **one shared chat** - a message sent in another channel's room also appears in yours. Lumio recognises these cross-channel messages and shows **which channel each came from**, attributed by channel (the public broadcaster), never by viewer. - Each shared-chat message row carries a square, rounded-corner **origin channel avatar** after the Twitch icon and before the badges. Its ring has that channel's colour; the avatar stays on older messages after a channel leaves. YouTube and Kick rows have no origin avatar. - The **Shared Chat** bar appears below the tabs as soon as a session starts, even before a partner writes. It shows the Twitch icon, the host first with a crown, your channel next, then partners in joining order (up to four avatars and `+N`), live status and **Summe aller Kanäle**. It appears in **All** and **Twitch**; **YouTube**, **Kick** and keyword tabs show neither the bar nor Shared Chat session event lines. The tabs themselves have no channel avatars. - Open the bar for a scrollable channel list with categories and viewers. **Only this channel** filters the chat locally; **Open on Twitch** visits that channel. Select a channel to see its card with live information, follower count when available and messages observed in this browser since session start. Escape returns from the card to the list, then closes it. Hovering a channel avatar with a mouse or activating it opens the same card directly. - In an OBS popout the bar follows **Show Header**: with the header hidden, the bar is hidden too, while origin avatars and event lines remain. There is no separate Shared Chat bar setting. **Show Chat Input** does not affect the bar. Start, join, leave and end appear as Twitch event lines at their event time. Start and end have expandable channel details in chat; the Events feed shows those details expanded. The ended bar gives the session duration and the number of all partners seen, including partners who left earlier, then closes after eight seconds. A reload or tab switch shows the current state without an entrance animation. Full motion animates fresh joins and departures and pulses the live dot; Reduced fades without movement; Off switches immediately. Your **Chat/Events/Music** motion choice can reduce motion further. Events in a partner channel of the session - subs, resubs, gift subs, upgrades, pay-it-forward, raids into the partner and Bits - show up in your chat and events feed too. Each one carries a **channel chip** - the same channel-coloured origin avatar as a shared-chat message plus the channel's name in a grey pill - directly before the event icon, for example `12:04 · Twitch · avatar · [▢ KiraPlays] · Gift Sub · Nordfan_24 · 5× Tier 1`. A long channel name is shortened with `…`; hover the chip for **"Happened in {channel}'s channel (Shared Chat)"**, which screen readers also announce. The chip shows even when the platform icon, the event label or the event avatar is switched off. Shared-chat **messages** keep the bare origin avatar without a name, like on Twitch. Partner events follow the same event filters as your own events of that type. To hide all of them at once, switch off **Events from Shared Chat partners** on the **Events** tab of the chat settings (on by default). Shared Chat start, join, leave and end stay visible with the switch off, and messages from partner channels are not affected (use **Only this channel** in the Shared Chat bar for that). The switch only changes what the chat shows. Partner events belong to that channel, so they do not count in your stream history, do not trigger your automations and never open one of your own alerts (raid countdown, hype train) above the chat. Messages sent through a channel-points reward with text input carry an orange **Redeemed with channel points** chip after the badges, in your own chat and from partner channels (where the origin-channel avatar names the channel). Twitch does not send the reward's name or cost with the message, so the chip names neither. Shared-chat display is automatic - there is nothing to enable, and it needs no extra permission. ### Hide and highlight lists The **Filters** tab of the chat settings (gear icon) carries four editable lists, grouped under *Hide* and *Highlight*. Each is a chip field: type a value and press `Enter` or `,` to add it, `Backspace` on an empty field removes the last chip, and the `×` on a chip removes it. Every change is saved to your browser immediately and survives a reload - including in a token pop-out opened without signing in. The two **user** lists - *Hidden users* and *Highlighted users* - suggest matching viewers as you type (from two characters on), drawn from the people your account has seen in chat. Pick one with the mouse or the keyboard (`↑`/`↓` to move, `Enter` or `Tab` to take the marked entry, `Esc` to close) and the exact **username** is added - so the rule keeps matching even after that viewer changes their display name. A rule is not tied to a platform: one entry matches the same name everywhere you are connected, and a name seen on several platforms appears once. The suggestions need the *View user info* permission; without it - or if the lookup is briefly unavailable - the field simply lets you type the name in by hand, exactly as before. The *Command prefixes* and *Hidden words* lists are word lists and do not suggest. Under **Hide**: - **Hidden users** - messages from these users are hidden. The match is case-insensitive against the username *and* the display name. - **Command prefixes** with a **Hide commands** switch - while the switch is on, any message whose text starts with one of these prefixes is hidden. `!` is pre-filled; add your own (for example `#` or `?`) or remove them. The switch is the master toggle - with it off, the prefixes are ignored. - **Hidden words** with a **Whole word only** switch - messages containing one of these words are hidden. By default the word matches anywhere in the text; turn on *Whole word only* to match on word boundaries instead (so `art` no longer hides `start`). Under **Highlight**: - **Highlighted users** - messages from these users get a **highlight colour**, matched the same way as the hidden-user list. Each highlighted user has its own colour, edited from the **colour button** on the left of the chip - a small pill showing the current colour and a pencil icon. Clicking it opens a **colour dialog** with a **Highlight** switch, the colour picker, a **Default colour** option, and a live preview of a chat line in the chosen tone. Leave the switch on and keep the default to use the standard highlight colour, pick a colour for a custom one, or **switch it off** to keep the name in the list but stop highlighting it (the chip then reads dimmed, with no colour dot). Changes apply only when you press **Apply**; **Cancel** discards them. This highlight **overrides** the automatic mod/VIP row colouring - a highlighted moderator's row shows the highlight colour, not the mod colour - while their mod/VIP **badges stay visible**. Choosing a custom colour only changes how the row looks; it does **not** change priority - the highlight still ranks below a self-mention, a keyword-tab colour, and any moderation treatment, and it follows the same border/background highlight style as the rest of the chat. - **Notification sound** - one built-in sound that plays when any highlighted user sends a message. Pick a sound from the dropdown - twelve built-ins grouped by character (`Chime`, `Ding`, `Marimba`, `Pop`, `Blip`, `Soft`, `Bubble`, `Click`, `Knock`, `Alert`, `Arpeggio`, `Cascade`) - or leave it on `No sound`. Every row has its own **play** button, so you can preview each sound right in the open menu (it plays the row you click without changing your selection or closing the menu), audible even when the volume slider is at `0`. All twelve are loudness-matched, so switching between them does not jump in volume. This is **one sound for the whole highlight list** - not a separate sound per user. A **switched-off** highlighted user is silent, exactly as their row is no longer coloured. If a message both belongs to a highlighted user **and** matches a keyword rule that has its own sound, the **keyword rule's sound wins** - the same order as the colour cascade. The shared **Notification volume** slider and its 1500 ms anti-spam gap cover this sound too, so there is no second volume control and no double-ringing. A message you sent yourself is never hidden by these lists, and a message under a `Monitor` or `Restrict` moderation treatment always stays visible with its treatment marking, regardless of the hide lists. A keyword-tab match also shields a message from the hide filters, so a `!frage` tab is not blanked by the default `!` command prefix. ### Hidden and deleted messages Filter-hidden messages are only hidden in your Lumio view. They are not deleted from the source platform and they are not written as moderation actions. By default a matching message is **removed from the feed entirely** - the row does not appear at all. If you would rather keep a placeholder you can click to expand and moderate what the filter took away, switch `Hidden Messages` to `Show placeholder`. A keyword rule's own tab also shields its matching rows from the hide filters so the tab does not immediately hide the messages it was created to collect. Deleted messages are different: Lumio marks a row as deleted when it receives a platform deletion event or when a delete/ban/timeout action succeeds in Lumio. Twitch and YouTube deliver individual message deletions to Lumio, including moderator deletes and YouTube author retracts. A message deleted on kick.com (by a moderator or by Kick's AutoMod) reaches Lumio through Kick's unofficial chat connection, so it is greyed out too - on a best-effort basis, since Kick offers no official event for it. Kick bans and timeouts always sweep the user's messages. The **Deleted Messages** setting only controls rows Lumio has actually received as deleted, and it affects the live Multichat feed only; the User Info Card still shows deleted rows in that user's message history with a muted deleted style. A deleted row is attributed to whoever removed the message. For a **Twitch** moderator delete the acting moderator's display name is shown ("Deleted by {name}"): Twitch's single-message delete event omits the moderator, so Lumio sources the name from the richer `channel.moderate` event and reconciles the two, correcting the live row the moment the moderator is known. **YouTube** moderator deletes stay attributed generically ("Deleted") because YouTube's delete payload carries no moderator identity to source a name from. ### Notification sounds Lumio plays a **notification sound** from two sources: a **keyword rule** that matches a message, and the **highlighted-user list** (see *Hide and highlight lists* above). In each case, pick one of **twelve** built-in sounds - grouped by character from soft to attention-grabbing (`Chime`, `Ding`, `Marimba`, `Pop`, `Blip`, `Soft`, `Bubble`, `Click`, `Knock`, `Alert`, `Arpeggio`, `Cascade`) - or leave it on `No sound`. Each row in the dropdown has its own **play** button that previews that sound in place, without changing the current selection, so you can audition the whole catalog without picking blindly. The sounds ship with Lumio - there is no upload - and are all loudness-matched, so switching between them never jumps in volume. - A keyword rule's sound plays on any live match, **regardless of whether the rule is shown as a tab** - a rule set to highlight-only (no visible tab) still rings. - The highlight-list sound plays when any switched-on highlighted user posts. It is **one sound for the whole list**, not one per user. - If a message matches **both** a sound-carrying keyword rule and a highlighted user, the **keyword rule's sound wins** - the same priority as the colour cascade - and only one sound plays. - A sound only plays for a message you can actually see. **Hidden messages do not ring:** a message removed by your hidden-user or hidden-word lists is silent, even if its author is also a highlighted user - you cannot get a chime for a line that never appears. The reverse holds as well: anything the feed shows can ring, including your **own** messages (so you can test a rule by posting the keyword yourself) and messages from users under a `Monitor`/`Restrict` moderation treatment. (A keyword-rule match still shields a message from the hidden-word list, so such a message stays visible **and** rings.) Sounds from a platform or keyword tab you are not currently viewing **do** still play - the chime is the cue to look there. - A single **Notification volume** slider in the chat settings' *Filter* tab controls the volume for **both** sources; set it to `0` to mute every notification sound. - Rapid matches are rate-limited, so a burst of matching messages plays at most one sound at a time rather than a wall of overlapping beeps. A sound the browser refuses to play does not consume that gap - the next match can ring immediately. - Sounds work the same in the pop-out chat, including a token pop-out opened without signing in. - Browsers only allow audio once you have interacted with the page, so Lumio enables playback on your first click or keypress anywhere in the chat. If a sound is still refused - most often in a pop-out window parked on a second monitor that you never click into - a **`Your browser blocked the notification sound.` / `Enable sound`** bar appears above the feed. One click on it both explains the silence and turns the sound back on; it disappears as soon as playback is allowed. Every window has its own permission, so a pop-out needs its own click even when the dashboard already rings. ### Platform toggles and YouTube multi-stream The platform-icon row at the top of the feed lets you enable/disable each platform for **reading** and **sending** independently. YouTube gets an extra dropdown for broadcast selection: - **All Broadcasts** shows messages from every YouTube broadcast (current and historical). - Selecting a specific broadcast filters the chat feed to show only messages from that broadcast's `live_chat_id`. Messages from other platforms (Twitch, Kick) are always visible regardless of the YouTube broadcast filter. - Outgoing messages are sent to the selected broadcast. Replies always target the original message's broadcast. - The dropdown works in both the dashboard and the popout chat window (browser source). ### Reconnect required If a platform's OAuth connection expires and needs reconnecting, that platform **keeps its tab** (it is never silently dropped) and shows a **reconnect-required marker** in place of its live counts, in both the dashboard and the popout. This is a per-platform OAuth state and is deliberately kept distinct from the WebSocket `Connection lost - reconnecting…` notice, which is about the live chat socket rather than a platform grant. Reconnect from **Account → Profile** or **Connections**; see [Connections → Reconnect flags](../features/connections.md#reconnect-flags). When a platform you can **send** on needs reconnecting, the [connection notices](../features/connections.md#connection-notices) under the header say so before a send fails - one entry per affected platform, paged with **‹ ›** when there are several. This covers **both** an expired *channel* connection **and** an expired *personal login* (the login you actually send with); they are two separate entries because they are repaired in different places. A dead **personal login** entry is red, comes first and sends you to **Account → Profile**; a dead **channel** connection entry starts the platform's OAuth flow when you may reconnect channels (`connections:create`), and otherwise - for a Moderator who can send there - links to the **Connections** page. The notices sit in the normal layout: they shorten the message feed and never cover chat messages, and a chat scrolled to the bottom stays at its newest message while they appear, change or disappear. The affected platform's send toggle is disabled so you cannot accidentally send a message that would just fail. In a delegated pop-out (a shared token window, not your own login) the entries have no button, because a guest cannot repair the connection, and the login-assignment notice is not shown. With the pop-out **header hidden** but the input visible, only the notices that concern sending (expired login or channel on a platform you can send on) appear, compactly, **directly above the message input**. With header and input both hidden nothing is shown, so a browser source never composites a notice into the stream. If a send still fails, the failed-send notice shows a plain, translated message - for a dead personal login it shows the reconnect line and a **Jetzt neu verbinden** button that opens **Account → Profile**; a message the platform itself refused (for example slow mode, a ban, or blocked content) shows "*Platform* rejected the message."; an internal Lumio error shows "Lumio could not send the message - please try again."; any other failure shows only "Send failed" with the status code. Every failed message keeps a **Retry send** and a **Dismiss** control. The raw server error is never shown. ### New permissions available When Lumio starts requesting a scope that an existing connection was granted without (for example Kick's KICKs and channel points), the connection keeps working but those events cannot arrive until you reconnect it. The Chat page then shows an amber **needs new permissions** entry in its [connection notices](../features/connections.md#connection-notices), with a **Grant {platform} permission** button that starts the same OAuth flow as the Connections page (from a popout window it opens in a new tab). Only chat platforms appear here. The entry is only shown to members who may reconnect channels (`connections:create`), and a connection that already needs a reconnect (above) shows only that entry. The Events page shows the same entry; see [Connections → Missing scopes](../features/connections.md#missing-scopes). ### Alerts overlay When a mod-facing event fires, a compact alert card pins to the top of the feed so you can act from the same screen: - **Raid incoming** - shows the raider and viewer count, with quick links. - **Raid outgoing** - the `Cancel Raid` button stays visible until the raid fires or Twitch reports `raid_done` / `unraid`. - **Ad break** - shows the duration and whether the break was `Automatic`. - **Hype Train** - live progress and `Hype Train Complete` summary at the end. - **Polls** - running totals per option with an `End Poll` shortcut. A YouTube poll shows its question and each option's share of the votes, with the vote count when YouTube sends one (its InnerTube feed sends only the share). The card updates on every vote, turns **ended** when YouTube closes the poll, then disappears after 90 seconds. It has no `End Poll` shortcut: Lumio can end Twitch polls only. - **Predictions** - `Lock`, pick a `Winner`, or `Cancel (Refund)` from the card. These shortcuts appear only with the relevant chat permission (`chat:raid`, `chat:poll`, `chat:prediction`). The broadcaster's channel connection token performs the action, including from a popout; a popout token's own permissions determine which buttons its viewer sees. A failed action leaves the card in place and shows an error. `TWITCH_CHANNEL_RECONNECT_REQUIRED` includes a hint to reconnect the Twitch channel. **Alerts survive a page refresh.** The alert box remembers the cards that are currently on screen - a hype train, ad break, poll, prediction, or outgoing raid - so a reload or navigating away and back no longer wipes them. Countdowns are anchored to absolute end times, so an ad break or raid resumes with the correct remaining seconds instead of restarting. A restored card looks exactly like a live one - there is no extra badge or marker - and its numbers are the last ones this browser saw until the next live event updates them. Anything whose end has already passed comes back **ended** (with its wind-down countdown) or not at all - never as "still running" - and nothing older than a few minutes reappears. A card you dismiss with the `X` stays dismissed across the refresh, and the dismiss also propagates to a second open window (dashboard ↔ pop-out) within a moment. If your storage was cleared, or you open the chat on a **different device**, Lumio additionally replays the recent Twitch event history (the last 30 minutes) to reconstruct a running alert - an ad break, poll, prediction, hype train, or outgoing raid. This server replay needs the `events:read` permission: the dashboard and the normal chat pop-out have it through your login, so both behave identically. An OBS dock opened with a **share token** that does not include `events:read` simply skips the replay and still shows everything that browser saw itself - it never errors and never blanks the box. YouTube polls are remembered on the device that saw them but are not part of the server replay. ## Sending messages The footer input supports: - **Plain text** - `Enter` sends, `Shift+Enter` inserts a newline. - **@mentions** - autocompletes against recent chatters. - **Emotes** - click the smiley icon to open the **Emote Library**: Recently Used, then one section per channel (Twitch and Kick alike, each labelled with the streamer's name and avatar), then 7TV/BTTV/FFZ, then Twitch Global, YouTube Global, **Kick Global** and **Kick Emojis**. The platform tabs above the grid narrow it to one platform; the **Kick** tab shows only your connected Kick channels' emotes, Kick Global, Kick Emojis and the 7TV/BTTV/FFZ emotes of those channels. Kick has no way to list the emotes of other Kick channels you are subscribed to, so those do not appear. Kick emotes that only the channel's subscribers can use carry a small lock and the tooltip `Subscribers only`. Clicking an emote inserts its **name** into the input; when the message goes to Kick, Lumio sends every known Kick emote name as a Kick emote. Search with the placeholder `Search emotes…`. The emote goes where the cursor is in the message field (at the end if you never clicked into it), and the cursor moves behind it, so several emotes land in the order you pick them. Lumio adds a space before and after an emote only where one is missing, so it never sticks to a word and never leaves a double space. The library stays open after an insert; close it with the `X`, the smiley icon or `Escape`. **Narrow windows and phones.** In a window up to 480 pixels wide (a narrow chat popout, OBS docks, phones) the library opens as a **bottom sheet** across the full width, right above the message bar. The message bar stays visible and usable: you can see what you typed, click into it, keep typing and send while the sheet is open. The chat above the sheet is dimmed; tapping it closes the sheet. The sheet is 60 % of the window high. Drag the grip at its top to make it taller or smaller (with the mouse or a finger, or focus the grip and use the arrow keys); Lumio remembers that height on this device, and a double click or double tap on the grip resets it to 60 %. Drag it down past its smallest size, swipe down quickly, tap the dimmed chat, use the `X`, `Escape` or your phone's Back button to close it. The platform tabs sit next to the search field and the section shortcuts run in a row under it. Wider windows keep the library as a panel above the message bar. **On a phone with the on-screen keyboard.** Opening the library closes the keyboard, so the sheet has its full height; the search field does not open the keyboard until you tap it, and then the sheet shrinks to 60 % of what is still visible. When you tap into the message field while the library is open, the keyboard opens and the library folds into **one row of emotes** right above the message bar: your recently used emotes first, then the rest in library order. Tap one to insert it at the cursor; the keyboard stays open. **All** (or dragging the grip up) closes the keyboard and opens the full library again. The message bar always sits directly on the keyboard, in the dashboard chat and in the chat popout, also when you switch between the search field and the message field. - **Reply** - click `Reply` on any message; the input shows `Replying to {name}` with an `X` to cancel. Sending requires the `chat:write` permission. The send toggles next to the input show a platform only when **both** are true for the **active account**: - the account has a **channel connection** for that platform, and - one of your **login connections** for that platform is assigned to the account. A login on its own is not enough - if you have a YouTube login but the active account has no YouTube channel, no YouTube toggle appears and nothing is sent to YouTube. Switch to an account that has the channel connected and the toggle returns (with your previously saved selection). A shown toggle is greyed out only while its connection needs reconnecting (see [Reconnect required](#reconnect-required) above). ## Twitch AutoMod held messages Twitch AutoMod holds some messages for a moderator instead of publishing them. With `chat:automod-read`, you see each hold at its original time in Multichat, with the sender, marked text and reason, such as a blocked link, blocked term or AutoMod category and level. The row shows the sender's avatar, name color and badges as Lumio last saw them when the message was held. If that look is unavailable, it uses the sender's latest chat row in the feed; a first-time chatter has the usual no-avatar placeholder and no badges. Your avatar, badges and compact-density settings apply to the held row too. With `chat:userinfo`, selecting the name opens the same user card as a regular chat message; without that right it remains plain text in every hold state. Twitch emotes and Cheers show their images and Cheer amounts, and 7TV, BTTV and FFZ emotes are matched from the channel's emote set. Links in held text are never clickable. The row shows how long it has waited; Twitch does not currently send Lumio an expiry deadline, so there is no countdown or expiry bar. Held text never goes to overlays or widgets. **Allow** and **Deny** require separate `chat:automod-allow` and `chat:automod-deny` rights. The action uses your own Twitch sign-in, and you must be a moderator for the channel. A channel connected before the AutoMod scope was requested needs one [reconnect](./connections.md). If your stored personal Twitch sign-in lacks `moderator:manage:automod`, the dashboard replaces the decision buttons on an open hold with **Sign in with Twitch again** and shows a [connection notice](../features/connections.md#connection-notices). A member with only read access sees the hold without action buttons. Popout tokens can be granted these three rights independently; a popout keeps its Allow and Deny buttons, and a failed click shows an error toast. The backend refuses a decision with `login_scope_missing` before contacting Twitch when the missing scope is known. An allowed message appears later as a normal chat message with a new Twitch message ID; the held row does not flash green. **Go to message** appears only when exactly one message from the same sender with identical text arrives within 30 seconds of the decision. A denied or expired hold is never published. A decision made on twitch.tv also resolves the row; if another moderator acted first, Lumio says your click came too late. After a reconnect, a hold that can no longer be found may say **No longer open**. If the queue cannot be read, the chat points you to twitch.tv. Lumio records allow and deny in the chat moderation log, with no separate audit event. Holds survive a reload and appear when you scroll back: the chat history loads them at their original time. Open holds retain their decision buttons; resolved rows say who allowed or denied them, or that they expired. The name still opens the user card when permitted in open, allowed, denied, expired, conflict and read-only states. History rows require `feature:multichat`, `chat:read` and `chat:automod-read`; with `history:read` you see holds across the account's chat retention window, without it the last 24 hours. Members, user and service keys, and popout tokens follow the same window rule. This queue is Twitch-only and uses EventSub and Helix, not Twitch IRC or PubSub. Kick AutoMod deletes immediately; Lumio attributes that deletion to **Kick AutoMod**, but there is no Kick queue or Allow/Deny action. Held messages can be reviewed later in the private [Stream History AutoMod tab](./stream-history.md#automod-log), subject to its retention. ## Moderation actions Hovering any message reveals a chevron that opens the **Moderation dropdown**. Lumio enforces a role hierarchy (broadcaster > lead moderator > moderator > user) so moderators cannot act on the broadcaster or peers. ### What each platform supports Moderation actions depend on what the platform's API allows. Actions a platform cannot perform are greyed out in the dropdown and the user info card, so you can see the limit before you click: | Platform | Delete message | Timeout | Ban | |----------|:--------------:|:-------:|:---:| | Twitch | ✅ | ✅ | ✅ | | YouTube | ✅ | ✅ | ✅ | | Kick | ✅ | ✅ (up to 7 days) | ✅ | The same table applies to the dropdown and to the [User Info Card](#user-info-card), whether you open the card from a chat name or from an event row. **Treatments** (monitor / restrict, below) are Lumio-native and work on every platform regardless of this table. **Timeout limits.** Kick accepts timeouts in whole minutes up to **7 days** (10 080 minutes), Twitch up to **14 days**. A longer custom value is not shortened silently: the timeout button stays disabled and a hint shows the platform's longest timeout. **Kick moderation uses your Kick login.** Lumio moderates Kick with your personal Kick login, so that login must include Kick's moderation permissions. If you logged in with Kick before Lumio asked for them, a Kick timeout, ban or delete shows *"Kick has not allowed Lumio to moderate yet - log in with Kick again"*. Open **Account → Profile**, click **Reconnect** on your Kick login connection (or sign in with Kick again) and approve the moderation permissions once. If you are not a moderator in that Kick channel, Lumio says so instead. If a moderation action fails for any other reason - an expired login or a platform rate-limit - Lumio shows an error message with the reason and leaves the user's messages in place. It never pretends a failed ban succeeded. ### Delete a single message Click the trash icon or `Delete` in the menu. Deleted messages show `Deleted message` in-place so the context of a conversation remains visible for other mods. Requires `chat:delete`. ### Timeout Presets: `1min`, `5min`, `10min`, `30min`. Pick `Custom timeout…` for any duration in minutes, up to the platform's limit (see above). Every timeout opens a confirmation dialog: > Are you sure you want to `Timeout 10min` `{name}`? Requires `chat:timeout`. ### Ban The `Ban user` button is destructive and always requires confirmation. Bans are permanent until reversed on the source platform. Requires `chat:ban`. ### Treatments (monitor / restrict) Lumio-native moderation flags that travel across platforms: - **Monitor user** - flags the user; their messages get a subtle amber badge and mod notes are surfaced. - **Restrict user** - stronger warning; messages get a red badge and are down-weighted in alerts. - **Remove treatment** - clears the flag. Treatments are stored in Lumio, not on the platform, so they work on every platform, independent of what its moderation API supports. ### Raids, polls, and predictions The alerts overlay surfaces Twitch raids, polls, and predictions as they happen and lets you act on the one in progress: - **Cancel a pending raid** - requires `chat:raid`. - **End an active poll** - requires `chat:poll`. - **End or cancel a prediction** - requires `chat:prediction`. Lumio does not start raids. It surfaces ongoing polls and predictions and lets permitted viewers end or resolve them. A raid card also disappears when the replayed `raid_done` or `unraid` event ends it after a reload. ## User Info Card Click any username (in chat or in the event feed) to open the user's card. It opens as a **full-page view** over the dashboard: a thin line in the platform's colour runs along the top, the platform's colours tint the background, and the page behind stays faintly visible through a slight blur. Close it with the `X` in the top-right corner or `Esc`. **Layout.** The header always shows the avatar, display name (with a `Partner` / `Affiliate` badge where the platform has one), the user's chat badges, `@username` and the platform **ID** (click to copy; hidden in [streamer mode](./streamer-mode.md)), plus **Open Profile** and **Refresh profile data**. On a wide screen the body splits into two columns: on the left, **who** the user is (profile facts, treatment status and the action buttons, which stay in view while you scroll); on the right, **what** they did (the tabs, at full height). On a narrow screen and in the popout chat (e.g. an OBS dock) everything stacks into one column: header, actions, then tabs. Profile facts and actions: - **Follower** / `Not following` badge with `Following since {date}`. - **Follower count** - how many followers the user's own channel has, in compact form in your language (e.g. `12K followers`, `1,5 Mio. Follower`). Twitch only: YouTube and Kick profiles, and Twitch profiles whose data has not been fetched yet, show no follower-count line. - **Account created** and **last updated** dates, the user's **message count** and when Lumio **first saw** them. - **Bio** (where the platform exposes one). - **Treatment status** - `Monitored` / `Restricted`, and which moderator set it. - **Moderation actions** - Monitor, Restrict, remove treatment, **Timeout** and **Ban**, subject to your permissions and the platform (see below). `Timeout` expands the duration choices (`1m` to `1d`, or a custom value in minutes or hours) right under the buttons; `Esc` collapses them again. A custom value above the platform's longest timeout (Kick 7 days = 168 hours, Twitch 14 days) is refused with a hint. Kick timeout and ban work here exactly as in the dropdown, from the chat card and from an event row alike. Every action asks for confirmation, and the confirmation appears **on top of** the card. - **Tabs**: - `Messages` - recent messages by that user in this channel, with the same moderation controls as the live feed. Links in these messages - including deleted ones - are clickable and open through the `External Link` warning, which appears on top of the card; `Esc` closes only the warning (or the confirmation) and leaves the card open. - `Mod Notes` - private notes shared with your mod team. Create, edit, and delete with `chat:notes`. Great for tagging repeat offenders or regulars. - `Events` - every event this user has triggered (follows, subs, cheers, raids, superchats, tips). - **Moderation History** - timeline of bans, unbans, timeouts, deletions, and treatment changes, each annotated with `by {moderator name}`. Timeout lengths show up to two units, e.g. `10m`, `1h 30m` or `1d 12h`. - **Currently Banned** - shows `Banned on {date}` if the user is still banned on this platform. - **Open Profile** - deep link to the user's page on the source platform. - **Highlight** / **Hide** - one-click shortcuts to the *Highlighted users* and *Hidden users* filter lists (see [Hide and highlight lists](#hide-and-highlight-lists)), to the left of the moderation buttons. They write the **same** name-based rule the Filters tab does, so the change also shows up there. Both rules are **cross-platform**: the button hides or highlights that **name** on every platform (and any same-named account elsewhere) - the button tooltip spells this reach out, which is why the buttons carry no platform icon. Each button toggles: click again to remove the rule (or, for a highlight you had *switched off* in the Filters tab, to switch it back on). While a user is hidden the **Highlight** button is disabled - hiding wins over highlighting. Hiding removes all of that person's messages from the live feed at once; the button flips to **Hidden ✓** so you can undo it, and the `Messages` tab notes that you are still looking at their history even though the live feed dropped them. These buttons appear only in the chat's card, not when the card is opened from an event row. Requires `chat:userinfo` (or `events:userinfo` when opened from an event row). ## Popout chat for OBS `/popout/chat` gives you a borderless chat window perfect for an OBS browser source, a Stream Deck, or a secondary monitor. The same moderation tools are available subject to the token's permissions. 1. Create a popout token under **Settings → Channel → Access → Popout Tokens** and copy it. 2. Open `/popout/chat?token=YOUR_TOKEN` in OBS as a custom browser dock, or in a secondary browser. The [seasonal look](/user-guide/preferences#popouts-and-obs) also shows in this popout; append `&season=off`, `&seasonStrength=subtle` or `&seasonMotion=off` to the URL to change it for this popout only. 3. Use the **Settings** cog (top right) to configure: - **General**: `Font Size`, `Emote Size` (a `0.5×`-`2.0×` scaling factor for inline emotes, shown as a factor next to the slider), `Density` (`Full` or `Compact`; sets the line spacing for both message rows and the inline event lines, and `Compact` also shrinks the whole chat slightly - see [below](#inline-stream-events)), `Timestamp`, `User Badges`, `Platform Icon`, `User Avatars`, `Deleted Messages`, `Show GIFs` (render Twitch GIF-keyboard GIFs; on by default - off loads nothing from GIPHY), `Show Header`, `Show Chat Input`, the **Viewer list** section (on/off, position, width, font size - see [Viewer List](./viewer-list.md)), `Highlight Events`, `Theme`, and a **Region & language** block (language, time zone, date format, and time format). The Region & language block is a second entry point to the same global user preference as [Dashboard → Settings](/user-guide/preferences); it applies everywhere you sign in, not just to this channel, and a hint says so. In a token-authenticated popout it is read-only with a pointer to the settings page. - **Events**: a `Show Event Lines` master switch at the top (turns the inline event lines off at once, leaving the per-type selection below untouched - except the first-message marking and the Watch Streak, which always stay visible), the **Greeting text** for the [Greet button](#spotlight-cards-first-time-chatters-and-watch-streaks), then how the [inline stream-event lines](#inline-stream-events) look (`Avatar`, `Icon`, `Timestamp`, and `Label`; icon and timestamp inherit the chat's own setting until you set them here), and a per-type visibility list grouped by platform, where **each platform heading is itself an on/off switch** for that whole platform's event lines. **Every event type is on by default** - untick the ones you do not want inline, or switch off a whole platform at its heading (the always-visible types have no toggle). Twitch, YouTube, Kick, and Spotify groups appear when the account has the corresponding channel connection; StreamElements appears when a StreamElements token is stored. Discord, Shopify, 7TV, Copyright, and System do not depend on these connections. If Lumio cannot read a connection or token status, its group stays visible (for example, StreamElements remains listed for a moderator without `se-tokens:read`). Disconnecting a platform does not erase its saved switches or its older event lines from chat history. - **Filters**: `Hidden Messages` (`Remove entirely` by default, or `Show placeholder`), the *Hide* and *Highlight* lists (hidden users, command prefixes with a `Hide commands` switch, hidden words with a `Whole word only` switch, and highlighted users each with a per-user colour that can also be switched off, plus one `Notification sound` for the whole highlight list), `Notification Volume`, and the keyword-rule list for editing, reordering, or deleting keyword tabs. **Show Header** also controls the Shared Chat session bar. Hiding it does not hide the origin avatars or Shared Chat event lines, and there is no separate Shared Chat bar switch. Display settings are stored per browser, so each popout window keeps its own look. Chat filters and keyword tabs are stored per account and are also mirrored so a token-authenticated popout picks up the same filter setup without a signed-in session. **Which account and user a popout represents.** A popout is tied to the Lumio account and member that its token belongs to. A token you assigned to a specific member acts as that member; a token left on **Yourself** acts as you, the member who created it (or who last removed its member assignment). That pairing — fixed by the token, not by anything the popout can see on screen — decides whose channel settings the popout loads for the account. ### Animations for this view Open the settings gear and choose **Animations → As global, Reduced, or Off**. The choice affects this view on the dashboard and in its popout, and is stored per browser and per user/account pair. It can only reduce motion further than your [global Animations in Lumio preference](/user-guide/preferences#animations-in-lumio); an unavailable looser choice is disabled. For an OBS popout, append `&motion=reduced` or `&motion=off` (use `?` if it is the first URL parameter) to apply a stricter choice to that URL without saving it. The global preference is read-only in a token popout. Live 7TV changes get a brief tint and emote/name motion at full speed. Reduced keeps only short fades; Off shows the final line immediately. History and reconnect gap-fill never animate. ## Where these settings apply **These settings apply to you on this account.** Every Multichat setting is kept in your browser (there is no server copy) and keyed to the pair *(you, the channel account you are viewing)* — never global, never shared across the accounts you can switch between, and never shared between team members of the same account. This covers **all** of them: the display options (font size, emote scaling, row density, whether timestamps, icons, avatars and badges show, the highlight style, how hidden and deleted messages look, GIF playback, and the inline stream-event controls — the event types shown, the on/off switch, per-platform toggles and their density) *and* the channel-facing ones (your **hide and highlight lists**, **keyword tabs**, the running **alerts overlay**, the selected **send platforms**, and the active platform tab). - **A different channel account starts empty.** Switch to a second account and these settings start from defaults — a fresh channel never inherits the viewer names or keyword rules you set on another one. - **A different team member starts empty too.** Each member on the same channel account keeps their own view, keybinds and filters; your settings are never applied to a teammate. - **The pair is resolved before anything loads**, so nothing flickers and no setting is ever written under the wrong account — a dashboard resolves it from your session, an OBS popout from its popout token's account and user. To reuse a filter list on another channel or hand it to a teammate, move it deliberately with [Export / Import](#save-and-transfer-your-settings) or a [preset](#presets); there is no automatic copy between pairs. ## Save and transfer your settings Because chat settings live in your browser, they exist only on the device (and browser profile) where you set them. That is a problem when you set up an OBS browser source, move to a second computer, clear your site data, switch accounts, or want a teammate to use the same filter list. **Transfer settings** solves this without any server. Open the **Settings** cog, go to the **Presets** tab, and use the **Export** / **Import** buttons below the preset cards (available in the dashboard and in every popout). On a plan without the presets feature there is no Presets tab, so the same Export / Import buttons appear at the bottom of the **General** tab instead. **Export** - tick the sections you want (grouped *Chat / Events / Music*); sections with nothing saved are greyed out. You can export settings from other surfaces too - e.g. export your Events and Music settings from the chat panel. There are four ways to take them with you: - **Copy code** - a single `LS1…` line you can paste into Discord, WhatsApp or an email. This is the easiest way to send settings to someone else. Its length is shown next to the button. - **Copy link** - a share link (`…/dashboard/chat#s=LS1…`) that carries the same code in its address. Opening it on any device signed in to Lumio lands in that feature's settings with the import preview directly, no typing. Both the code and the link update live as you change the selection. Very long links can be broken up by some messengers - for those, use *Copy code* or the file. - **Copy JSON** - the raw JSON on your clipboard, handy for support or hand-editing. - **Download** - a `lumio-settings-YYYY-MM-DD.json` file. Best for a backup. **Import** - pick a file, drop it onto the dialog, or paste **a JSON file, an `LS1…` code, or a share link** into the one input field (Lumio detects which it is automatically - a pasted link is reduced to the code it carries). A **preview** lists every section it found and shows what each one will replace **before** anything is written. Choose which sections to apply, then confirm. **Import replaces a section wholesale - it does not merge** with what you have. The page reloads after import so every view picks up the new settings. What can be transferred: chat display, chat filters, keyword rules, events display, event filters, and music display. What is **not** included: your theme, your Region & language preferences (language, time zone, date and time format), streamer mode, active account, the currently selected platform/tab, and emote caches - those are device, user, or account properties, not feature settings. > **Caution:** > The code, link, JSON and file are **reversible plain text - not a hash and not > encryption**. If you selected **chat filters** or **keyword rules**, they contain > the viewer names you personally hid or highlighted, and your keyword lists. That is > fine as a backup, but anyone you send the code or link to gets those names too. Importing something from a **newer** version of Lumio, an incomplete/truncated code, or anything that is not a valid Lumio settings file or code, is refused with a clear message - nothing is half-written. ## Presets Where **Transfer settings** moves settings between devices without a server, **Presets** save named snapshots to your Lumio account so you can switch layouts and reuse them across devices. Open the **Settings** cog and pick the **Presets** tab. - **New preset** opens a full-screen assistant: choose which of your current settings to store, then name it (and optionally mark it as your default). - Each saved preset is a card with **Load** (apply it here - the page reloads), plus icons to **share** it, **overwrite** it with your current settings, **rename** it, or **delete** it. Overwrite and delete ask for confirmation first. - **Share** opens a dialog to copy a **code** or **share link** for that preset (the same transport as Export/Import), and - with the `presets:share` permission and an active account - a switch to make the preset **visible to every member of the account**. Members can then load it; they can't edit, delete or re-share it. - A **Shared from the channel** section below your own cards lists presets teammates have shared into your account. You can **Load** them or **Save as your own preset** (which copies it into your list); loading one does not count against your quota. - A quota line shows how many presets you have used of your plan's limit (only your own presets count). Presets are per user and filtered by surface, so the Multichat Presets tab shows only chat presets. In an OBS popout you can **load** a preset but not create, change or share one - presets are authored in the dashboard. See the [Setting Presets](../features/setting-presets.md) feature page for the full details. ## Permissions | Permission | What it unlocks | |-------------------|---------------------------------------------------------| | `chat:read` | View the Multichat feed and the popout | | `chat:write` | Send messages from Lumio | | `chat:delete` | Delete individual chat messages | | `chat:timeout` | Timeout users (preset and custom durations) | | `chat:ban` | Ban users | | `chat:userinfo` | Open the User Info Card | | `chat:notes` | Read and write mod notes | | `chat:raid` | Cancel a pending raid | | `chat:pin` | Pin, re-time and unpin Twitch chat messages | | `chat:poll` | End an active poll | | `chat:prediction` | End or cancel a prediction | | `chat:refresh_user` | Manually refresh a user's platform profile from the User Info Card (once per 10 minutes per user) | The broadcaster / moderator hierarchy applies on top of these permissions - moderators cannot delete the broadcaster's messages even with `chat:delete`, for example. ## Tips & best practices - **Use treatments instead of escalating immediately.** `Monitor User` lets you keep an eye on borderline chatters before committing to a timeout or ban, and the badge stays visible across platforms. - **Add mod notes the moment a user does something worth remembering.** Future you (and every co-moderator) will thank you. - **Separate popouts for your team.** Give each moderator their own scoped token with `chat:delete`, `chat:timeout`, and `chat:ban` but not `chat:notes` if you want to keep notes private to a smaller circle. - **Keep the alerts overlay visible during events.** The raid/ad-break/hype-train cards are context you cannot get from the native platform dashboards without tab-switching. - **Emote search is fuzzy.** Type the first few letters to narrow huge emote collections like BTTV/7TV channel sets. ## Troubleshooting ### Messages appear only from some platforms - Check the platform toggles at the top of the feed - a toggled-off platform is hidden. - Check the **Filters** tab in the Settings cog. A hide-user, hide-command, or hide-word rule removes matching rows from the feed entirely by default (or collapses them into a placeholder if you switched `Hidden Messages`). - Open **Manage → Connections** and confirm each platform shows a green **Connected** badge. - For YouTube, make sure the dropdown on the toggle includes the stream IDs you expect. ### `Connection lost - reconnecting…` banner The WebSocket dropped. Lumio reconnects automatically with exponential backoff. If it persists, your token is probably expired - log out and back in. ### Send box is disabled You do not have `chat:write`, or you have no login/bot connection for the currently-filtered platform. Check your role and confirm the bot connection under **Connections → the platform tab → Chat bot**. ### Ban / timeout silently fails The target may outrank you - Lumio refuses to run a moderator action against the broadcaster or (on Twitch) against a lead moderator from a regular moderator. You will see `Mod-only on Twitch` or the button disabled on the dropdown. ### Emotes render as plain text The emote library has not loaded yet (check the `loadingEmotes` state) or the channel does not have the relevant 7TV/BTTV/FFZ set linked. Click `Refresh playlists` in the popout, or reload the page to force a refetch. ### External-link warning keeps popping up Lumio deliberately intercepts outbound links with the `External Link` modal so a single misclick cannot push a viewer to a malicious URL - in the live feed and in the User Info Card's `Messages` tab alike. Approve only links you actually trust. ## Related - [Events](./events.md) - shares the same WebSocket pipeline; mod-facing events surface in the chat alerts overlay. - [Roles & Permissions](./roles-and-permissions.md) - how to design scoped mod roles. - [Connections](./connections.md) - connect platforms and custom bot identities. - [Overlays](./overlays.md) - show Multichat on stream with the chat widget. --- ## User Guide / Viewer List Source: https://docs.lumio.vision/user-guide/viewer-list # Viewer List The viewer list is a side panel in [Multichat](./chat.md) that shows **who is in your chat right now** and **for how long**, grouped as **Broadcaster → Moderators → Bots → VIPs → Viewers**. People who left in the last 30 minutes are listed below in **Last seen**. It is available on the chat page and in the chat popout (`/popout/chat`), including OBS docks. The events page and the events popout have no viewer list. The list counts logged-in chat participants. Logged-out viewers are invisible to every platform, so the number is less than or equal to your viewer count. Twitch is supported; Kick appears when your account has a connected Kick channel. The settings name the supported platforms. The **Bots** group includes automatically recognised bots and accounts your channel marks under [Settings → Channel → Bots](./channel-settings.md#bots). A marked bot stays in Bots even if it is a moderator or VIP. Members with `chat-bots:edit` can manage the channel list; `chat-bots:read` allows viewing it. ## Turning it on 1. Open the chat settings (cog in the chat header). 2. On the **General** tab, find **Viewer list** and switch on **Show viewer list**. 3. Pick the **Position** (left or right) and the **Width** (*Auto* or *Fixed* with a 200–480 px slider). 4. Set the list's own **Font size** (8–32 px, default 14 px). It is independent of the chat's font size: a larger chat leaves the list as it is, and a larger list leaves the chat as it is. Position, Width and Font size are greyed out while the list is off. Every change applies at once, on the chat page and in the chat popout. The list is off by default. Switching it on — or applying a preset with the list on — always opens the panel. After that, the panel stays the way you left it with the header button or its **X**. - Without a connected supported platform the section is visible but locked, and it links to your connections. - Without the viewer list in your plan, or without the `chat:read` permission, the section is not shown. ## The header button When the list is on, a **people** button sits directly left of the settings cog — in the chat header, in the input row when the header is hidden, or in the floating corner group when header and input bar are both hidden. The button is an icon only (green while the panel is shown); the count lives in the panel head. - **Green** = panel shown, **grey** = hidden. - A click hides or shows the panel; the **X** in the panel head hides it too. The choice is saved **in this browser** for your account: after a reload the panel stays hidden or shown, and the chat page and the chat popout in the same browser follow it. An OBS dock has its own browser storage and keeps its own choice. - While the panel is hidden, Lumio does not follow the chat's viewers; the list refreshes as soon as you show it again. - The hide is not part of your display settings: presets, export and import never carry it. Applying a preset or an import of the chat display, or switching the list off and on again in the settings, shows the panel again. ## Reading the list - The head shows **In chat · count** (people in the chat now; *Last seen* is not counted), when the platform list was last updated, and the search box — with two or more platforms, the platform filter sits left of it. A divider under the search separates the head from the list; only the list scrolls. - Status notes (reconnect hint, source availability, or a platform's entry cap) appear below the divider, above the list. - Groups have sticky headers with counts and can be collapsed; collapsed groups are remembered. - Inside a group names are sorted alphabetically; newcomers appear at their alphabetical place. - Each row has two lines next to a round avatar (the initial in the user's colour until the avatar arrives): the display name, and below it **how long the person has been in the chat** — *just arrived*, *here 48 min*, *here 1 h 52 min*. Twitch lurkers can appear and get a time too; Kick's source lists only recent writers, so Kick lurkers do not appear. When a Twitch display name uses a non-Latin script (for example こさむん), the login follows the name in grey, the same rule as in the chat. - The times count up on their own (about every 30 seconds) and start when the person entered the chat during the current stream. ### Last seen Someone who leaves the chat moves from their group into **Last seen** at the bottom of the list, below Viewers. They stay there for 30 minutes, greyed out, with an eye icon and the time they were last seen — *last seen 4 min ago* — newest first; then they disappear. If they come back in that time, they move back to their group and their time starts again. - *Last seen* is collapsible like the other groups, and the collapsed state is remembered. - Its people are never part of the counts. - A click still opens their user info card. - A small speech-bubble mark means the person wrote in the last two minutes. - Use the search box to filter by display name or login. - With two or more supported platforms connected, a **platform filter** sits left of the search box: an icon button (stacked layers for *All platforms*, otherwise the chosen platform's icon) that opens a list of *All platforms* plus every connected platform. Pick one platform to show only its people — the groups, their counts, the head count and the search results follow at once. The choice is remembered; if that platform is no longer connected, the list shows all platforms again. Every row then also shows its platform icon at the end of the row (the *Auto* width makes room for it). With one platform there is no filter and no icons. - The whole list follows its own **Font size** (Viewer list section of the chat settings), not the chat's: the head (title and count, *updated …*, platform filter, search box), the group names and their counts, the loading animation, and every row — name, time icon and time, avatar, platform icon and row height — grow and shrink with it. Click a name to open the **user info card** — the same card as a click on a name in the chat. It also works for people who have not written anything yet. Without the `chat:userinfo` permission the rows are plain text. ### How fast does it update? People who write appear **immediately**. Twitch also lists people who only watch with a snapshot every 30 s, plus Twitch's own delay — often 30 s to a few minutes. Kick checks its recent-writer list every 60 s and does not list lurkers. Leaving can show up after the platform's delay, so a *last seen* time may be later than the moment the person actually left. ### States | What you see | Meaning | |---|---| | Loading animation | The list is waiting for its first snapshot. With the season look on it shows the season's icons instead of the spinner. If no platform has delivered a snapshot after 15 seconds, the empty state appears instead | | **Only people who write are listed. Reconnect Twitch once …** | The Twitch connection predates the viewer-list permissions. Reconnect it once under **Connections** for the full list | | **Kick list temporarily unavailable — showing people who write** | Kick's list source cannot be read. Recent writers still appear, but people known only from the list move to *Last seen* after two failed polls. The hint appears only when Kick is included in your platform filter. No reconnect is needed; Twitch keeps working | | **Nobody in the chat right now** | The platform reports an empty chat | | **5,000 of 12,400** or **100 of 538** | The platform supplied a larger total than the entries it lists. Lumio caps Twitch snapshots at 5,000; Kick's ordinary chatter group stops at 100, while its role groups are complete. The head still shows the full count | ## Width and position - **Auto** fits the widest row — the longer of the name and its time line (200–320 px at the default 14 px list font size; with a larger list font the upper limit grows with it, up to 480 px). Changing the list font size re-fits *Auto* at once; a fixed width stays as it is. It grows during a session but never jumps on single arrivals; it fits again the next time the panel opens. - **Drag the inner edge** of the panel to resize it, in any mode — no detour through the settings. The panel edge follows the pointer directly (no pixel readout while dragging; the slider in the settings shows the value). Releasing stores a fixed width (the settings slider follows). **Double-click** the edge to go back to *Auto*. - Keyboard: focus the edge, then use the arrow keys (±8 px) and **Home** for *Auto*. - The chat always keeps at least **420 px**. The panel docks beside the chat while the chat area is at least 420 px + the panel width; otherwise it lies over the chat as a drawer. A 48 px strip of chat stays visible next to the drawer — click it or press **Esc** to hide the drawer. The drawer has a resize edge from 288 px chat-area width on. - With the list on, the **Open popout** button on the chat page opens the popout wide enough to dock it (740 px with *Auto* at the default list font size). The chat page and the chat popout share the settings in the same browser. An OBS dock has its own browser storage; use [presets](../features/setting-presets.md) to carry the setup over. ## Presets and export On/off, position, width, font size, collapsed groups and the platform filter are part of the chat display settings, so presets, export and import carry them. The export dialog summarises them as e.g. *Viewer list on · right · Auto*. The header-button hide is never exported. ## Motion The panel slides in and out from its side, new rows fade in, leaving rows fade out. Someone who leaves fades out of their group and fades in under *Last seen*; the list closes the gap smoothly instead of jumping. Large batches of changes (more than 20 at once) change without animation. Under **Reduced** or **Off** — from the system setting, **Animations in Lumio**, or the chat's own **Animations** setting — everything changes instantly. See [Chatter List](../features/chatter-list.md) for how the list is built. --- ## User Guide / Members Source: https://docs.lumio.vision/user-guide/members # Members Lumio is built around team-managed accounts. The **Members** tab of the channel settings is where you invite teammates (moderators, editors, co-hosts), change the role of existing members, and revoke invitations that are no longer needed. This guide walks through every tab, dialog, and invite method. ## Where to find it - Navigation: **Settings → Channel → Members** - Short link: `/dashboard/settings/channel/members` (opens the area over the dashboard home) - Required permission to open: `members:read` The tab starts with the description _"Manage your team and invitations"_. If you have `members:create`, an **Invite** button with a user-plus icon is shown on the right of it (full width on phones). ## Quick start 1. Click **Invite** at the top of the Members tab. 2. In the wizard, pick **Invite Link** (default) as the method. 3. Click **Next**, pick the role (e.g. **Moderator**), click **Next** again. 4. Choose how long the link should be valid and how many people can redeem it (default: 7 days, 1 use). 5. Click **Create Invite**, copy the generated URL, and send it to your teammate. They open the link, sign in to Lumio, and click **Accept Invite** - they are now a member of your account. ## Tabs The page has two tabs, each with a count badge: **Members** and **Invites**. ### Members tab Lists all current members of the account. For each member you see: - Avatar and display name (plus a **You** badge if it is you). - Badges for their assigned platform identities (Twitch/YouTube/Kick), when they have any. Discord sign-in connections cannot be assigned to a channel. - **Role** on the right - either a coloured badge or a dropdown if you can change it. - **Joined** date. - A red **Remove** button (person-minus icon) if you have `members:delete` and the member is neither the owner nor yourself. #### Changing a role If you have `members:edit`: 1. Click the role dropdown next to the member. 2. Pick a new role from the list. The dropdown shows each role's colour dot, name, and short description. 3. The change is saved immediately. The page refreshes to reflect the new role. The Owner role cannot be assigned to anyone - it is reserved for the original creator of the account and appears as a static badge labelled **Owner**. #### Removing a member 1. Click the red **Remove** icon to the right of the member row. 2. A confirmation dialog appears: _"Are you sure you want to remove {name} from this account?"_ 3. Click **Remove** to confirm, or **Cancel** to abort. Removing a member revokes their access immediately. They stay signed in to Lumio but no longer see this account in their account switcher. The account owner's row never shows a role dropdown or a remove button - the owner cannot be changed or removed from within the UI. ### Invites tab Lists every invite ever created for the account, filterable by status. Above the list there are four filter pills with count badges: - **All** - every invite. - **Pending** - accepting still possible. - **Accepted** - already redeemed. - **Expired** - past validity, or reached max uses. Each invite row shows: - An icon indicating the type: **link** icon for shareable link/token invites, **user-plus** icon for direct invites. - The label - **Invite Link** for anonymous invites, or the email/code/user for direct invites. - A coloured status badge (**Pending**, **Accepted**, or **Expired**). - The creator (_"by {name}"_) and the expiry time. - The target role, as a coloured badge. - For pending link invites: a **Copy** button (with copy/check icon feedback). - A red **Trash** button to revoke if you have `members:delete`. It is not shown on **Accepted** invites - deleting the invite row would not remove the resulting membership, so manage those from the **Members** tab instead. Accepted and expired invites are dimmed to make pending ones easier to spot. ## Invite wizard The invite wizard is a four-step modal launched by the **Invite** button. The top bar shows _"Step X of 4"_. ### Step 1 - Method Three cards side by side. Click to select. - **Invite Link** _(default)_ - _"Generate a shareable link."_ Anyone with the link can redeem it, subject to expiry and max uses. - **Invite Token** - _"Generate a code to share manually."_ A short token the recipient types in their dashboard. Use when you cannot send a URL (voice only, on-stream). - **Invite Person** - _"Search by username, email, or platform."_ Requires the person to already have a Lumio account. A search box appears; type at least 3 characters. For **Invite Person**, the search hits the global Lumio user directory and returns matching users with their avatar, display name, and assigned platform badges (Twitch/YouTube/Kick). Click a result to select it; a check-mark shows which user is selected. You cannot advance to step 2 until a method is fully chosen. For **Invite Person**, that means a specific user must be selected. ### Step 2 - Role A single dropdown listing every role you can assign. Each item shows the role colour, name, and description. The Owner role is excluded - a note reads _"Owner role cannot be assigned."_ The default selection is the first non-owner role on the account. ### Step 3 - Options Two fields side by side plus a summary card. - **Validity** - dropdown with fixed choices: **1 hour**, **24 hours**, **7 days** _(default)_, **30 days**, **Never**. - **Max Uses** - dropdown with **1** _(default)_, **5**, **10**, **Unlimited**. For **Invite Person** this field is greyed out and fixed at `1` - direct invites are always single-use. The summary card below repeats your choices so you can sanity-check before creating: method, role, validity, max uses. Click **Create Invite** to submit. Errors are shown inline in a red banner. ### Step 4 - Result Depending on the method, you see: - **Invite Link** - a read-only URL field (`{origin}/invite/{code}`) with a **Copy** button. A short note shows the validity you picked. - **Invite Token** - a monospaced token field with a **Copy** button and the hint _"Share this token - the recipient enters it on their dashboard to join."_ - **Invite Person** - a confirmation card with the user's avatar, name, and target role badge. A notification has been sent inside Lumio. When you invite an existing Lumio user via the user search, they receive an in-dashboard notification with Accept and Decline buttons. If the invitee has a verified email address on file, the same invite email is sent in addition (otherwise the dashboard notification is the only channel). Click **Close** to dismiss the wizard. The Invites tab now shows the new invite in the **Pending** filter. ## Accepting an invite From the invitee's point of view: 1. They open the invite link (`{origin}/invite/{code}`) or enter the token on the invite-accept page. 2. The **Invite Details** screen shows the account name, the role they will receive, and who invited them. 3. If they are not signed in, they click **Sign in to join** and complete login. They are redirected back to the same invite. 4. They click **Accept Invite** to confirm. 5. A success screen shows _"You are now a member of {account} as {role}"_ with a **Go to Dashboard** button. 6. Their active account switches automatically to the new account. Specific failure screens are shown for expired invites, fully-used invites, invalid codes, when the user is already a member, and when a targeted invite belongs to a different user ("This invite was issued to a different user"). ## Permissions | Action | Required permission | | ------------------------------------ | ------------------- | | Open the Members tab | `members:read` | | Invite new members | `members:create` | | Change a member's role | `members:edit` | | Remove a member / revoke an invite | `members:delete` | Owners implicitly have all four. If you cannot see the **Invite** button, the role dropdown, or the delete icons, your role is missing one of these permissions - ask the account owner. ## Tips and best practices - **Start with the three default roles** (Owner, Administrator, Moderator). Only create custom roles when the defaults cannot express what you need. - **Use short validity for public links.** A 7-day link floating in a Discord server is a risk - a 1-hour link for onboarding a specific person is safer. - **Prefer Invite Person for long-term team.** Direct invites bind to a specific Lumio user and cannot be forwarded or leaked. - **Use Invite Token for voice-only handoffs.** Much easier to read over voice than a 40-character URL. - **Set max uses to match the intent.** A single-use link works well for one moderator; a multi-use link for a small team. Do not default to Unlimited. - **Audit the Invites tab monthly.** Revoke anything pending you do not remember creating. ## Troubleshooting **Invite button is missing.** You do not have `members:create`. Ask the account owner or someone with role-editing rights. **Role dropdown is missing but I see the member row.** You have `members:read` but not `members:edit`. **Invite link says "Invite Expired" immediately after creating it.** Your system clock is wrong, or the invite was created with an expiry of `1 hour` while you were in a very slow network round-trip. Create a new one with a longer validity. **"Invite Full" on accept.** The `max_uses` was reached. Ask the inviter to create a new invite or raise the limit. **"Already a Member" on accept.** The invitee is already part of the account. They can switch to it from the account picker in the top bar. **Cannot find a user via Invite Person search.** The search requires at least 3 characters and looks at display name, email, and connected platform identities. The user must have signed in to Lumio at least once - users who have never signed in are invisible. Use **Invite Link** or **Invite Token** instead. **A removed member still sees the account.** Removal takes effect immediately on the server, but their browser tab may keep a cached account list until it reloads. Ask them to refresh. ## Related - [Dashboard](./dashboard.md) - overview of everything members can access. - [Roles and permissions](../features/roles.md) - design roles that match your team's responsibilities. - [Connections](./connections.md) - what members can manage once they have `connections:*` permissions. --- ## User Guide / Roles & Permissions Source: https://docs.lumio.vision/user-guide/roles-and-permissions # Roles & Permissions Lumio uses role-based access control (RBAC) to decide exactly what every team member can see and do inside your account. Roles are **account-scoped**: the same user can be a Moderator on your channel and an Owner on a friend's channel, and Lumio tracks that separately. **Who needs this guide?** - **Account owners** setting up a team for the first time. - **Administrators** who need to create custom roles (for example, an "Editor" that can manage overlays but not chat). - **Members** who want to understand why a page or button is hidden for them. - **Popout-token users** configuring OBS browser-source docks with the right permissions. ## Where to Find It - **Roles** live under `Settings → Channel → Roles` (`/dashboard/settings/channel/roles`). You need `roles:read` to see the area and `roles:edit` / `roles:delete` to modify roles. - **Members** live under `Settings → Channel → Members` (`/dashboard/settings/channel/members`). You need `members:read` to see the area and `members:edit` to change someone's role. - **Your own permissions** are reflected everywhere - buttons, nav items, and entire pages simply disappear when you lack a permission, and protected pages show a "No Access" state with the message "Contact the account owner to get access." ## Quick Start 1. The account creator is always the **Owner** - they start with every permission. 2. Two other default roles exist for every new account: **Administrator** and **Moderator**. 3. To bring someone onto your team: - Open `Settings → Channel → Members`, click **Invite**. - Choose a method: **Invite Link**, **Invite Token**, or **Invite Person** (search by username/email/platform). - Pick a role (the Owner role cannot be assigned this way - it's reserved for the account creator). - Configure validity (1 hour, 24 hours, 7 days, 30 days, or Never) and max uses. - Share the link or token. 4. If the three default roles don't fit, create a custom role under `Settings → Channel → Roles → Create Role` and pick exactly the permissions it needs. ## How It Works Every action in Lumio - sending a chat message, saving an overlay, creating an invite - is guarded by a **permission string** in the form `resource:action` (for example `chat:ban`, `overlays:edit`). Wildcards like `*` or `chat:*` are **not** supported; permissions are always granular. When a user performs an action, Lumio looks up the roles assigned to them on the current account, unions their permissions, and checks whether the required string is present. - The **backend** enforces permissions on every GraphQL resolver and REST handler, so switching protocols can't bypass a guard. - The **frontend** uses the same permission list to hide nav items, dim disabled buttons, and render graceful "No Access" pages instead of raw 403 errors. - Permissions are **cached in Redis** per user + account for performance. The cache is automatically invalidated whenever a role's permissions change, a member is added/removed, or a role is reassigned, so changes take effect within seconds. ### Role anatomy Every role has: | Field | Purpose | |-------|---------| | `name` | Human-readable label shown in the UI (e.g. "Moderator", "Content Editor"). | | `slug` | Machine identifier auto-generated from the name (e.g. `content-editor`). Used for translations and API calls. | | `description` | Free-text hint shown in the role list. | | `color` | Hex color used in the member list badge. | | `is_system` | If `true`, the role is protected: it can't be deleted and, for Owner, can't be edited. | | `permissions` | The list of `resource:action` strings granted by this role. | ## Default Roles Lumio seeds three roles into every new account. All slugs are stable and match those checked by the backend. Accounts created earlier may have a fourth role named `viewer`: unused ones were deleted, while those with a member or a redeemable invite became ordinary custom roles with unchanged permissions. ### Owner (`owner`) - `is_system: true` - cannot be deleted and is the only role that cannot be revoked from the account creator. - Holds **every** account permission including `account:delete` (dissolve the account) and `plan:edit` (change billing). - Automatically assigned to whoever created the account; ownership can be transferred via the settings page, but the role itself always exists. ### Administrator (`administrator`) - Same broad access as Owner, **minus** `account:delete`, `plan:read`, `plan:edit` and `extension-dev:payouts`. - Can manage team, overlays, chat, automations, connections, etc. - essentially everything a co-streamer or production lead needs. - Can be edited (it is not a system role), but like every seeded default role it has no delete button. ### Moderator (`moderator`) - Built for chat mods: `chat:read`, `chat:write`, `chat:userinfo`, `chat:delete`, `chat:ban`, `chat:timeout`, `chat:notes`, `chat:raid`, `chat:pin`, `chat:poll`, `chat:prediction`, `chat:refresh_user`, `chat:automod-read`, `chat:automod-allow`, `chat:automod-deny`. - Events: `events:read`, `events:create` (fire test events), `events:userinfo`. - Read access to overlays, connections, uploads, rewards, widgets, sounds, bot-modules, bot-commands, bot-connections, account chat bots, and the OBS config (`obs:read`). - Spotify: read + playback + queue + playlist + device (no volume, no worker start/stop). - Automations: read + execute + history (cannot create or edit). - Members/roles: read-only (`members:read`, `roles:read`) - mods can see the team but not change it. - Sessions: can view and revoke their own active sessions. - Copyright: the viewer-style actions only - `copyright:vote`, `copyright:report`, `copyright:recommend`. - Extensions: `extension-store:read`, `extension-store:review`, `extension-data:read`. - Sounds: `sounds:read` + `sounds:play`. - Stream History: `history:read` + `history:export`. - Setting Presets: `presets:share` (may share their own presets with the account). - Public Stats: `public-stats:read` (view publicness settings; cannot change them). ## Custom Roles If the defaults don't fit - the most common case is "I want a role that can do X but not Y" - create a custom role. 1. Open `Settings → Channel → Roles`, click **Create Role**. 2. **Step 1 - Role Details:** enter a name, an optional description ("What can this role do?"), and pick a color. The slug is auto-derived. 3. **Step 2 - Permissions:** check exactly the permissions you want. They are grouped by category (Events, Overlays, Spotify, Chat, …). The UI shows a running count at the bottom: "N permissions selected". 4. **Step 3 - Summary:** review and click **Create Role**. Editing works the same way - click the pencil icon on the role card and the three-step wizard reopens with the current values; in edit mode you can jump to any step and save from there. The Owner role shows a **System** badge and has no pencil icon. None of the three seeded default roles show a delete icon - only custom roles you create can be deleted. To assign a custom role: go to `Settings → Channel → Members`, find the member, and change their role from the Role column. You need `members:edit` to do this. ## Permission Categories Platform operators can be granted the admin-scope `support:verify` permission to view and check a user's hourly support code in the Admin app. It does not belong to a channel role. Resetting a user's 2FA is a different operator action guarded by admin `users:edit` and requires a reason. Permissions are grouped into categories that double as UI section headers in the role editor. Below are the categories and the `resource:action` strings inside each. ### Granular pattern Almost every category follows the same shape: - `resource:read` - see the data. - `resource:create` - make new entries. - `resource:edit` - modify existing entries. - `resource:delete` - remove entries. **One historical exception uses `:write` instead of granular create/edit/delete:** - `chat:write` - permission to send chat messages. (Deleting chat messages is separate: `chat:delete`.) Everywhere else, stick to the granular triple. ### Events - `events:read` - view the events feed and history. - `events:create` - emit test events. - `events:edit` - hide the buyer name of a Shopify order in the events feed. Owner and Administrator have it by default. - `events:userinfo` - open the user card from an event row. ### Overlays - `overlays:read`, `overlays:create`, `overlays:edit`, `overlays:delete`. - `overlays:access-read` - read per-overlay access entries/candidates and see all overlays (access-manager visibility; Owner and Administrator only). - `overlays:access-grant` - grant (set) per-overlay access for a user (Owner and Administrator only). - `overlays:access-revoke` - revoke per-overlay access for a user (Owner and Administrator only). ### Widgets - `widgets:read`, `widgets:create`, `widgets:edit`, `widgets:delete` - standard widget instance CRUD. - `widgets:access-read` - read per-widget access entries/candidates and see all widgets, including ones set to `none` for you (access-manager visibility; it does not grant editing; Owner and Administrator only). - `widgets:access-grant` - grant (set) per-widget access for a user (Owner and Administrator only). - `widgets:access-revoke` - revoke per-widget access for a user (Owner and Administrator only). ### Sounds - `sounds:read`, `sounds:create` (upload), `sounds:edit`, `sounds:delete` - the sound library used by alerts and automations. - `sounds:play` - trigger playback of a sound. Moderators get `sounds:read` + `sounds:play`. ### Extension Store - `extension-store:read` - browse the store and the Installed list (Owner, Administrator, Moderator). - `extension-store:install`, `extension-store:uninstall`, `extension-store:configure` - install, remove and configure extensions on this account (Owner and Administrator only). - `extension-store:review` - write a store review for an extension. ### Extension Developer - `extension-dev:read` - open the developer dashboard. - `extension-dev:create`, `extension-dev:edit`, `extension-dev:delete` - manage your own extensions. - `extension-dev:publish` - submit and publish versions. - `extension-dev:analytics` - see install and usage analytics for your extensions. - `extension-dev:payouts` - manage payout details and request payouts (Owner only). These only matter once your user has been approved as a developer - see [Developer Application & Team Roles](#developer-application--team-roles). ### Extension Data - `extension-data:read` - read data from an installed extension's server query functions (Owner, Administrator, Moderator). - `extension-data:edit` - insert / patch / delete data through an installed extension's server functions (Owner and Administrator only). - These gate only direct calls from the dashboard editor or the `lumio run` CLI. Widgets running in a Browser Source use the extension install's own scope and are unaffected. ### Stream History - `history:read` - view the session list, reports and time-series stats, and the current Sub count of each connected channel (Owner, Administrator, Moderator). - `history:export` - export filtered lists and finalized reports as CSV / PDF / TXT / JSON (Owner, Administrator, Moderator). - `history:share` - create, extend and revoke shareable report links (Owner and Administrator only). - `history:delete` - delete stored sessions (Owner and Administrator only). - `history:revenue-read` - read estimated revenue and assumptions and include revenue in shared reports (Owner and Administrator only). - `history:revenue-edit` - edit revenue assumptions (Owner and Administrator only). - `history:keywords-edit` - add, change and remove tracked keywords (Owner and Administrator only). `history:read` also permits listing keywords and testing a pattern. - All seven are gated behind the Stream History feature (available on the Free and Pro plans). ### Setting Presets - `presets:share` - share one of your own setting presets with the members of your active account, so the team can load ("use") it (Owner, Administrator, Moderator). - Only *sharing* is gated: creating, editing and loading presets is per-user and needs no permission, and reading the shared section needs only account membership. - Gated behind the Setting Presets feature. When you share a preset, every member of the account can see and load it - the confirmation says so. ### Public Stats - `public-stats:read` - view your channels' publicness settings for the public Stats app (Owner, Administrator, Moderator). - `public-stats:edit` - toggle a channel's publicness (opt out / opt back in) and hide or show its chatter leaderboard (Owner and Administrator only). - Both are gated behind the Public Stats feature (available on the Free and Pro plans). Publicness is **public-by-default**: a channel is listed until its broadcaster opts out. ### Crawler Platform-operator controls for the public stats crawler, exposed in the Admin app (and gated on `admin:access` to reach it). These are **admin-scope** permissions (ZAF-725), assigned via admin roles (seeded to `system_admin`) - they are **not** account role permissions, so no account owner/administrator/moderator holds them. - `crawler:scope-read` / `crawler:scope-edit` - read and set which live channels the crawler covers (`lumio`, `list`, `lumio_plus_list`, or the opt-in `global` sweep). The default is the bounded `lumio_plus_list`. - `crawler:watchlist-read` / `crawler:watchlist-create` / `crawler:watchlist-delete` - view and curate the crawl watchlist (the `list` component of the scope). Each scope change or watchlist edit is recorded in the operator audit log. ### Audit Log - `audit-log:read` - view this account's [Audit Log](./audit-log.md): role and permission changes, member-role assignments, channel connections, and account-scoped GDPR erasures (Owner, Administrator). This account-scope permission is separate from the Lumio-internal admin audit log, which uses a same-named admin permission. It is not needed for **Account → My Activity**, which is self-only for every signed-in user. ### Spotify - `spotify:read` - now playing, queue, devices, playlists, search. - `spotify:playback` - play / pause / skip / shuffle / repeat. - `spotify:volume` - change playback volume (hides the volume slider when missing). - `spotify:queue` - add tracks to the queue. - `spotify:playlist` - create / rename / delete playlists, manage tracks. - `spotify:device` - transfer playback between devices. - `spotify:worker` - start / stop the manual Spotify connect worker. ### Chat - `chat:read`, `chat:write`, `chat:userinfo`, `chat:delete`, `chat:ban`, `chat:timeout`, `chat:notes`, `chat:raid`, `chat:pin`, `chat:poll`, `chat:prediction`, `chat:refresh_user`, `chat:automod-read`, `chat:automod-allow`, `chat:automod-deny`. - `chat:automod-read` shows held Twitch messages in Multichat. The AutoMod tab in Stream History additionally requires `history:read`; allow and deny have their own permissions and use your Twitch sign-in. A popout token can carry the three live AutoMod permissions, but cannot open the history log. - `chat:refresh_user` - manually refresh a user's platform profile (avatar, bio, follow status) from the UserInfoModal. Rate-limited to once per 10 minutes per user. Available to Owner, Administrator, and Moderator. - `chat:userinfo` also controls whether the `{{username}}`, `{{displayName}}`, `{{avatarUrl}}`, and `{{userId}}` automation template variables resolve (see [Automation Templates](./automation-templates.md)). ### Connections - `connections:read`, `connections:create`, `connections:edit`, `connections:delete` - for Twitch/YouTube/Kick channel connections, Discord, Spotify, StreamElements, the Shopify store, etc. `connections:edit` changes the Shopify customer label. `connections:read` also controls the dashboard's channel status and the `channelStatus` / `GET /v1/channel-status` reads; `spotify:read` controls Spotify music and manual status separately. ### Settings - `settings:read`, `settings:edit`. ### Members - `members:read` - see the members and invites tabs. - `members:create` - create invite links / tokens / direct invites, search users. - `members:edit` - change someone's role. - `members:delete` - remove a member or revoke an invite. ### Roles - `roles:read`, `roles:edit`, `roles:delete`. ### Uploads - `uploads:read`, `uploads:create`, `uploads:delete`. Plan limits apply on top - see [Billing & Subscription](./billing.md) for the current per-file and total storage limits. ### Rewards - `rewards:read`, `rewards:create`, `rewards:edit`, `rewards:delete` - channel-point rewards and their handlers. ### Tokens - `tokens:read`, `tokens:create`, `tokens:delete` - view, create, and revoke **popout tokens** (OBS browser-source docks). Managed under the `feature:tokens` feature gate. - `tokens:edit` - edit popout token labels, expiry, user assignment, and permissions. ### API Keys Both personal **user API keys** (`lm_usr_…`) and account **service keys** (`lm_svc_…`) share one dedicated permission family, separate from popout tokens. The **Settings → Channel → Access → API Keys** page uses these permissions plus the `feature:apikeys` feature gate - see [API Keys](./api-keys.md) for the personal-vs-service distinction. - `apikeys:read` - view API keys. - `apikeys:create` - create a new key (scoped within your own permissions - a key can never exceed what its creator holds). - `apikeys:edit` - rename a key **or rotate its secret**. The scopes and expiry are immutable once minted; you can change the display name and regenerate (rotate) the secret in place. Rotation returns the new full key once and invalidates the old secret immediately. - `apikeys:delete` - revoke a key. Service keys introduce **no new permission** - they reuse the same `apikeys:{read,create,edit,delete}` family. The one behavioural difference is ownership: a service key belongs to the account and keeps working after the member who created it leaves, whereas a personal key stops the moment its owner is no longer a member. **Operator management (support).** Lumio platform operators and support staff can view, edit (label + scopes), rotate, and revoke **any** account's or user's API keys from the admin app, under the separate admin-scope `account-apikeys:{read,edit,rotate,delete}` family (never assignable to a tenant role). This is distinct from the account-scope `apikeys:*` above - an operator editing a key can never push it above the key's own principal (a personal key stays capped to its owner's permissions). See [Admin → API Keys](../admin/api-keys.md). ### Automations - `automations:read`, `automations:create`, `automations:edit`, `automations:delete` - standard automation CRUD. `automations:edit` also covers renaming, toggling enabled, and modifying nodes/edges. - `automations:execute` - manually trigger and start/stop automations. ### Account & Plan - `account:read`, `account:edit` (the channel name and picture: source, own name, own picture), `account:delete` (dissolve the whole account - Owner only by default). - `plan:read`, `plan:edit` (subscription / billing - Owner only by default). ### Sessions Session management is **not** an account-role permission. Every authenticated user can view and revoke their **own** active browser sessions regardless of account role, and regardless of whether an account is selected in the account switcher at all. The former `sessions:read` / `sessions:delete` role permissions were removed in ZAF-1094 - they gated nothing. ### OBS - `obs:read`, `obs:edit`, `obs:delete` - the OBS WebSocket remote-control configuration. ### Copyright - `copyright:read`, `copyright:edit`, `copyright:delete` - this account's own copyright review lists. - `copyright:vote`, `copyright:report`, `copyright:recommend` - viewer-style actions (moderators get these but not edit/delete). - `copyright:moderate` - approve/dismiss community vote candidates into the **global** cross-account blocked/safe catalogue. This is a **platform-operator (admin) permission**, not an account-role permission: it is held only by platform staff via the admin (`system_admin`) role, never by a tenant Owner or Administrator. It was moved out of account scope in ZAF-742 because it writes the shared global catalogue, not your own account's lists (which `copyright:edit` covers). ### Bot Modules / Bot Commands / Bot Connections / SE Tokens - `bot-modules:read`, `bot-modules:edit` - link protection, spam, word filter, timed messages. - `bot-commands:read`, `bot-commands:create`, `bot-commands:edit`, `bot-commands:delete`. - `chat-bots:read` - see the channel bot list and automatically recognised bots (Owner, Administrator, Moderator). - `chat-bots:edit` - mark and unmark platform accounts as bots (Owner and Administrator). See [Channel settings → Bots](./channel-settings.md#bots). - `bot-connections:read`, `bot-connections:create`, `bot-connections:delete` - custom bot identities. - `se-tokens:read`, `se-tokens:create`, `se-tokens:delete` - StreamElements tip-jar tokens. ### Login Assignments (User-Level) | Permission | Description | |-----------|-------------| | `login-assignments:read` | View which login connections are assigned to the account (all users) | | `login-assignments:create` | Assign login connections for any user on the account | | `login-assignments:delete` | Remove login connection assignments for any user | Every user can always manage their own login assignments without these permissions. The permissions above are user-level and control access to other users' assignments on the same account. ## Developer Application & Team Roles Becoming a developer on Lumio requires an application. The developer application flow and team roles use a separate permission layer from account roles. ### Becoming a Developer 1. Navigate to **Account > Developer** (`/dashboard/settings/developer`). 2. Fill out the application wizard: choose **Solo** or **Team**, provide a display name, URL slug, description, motivation, and what you plan to build. 3. Submit the application for review. 4. A Lumio admin reviews the application and approves or rejects it with notes. 5. On approval, a developer profile is created. For team applications, a team is also created with default roles. The `me` query exposes an `isDeveloper` boolean field that is `true` when the user has a developer profile or has the admin `extension_dev_mode` override. This field controls access to the developer dashboard. ### Developer Team Roles Developer teams use their own RBAC system, separate from account roles. Each team has custom roles with granular `team-*` permissions covering extensions, versions, secrets, testers, members, settings, and payouts. Three default roles are seeded for every new team: | Role | Description | |------|-------------| | **Owner** | Full team access. System role, cannot be deleted. | | **Admin** | Manage extensions and members. Cannot edit team settings, payouts, or remove members. | | **Member** | Edit extensions, create and upload versions, and view analytics. Cannot submit a version, delete an extension, or manage or list testers and private access. | For team-owned extensions, the role controls each action after Lumio confirms you belong to the owning team. A missing grant returns `Missing team permission: `; role changes take effect on your next request. A solo extension has no team-role check. The Owner and Admin roles can submit versions and manage testers; a custom role can grant those actions selectively. Custom team roles can be created through the team settings page. The team role editor works the same way as the account role editor: pick a name, description, and a set of `team-*` permissions. ### Notifications The developer lifecycle includes email and in-app notifications for: - Application submitted, approved, or rejected - Extension created, version submitted/approved/rejected/published, extension suspended - Access granted or revoked, tester invited/removed, tester feedback - Team invite sent/accepted, team member removed, team role changed ## Concrete Recipes ### "A mod who can ban but not touch overlays" The default Moderator already does this - they have `chat:ban` + `chat:timeout` + `chat:delete`, and only `overlays:read`. If you want to also block overlay viewing, create a custom role with the Moderator's permissions minus `overlays:read`. ### "A content editor who designs overlays but should never moderate chat" Create **Content Editor** with: - `overlays:read`, `overlays:create`, `overlays:edit`, `overlays:delete` - `uploads:read`, `uploads:create`, `uploads:delete` - `rewards:read`, `rewards:edit` - `events:read`, `events:userinfo` - `settings:read` (to see OBS URLs) - `tokens:read`, `tokens:create` (so they can create popout tokens for preview docks) Leave chat, automations, connections, members, roles untouched. ### "A stats-only seat for an agency / manager" Create a custom role with only the read permissions this person needs, such as `events:read`, `events:userinfo` and `overlays:read`. Add `chat:read` + `chat:userinfo` if they need chat context. Do **not** grant `chat:write`. ### "A co-host who can run the whole stream except billing" That's exactly **Administrator**. Assign it as-is. ### "A VA who triggers automations but can't change them" Start from Moderator; ensure `automations:read` + `automations:execute` are on (Moderator already has both by default); leave `automations:create`, `automations:edit`, and `automations:delete` off. Optionally remove chat moderation if they shouldn't ban/timeout. ## Popout Tokens When you create a popout token under `Settings → Channel → Access → Popout Tokens`, you choose a subset of permissions for that token. The token can **never** have more permissions than the creator's role - Lumio intersects the two lists on save. A Moderator creating a popout token for OBS can therefore never produce a token with `settings:edit` or `automations:edit`, even by editing the request manually. ## Permissions Cheat-Sheet | Capability | Permission | |------------|------------| | Read chat | `chat:read` | | Pin, change the duration or unpin a Twitch message | `chat:pin` | | Send chat messages | `chat:write` | | Timeout users | `chat:timeout` | | Ban users | `chat:ban` | | Create overlays | `overlays:create` | | Edit overlays | `overlays:edit` | | Create / duplicate widgets | `widgets:create` (duplicating also needs Editor access to the source widget) | | Edit widget config | `widgets:edit` | | Read widget access | `widgets:access-read` | | Grant widget access | `widgets:access-grant` | | Revoke widget access | `widgets:access-revoke` | | Upload files | `uploads:create` | | Connect Twitch/YouTube/etc. | `connections:create` | | Invite members | `members:create` | | Change member roles | `members:edit` | | Create custom roles | `roles:edit` | | Create automations | `automations:create` | | Edit automations (nodes, edges, enabled) | `automations:edit` | | Delete automations | `automations:delete` | | Run automations manually | `automations:execute` | | Create popout tokens | `tokens:create` | | Create personal API keys | `apikeys:create` | | Change plan / billing | `plan:edit` | | Dissolve account | `account:delete` | | View account audit log | `audit-log:read` | | View your own activity log | none - signed-in user only | ## Lumio Internal: Privacy Tooling `admin:privacy-erase` is a Lumio-internal **admin-scope** permission used by support staff to honour GDPR Art-17 erasure requests (currently: YouTube member-channel records, chat-subject data, a person's entries on Lumio's own surfaces, and orphaned extension upload files). A chat-subject or member erasure acts in one channel only, on that channel's instruction; the Lumio-only erasure touches no channel's data. It is **not** assigned to per-account roles like Owner/Administrator/Moderator - only the `system_admin` Lumio admin role carries it. End users do not see this permission in the role editor. Erasures are audit-logged. `admin:privacy-objection-create` and `admin:privacy-objection-delete` are Lumio-internal **admin-scope** permissions of the same kind. Support staff use them to record or lift a chatter's GDPR Art-21 objection: afterwards, public Stats leaderboards hide the name, also on streams published earlier, without erasing anything. A channel's own stream reports, share links and AutoMod log are not affected. Only the `system_admin` Lumio admin role carries them; end users do not see them in the role editor. Both actions are audit-logged. `admin:privacy-erase-withdraw` is another Lumio-internal **admin-scope** permission. Support staff use it when a channel withdraws its instruction to erase a chat participant: that channel's stream reports created afterwards show the participant's name again. Nothing the erasure deleted comes back, and other channels are not affected. Only the `system_admin` Lumio admin role carries it; end users do not see it in the role editor. The withdrawal is audit-logged. `csp-reports:read` is likewise a Lumio-internal **admin-scope** permission. It lets platform staff read the CSP-violation calibration aggregate (which browser Content-Security-Policy rules would have blocked, and on which host) so the policy can be tightened safely. It stores only the violated directive and the scheme+host of the blocked resource - **no page URL, no viewer identity** - and is **not** assigned to per-account roles; only the `system_admin` Lumio admin role carries it. End users do not see it in the role editor. ### Withdrawal and cancellation permissions A signed-in member needs `plan:edit` to submit a withdrawal or cancellation for a plan and `extension-store:uninstall` for a paid extension purchase or subscription. The public `/withdraw` and `/cancel` forms require no account role. A signed-in **Other contract** withdrawal needs an interactive session. [Withdrawal & Cancellation](/user-guide/withdrawal-and-cancellation) explains the forms. The operator queue uses admin-scoped `contract-declarations:read` to view cases and `contract-declarations:edit` to match, close or extend them. These grants do not appear in account role pickers and are assigned to `system_admin` by default. `developer-funds:release-deletion` is a Lumio-internal **admin-scope** permission. Support staff use it, on a developer's request, to delete a developer profile whose balance cannot be paid out (no payout method or no verified identity); Lumio keeps the balance as a claim and mails the person a claim code. It is assigned to `system_admin` by default and does not appear in account role pickers. `developer-funds:settle` is the matching **admin-scope** permission for when that person comes back with the claim code: support staff record the claim and credit the kept balance to the person's new, verified developer profile, from where the normal payout pays it out. It is assigned to `system_admin` by default and does not appear in account role pickers. `extension-purchases:read` is likewise a Lumio-internal **admin-scope** permission. Support staff use it to find paid extension checkouts that were never delivered (for example, an automatic refund that Stripe refused), so they can refund the buyer or install the extension. It is **not** assigned to per-account roles; only the `system_admin` Lumio admin role carries it. End users do not see it in the role editor. `devtools:read` is likewise a Lumio-internal **admin-scope** permission. It shows the Dev & Admin menu in the web app to Lumio staff (for example, to preview a seasonal look in their own browser). It is **not** assigned to per-account roles; the `system_admin` Lumio admin role carries it, and Lumio can grant it to further internal admin roles. End users do not see it in the role editor. ## Ideas Hub Roles The sections above describe **account-scoped roles** - they control what you can do within a specific account (overlays, chat, music, automations, etc.). The Ideas Hub uses a separate permission layer: **user-scoped roles**. These roles are tied to your user identity across Lumio, not to any single account. ### Why a separate layer? Account roles answer "What can this person do on *my* channel?" User roles answer "What can this person do in *cross-account* features like the Ideas Hub?" The two layers are independent: an Owner on their own account still has whatever user role they hold when they visit the Ideas Hub, and a Restricted user in the Ideas Hub can still be an Administrator on someone else's account. ### Default User Roles Lumio assigns one of three user roles to every user. All new users start as **Member**. #### Member The default role. Members have full access to the Ideas Hub: - Create new ideas - Edit and delete their own ideas - Vote on ideas - Read and create comments - Vote on comments and replies - Edit and delete their own comments #### Restricted Read-only access. Restricted users can browse ideas and read comments but cannot contribute content or vote. This role is assigned by Lumio moderators to users who have violated community guidelines. #### Moderator Everything a Member can do, plus moderation capabilities across the entire Ideas Hub: - Review and change the status of any idea (e.g. mark as planned, completed, or declined) - Edit or delete any idea, regardless of author - Delete any comment, regardless of author (editing a comment is always author-only) ### Permissions by Role | Action | Member | Restricted | Moderator | |--------|--------|------------|-----------| | Browse ideas | Yes | Yes | Yes | | Create ideas | Yes | No | Yes | | Edit own ideas | Yes | No | Yes | | Delete own ideas | Yes | No | Yes | | Vote on ideas | Yes | No | Yes | | Read comments | Yes | Yes | Yes | | Create comments | Yes | No | Yes | | Vote on comments | Yes | No | Yes | | Edit own comments | Yes | No | Yes | | Delete own comments | Yes | No | Yes | | Moderate all ideas | No | No | Yes | | Change idea status | No | No | Yes | ## Tips & Best Practices - **Prefer custom roles over editing the defaults.** If you change the Moderator role, every mod on the account changes with it. A custom "Chat Mod (no polls)" role keeps the default intact. - **Start restrictive.** It's easier to add permissions on request than to explain a security incident. - **Name roles by function, not by person.** "Content Editor" ages better than "Sarah". - **Use the color field.** Member badges use it - green-for-mods, blue-for-editors is much faster to scan than reading labels. - **Invite tokens for manual distribution, invite links for DMs.** Invite links include the role; the recipient clicks and lands on a "You have been invited" page. Tokens are paste-into-dashboard for people already signed in. - **Sessions matter.** Every user manages their own sessions under `Account → Sessions`; if a collaborator's laptop is lost, have them revoke their sessions there before you change their role - otherwise their existing browser tab keeps the cached permissions until the next refresh. ## Troubleshooting ### "Why can't I see the Music page?" Your role lacks `spotify:read` (and usually also `spotify:playback`). Ask the Owner to add the Spotify permissions - the entire Music nav item is hidden when `spotify:read` is missing. ### "I see 'No Access - Contact the account owner to get access.'" You navigated to a URL (maybe from a bookmark) for a feature you don't have permission to use. The message is a deliberate friendly 403; check your role under `Settings → Channel → Roles`. ### "I edited the role but the member still can't do the thing." The permission cache refreshes when the role is saved, but the member's browser may need a reload to pick up the new hidden/visible UI. Ask them to refresh. If it still fails, verify the permission string is really on the role (open the role, check the Permissions step). ### "The Owner role is greyed out - I can't edit it." Correct. Owner is a **system role** (`is_system: true`). It always has every permission and cannot be modified or deleted. If you need a "near-Owner" role, edit or clone **Administrator** instead. ### "I can't assign the Owner role when inviting." By design. The invite wizard hides Owner ("Owner role cannot be assigned"). Ownership transfers go through account settings, not invites. ### "Can a member belong to multiple roles?" Currently no: each member has exactly one role per account. To give someone a superset of permissions, create a custom role combining the two. ### "How do I give a popout token fewer permissions than my role?" Create the token under `Settings → Channel → Access → Popout Tokens → New Token`, and uncheck permissions you don't want on it. The list is pre-filled with your current permissions as the upper bound. ## Related Docs - [Automation Templates](./automation-templates.md) - which permissions gate which template variables. - [Account Switching](../features/accounts.md) - how roles work across multiple accounts you belong to. - [Popout Tokens](../features/tokens.md) - permission-bounded tokens for browser sources. --- ## User Guide / Automation Templates Source: https://docs.lumio.vision/user-guide/automation-templates # Automation Templates Template variables let you weave live data - the viewer's name, the current song, the stream title, a custom variable you set - into an automation action or a bot command response. You wrap a variable name in curly braces, and Lumio replaces it with the matching value at runtime. Lumio has **two** template surfaces, and they use **different** brace styles and different variable sets: | Surface | Braces | Variable set | |---------|--------|--------------| | **Automation actions** (the automation builder) | double - `{{displayName}}` | the large reference below | | **Bot command responses** (Response Type **Template**) | single - `{user}` | seven built-ins, see [Bot command variables](#bot-command-variables-single-braces) | Most of this guide covers the **automation** engine. If you are editing a `!command` response, jump straight to [Bot command variables](#bot-command-variables-single-braces). > **Note:** > Trigger nodes fire only where the operator has turned automation triggers on. Otherwise an automation runs only when you execute it manually. Variables that a trigger fills (the triggering user, the event, captured command arguments) are empty in a manual run. See [Automations → Data Flow](../features/automations.md#data-flow). **Who needs this guide?** - Anyone building **automation actions** that send chat messages, Discord posts, or overlay alerts. - Anyone writing a **bot command** whose response should include the caller's name or the current song. - Anyone debugging a template that resolves to an empty string or leaks a literal `{{name}}` into chat. ## Where Templates Are Used The double-brace engine resolves variables in these **automation action** fields: - **Chat Message** - the message body. - **Discord Post** - the message body. - **Overlay Alert** - the alert message. - **Webhook Call** - the URL, every header value, and the request body. - **Set Variable** - the value you assign. Bot command responses are handled by the separate single-brace engine described below. **Timed messages** (the Timed Messages bot module) send their configured lines verbatim - they do not resolve variables. ## Syntax: `{{variable}}` Variables are wrapped in **double** curly braces: ``` Hello {{displayName}}, welcome to the stream! ``` Rules: - The name is letters, digits and underscores - `camelCase` is the convention. - Dotted paths are allowed: `{{event.type}}`, `{{discord.serverName}}`, `{{var.myCustomVar}}`, `{{event.raw.someField}}`. - **Single** curly braces (`{name}`) are left untouched by the automation engine. In a **bot command** response, single braces are exactly what you want - see [Bot command variables](#bot-command-variables-single-braces). - Replacement is done left-to-right in one pass. The resolved value is **not** re-parsed, so a variable whose value happens to contain `{{otherVar}}` will appear literally. ## Quick Start | Template | Typical output | |----------|----------------| | `Welcome {{displayName}}!` | `Welcome Rapha!` | | `{{username}} cheered {{cheerAmount}} bits!` | `rapha cheered 500 bits!` | | `Now playing: {{songName}} by {{songArtist}}` | `Now playing: Blinding Lights by The Weeknd` | | `Stream: {{streamTitle}} ({{category}})` | `Stream: Valorant Ranked (Valorant)` | | `Uptime: {{uptime}} / {{liveViewerCount}} viewers` | `Uptime: 2h 30m / 1234 viewers` | | `Shout out to {{var.lastRaider}}!` | `Shout out to someStreamer!` | | `Order #{{orderNumber}} - {{orderTotal}} {{orderCurrency}}` | `Order #1082 - 42.00 EUR` | ## Resolution Rules There are three cases, and knowing which applies saves a lot of debugging time. 1. **Known variable, data present** → replaced with the value. 2. **Known variable from a permission-scoped group, data missing** → replaced with an **empty string**. This covers every variable listed under `chat:userinfo`, `events:read` and `spotify:read` below. The rest of the message still renders, which is often why you see weird double-spaces (`" "` where you expected a value). 3. **Everything else that does not resolve** → **left as-is**, braces and all. That includes typos (`Hello {{unknwn}}!` stays `Hello {{unknwn}}!`) **and** the ungated groups: a `{{channelName}}`, `{{botName}}`, `{{date}}`, `{{sceneName}}`, `{{orderNumber}}`, `{{discord.serverName}}` or `{{var.something}}` with no value behind it stays visible in the output rather than vanishing. Rule 3 is worth internalising: an **unset custom variable does not disappear** - `{{var.lastRaider}}` will be posted to chat literally until something has written a value into `lastRaider`. The empty-string rule for the gated groups is what lets you write defensive templates like: ``` Currently playing {{songName}}{{songArtist}} ``` If Spotify isn't connected, nothing renders for those variables. If it is, you get `Blinding LightsThe Weeknd` (you'll want to add a separator - see [Tips](#tips--best-practices)). ## Output Encoding Automation steps encode every substituted value for the place it lands, so text from a viewer, donor or buyer (a chat message, a donation note) cannot change the shape of what the step sends. Only the values are encoded; the text you type into the template stays exactly as written, and an unresolved placeholder stays literal. | Step and field | How substituted values are encoded | |----------------|------------------------------------| | Chat message, overlay alert, condition, Set Variable | Inserted as they are | | Discord post message | Markdown punctuation (backslash, asterisk, underscore, tilde, backtick, pipe, brackets, parentheses, angle brackets, `#`, `@`) is backslash-escaped, so a value shows as plain text and cannot form a masked link, formatting or a mention. Your own markdown in the template still works | | Webhook URL | Percent-encoded as one URL component when the placeholder sits in the path, query or fragment (after the literal `https://host`). A placeholder in the scheme or host part - including a template that is only `{{var.url}}` - is inserted as it is; the [target filter](../features/automations.md#webhook-call-targets) still checks the result | | Webhook header value | Line breaks and other control characters become spaces | | Webhook body | With a `Content-Type` header containing `json`, or no `Content-Type` and a body that starts with `{` or `[`: JSON-string-escaped, so put the placeholder inside quotes, e.g. `{"msg": "{{event.raw.message}}"}`. With `application/x-www-form-urlencoded`: percent-encoded. Any other body: inserted as it is | ## Permission Scopes Every variable belongs to a group, and each group is associated with a permission. The groups matter for two reasons: they tell you which part of Lumio a variable's data comes from, and they decide whether a variable that fails to resolve becomes an empty string (gated groups) or stays literal in the output (ungated groups - see rule 3 above). | Variable group | Associated permission | |----------------|-----------------------| | Channel, Bot, Time, Shopify, OBS, Discord, Custom (`var.*`) | **none** | | User (`username`, `displayName`, `avatarUrl`, `userId`) | `chat:userinfo` | | Stream, Channel Status, Follower, Sub, Cheer, Raid, Redemption, Tip, Hype Train, Poll, Prediction, Event metadata | `events:read` | | Spotify (`songName`, `songArtist`, …) | `spotify:read` | Automations run under the **account** that owns them, not under the viewer who triggered them, so a variable's value never depends on who typed in chat. ## Variable Reference ### Ungated groups (no associated permission) #### Channel | Variable | Description | |----------|-------------| | `{{channelName}}` | Display name of the channel. | | `{{channelUrl}}` | URL to the channel page. | | `{{platform}}` | Platform slug (`twitch`, `youtube`, `kick`). | #### Bot | Variable | Description | |----------|-------------| | `{{botName}}` | Bot account's username. | | `{{commandPrefix}}` | Current command prefix (for example `!`). | #### Time | Variable | Description | |----------|-------------| | `{{date}}` | Current date in the configured timezone. | | `{{time}}` | Current time. | | `{{timestamp}}` | Unix timestamp. | | `{{timezone}}` | Configured timezone name. | #### Shopify (from Shopify order events) | Variable | Description | |----------|-------------| | `{{orderNumber}}` | Order number. | | `{{orderTotal}}` | Order total. | | `{{orderCurrency}}` | ISO currency code. | | `{{productName}}` | Product name from the order. | #### OBS (from the OBS integration) | Variable | Description | |----------|-------------| | `{{sceneName}}` | Current scene. | | `{{bitrate}}` | Current streaming bitrate. | | `{{fps}}` | Current frames per second. | | `{{droppedFrames}}` | Dropped frames counter. | #### Discord (dotted paths) | Variable | Description | |----------|-------------| | `{{discord.serverName}}` | Discord server display name. | | `{{discord.channelName}}` | Discord channel name. | | `{{discord.memberCount}}` | Member count at event time. | #### Custom variables (`var.*`) Values written by a **Set Variable** action, or captured by the automation's trigger. A **Keyword trigger** with capture groups exposes them here, and a **Command trigger** fills `{{var.command}}` with the command name and `{{var.args}}` with everything the caller typed after it. There is no hard list - any name under `var.*` looks up the matching entry. | Variable | Description | |----------|-------------| | `{{var.anyName}}` | Value of the named custom variable. If nothing has set it, the placeholder is left in the output unchanged. | ### User group - `chat:userinfo` Context: info about the user who triggered the event (the viewer who followed, subscribed, sent the bits, ran the command, etc.). When the triggering event carries no user, these resolve to empty strings. | Variable | Description | |----------|-------------| | `{{username}}` | Login / handle. | | `{{displayName}}` | Display name (may include capitalization / unicode). | | `{{avatarUrl}}` | Profile picture URL. | | `{{userId}}` | Platform user ID. | ### Event group - `events:read` Stream snapshot: | Variable | Description | |----------|-------------| | `{{streamTitle}}` | Current stream title. | | `{{category}}` | Current category/game. | | `{{gameName}}` | Legacy alias for `{{category}}` - kept for backward compatibility. | | `{{viewerCount}}` | Viewer count at event time. | | `{{uptime}}` | Stream uptime. | | `{{streamUrl}}` | Link to the live stream. | | `{{streamPreviewUrl}}` | URL of the preview thumbnail image. | Channel-status (multi-platform live state): | Variable | Description | |----------|-------------| | `{{isLive}}` | `true` / `false`. | | `{{liveViewerCount}}`, `{{liveCategory}}`, `{{liveTitle}}`, `{{livePlatform}}` | Current live data aggregated across connected platforms. | Follower: | Variable | Description | |----------|-------------| | `{{followerName}}` | Display name of the new follower (alias for `{{displayName}}` scoped to follower events). | Subscription: | Variable | Description | |----------|-------------| | `{{subTier}}` | `Tier 1`, `Tier 2`, `Tier 3`, or the platform equivalent. | | `{{subMonths}}` | Cumulative subscription months. | | `{{isGift}}` | `true` / `false`. | | `{{giftRecipient}}` | Display name of the gift-sub recipient (only when `isGift=true`). | Cheer / bits: | Variable | Description | |----------|-------------| | `{{cheerAmount}}` | Bits cheered. | | `{{cheerMessage}}` | Message sent with the cheer. | A Twitch cheer event also carries `event.raw.cheermote` - the cheer graphic for the total amount - so `{{event.raw.cheermote.color}}` and `{{event.raw.cheermote.urls.dark.animated}}` resolve to its tier colour and image URL. The field is empty when Lumio could not load the channel's cheermotes. Raid: | Variable | Description | |----------|-------------| | `{{raidViewers}}` | Number of incoming raiders. | | `{{raidFrom}}` | Name of the raiding channel. | Channel-point redemption: | Variable | Description | |----------|-------------| | `{{rewardName}}` | Reward title. | | `{{rewardCost}}` | Channel-point cost. | | `{{rewardInput}}` | Viewer-supplied text, if the reward requires input. | Tip / donation: | Variable | Description | |----------|-------------| | `{{tipAmount}}` | Numeric amount. | | `{{tipCurrency}}` | ISO currency code. | | `{{tipMessage}}` | Viewer's tip message. | Hype train: | Variable | Description | |----------|-------------| | `{{hypeTrainLevel}}` | Current level. | | `{{hypeTrainTotal}}` | Total contributions so far. | | `{{hypeTrainProgress}}` | Progress toward the next level. | Poll / prediction: | Variable | Description | |----------|-------------| | `{{pollTitle}}`, `{{pollChoices}}`, `{{pollWinner}}` | Live poll state. | | `{{predictionTitle}}`, `{{predictionOutcomes}}`, `{{predictionWinner}}` | Live prediction state. | Event metadata (dotted paths): | Variable | Description | |----------|-------------| | `{{event.type}}` | Event type, e.g. `twitch:follower`, `youtube:superchat`. | | `{{event.platform}}` | Source platform slug. | | `{{event.raw.}}` | Any raw field of the source event - useful for platform-specific data not yet surfaced as a first-class variable. | ### Spotify group - `spotify:read` | Variable | Description | |----------|-------------| | `{{songName}}` | Current track title. | | `{{songTitle}}` | Legacy alias for `{{songName}}`. | | `{{songArtist}}` | Track artist(s). | | `{{songAlbum}}` | Album name. | | `{{songUrl}}` | Spotify track URL. | | `{{songAlbumArt}}` | Album art image URL. | | `{{albumArt}}` | Legacy alias for `{{songAlbumArt}}`. | | `{{songDuration}}` | Track duration. | | `{{songProgress}}` | Current playback position. | | `{{isPlaying}}` | `true` / `false`. | | `{{shuffleState}}` | `off`, `on`, or `smart`. | | `{{repeatState}}` | `off`, `track`, or `context`. | | `{{spotifyDevice}}` | Active playback device name. | | `{{spotifyPlaylist}}` | Active playlist name, if any. | Spotify must actually be connected and playing for these to have values. ## Bot command variables (single braces) Bot command responses do **not** use the double-brace engine. In the command editor, set **Response Type** to **Template** and write your **Response Text** with **single** braces. The editor shows the available names inline as a hint. | Variable | Description | |----------|-------------| | `{user}` | Display name of the viewer who ran the command. | | `{channel}` | Name of the channel the command was used in. | | `{platform}` | Platform the command came from, e.g. `twitch`. | | `{count}` | How many times this command has been used. | | `{uptime}` | Stream uptime, e.g. `2h 30m`. Empty when the stream is offline. | | `{song}` | Currently playing Spotify track. Requires the **Spotify Now Playing** feature on your plan and a connected Spotify account. | | `{random:a,b,c}` | Picks one item at random from the comma-separated list. | Anything else in single braces - a typo, or a name that is not in this list - is replaced with an **empty string**, so a mistyped variable silently disappears from the message rather than showing up literally. If you need any of the richer variables from the reference above in a chat command, set **Response Type** to **Automation** instead and point the command at an automation whose **Chat Message** action carries the double-brace template. ## Recipes ### Welcome message with username Bot command, Response Type **Template**: ``` Welcome to the stream, {user}! Enjoy your stay :) ``` The equivalent inside an automation's **Chat Message** action: ``` Welcome to the stream, {{displayName}}! Enjoy your stay :) ``` ### Shoutout template ``` Go check out {{var.lastRaider}} at https://twitch.tv/{{var.lastRaider}} — they raided us with {{raidViewers}} viewers! ``` `{{var.lastRaider}}` is whatever value you stored in the custom variable `lastRaider` - typically via a **Set Variable** action earlier in the same automation, on a raid trigger. Until something writes it, the placeholder stays visible in the message. ### Now-playing announcement In an automation's **Chat Message** action: ``` 🎵 Now playing: {{songName}} — {{songArtist}} [{{spotifyDevice}}] ``` When Spotify is paused or disconnected, the fields resolve to empty strings, producing `🎵 Now playing: - []` - so use a **Condition** step that only sends the message when `{{isPlaying}}` equals `true`. For a plain `!song` chat command, the single-brace `{song}` variable is simpler: ``` 🎵 Now playing: {song} ``` ### Sub announcement ``` {{displayName}} just subscribed at {{subTier}} for {{subMonths}} months! 🎉 ``` Pair this with a subscription event trigger, which is what populates the sub variables. ### Tip thank-you (with currency) ``` Thank you {{displayName}} for the {{tipAmount}} {{tipCurrency}} tip! Your message: "{{tipMessage}}" ``` ### Reading an uncommon event field Every event has a `raw` map containing platform-native fields. If Lumio doesn't expose a first-class variable for what you need, reach into `event.raw`: ``` New Kick follow from kick.com/{{event.raw.user_login}} ``` Use this as a fallback; first-class variables should be preferred for portability across platforms. ### Kick event triggers and their raw fields The automation trigger is the event type as free text, so every Kick type below works as a trigger without further setup. Kick events keep Kick's original payload in `raw` and add the same top-level field names Twitch events use, so a template written for Twitch usually reads a Kick event too. Every Kick event with a viewer behind it carries: | Raw field | Meaning | |-----------|---------| | `user_id` | The viewer's Kick user ID (a string). | | `user_login` | The viewer's Kick channel slug - the part after `kick.com/`. | | `user_name` | The viewer's Kick username. | | `avatarUrl` | Profile picture URL; absent when the viewer has none. | | `is_anonymous` | `true` for an anonymous gifter. `user_name` is then `AnAnonymousGifter` and there is no `user_id` or `avatarUrl`. | Per trigger type: | Trigger | Additional raw fields | |---------|-----------------------| | `kick:follower` | - | | `kick:subscribe` | `cumulative_months`, `expires_at` | | `kick:resub` | `cumulative_months`, `expires_at` | | `kick:gift` | `giftCount` (subs in the bundle), `giftRecipients` (usernames), `is_gift`; with exactly one recipient also `recipient_user_id`, `recipient_user_login`, `recipient_user_name`; `gifter_is_anonymous` for an anonymous gifter | | `kick:kicks` | `kicks` (the amount - a count like Twitch bits, not money), `message`, `kick_gift_name`, `kick_gift_type`, `kick_gift_tier`, `kick_pinned_time_seconds`; for a known gift also `kick_gift_id` (e.g. `rage_quit`), `kick_gift_image.static` / `kick_gift_image.animated` (sticker URLs; `animated` is empty for the four cheapest gifts) and `kick_gift_color` | | `kick:reward` | `id` (redemption), `status` (`pending`, `accepted` or `rejected`), `user_input`, `redeemed_at`, `reward.id`, `reward.title`, `reward.cost`, `reward.prompt` | | `kick:reward_status` | Same fields as `kick:reward`. Fires on every status change after the first delivery of a redemption - trigger on it and check `status` to react to _accepted_. | | `kick:stream_online` | `title`, `started_at` (no category - Kick sends it only through its API) | | `kick:stream_offline` | `ended_at` | | `kick:stream_update` | `title`, `category_id`, `category_name`, `language`, `has_mature_content`, `title_changed`, `category_changed`, `language_changed`; `old_title` / `old_category_name` / `old_language` when that field changed. Fires only when something changed - also while you are offline. | Nested objects use dotted paths, for example `{{event.raw.reward.title}}` for the redeemed reward. Twitch's `twitch:stream_update` carries the same change fields (`title_changed`, `category_changed`, `language_changed` and the matching `old_*`) plus `tags`, `tags_changed` and `old_tags`. Twitch does not report a tag-only edit, so a tag change shows up with the next title, category or language change. Use `{{event.raw.old_title}}` → `{{event.raw.title}}` to announce a new title. ### YouTube stream triggers and their raw fields YouTube's go-live and go-offline events work as triggers like their Twitch and Kick counterparts. They have no viewer behind them, so they carry no `user_*` fields. | Trigger | Raw fields | |---------|------------| | `youtube:stream_online` | `broadcast_id` (the live video's ID), `title`, `started_at` (no category - YouTube's live data has none) | | `youtube:stream_offline` | `broadcast_id` | ### Random picks In a **bot command** template, `{random:a,b,c}` picks one of the listed items directly. The automation engine has no `{{random}}` built-in - there, use a **Random** logic node to branch, or a **Set Variable** action to store a value and reference it as `{{var.pickedUser}}` in a later step. ## Tips & Best Practices - **Always add separators.** Missing data resolves to empty string, so `{{songName}} by {{songArtist}}` becomes ` by ` when Spotify is paused. A small conditional upstream avoids the awkward output. - **Prefer `{{displayName}}` over `{{username}}`** for user-facing messages - `displayName` preserves capitalization and localized characters; `username` is the raw handle. - **Use `{{event.raw.*}}` sparingly.** It ties your template to a specific platform's payload. When a field starts mattering to more than one command, request a first-class variable. - **Custom variables are your escape hatch.** Anything not covered by a built-in (accumulated counters, last caller) can live in `{{var.*}}` and be updated by a **Set Variable** step - but seed it before you reference it, or the placeholder shows up literally. - **Try it on a quiet channel first.** Neither editor renders a template preview, so the first real output you see is the one your viewers see. Run the automation manually, or point the command at a test channel, before you rely on it live. - **Legacy aliases exist for compatibility.** `{{songTitle}}` = `{{songName}}`, `{{albumArt}}` = `{{songAlbumArt}}`, `{{gameName}}` = `{{category}}`. Prefer the canonical names in new templates. ## Troubleshooting ### My template output contains a literal `{{name}}` Two things produce this: 1. **A typo or a name that doesn't exist** - `{{usrname}}` instead of `{{username}}`, or `{{viewerName}}`, which isn't a real variable (use `{{displayName}}`). Cross-check the [Variable Reference](#variable-reference). 2. **An ungated variable with nothing behind it** - the Channel, Bot, Time, Shopify, OBS, Discord and `var.*` groups stay literal rather than collapsing to an empty string. An unset `{{var.something}}` or an `{{sceneName}}` with OBS disconnected will land in chat as-is. ### The output is missing where I expected data A variable from the `chat:userinfo`, `events:read` or `spotify:read` groups resolved to an empty string. Possible causes, in order: 1. **The variable category isn't populated** for this trigger. A follower event doesn't populate `{{cheerAmount}}` - it's simply absent. 2. **Source feature not connected or idle.** `{{songName}}` needs Spotify connected *and* playing. 3. **Wrong group for the trigger.** `{{displayName}}` needs an event that carries a user; a timer trigger has none. ### Why isn't `{name}` (single braces) working? Check which surface you are on. Automation actions need **double** braces; bot command responses need **single** braces *and* Response Type set to **Template** - a command left on Response Type **Text** sends its body verbatim, braces and all. ### Can I nest variables? Not directly. The replacement pass doesn't recurse. If you need composition, pre-compute the value into a custom variable with an automation step, then reference `{{var.composed}}` in the final template. ### It worked once and now resolves to nothing The Spotify, OBS and stream-snapshot variables read live state. `{{songName}}` is empty whenever nothing is playing, `{{uptime}}` is empty while you are offline, and `{{sceneName}}` stays literal while OBS is disconnected - the template itself is fine. ### `{{event.raw.someField}}` is always empty The field name doesn't exist in this event's raw payload. Raw fields are platform-native and differ per platform and per event type, so a name that works for a Kick follow won't necessarily exist on a Twitch follow. Watch the **Events** page while the event fires to see what a real payload for that trigger looks like. ## Related Docs - [Roles & Permissions](./roles-and-permissions.md) - how roles and permissions are organised. - [Automations](../features/automations.md) - triggers, actions, and the automation builder. - [Bot Commands](../features/bot-commands.md) - creating and overriding `!commands`. - [Chat](./chat.md) - how to send chat messages from the dashboard. --- ## User Guide / Bot Modules Source: https://docs.lumio.vision/user-guide/bot-modules # Bot Modules Bot modules automate chat moderation and messaging. Every bot module - including the four that ship with Lumio - is an extension. Lumio provides four built-in modules (link protection, spam protection, word filter, and timed messages), and additional bot modules can be installed from the Extension Store. ## Built-in Modules The four built-in modules ship with Lumio and are available on every plan. You manage them under **Dashboard > Bot Modules** (in the sidebar under **Tools**). | Module | What it does | |--------|--------------| | Link Protection | Detects URLs in chat messages and applies a whitelist and a blacklist of domains. Action: warn, delete, timeout, or ban, with a configurable timeout duration and warning message. | | Spam Protection | Detects excessive capitals, excessive symbols, and emote spam. Thresholds cover the caps percentage, the minimum message length before the caps check applies, the maximum emote count, and the symbol percentage. Same four actions as Link Protection. | | Word Filter | Blocks messages containing banned words, phrases, or regex patterns, with an optional wildcard mode. Same four actions as Link Protection. | | Timed Messages | Sends rotating messages on a configurable interval (60 seconds and up). Options: send only while you are live, and require a minimum number of chat lines between two messages. | Each module supports: - **Exempt roles** - moderators, VIPs, and subscribers can each be exempted from enforcement. - **Module-specific settings** - thresholds, actions, and messages, as listed above. ## The Bot Modules page **Dashboard > Bot Modules** lists every bot module installed on the account. Each entry shows the module's icon, name, and description, plus a **Built-in** badge for the four modules that ship with Lumio. Per entry you get: - **On/off toggle** - enables or disables the module for the account. The toggle is disabled, and the card dimmed, when the module is unavailable - a tooltip and an inline note say why (not included in your plan, disabled by an administrator, or disabled for your account). - **Configure** - expands the entry to show the exempt-role selection and the module's current configuration values. This expanded view is read-only. - **Add Module** - opens the Extension Store filtered to the **Bot Modules** category. ## Installing another bot module 1. Open **Store > Browse Store** from the sidebar, or click **Add Module** on the Bot Modules page. 2. Filter by the **Bot Modules** category, or search for the module by name. 3. Open the module to review its description, version, and developer. 4. Click **Install**. A dialog lists the permissions the module requests and its price if it is not free; confirm with **Install**. 5. The module then appears on **Dashboard > Bot Modules** alongside the built-in ones. Installed extensions - bot modules included - are also listed under **Store > Installed**, where you can toggle auto-update and uninstall. Built-in modules cannot be uninstalled. ## Module settings, logs, and errors Each installed bot module has its own settings page with three sections: - **Configuration** - the module's stored configuration values. - **Permissions & Triggers** - a placeholder; there are no settings here. - **Diagnostics** - links to **View Logs** and **View Errors** for that installation. ### Logs The log view lists the module's recent handler runs, newest first. Each line shows: - Time - Log level - The handler that ran - The log message Filter buttons across the top narrow the list to **All**, **DEBUG**, **INFO**, **WARN**, or **ERROR**, and a **Refresh** button re-fetches the list. Logs appear once the module starts processing events. ### Errors The error view shows only failures. Two counters at the top give the **Total Errors** and the number of **Unique Errors**. Each entry lists the error message, the handler it came from, when it was last seen, and how often it occurred. Click an entry to expand its stack trace. ## Turning a misbehaving module off If a module is behaving badly - moderating the wrong messages, or posting too often - switch its **on/off toggle** off on **Dashboard > Bot Modules**. The change takes effect without restarting the bot, and the module's configuration is kept, so you can switch it back on once the problem is resolved. ## Plans and permissions - The four built-in modules are available on every plan. - Bot modules installed from the Extension Store require a paid plan; they are switched off on the free plan. | Capability | Permission | |---|---| | Open **Dashboard > Bot Modules**, view a module's configuration, logs, and errors | `bot-modules:read` | | Toggle a module on or off and change its configuration | `bot-modules:edit` | | Browse the Extension Store | `extension-store:read` | | Install a module from the store | `extension-store:install` | | Uninstall a module | `extension-store:uninstall` | Without `bot-modules:read`, the **Bot Modules** entry does not appear in the sidebar. --- ## User Guide / Channel Status Source: https://docs.lumio.vision/user-guide/channel-status # Channel Status Channel Status is Lumio's always-on awareness of whether you are **live** right now. It drives the live-indicators on your dashboard, gates which background workers are running (most notably the Spotify integration), and keeps API usage down when you're offline. This guide explains how the live detection works, what the dashboard shows you, and the one case where you might need to intervene manually (Spotify). ## Where to find it The primary surface is the **channel card grid** on the main **Dashboard** page. Every connected Twitch, YouTube, or Kick channel gets its own card with avatar, channel name, platform name, and a link out to the channel. Live status is detected for **Twitch, YouTube and Kick**. When one of those is live, its card additionally shows a **Live** badge, a pulsing red dot on the avatar, the viewer count, the stream title, and the category. ## Quick start Nothing to set up. Once you've connected a platform via **Dashboard > Connections**, Lumio tracks its live status automatically: 1. Go live on the platform as usual. 2. Within seconds (Twitch, Kick) or up to about a minute (YouTube), the dashboard card flips to **Live**. The viewer count follows within a minute on Kick and YouTube. 3. When you end the stream, the card flips back to offline. The only manual control is the Spotify **Connect** button on **Dashboard > Music** (see below), which is only relevant if you want Spotify active before you actually go live. ## How live detection works Lumio uses the most efficient signal available on each platform: | Platform | Detection method | Typical latency | |---|---|---| | Twitch | EventSub webhook (`stream.online` / `stream.offline`) | Seconds | | YouTube | Polling | Up to ~60 seconds | | Kick | Webhook (`livestream.status.updated`) plus a 60-second poll while live | Seconds; viewer count, title and category refresh every ~60 seconds | Twitch and Kick are nearly instant because they push events to Lumio the moment you go live. YouTube offers no comparable push mechanism for this, so Lumio polls on a background cadence - roughly once a minute. Going offline is detected the same way. **Kick in detail.** The webhook carries go-live, stream end and title/category changes. While a Kick channel is live, Lumio also asks Kick's API once a minute for the viewer count, title and category - one call per live channel, none while you are offline. That poll is also a safety net: if the stream-end webhook never arrives, the card flips to offline once Kick has reported the stream as not live **twice in a row** (about two minutes). One failed or "not live" answer is never enough to end a running stream. When the Lumio API starts, or when you connect Kick, Lumio checks the channel once, so a stream that started or ended while no webhook could reach Lumio is picked up right away. Kick reports the category only through its API, so it appears with the first poll, up to a minute after go-live. ### What changes the indicator The dashboard card switches to **Live** when the backend receives: - A Twitch EventSub `stream.online` event, **or** - A YouTube polling response showing a broadcast as live, **or** - A Kick `livestream.status.updated` webhook with `is_live: true` (or Kick's API reporting the channel as live when Lumio checks it at start). It switches back to offline when the platform stops reporting the stream as live (EventSub `stream.offline` for Twitch, the poller sees the broadcast ended or disappear for YouTube, and for Kick the `is_live: false` webhook or two consecutive "not live" poll answers). ### What the UI polls The dashboard itself polls the Lumio API on a **30-second** cadence for the latest cached status. It also listens for **WebSocket events** (`channel:online`, `channel:offline`, `channel:status_update`) so changes appear faster than the 30-second tick when the WebSocket is connected. When the browser tab becomes visible again (e.g. you alt-tab back), it fetches immediately - so switching windows and coming back always gives you a fresh read. ## What you see while live Per-platform, the live card shows: - **Channel avatar** with a pulsing red dot indicator. - **Channel name** and platform name, plus an external-link icon that opens the channel (for a live YouTube broadcast, the link points at the broadcast itself). - **Live** badge in red. - **Viewer count**, written out in full (for example `12,345`). - **Likes** and **total views** for YouTube, when the broadcast reports them. - **Stream title** (truncated if long). - **Category / game** (if the platform provides it). Not every platform reports every field - the category may be blank if you haven't set one, and the viewer count is omitted when the platform doesn't report it. The card hides fields that aren't present. In the Multichat, the viewer count next to each platform tab is shortened instead (`1.2k`, `15.3k`, `1.4M`), and its display - along with likes and total views - is controlled by the toggles in the chat settings panel. ### Upcoming YouTube broadcasts YouTube channels also display **upcoming (scheduled) public broadcasts** below the main channel card, and the avatar carries a yellow dot while an upcoming broadcast is pending and nothing is live. Each upcoming broadcast shows a yellow **Upcoming** badge, the broadcast title, the scheduled start date/time when available, and a link to the broadcast. It appears here even if YouTube has not supplied a chat ID yet. When the broadcast goes live, it moves up into the main live card. Upcoming broadcasts that are deleted from YouTube are cleaned up automatically. Unlisted and private broadcasts do not appear through public Streams-tab discovery. ## Why it matters: resource-aware workers Channel status isn't just cosmetic - the **Spotify worker** keys off it. It only runs while a channel is live (or while manual connect is active). This saves API calls against Spotify and avoids writing track-change events to the database when nobody is watching. This is why you may notice Spotify "Now Playing" not updating the moment you open the dashboard - it waits until you're live. The Connect button below is the escape hatch. ## Spotify: manual connect If you need Spotify running **before** you actually go live - for pre-stream testing, a countdown scene that shows the upcoming song, or overlay development - use the **Connect** button. 1. Open **Dashboard > Music** (or the **Popout > Music** page). 2. Click **Connect**. The Spotify worker starts immediately. 3. The button is replaced by a status row showing the remaining time as a countdown, next to a **Disconnect** button. 4. The worker runs for up to **30 minutes**. 5. If you go live during that window, the worker stays up automatically beyond the 30 minutes, following your real live status. 6. If you don't go live, the worker stops when the countdown hits zero. You can also **Disconnect** manually to stop it early. While a channel is live, the whole control is hidden - the worker is managed for you and there is nothing to switch on. Manual connect state is synced across browser tabs and popout windows - start it in one tab and the control in another updates immediately without a reload. ## Permissions Live status and Spotify's manual connect are both governed by the Spotify permissions on your role. | Capability | Permission | |---|---| | View channel status (dashboard cards, Multichat viewer counts) | `spotify:read` | | See Spotify manual connect status | `spotify:read` | | Start / stop Spotify manual connect | `spotify:worker` (backend returns 403 otherwise) | If a 403 is returned for the Spotify worker toggle, the UI surfaces a toast - you likely don't have `spotify:worker` on your role. Ask an admin to grant it, or use a role that includes it. The **Connect** and **Disconnect** buttons are hidden outright for roles without `spotify:worker`. The toast closes after five seconds, or you can click its ✕. Hover over it or move keyboard focus into it to pause the countdown; it resumes with the time left when you leave. ## YouTube broadcast selector in Multichat When YouTube broadcasts are active (live or upcoming with pre-stream chat), a broadcast dropdown appears next to the YouTube platform tab in the Multichat. A small chevron opens the list: An upcoming broadcast needs a `liveChatId` before it appears in this dropdown. It may already be visible on the dashboard while the chat ID is still being resolved. - **All Broadcasts** shows messages from every active broadcast (useful for reviewing history across streams). - Each broadcast entry shows a status badge (**Live** in red, **Upcoming** in yellow) along with the broadcast title and scheduled start time (for upcoming). - The dropdown auto-selects the live broadcast when one starts. - The selection is remembered across page reloads. When sending a message with YouTube selected, it goes to the currently selected broadcast. If replying to a message, the reply always targets the original message's broadcast regardless of the dropdown selection. ## Tips & best practices - **Don't poll the dashboard yourself.** The built-in 30-second polling + WebSocket push is enough. Reloading the page doesn't make detection faster. - **Trust EventSub for Twitch.** If Twitch says you're offline but you just went live, give it ~10 seconds. If it still doesn't flip, check **Dashboard > Connections** for token issues. - **Use manual Spotify connect sparingly.** It's fine for testing, but leaving it on burns API quota against Spotify. It auto-stops after 30 minutes on purpose. - **Bookmark the Music popout.** If you use Spotify manual connect routinely (e.g. to warm it up before every stream), the popout URL is shorter to hit than clicking through the dashboard. - **Keep tokens healthy.** Status detection depends on valid platform OAuth tokens. A failed token refresh = stale status. Token refresh runs automatically, but a "connection needs attention" notification in the bell is a sign to reconnect. ## Troubleshooting **I'm live but the dashboard still says offline.** - Twitch: wait 10-15 seconds. If still offline, check **Dashboard > Connections** - your Twitch connection may have lost permissions. - YouTube: wait up to 60 seconds for the next poll. Refresh the page or alt-tab away and back to force an immediate refetch. - Kick: the badge depends on Kick delivering its webhooks to Lumio. If it never flips, the webhook URL is probably not set in the Kick developer settings (see [Webhooks](/features/webhooks#kick)); Lumio's check at API start or at the next Kick reconnect still picks the stream up. **I ended my stream but the dashboard still says live.** - Same as above - Twitch and Kick should clear in seconds; YouTube takes up to a minute. On Kick, a missed stream-end webhook is corrected by the poll after about two minutes. - If it persists for more than a couple of minutes, refresh the browser tab. **Viewer count shows 0 (or is missing).** - Some platforms report 0 on stream start before viewers arrive. - When the count isn't reported at all, the UI simply hides the field. **Spotify shows nothing even though I'm live.** - Check that Spotify is connected in **Dashboard > Connections**. - If you're live but there's no currently-playing track on your Spotify account, there's nothing to show. - If you recently reconnected Spotify, wait 30 seconds for the worker to pick up the new token. **Manual connect countdown is showing "0:00".** - Expired. Click **Connect** again to start a new 30-minute window. **"Connect" button returns a permission error.** - Your role lacks `spotify:worker`. Ask an admin (or whoever manages roles on the account) to grant it. ## Related docs - [Connections](/user-guide/connections) - connecting Twitch/YouTube/Kick/Spotify and handling token refresh errors. - [Music](/user-guide/music) - full Spotify feature overview including the Now Playing widget and overlay integration. - [Events](/user-guide/events) - the stream-time events that get logged while you're live. - [Notifications](/user-guide/notifications) - where connection/token issues surface if detection stops working. --- ## User Guide / Tokens Source: https://docs.lumio.vision/user-guide/tokens # Tokens Generate popout tokens for browser sources, dashboards, and other unattended views that need access to your channel without a logged-in browser session. Open **Settings**, select the channel, then choose **Access → Popout Tokens** (`/dashboard/settings/channel/tokens`). A token link is a secret: treat it like a password. [API keys](./api-keys.md) authenticate programmatic API calls; popout tokens authenticate popout views such as OBS browser sources. ## What Are Popout Tokens? Popout tokens (prefix `lm_pop_`) are secret strings that authenticate a single URL without your browser cookies. By default they never expire, but you can optionally set an expiry time or revoke a token at any point (see [Token Management](#token-management)). They are designed for: - **Browser sources** - drop a URL with `?token=lm_pop_…` into OBS, no login UI required - **Stream-deck buttons** - open a popout view directly to a control surface - **Shared dashboards** - give a moderator read-only access to a single view - **Remote control overlays** - receive live events on another machine ## Creating a Token Creating a token opens a **full-screen wizard** over the settings window with three steps. Choose **New Token** in **Access → Popout Tokens** to start: 1. **Identity** - optionally enter a **label** (only for your reference) and choose who the token belongs to. Leaving the assignment on **Yourself** binds the token to you; pick a **member** to attribute its actions to that user instead. Every token is always bound to someone - removing a member assignment later binds the token to you, the person editing it. You can only pick a member whose role has no permission you lack yourself, so only someone holding every permission can assign a token to the account owner. 2. **Permissions** - tick the **permissions** the token may use (e.g. `events:read`, `events:create`, `events:userinfo`, `overlays:read`, `spotify:read`, `spotify:playback`, `chat:read`, `chat:write`, `chat:userinfo`, `chat:ban`, `chat:timeout`, `chat:delete`, `chat:notes`). The `chat:userinfo` / `events:userinfo` scopes are what let a popout open the **user info card** when you click a name (profile, follow status, moderation log); leave them unticked for a display-only browser source. You can only select permissions you hold yourself - ones outside your own access are shown disabled. At least one permission is required before you can continue. 3. **Lifetime** - choose when the token expires (see [Token lifetime](#token-lifetime) below). The default is **Never**. Click **Create token** on the last step, then **copy the full token immediately** - the secret is shown only once on the final screen. That screen has no close (✕); press **Done** when you have copied the token. A token can never grant more permissions than the user who creates or edits it. This is enforced on both the create and the edit path (identically on the GraphQL and REST APIs): if you request a permission you do not currently hold, the request is rejected. If the member a token is assigned to loses a permission later, the token stops using it on the next request; it works again if the member gets the permission back. ## Using a Token Append the token as a `?token=` query parameter to any URL that supports popout authentication: ``` https://lumio.vision/popout/events?token=lm_pop_xxxxxxxxxxxx ``` On the **first load**, the raw token in the URL is exchanged **once, server-side**, for a short-lived **popout session** (a 15-minute `httpOnly` cookie), and the page redirects to the token-less URL. From then on the raw token no longer appears in the address bar, the WebSocket URL, or any network request - WebSocket subscriptions, REST proxy calls, and SSR data all authenticate through the session cookie. The session is refreshed transparently for as long as the token stays valid, so a browser source can stay open indefinitely. You still only pass the token once in the URL. **Saved URLs keep working:** an existing OBS browser source whose stored URL contains `?token=lm_pop_…` is exchanged automatically on load - no setup breaks. ### Where Popout Tokens Work Lumio exposes five popout pages plus the overlay route. Popouts live on the dashboard host (`lumio.vision`) and accept `?token=` for popout-token auth. Overlays live on their own host (`overlay.lumio.vision`) and carry their **own** access token, which is not a popout token - you get the complete URL from **Copy Link** on the overlay card. | URL | Host | What it shows | Recommended permissions | |-----|------|---------------|-------------------------| | `/popout/events` | `lumio.vision` | Live event feed (followers, subs, cheers, raids, redemptions, tips) with channel-status indicator | `events:read` (+ `events:userinfo` to open the user info card from event rows) | | `/popout/test-events` | `lumio.vision` | Test-event catalog and sending confirmation, usable as an OBS dock from 320 px width | `events:create` (required to show the catalog and send) | | `/popout/chat` | `lumio.vision` | Unified multi-platform chat view with moderation actions | `chat:read` (+ `chat:write` to send, `chat:ban`/`chat:timeout`/`chat:delete` to moderate, `chat:userinfo` to open the user info card from chat rows) | | `/popout/music` | `lumio.vision` | Spotify "now playing" widget with playback controls | `spotify:read` and `connections:read` for channel status (+ `spotify:playback` to control playback) | | `/popout/obs` | `lumio.vision` | OBS Remote control panel (scenes, stream/recording state) | `settings:read` | | `/overlay/{key}` | `overlay.lumio.vision` | Any overlay, rendered as a browser source | _none - the overlay's own access token is already in the URL_ | | `/overlay/{key}/preview` | `overlay.lumio.vision` | Preview of an overlay inside the dashboard | _same as above_ | Overlays are intentionally separate: each one has its own access token, minted for the overlay rather than for a person, and revocable on its own from the overlay's menu. Loading `/overlay/{key}` without that token returns _"403 - Access token required"_. See [Overlays](./overlays.md). The Test-Events popout uses the same searchable catalog as the dashboard side sheet. A popout link must carry `events:create` in its own permission list. If it does not, the page says **This popout link is missing a permission** and sends nothing. A signed-in browser uses the current user's permissions. Tests appear in overlays and dashboard activity, not in the Events popout or stored history. The dashboard **Popouts ▾** menu opens or copies the token-free page URL; create the token under **Tokens** for a delegated OBS dock. ## Browser Source Setup 1. Create the popout token with the permissions you need (for `/popout/events` you only need `events:read`) and **copy the secret on the final screen** - it is never shown again 2. Build the URL yourself: take the popout page you want and append `?token=` plus the secret, e.g. `https://lumio.vision/popout/events?token=lm_pop_…` 3. In OBS: **Add → Browser**, paste the URL 4. Width/height: 400×600 for chat, 600×120 for music, 500×400 for events, or at least 320 px wide for the Test-Events dock (rule of thumb) 5. Custom CSS (optional): `body { background: transparent; }` if the popout doesn't already render transparent 6. Optional: during a season the popout wears the [seasonal look](/user-guide/preferences#popouts-and-obs) of the token's user. Append `&season=off`, `&seasonStrength=subtle` (colours only) or `&seasonMotion=off` (no animations) to change it for this URL only - the final screen of the wizard lists these parameters next to the token ## Token Management The list groups tokens into **Active**, **Expiring soon**, and **Revoked & expired**. The last group starts collapsed; expand it to inspect or restore a revoked token. Open a token row to see permissions grouped by category, its assigned member, prefix and usage dates, then use its edit, revoke or delete actions. Rows stack their details on a phone or narrow window. Each token in **Settings → Channel → Access → Popout Tokens** shows an **always-visible status line** - **Never expires**, **Expires on {date}**, **Expired on {date}**, or **Revoked on {date}** - alongside a badge that flags a token that is **Expired**, **Revoked**, or **Expiring soon** (within 7 days of its expiry). Each card also shows the assigned member - or **Unknown member** when your role cannot resolve member names (`members:read`) - plus when the token was created and last used. Clicking the pencil icon opens the token in the same three-step wizard, where you can jump straight to any step: - **Edit** - change label, assigned member, or permissions any time; **Save** is available on every step and only submits the fields you actually changed. - **Set an expiry** - on the **Lifetime** step, pick a preset or a custom date (see [Token lifetime](#token-lifetime)); the token stops working automatically after that moment. Choose **Never** to make the token non-expiring again. An already-expired token can be pushed back into the future here - its status updates accordingly. - **Revoke** - on the **Lifetime** step, mark the token revoked without deleting it; all popout URLs using it stop working immediately. A revoked token can be **restored** later from the same step (its secret stays valid). - **Delete** - permanently remove the token (the trash icon on the card). Unlike revoke, a deleted token cannot be restored; deletion is confirmed in a separate dialog. ### Token lifetime The **Lifetime** step offers presets - **In 7 days**, **In 30 days**, **In 90 days**, **In 1 year**, **Never**, and **Custom date** - with **Never** as the default. A finite preset expires at the **end of that day in your browser's timezone** (not at an arbitrary minute), and **Custom date** opens a date picker for any future day. Only the resulting absolute moment is stored, never a relative "7 days" rule, so the expiry does not shift if you edit the token later. When creating a token you cannot pick a date in the past; when editing, a back-dated expiry is allowed and acts as a soft-revoke. Expiry and revocation are enforced on every request by the backend resolver - for both the raw popout token and the short-lived session it is exchanged for on load (the OBS session cookie). An expired or revoked token is rejected exactly like an invalid one, without disclosing which state it is in. A popout that is already open notices within about 30 seconds: its live connection is checked again and closed once the token is revoked, expired or deleted. **Assigned member leaves** - a token assigned to a member stops working once that member is no longer part of the account. When you remove the member (or they leave), the tokens assigned to them are deleted, and any of their popouts that are still open close within about 30 seconds. Tokens bound to other members are not affected. **Audit** - actions performed via a popout token are attributed in the audit log to the member the token is bound to (the creator, unless you picked someone else). ## Security Notes - A popout token is a **bearer credential** - anyone with the URL has the same access. Treat it like a password. - On load the raw token is swapped for a short-lived session cookie and dropped from the live URL and WebSocket URL, so it no longer lingers where a screen share or proxy log can catch it mid-stream. The URL you **store** (in OBS, a bookmark, a stream-deck button) still contains the raw token, though - so the storage points below still matter. - URLs are visible in browser history, screen shares and OBS scene exports. Use a member assignment + minimum-permission set so a leaked token can't be abused. - Tokens assigned to a member are removed automatically when that member leaves the team. A token bound to you keeps working when someone else who used it leaves - revoke it yourself in that case, and revoke any token immediately if you suspect a leak. - Never paste a popout token into a public chat, ticket, or screenshot. ## API | Type | Endpoint | Permission | |------|----------|------------| | GraphQL Query | `popoutTokens` | `tokens:read` | | GraphQL Query | `myPermissions` | _any authenticated_ - used by popout pages to know what they can render | | GraphQL Mutation | `createPopoutToken(input)` | `tokens:create` | | GraphQL Mutation | `updatePopoutToken(id, input)` | `tokens:edit` | | GraphQL Mutation | `deletePopoutToken(id)` | `tokens:delete` | | REST | `GET /v1/tokens` | `tokens:read` | | REST | `GET /v1/tokens/me` | _any authenticated_ - returns the current caller's resolved permission set | | REST | `POST /v1/tokens` | `tokens:create` | | REST | `PATCH /v1/tokens/{id}` | `tokens:edit` | | REST | `DELETE /v1/tokens/{id}` | `tokens:delete` | --- ## User Guide / API Keys Source: https://docs.lumio.vision/user-guide/api-keys # API Keys Create keys for programmatic access to the Lumio API and the `lumio` CLI. Open **Settings**, select the channel, then choose **Access → API Keys** (`/dashboard/settings/channel/api-keys`). This area holds **Personal keys** tied to your membership and **Service keys** that outlive it. ## Two kinds of key Both kinds are bearer credentials that authenticate REST and GraphQL requests, carry an explicit permission set, and are shown in full **only once** when created. They differ in who owns them and what happens when a member leaves. The **Which key for what?** chooser explains both kinds and starts creation. Below it, **Personal keys** and **Service keys** are tabs with counts. Expand a row to see its permissions grouped by category, creation and last-use details, and the actions your role allows. Expired keys are marked in the list. On a narrow window, each row stacks its details beneath the name. | | Personal key | Service key | |---|---|---| | Prefix | `lm_usr_…` | `lm_svc_…` | | Owned by | You, within the active account | The account | | Bound to a member | Yes - the member who created it | No member identity at all | | Survives member departure | **No** - stops working when you leave | **Yes** - keeps working after its creator leaves | | Best for | Your own scripts, notebooks, the CLI | CI/CD pipelines, servers, long-lived integrations | Both sections live in the same area and share the same access gate: the `feature:apikeys` feature must be enabled for your plan, and each action is guarded by the `apikeys:*` permission family - `apikeys:read` to view keys, `apikeys:create` to create, `apikeys:edit` to rename **or regenerate**, and `apikeys:delete` to revoke. If an action is missing, your role is lacking the matching permission or the feature is off for your plan. ## A Pro-plan feature Creating and renaming API keys requires the **Pro** plan - `feature:apikeys` is enabled on Pro and locked on the Free plan. **Downgrading never strands a live key.** If your account moves off Pro, any keys you already created **keep working** - the credential is not revoked. So that you are never locked out of a live credential, the API Keys area stays reachable in a **locked state**: you can still **view and revoke** your existing keys, but **Create**, **Rename** and **Regenerate** are disabled with an upgrade hint until you return to Pro. This mirrors the API itself: `apikeys:read` and `apikeys:delete` stay available on a plan-locked account, while `apikeys:create` and `apikeys:edit` (which also gates regenerate) are rejected on both the GraphQL and REST endpoints. **Finding keys after a downgrade.** If your channel still has live keys, **API Keys** remains in the settings list with a lock. It shows the existing keys and lets you revoke them; creation and editing stay disabled. The **Subscription** area also links to it. Without live keys, the area is hidden when the plan lacks the feature. A direct link to `/dashboard/settings/channel/api-keys` opens the same area. ## Personal keys (`lm_usr_…`) A personal key is bound to **you within the current account**. Use it for your own scripts and for signing in to the `lumio` CLI. - **Member-bound lifetime.** A personal key stops authenticating the moment you are no longer a member of the account. If you leave - or an admin removes you - your personal keys for that account are purged and can no longer be used. This is the deliberate contrast with service keys below. - **Create** - click **Create API key**, give it a **Name**, optionally set an **Expires** date (leave it empty for no expiry), and tick the **permissions** the key may carry. The picker lists the full permission registry, grouped by category with a search box; scopes you do not currently hold are shown disabled. A personal key can additionally carry the **user-scoped** permissions (the Ideas Hub family), since it acts as you. - **Rename** - the pencil icon changes the display name only; the key material, its permissions, and its expiry are fixed once minted. - **Regenerate** - the circular-arrows icon rotates the key's secret in place. The name, permissions and expiry stay the same, but the **old secret stops working immediately** and a brand-new key is shown **once**. A confirmation step names the key and warns that anything still using the old secret breaks until you update it. Use it when a key leaks - you recover without a support ticket and without losing the key's configuration. - **Revoke** - the trash icon deletes the key. Any script using it stops working immediately, and this cannot be undone. ## Service keys (`lm_svc_…`) A service key is **owned by the account, not by you**. It carries no member identity, so it keeps working even after the member who created it leaves the team - which is exactly what you want for a deploy bot, a server, or a scheduled job that must not break when someone offboards. - **Survives member departure.** Revoke a service key manually once it is no longer needed - it will not stop on its own when its creator leaves. A card whose creator has since left the account is labelled *Created by a member who has left the account*; the key itself keeps working. - **Create** - while signed in with an interactive session, click **Create service key**, give it a **Name**, optionally set an **Expires** date, and tick the **permissions** from the same grouped, searchable picker. You can grant only permissions you hold. Because a service key carries **no member identity**, it offers only account-scoped permissions - the user-scoped (Ideas Hub) family is not shown, unlike the personal-key picker. - **Rename** - changes the display name only; permissions, key material, and expiry are immutable once minted. - **Regenerate** - with an interactive sign-in session, rotates the key's secret in place (name, permissions and expiry unchanged). You must still hold every scope on the key. The **old secret stops working immediately** and the new key is shown **once**, behind a confirmation step that warns every consumer using the old key will break. Rotate a leaked deploy key without recreating and re-scoping it. - **Revoke** - deletes the key. Any server or automation using it stops working immediately, and this cannot be undone. Service key lifecycle actions are recorded in the [audit log](./audit-log.md) as account-scoped events (`account:service_key_created`, `account:service_key_updated`, `account:service_key_revoked`). ## Developer keys (`lm_dev_…`) Developer keys are managed separately under **Settings → You → Developer → Developer keys** (`/dashboard/settings/developer/keys`), because they are a developer credential rather than a channel key. They are the key `lumio deploy` and CI pipelines use to publish extensions, and they behave differently from personal and service keys: - **Bound to a developer identity, not an account.** A developer key is owned by either your **developer profile** or a **developer team** - never an account and never a member. Like a service key it keeps working after the member who minted it leaves a team. - **Not tied to your plan.** Eligibility is *approved-developer status* - an approved developer profile or membership of a developer team - **not** the Pro plan. Free-tier developers can create developer keys even when the `feature:apikeys` page above is Pro-only. - **Scoped to `extension-dev:*` only.** The scope picker offers only the extension-developer family - `read`, `create`, `edit`, `delete`, `publish`, `analytics`, `payouts`. A developer key can never carry an ordinary account scope (such as `chat:ban`). - **Profile vs. team.** When creating a key you choose an **Owner**: your own developer profile, or any team where you hold the `team-secrets:edit` right. Teams you belong to without that right are not offered - the backend enforces the same rule. You can see every key across your profile and your teams, but can only rename or revoke a team's keys when you hold `team-secrets:edit` for that team. - **Create / rename / revoke** work exactly like the other kinds: the full key is shown **once** on creation, rename changes the display name only, and revoke stops the key immediately. Developer key lifecycle actions are recorded in the [audit log](./audit-log.md) as user-scoped events (`user:developer_api_key_created`, `user:developer_api_key_updated`, `user:developer_api_key_revoked`). ## The one-time reveal When you create either kind of key, the full secret is shown **once**, on the creation dialog's final screen, with a **Copy** button. Copy it before pressing **Done** - Lumio stores only a hash of the key, so it can never be shown again. The card afterwards shows only the key's short prefix (for example `lm_svc_ab…`), never the secret. **Regenerating** a key reveals its new secret exactly the same way - once, with a **Copy** button - and the previous secret is invalid from that moment on. So if you lose a secret you no longer have to revoke and recreate the key: **Regenerate** it instead, keeping the same name, permissions and expiry. ## What scopes a key can carry The picker renders the account's full permission registry - the same catalogue used by the role editor - grouped by category with a search box, rather than a short curated list. This lets a key do everything the channel or user behind it can, which is the whole point of an API key. - **Personal keys** offer account-scoped permissions **and** the user-scoped Ideas Hub family (`ideas:*`), because a personal key acts as the member who created it. - **Service keys** offer account-scoped permissions **only** - they have no member identity, so user-scoped permissions would be meaningless. A small set of scopes is **never assignable to any key**, regardless of what its creator holds, because a bearer credential that can carry them is too dangerous: - `apikeys:create` and `apikeys:edit` - a key that can mint or manage other keys would outlive the human it belongs to. - `tokens:create` - minting popout tokens. - `account:delete` - deleting the account a key deploys to. These are the only exclusions; everything else in the registry is offered. The scope list a picker offers is fetched live from the backend (`apiKeyAssignablePermissions`), so it always matches what the API will actually accept - the picker and the API can never drift. ## Permission subset limit The picker only lets you select permissions **you currently hold**. A permission outside your own access is shown disabled, and the backend enforces the same rule on both the GraphQL and REST APIs - a request for a permission the creator does not hold is rejected. A key can therefore never grant more access than the person who minted it. A personal key also follows your role **after** creation. If your role loses a permission - for example you are moved from Administrator to Viewer - the key stops using that permission at once. The permission stays on the key and works again if you get it back. Service keys have no member behind them and keep their full scope. Permissions are **immutable after creation**: renaming a key changes its display name only, never its scopes. To change what a key can do, revoke it and create a new one with the set you want. ## Using a key Send the key as a bearer token on the `Authorization` header of any REST or GraphQL request: ``` Authorization: Bearer lm_svc_xxxxxxxxxxxx ``` The same header works for a personal `lm_usr_…` key. The `lumio` CLI stores your personal key for you after `lumio login`. See the [REST API](../api-reference/rest-api.md) and [GraphQL](../api-reference/graphql.md) references for endpoints, and [Auth](/features/auth) for how the request is authenticated. ## Security notes - An API key is a **bearer credential** - anyone holding it has the access it carries. Treat it like a password. - Grant the **minimum permissions** the integration needs, and set an **expiry** where the key only needs to be short-lived. - **Revoke immediately** if a key leaks or an integration is retired. For service keys, revoke on offboarding as part of your checklist - they do not expire on member departure. - Never paste a key into a public chat, ticket, screenshot, or committed source file. --- ## User Guide / Audit Log Source: https://docs.lumio.vision/user-guide/audit-log # Audit Log Lumio has two audit views in the web dashboard: **My Activity** for your own security history and **Account Audit Log** for account-wide operational events. Both views are read-only, show timestamps in your selected timezone, and use the same table controls for filtering, pagination, and row details. For the backend readers and event catalog behind these pages, see the [Audit Log feature reference](../features/audit-log.md). ## Where to find it | View | Sidebar path | URL | Who can open it | |------|--------------|-----|-----------------| | **My Activity** | **Settings → You → Security → Activity** | `/dashboard/settings/security/activity` | Any signed-in user | | **Account Audit Log** | **Settings → Channel → Control → Audit log** | `/dashboard/settings/channel/audit-log` | Account members with `audit-log:read` | **My Activity** is self-only. It does not use account RBAC and it does not show other members' activity, even when you are an Owner or Administrator. **Account Audit Log** is account-scoped. It is the **Audit log** tab of the [channel settings](./channel-settings.md); the tab is visible only to members whose active account role grants `audit-log:read`, and the tab's route is protected by the same permission. Owner and Administrator hold it by default; Moderator does not. ## My Activity The **My Activity** page shows personal security events tied to your Lumio user: - Sign-ins and failed sign-ins. - OAuth grants. - Personal API-key lifecycle: creating or revoking one of your own `lm_usr_*` API keys. - Two-factor setup, authenticator app and passkey additions/removals, recovery-code use and changes, password and sign-in-method changes, data exports, and deletion requests. Rows are keyed to your user identity, not to the active account. Switching accounts does not turn this into an account-wide log, and a popout, overlay, widget, or extension token cannot read it. ## Account Audit Log The **Account Audit Log** page shows tenant-level events for the active account: - Role creation, updates, deletion, and permission changes. - Member role assignments, with the acting user shown in the **Actor** column. - Channel connections added or removed. - GDPR erasure rows written for account-scoped data cleanup. The page never shows another account's rows. If you belong to multiple accounts, use the account switcher first, then open **Settings → Channel → Audit log** for the account you want to inspect. ## Filtering Both pages have a filter panel above the table: | Filter | Effect | |--------|--------| | **Event type** | Narrows the table to one exact event type. The list only contains event types that belong to the current view's scope. | | **From** | Shows events on or after the selected day in your timezone. | | **To** | Shows events on or before the selected day in your timezone. | Changing a filter applies it immediately and returns the table to the first page. ## Table Events are shown newest first. Each row includes: - **Time** - the event timestamp in your selected timezone; expanded details also show UTC. - **Event** - a readable label for the audit event type. - **Actor** - account view only; the acting user's ID prefix when the row has an actor. - **IP address** - the captured client IP; shows **Unknown** ("Unbekannt" in German) when none was captured. - **Device** - the device, browser and operating system read from the captured user agent (e.g. "Chrome 141 · macOS"), as text without an icon. It never shows a raw or bogus value and never guesses: a user agent it cannot read - absent, a non-browser client, or junk - shows **Unknown**, and a known browser with an unreadable operating system still shows the browser (e.g. "Chrome 141 · Unknown"). - **Location** - city and country when GeoIP enrichment is enabled and resolved. - **Details** - expands the row. Click a row to expand its actor, session, involved channels, exact UTC time, raw JSON (with **Copy**), and user-agent. In Streamer Mode the IP is partly covered, the location shows as `••••••••`, the user-agent is hidden, and raw JSON starts closed; each row has its own **Eye** to reveal the IP and the location. Rows older than 12 months are labelled **Anonymized**; rows older than 24 months are deleted (GDPR-erasure records after 36 months). Select rows across pages to **Export selection**, or export the current filter or all rows as CSV or JSON. The account export requires `audit-log:read`; the personal export is self-only. CSV values are escaped for spreadsheet use. A selected export accepts at most 1,000 event IDs. ## Pagination Each page loads 50 events. Use **Previous** and **Next** below the table to move through older or newer pages. The footer shows the current page number and the total number of matching events for the active filter. ## Related - [Roles & Permissions](./roles-and-permissions.md) - grant `audit-log:read` to account roles. - [Audit Log feature reference](../features/audit-log.md) - scoped readers, permissions, API fields, and event catalog. - [REST API](../api-reference/rest-api.md#audit-log) and [GraphQL API](../api-reference/graphql.md#audit-log) - protocol details for `GET /v1/me/audit-log`, `GET /v1/account/audit-log`, `myAuditLog`, and `accountAuditLog`. --- ## User Guide / Developer Limits Source: https://docs.lumio.vision/user-guide/developer-limits # Developer Limits Open **Settings → You → Developer → Limits** (`/dashboard/settings/developer/limits`) to see the resource limits that apply to your developer account and its extensions. The area is in the settings window, whether you currently have a channel selected or not. ## Viewing Limits The area has three tabs, plus a **Request increase** button in its header that starts a request for any configurable limit: - **Developer / Team** - the developer-level limits that apply as defaults to all your extensions - **Extensions** - a table of your extensions with their resolved sound limits and a **Request increase** button per row - **Requests** - your limit increase requests, with a badge showing how many are still open ### Limit Categories | Category | Limits | Configurable | |----------|--------|:---:| | Bundled Sounds | Sounds per extension, file size per sound, total sound storage | Yes | | Extension Bundle | Files per version, file size, total bundle size | No | | Runtime | Uploads per install, upload size | No | | Store Listing | Screenshots, screenshot size, icon size | No | Each category card is badged **Configurable** or **Fixed**. Configurable limits can be increased via a request and carry a source label - **Default**, **Developer override** or **Extension override** - showing where the current value comes from. Fixed limits apply equally to all developers and cannot be requested. ## Requesting a Limit Increase The request wizard runs in four steps: 1. **Select Scope** - **Team / Developer account** (applies to all your extensions) or **Specific extension** (then pick one from the list) 2. **Select Limits** - tick one or more of the configurable limits; each shows its current value 3. **Enter Values** - pick a preset for each selected limit or switch to **Custom** and type a number. The requested value must be higher than the current one. Add a reason of at least 20 characters. 4. **Confirmation** - review the scope, every current → requested pair and your reason, then **Submit Request** Selecting several limits in one pass submits one request per limit. ## Request Status Each request goes through a review process: | Status | Meaning | |--------|---------| | Open | Submitted, waiting for admin review | | In Review | An admin is reviewing the request | | Approved | Approved - the limit is applied automatically | | Rejected | Rejected - check the review notes for details | The **Requests** tab splits them into **Open** (which covers both Open and In Review), **Approved** and **Rejected**, each with a count. Click a request to expand your reason, any review notes, and the full timeline of who moved it to which status and when. --- ## User Guide / Developer Payouts Source: https://docs.lumio.vision/user-guide/developer-payouts # Developer Payouts Extension developers are paid their revenue share through **Extensions → Payouts** (`/dashboard/extensions/payouts`). The page is visible to approved developers with the `extension-dev:payouts` permission while the `extension_development` feature is on. It shows your balance, lets you choose a payout method and request a payout, and lists the credit notes of your payouts. ## Before your first payout A payout needs three things: 1. **A verified developer identity.** Submit the form under **Extensions → Verification** and wait for the review. 2. **Your tax details.** Every payout is documented by a self-billed credit note (*Gutschrift*) that Lumio issues on your behalf, and its VAT depends on these details. Which ones you need depends on your country: | You are | You need | |---------|----------| | In Germany, small business (§ 19 UStG) | Tax number (Steuernummer) or VAT ID, and "Small business: Yes" | | In Germany, charging VAT | Tax number or VAT ID, and "Small business: No" — 19 % VAT is added on top of your revenue share | | In another EU country | VAT ID | | Outside the EU | Nothing | 3. **A payout method** (PayPal or SEPA bank transfer; Stripe Connect is not available yet). ## Tax details The verification form asks for the tax details in step 2 (**Business and tax data**). The small-business question appears only when the country is `DE`; "Not answered" leaves it open. After you submitted the form, the **Tax details** card on the verification page keeps them editable in every status, including after approval. Saving does not reset the review and never changes a credit note that was already issued. The tax number and the VAT ID take at most 50 characters each. ## Requesting a payout **Request payout** pays out your whole current balance with the saved method after a confirmation. If it cannot be booked, the page names the reason and nothing is paid out: | Message | What to do | |---------|------------| | Payouts require a verified developer identity | Complete the verification, then request again | | The credit note needs your tax number or VAT ID / your small-business answer / your VAT ID | Follow the link to the **Tax details** card, complete them, then request again | | Payouts are unavailable right now because Lumio cannot issue credit notes yet | Lumio's own invoicing data is not configured. Try again later | ## After the request The payout appears in your payout history as **Pending**: - **Stripe and PayPal** are sent automatically within about 15 minutes. A Stripe payout is then **Completed**. A PayPal payout is **Processing** until PayPal confirms it. - **SEPA bank transfers** are made by Lumio's team, and the payout is **Completed** once the transfer is recorded. - If the provider refuses the payout, it is **Failed**. Your balance is credited back, the credit note is cancelled, and you can request again. You can have one open payout at a time. In addition, Lumio pays out your balance automatically once per calendar month when it reaches the minimum for your payout method, unless you already had a payout that month. See [Developer Payouts](../features/developer-payouts.md) for the details. ## Credit notes The **Credit notes** section lists every credit note and cancellation, newest first, 20 at a time (**Load more** shows older ones). Each row shows: - the number (`GS--<6 digits>`) and whether it is a **Credit note** or a **Cancellation** — a cancellation carries negative amounts and the reason (payout failed, your objection, correction); a cancelled credit note is marked **Cancelled**; - the issue date and the service period (dates in Europe/Berlin); - the VAT treatment and, once it was mailed, the date you got it by e-mail; - the gross amount, which streamer mode hides. **PDF** downloads the document as issued. Right after a payout the PDF is still being generated: the row then says **Document is being generated** instead of offering a download. You also receive every PDF by e-mail. If Lumio credited you a balance from an earlier developer profile (you came back with a claim code), the next credit notes show that amount as its own first line, "earned through an earlier developer profile", with the date it was released. A payout smaller than that amount uses part of it; the rest appears on the next note. To object to a credit note, contact Lumio support. An operator records the objection and, if needed, issues a corrected note; the original is never changed. See [Developer Credit Notes](../features/developer-credit-notes.md) for numbering, VAT and retention. --- ## User Guide / Notifications Source: https://docs.lumio.vision/user-guide/notifications # Notifications Notifications are how Lumio nudges you about things that need your attention: pending account invites, someone mentioning you on the Ideas Hub, a recap when a stream ends, and developer-programme events. They live in a dropdown under the bell icon in the dashboard's top bar - no inbox, no separate page, just a quick glance and an inline action. ## Where to find it The **bell icon** sits in the top-right corner of the dashboard, right next to the Streamer Mode shield toggle. (Your account switcher and avatar live in the sidebar, not next to the bell.) If you have unread notifications, a small badge shows the count (`1`, `2`, … up to `99+`). Click the bell to open the dropdown. If there's no badge and the dropdown says **No notifications yet.**, there's genuinely nothing - the dropdown doesn't hide anything you haven't dismissed. ## Quick start 1. Click the bell icon in the top-right. 2. Opening the dropdown marks all unread notifications as read and clears the badge, including unread items older than the ten shown here. Items that were unread stay highlighted until you close the dropdown. 3. Read the titles and messages in your dashboard language. If a translation is unavailable, Lumio shows the notification's stored text. 4. If a notification has buttons, click **Accept** or **Decline** to act on an invite in the bell, or **View** / **Review** to open the related page. That's the quick path for acting on notifications. For controlling how and where notifications are delivered, see [Notification Preferences](#notification-preferences) below. ## The bell dropdown in depth When you open it, you see: - A header with the title **Notifications** and a bin (**Delete all notifications**) whenever the list isn't empty. - The most recent **10 notifications**, ordered newest first. - Each row shows: **title**, optional **message** (lighter text), **timestamp** (localized to your browser), and - if the notification is actionable - one or more **action buttons**. - A scrollable list: scroll inside the dropdown to see the full ten. The dropdown closes when you click outside it, or when you click the bell again. Any other open bell in the dashboard clears its badge at the same time. ## Polling and freshness The bell polls for notifications every **30 seconds** in the background, even when the dropdown is closed. That means: - A new notification can take up to 30 seconds to appear after the backend creates it. - The badge stays roughly in sync without any manual refresh. - Polling runs silently; if a request fails (network blip, API restart), the UI just keeps the last-known state and tries again 30 seconds later. If the badge shows unread notifications, opening the dropdown refreshes the list before marking them read. Otherwise, reload the page for a fresh view; the next polling cycle is at most 30 seconds away. ## Notification types Notifications today include: - **Account invites** - Someone invited you to join their account. These carry **Accept** and **Decline** action buttons. If the inviter cancels before you act, the entry turns into an "invitation was revoked" notice. - **@mention in an idea** - Someone mentioned you in a comment on the Ideas Hub. A **View** button takes you straight to the idea. - **Stream summary** - A recap for the account owner after one of your streams ends, delivered in the bell and (by default) as an email with a viewer chart. Sessions shorter than five minutes are skipped, so test streams don't generate one. - **Developer programme events** - The outcome of your developer application, extension lifecycle events (created, version submitted / approved / rejected / published, extension suspended), extension access grants, tester invitations, and developer-team invites and membership changes. A team invite addressed to you has **Accept** and **Decline** buttons. Titles, messages and action labels appear in the dashboard's UI language when translations are available. A notification with no matching translation, or one missing a value needed for its text, shows its stored title or message instead. User-written content, such as review notes or feedback, stays in the language in which it was written. Platform connection problems are *not* delivered through the bell. A failed token refresh surfaces as a banner at the top of the dashboard instead - see [Connections](/user-guide/connections). ## Notification Preferences Open **Settings → You → Notifications** (`/dashboard/settings/notifications`) to control how each notification type is delivered to you. Preferences are tied to your user account, not to a channel - they are available no matter which entry is selected in the account switcher, including your personal profile, and one setting applies across all your channels. Each category is its own section with an **In the app** and an **Email** switch per type: | Category | Notices | Configurable | |---|---|:---:| | Security | New sign-in, 2FA on/off, authenticator app or passkey added or removed, recovery code used or regenerated, password or sign-in method changed, failed attempts | No | | Account & channels | Invite; role change, removal, support identity check | Invite: no; others: yes | | Data & privacy | Export ready, profile deletion scheduled, channel dissolution scheduled, reminded or cancelled | No | | Stream | Stream summary, connection needs reconnection | Yes | | Billing (channel owner) | Invoice paid, payment failed, plan changed, cancellation receipt | Payment failure and cancellation receipt: no; others: yes | | Ideas Hub | @Mentions | Yes | For every configurable type you can choose one of four options: | Setting | What happens | |---------|-------------| | Off | No notification of any kind is sent | | In-App | A notification appears in the bell dropdown only | | Email | An email is sent, with no bell-dropdown entry | | In-App + Email | A notification appears in the bell dropdown **and** an email is sent | Defaults vary by type. Security and required privacy notices use **In-App + Email**. New invoices also default to **In-App + Email**. The matrix shows each type's current choice. Locked rows show a padlock and an **Always on** tag, and always deliver in both channels. They include invites, security notices, export readiness, deletion scheduling, payment failures and cancellation receipts. Changes take effect immediately; there is no save button. ### What language and timezone are my emails in? Emails are written in **your** language and show dates in **your** timezone - not the language of whoever triggered them. This follows the **Language**, **Timezone** and **Date format** settings you pick under **Settings → Appearance → Region & Language**: - If you chose a specific **Language** (English or German), your emails use it. - If you left **Language** on **Automatic**, emails follow the language your browser last used on Lumio. If you have never opened Lumio in a browser, they default to English. - Dates and times in an email use your **Timezone** choice first, then the last timezone your browser reported, then UTC. Numeric dates follow your **Date format**; written weekday and month names follow your language. The stream summary's heading uses your time format for its time range. You do not set this separately for email; it reuses your display preferences. For emails that go to a channel's **owner** (the stream summary and the *reconnect required* alert), it is the owner's language and timezone that apply. ## Actions Actionable notifications have one or more buttons below the message. **View**, **Review** and similar buttons open a page. Invite buttons act in the bell: - **Accept** - adds you as a member of the inviting account, switches your active account to it, and reloads the dashboard. - **Decline** - deletes the invite, so the link in it can no longer be used. - **Accept a developer-team invite** - joins the team and opens its dashboard page. - **Decline a developer-team invite** - removes an unused single-use invite. A shared or already-used invite stays available for others. If the invite has already gone, the bell simply clears the action. Clicking an invite action disables both buttons while the server processes the request. If it fails (e.g. the invite expired or you are already a member), an error message appears at the bottom right and the buttons re-enable. You can retry or open the invitation link. Once a notification has been **acted on**, the buttons disappear. The notification stays in your list (now read and acted) so you have a trail of what happened. ## Retention Read notifications are deleted automatically 90 days after they arrived. Unread notifications stay until you read or delete them. ## Permissions Notifications are always scoped to **you as a user**, not to an account. That means: - Switching accounts does not change what you see in the bell - your pending invites, mentions, etc. are yours. - No RBAC permission is required to view or interact with your own notifications; the backend only checks that the notification belongs to you. - Notifications are private to your own signed-in session. A popout token cannot read them or act on them, even though it belongs to the same user. | Capability | Required Permission | |---|---| | See your own notifications | (none - tied to your user) | | Open the bell to mark all as read | (none - your own) | | Delete all | (none - your own) | | Execute an action button | (none - accepting or declining an invite is a user-level decision, validated against the invite itself) | ## Tips & best practices - **Clear invites promptly.** Accept or decline them rather than letting them pile up. Declining an account invite removes it. Declining a developer-team invite removes it only when it is unused and single-use; a shared link can still be used by others. - **Watch the dashboard banners for connection problems.** A platform whose token refresh failed shows up as a banner, not a bell notification. Click through to **Connections** and reconnect. - **Don't rely on notifications for real-time events.** Chat messages, follows, subs, etc. flow through the WebSocket event pipeline, not the notification bell. Use **Dashboard > Events** or the overlay Event List for stream-time activity. - **Open the bell to review new notices.** Opening clears the unread badge; notifications remain in the list until you delete them or they expire. ## Troubleshooting **Badge shows a number but the dropdown contains no unread items.** The dropdown shows the 10 most recent notifications regardless of read state, while the badge counts every unread one. Older unread items may be outside the visible ten; opening the bell marks those as read too and clears the badge. **I accepted an invite but nothing happened.** Accepting switches your active account to the one you joined and reloads the dashboard. If it didn't, check **Dashboard > Members** for your new membership and switch via the account switcher. The bell notification stays (acted state) as a receipt. **Invite action buttons are disabled and stuck.** The request is in flight. Wait a few seconds; if they stay disabled, the action likely failed - refresh the page and the updated state will load via the next poll. **The bell doesn't appear at all.** You're probably looking at a page outside the authenticated dashboard (e.g. `/overlay/...`, `/login`), or at a fullscreen editor page that hides the top bar. The bell is part of the dashboard shell and only renders when you're signed in. **I want to disable or reduce notifications.** Open **Settings → Notifications** and set configurable types to Off, In-App (no email), or Email (no bell entry). Locked notices cannot be disabled. **New notification didn't appear for ~30 seconds.** Expected. Polling is on a 30-second cadence. Reload the page to fetch immediately. ## Related docs - [Members](/user-guide/members) - sending and managing invites, which is where most of the invite notifications originate. - [Connections](/user-guide/connections) - reconnecting a platform after a token refresh failure. - [Roles and Permissions](/user-guide/roles-and-permissions) - understanding the actions behind notification buttons. - [Ideas Hub](/user-guide/ideas-hub) - @mentions in idea comments that trigger mention notifications. --- ## User Guide / Billing & Subscription Source: https://docs.lumio.vision/user-guide/billing # Billing & Subscription Upgrade your channel to a paid plan, download invoices, and manage the subscription. ## Where To Find It Open **Settings** at the bottom of the dashboard sidebar, select the channel you own, then choose an area under **Subscription**: - **Subscription** (`/dashboard/settings/channel/subscription`) - see the active channel's plan and billing cadence, compare plans, start checkout, or request cancellation. - **Invoices** (`/dashboard/settings/channel/billing`) - see invoices, download PDFs, and open the Stripe Customer Portal to update payment details. These areas appear only for the owner of the selected channel. A direct short link opens the settings window over the dashboard home; a non-owner sees **No access**. Switch to a channel you own before opening either area. If billing (Stripe) is not set up on this Lumio installation, the **Subscription** area still shows your current plan with its limits and every plan card, Free included. Only what needs Stripe is switched off: the upgrade buttons, switching to Free and cancelling. A line above the plan cards says "Payments are not set up in this environment." The **Invoices** area shows the note "Billing is not set up in this environment." instead of invoices. Platform admins additionally see that the Stripe keys (`LUMIO__STRIPE__*`) are missing. ## Plans At A Glance Lumio has two plans: **Free** and **Pro**. | Limit | Free | Pro | |-------|------|-----| | Overlays | 1 | 5 | | Widgets | 3 | 20 | | Storage | 100 MB | 1 GB | | Upload size per file | 10 MB | 50 MB | | Integrations | 2 | 10 | | Channel data (chat, events, chatter profiles, moderation) | 365 days (1 year) | 1095 days (3 years) | | Accounts you can own | 1 | 3 | | Sounds | 10 | 50 | | Sound file size / sound storage | 5 MB / 100 MB | 20 MB / 1 GB | Prices are listed on the **Subscription** page and include VAT. Pro is billed in EUR; both monthly and yearly billing are available (yearly is cheaper per month, and the toggle shows the saving as a percentage). The plan card itself lists overlays, storage, integrations and how long channel data is kept ("1 year of channel data" on Free, "3 years of channel data" on Pro; a window that is not a whole number of years is shown in days). A muted line under it names what that covers: chat, events, chatter and moderation data. Stream reports do not count against the window: they stay until you [dissolve the channel](./channel-settings.md#danger-zone--dissolve-the-channel). Stream-history curves have separate detail windows: 60-second samples for 90 days and 5-minute samples for 12 months; older history uses the 1-hour rollup. The list is followed by the plan's feature list (automations, bot modules, Copyright Manager, …). Feature availability per plan is shown on every card, so you can compare before you upgrade. ## Picking A Plan 1. Open **Settings → Channel → Subscription**. 2. Compare the plan cards. The plan you are currently on is highlighted. 3. Pick **Monthly** or **Yearly** with the interval toggle. 4. Tick the checkbox above the plan cards (see [Right of withdrawal](#right-of-withdrawal) below). It starts unticked, and the **Switch to …** buttons stay disabled until you tick it. 5. Click **Switch to …** on the plan you want. 6. You are redirected to **Stripe Checkout** - a secure, Stripe-hosted page. Enter your card or other payment method and confirm. 7. On success you land back on `/dashboard?billing=success`. Your new plan is active within seconds. If you close the Stripe page without paying, you come back to `/dashboard?billing=cancelled` and nothing changes. ### Right Of Withdrawal As a consumer in the EU you have a 14-day right of withdrawal. Lumio starts your plan as soon as you pay, so before every plan checkout you confirm that in a separate checkbox above the plan cards: > I expressly request that Lumio starts providing the service immediately, before the 14-day withdrawal period ends. I understand that my right of withdrawal lapses once Lumio has fully performed the contract, and that if I withdraw, I pay a pro-rata amount for the service provided up to that point. The **Withdrawal information** link next to it opens the withdrawal section of the [terms of use](https://lumio.vision/legal/terms#withdrawal-information) in a new window. The checkbox is required: if you press a **Switch to …** button without ticking it, a message under the box asks you to tick it and no checkout starts. It is shown in your dashboard language (German or English) and never comes pre-ticked. After payment you get an order confirmation by e-mail that repeats the declaration you ticked, with the full withdrawal information and the terms of use. A renewal of the same subscription needs no new tick; every plan change goes through the checkout again and asks again. ### Starting With Free First A new channel always starts on Free, during onboarding and in the **Create New Account** wizard (`/account/new`) alike. Paid plans are shown there but cannot be picked: onboarding shows an "Available after Free setup" hint, the wizard a note that you switch to a paid plan afterwards under **Subscription** (or, without Stripe, that payments are not set up). Once your Free channel exists, open **Settings → Channel → Subscription** and upgrade from there - Stripe Checkout links the subscription to the existing channel automatically. ## Managing Your Subscription Go to **Settings → Channel → Subscription → Invoices** and click **Open customer portal** (or **Payment details at Stripe** in the header). The Stripe Customer Portal opens in a new tab, where you can: - Update your payment method - Switch between monthly and yearly billing - View all past invoices (not just the most recent) - Download receipts as PDF - Cancel the subscription Stripe sends subscription and payment updates back to Lumio. Refresh the page if a recent portal action is not visible yet. ### Cancelling Choose **Verträge hier kündigen** in **Subscription** to open the plan cancellation dialog, or use the public `/cancel` page from the footer. You can request an ordinary cancellation at the end of the paid period or an extraordinary cancellation with a reason and requested time. The dialog lets you check the receipt address and print the confirmation. See [Withdrawal & Cancellation](/user-guide/withdrawal-and-cancellation) for the signed-out flow and paid extension subscriptions. For an ordinary cancellation accepted by Stripe, you keep plan access until the paid period ends and the account then moves to Free. Older chat, event, chatter and moderation data follow Free-tier retention at the next sweep; review overlays and storage above Free-tier limits. An extraordinary request waits for operator review and does not change the Stripe subscription automatically. Lumio records and confirms the request even if Stripe needs a retry. ### Re-subscribing If you cancelled and are now back on Free, just go through **Subscription → Upgrade** again. Your existing Stripe customer is reused, so your payment method is remembered. ## Invoices The **Invoices** area lists your 20 most recent invoices with date, amount, status, and a PDF download link. For the full history, open the Stripe Customer Portal. Invoices are generated by Stripe on every successful payment (and on failed ones with status `open`). Lumio does not issue its own invoices - the Stripe invoice is the legal document. ## Coupons Coupons are created by Lumio staff for custom deals, beta rewards and partner offers. The **Subscription** page has no coupon field - a coupon reaches your checkout because Lumio applies it to the checkout session that is created for you, so you see the discounted price on the Stripe Checkout page itself. A coupon is **valid** if all of these are true: it exists in Stripe, it is marked active, it has not expired, and it has redemptions remaining. If a coupon you were given does not show up as a discount on the Stripe page, contact whoever issued it. ## What Happens When A Payment Fails - Stripe automatically retries the payment a few times over the following days. - Lumio sends the channel owner an in-app and email payment-failure notice; Stripe may also email about the payment. - The subscription stays active during the retry window - you are not immediately downgraded. - If all retries fail, Stripe cancels the subscription. Lumio then downgrades the account to Free on the next webhook. Update your card in the Stripe Customer Portal as soon as possible to avoid interruption. ## Permissions Two account permissions cover billing: | Permission | What it lets you do | |------------|----------------------| | `plan:read` | See the current plan and its limits | | `plan:edit` | Change the plan / manage the subscription | Only the **Owner** role holds these by default. The subscription and invoice areas additionally check that you own the active channel. The **Invoices** area (invoices + Stripe portal) is **owner-only**: a non-owner opening `/dashboard/settings/channel/billing` sees **No access**. Financial data is restricted to the channel owner. ## Troubleshooting **"Billing is not set up in this environment" / "Payments are not set up in this environment"** This Lumio installation has no Stripe connection, so there is nothing to buy or pay. Your plan and Free stay usable. On lumio.vision, contact Lumio support; on your own installation, a platform admin sets the `LUMIO__STRIPE__*` keys. **"Stripe price ID not configured for this plan"** That plan has no price configured for the interval you picked. Try the other interval, and let Lumio support know. **"No Stripe customer found for this account"** The account has never had a paid subscription, so there is no Stripe customer yet. Go through the Subscription page and complete a checkout - the customer is created at that point. **"No active subscription to cancel"** The account is already on Free or the subscription was already cancelled on Stripe's side. Nothing to do. **Checkout redirected me back but my plan didn't change** The plan change happens when Stripe posts a webhook to Lumio, not when the browser redirects. It normally takes 2–5 seconds. Refresh the Subscription page - if the plan still shows Free after a minute, the webhook may have failed. Contact support and include your checkout session ID from the URL. **My coupon did not show up on the Stripe page** The coupon may have expired, been deactivated, or reached its redemption limit. Contact whoever issued it. **I cannot open the Invoices area even though I am a member** The Invoices area is owner-only. A non-owner opening its short link sees **No access**. Ask the account owner to download invoices for you, or ask them to transfer ownership if you are taking over billing. --- ## User Guide / Withdrawal & Cancellation Source: https://docs.lumio.vision/user-guide/withdrawal-and-cancellation # Withdrawal & Cancellation ## Withdraw from a contract Open [Vertrag widerrufen](https://lumio.vision/withdraw) from the site footer. The page uses the legal layout and works while signed out. Enter your name, the email address for the receipt, and which plan or paid extension purchase you mean. If you are signed in, Lumio prefills your name and email address and groups eligible contracts by every channel you may manage; **Other contract** lets you describe a contract yourself. You can also start from **Vertrag widerrufen** beside a contract under **Settings → Channel → Invoices**, or from an installed paid extension's store page. A signed-out withdrawal requires a Cloudflare Turnstile check; a signed-in withdrawal does not, including **Other contract**. Review the repeated details, then submit. The confirmation shows the time of receipt in Europe/Berlin and a case reference; you can print it. Lumio emails the receipt to the address you entered. A declaration enters the operator queue for review. Any refund is handled manually. ## Cancel a subscription Open [Verträge hier kündigen](https://lumio.vision/cancel) from the footer. The page uses the legal layout and itself contains the **Jetzt kündigen** confirmation button. If signed in, select a plan or paid extension subscription from contracts grouped by every channel you may manage; already cancelled subscriptions are disabled. You can choose **Other contract** and enter a description. A signed-out cancellation and a signed-in **Other contract** cancellation both require a Cloudflare Turnstile check. A signed-in cancellation of a listed contract does not. Choose ordinary cancellation for the end of the current paid period, or extraordinary cancellation with a reason and requested time. Check the receipt address and submit. The confirmation shows the receipt time and case reference and can be printed. An ordinary signed-in cancellation is sent to Stripe for period-end cancellation; an extraordinary or public request is reviewed by an operator. Submission records your request even when a Stripe change is still pending. For a plan, **Settings → Channel → Subscription** also has a **Verträge hier kündigen** button. Its dialog uses the same fields and offers a printable receipt. The consequences of moving to Free remain visible on the Subscription page. See [Billing & Subscription](/user-guide/billing) for invoices and payment settings. --- ## User Guide / Ideas Hub Source: https://docs.lumio.vision/user-guide/ideas-hub # Ideas Hub The Ideas Hub is where you can share feature requests and feedback directly with the Lumio team - and vote on suggestions from other community members. It is open to all registered users and, depending on configuration, to unauthenticated visitors as well. ## Where to Find It - **Dashboard** → **Hub** → **Ideas** (`/hub/ideas`) - accessible when logged in to any account. - **Public site** (`/ideas`) - may be open for browsing without a login (when the public flag is enabled by the Lumio team). - **Feed** - subscribe at `/ideas/feed.xml` to follow new ideas in your feed reader. ## Browsing Ideas The main ideas page shows all submitted ideas. You can narrow the list using the controls at the top: - **Status filter** - show only `Open`, `Planned`, `In Progress`, `In Testing`, `Completed`, or `Closed` ideas. - **Category filter** - show ideas in a specific category. - **Search** - type to filter by title or description. - **Sort** - order by **Newest**, **Most Voted** (net score, upvotes minus downvotes), **Most Discussed** (comment count), or **Recently Updated**. Your filter and sort choices are written into the page URL, so you can bookmark or share a filtered view. Each idea card shows the category badge, a status badge, the title, a short description excerpt, up to three tags, the author and creation date, vote counts, and the comment count. The list loads more ideas automatically as you scroll - there is no pager. ## Statuses | Status | Meaning | |--------|---------| | Open | Accepting votes and comments | | Planned | Acknowledged and on the roadmap | | In Progress | Being actively worked on | | In Testing | Feature built, being tested before release | | Completed | Feature shipped | | Closed | Declined or no longer active | `Completed` and `Closed` ideas are read-only: voting, commenting, and editing are all switched off for them. ## Creating an Idea To submit a new idea, click the **Share your idea...** box at the top of the list. A full-screen, three-step wizard opens: | Step | Fields | Required | Notes | |------|--------|:--------:|-------| | 1 | Title, Category | Yes | Title is capped at 200 characters; pick one of the categories the Lumio team configured | | 2 | Description | No | A rich-text editor with formatting buttons - you style text directly, you do not write Markdown | | 3 | Tags | No | Type to search existing tags, or click the `+` suggestion to create a new one | Finish with **Submit Idea**. The idea appears in the list with `Open` status and a vote count of zero. > You need to be logged in to submit an idea. Anonymous visitors can browse and read but cannot create. ## Voting Each idea has two vote buttons - thumbs-up (upvote) and thumbs-down (downvote). Click either button to cast your vote. Your active vote is highlighted. Click the same button again or the other button to change or remove your vote. - You can only have one vote per idea at a time. - Removing a vote is done by clicking your active vote button again. - Ideas with status `Completed` or `Closed` no longer accept votes; the page shows a notice instead of the buttons. > Voting requires an account login. ## Commenting Open an idea by clicking its title to go to the detail page. A comment field appears below the idea content. **Posting a comment:** type your message in the rich-text field and click **Comment**. The editor gives you formatting buttons - you do not write Markdown. **Replying to a comment:** click the **Reply** button under any comment. A reply composer slides in from the bottom of the screen. Replies nest up to three levels deep; below that, further replies attach at the deepest level. **Editing your comment:** click the **Edit** (pencil) icon on your own comment. The body becomes an editable field; save with the **Save** button. **Deleting your comment:** click the **Delete** (trash) icon on your own comment. A confirmation prompt appears, and your comment is removed for good. If someone has replied to it, the replies stay and your comment shows as **This post was deleted.** - without its text, your name, votes or a reply button. **Deleting your idea:** if other people have commented on it, their comments stay and the idea shows as deleted, without its title, description or your name. Your own comments under it go, except those someone replied to. A deleted idea is titled **Deleted idea** and carries the note **This post was deleted.**; its comments stay readable, but it takes no more votes, comments or edits. It stays in the idea list with the same placeholder and is left out of the feed. Without comments by others, the idea is removed completely. The same applies to your ideas and comments when you [delete your profile](./privacy-and-export.md#delete-your-profile). Like votes, commenting is closed once an idea reaches `Completed` or `Closed`. ## @Mentions in Comments When writing a comment, type `@` to mention other users who have already interacted with the idea. An autocomplete dropdown appears showing matching participants - the idea author, users who voted, and previous commenters - filtered by whatever you type after `@`. - Select a name from the dropdown to insert a highlighted mention into your comment. - Mentioned users receive a notification in their bell dropdown and, depending on their notification preferences, an email as well. - Mentions render as highlighted `@Name` text in the posted comment. Only participants of the idea appear in the autocomplete - you cannot @mention arbitrary users, and a mention that slips past the dropdown is dropped when the comment is saved. Mentioning yourself sends no notification. Mentions work in comments and comment edits only; the idea description field has no mention autocomplete. > @mentions require a login. The autocomplete dropdown is not shown to anonymous visitors. ## Sharing an Idea Every idea has a public page you can link to directly. On an idea's detail page, click the **Share** button in the action bar to copy the idea's public URL (`/ideas/{id}`) to your clipboard - the button briefly shows **Link copied** to confirm. Anyone with the link can open the public idea page to read the idea along with its comments and timeline, without signing in (when the Ideas Hub public pages are enabled by the Lumio team). Visitors still need to sign in to vote or comment. The **Share** button appears on both the dashboard detail page and the public idea page. ## Editing Your Idea On your own idea's detail page, click the **Edit** button. The same three-step wizard opens, pre-filled, and you can update: - Title and Category (step 1) - Description (step 2) - Tags (step 3) Finish with **Save Changes**. Changes to the title, description, or category are recorded as timeline entries. Tag changes are saved but are not recorded on the timeline. > You can only edit ideas you submitted. Editing other users' ideas requires a moderation role. The **Edit** button disappears once an idea reaches `Completed` or `Closed`. ## Timeline The detail page for each idea shows a timeline of status changes and edits, between the idea body and the comments. This gives you a clear history of how the idea has progressed - when it was submitted, when it moved from Open to Planned, and when the title, description, or category was updated. Entries are listed newest first; the five most recent are shown, with a **Load more** control for the rest. ## Tags When submitting or editing an idea, start typing in the **Tags** field to search existing tags. If the tag you want does not exist yet, click the `+` entry at the top of the suggestion list to create it. Tags are shared across all ideas and are stored in lowercase, so `Overlays` and `overlays` are the same tag. An idea can carry up to 20 tags. ## Feed Subscribe to the Atom feed at `/ideas/feed.xml` to track new ideas without visiting the site. The feed contains the 50 most recently created ideas regardless of status (deleted ideas are left out), and each entry links to that idea's public page, carries its category and status, and summarises its vote and comment counts. The feed is served only while the public Ideas Hub pages are enabled by the Lumio team. ## Troubleshooting ### "I can't see the Ideas Hub" The dashboard Ideas Hub requires the `system:ideas_hub` feature flag to be enabled by the Lumio team. If **Ideas** is not visible under **Hub** in the sidebar, the feature may be temporarily unavailable. The public pages (`/ideas`, `/ideas/{id}`, and the feed) additionally require `system:ideas_hub_public`. With that flag off, the public pages return "not found" even though the dashboard hub still works for logged-in users. ### "I can't submit an idea or vote" Creating ideas and voting requires a login. If you are already logged in and the buttons are still disabled, your account may have the Restricted user role, which can read ideas and comments but cannot submit, vote, or comment. Contact the Lumio team. Votes and comments are also closed on ideas with status `Completed` or `Closed` - that applies to everyone, whatever your role. ### "The submit button is greyed out" The first step needs **both** a **Title** and a **Category** before it will let you continue, and the title has to be 200 characters or shorter. Description and tags are optional. ### "My comment disappeared but I didn't delete it" Comments can be removed by Lumio moderators. Deletion is permanent. Replies underneath a deleted comment stay; the deleted comment itself shows as deleted, without its text or its author. ## Related - [Ideas (Admin)](../admin/ideas.md) - moderation, categories, and tag management - [Ideas Hub feature doc](../features/ideas-hub.md) - architecture, voting model, WebSocket channels --- ## User Guide / Stream History Source: https://docs.lumio.vision/user-guide/stream-history # Stream History Stream History records running values during a stream and final figures after it ends. Every time you go live, Lumio records the run as a **session** and turns it into a **report** - viewer curves, follower and subscriber growth, chat, emote and GIF totals, donations, and a per-platform breakdown. If you multistream, one session covers all the platforms you broadcast to at once, and each platform's own title and numbers are kept separate. This guide covers the webapp surface. For how the data is collected and the API behind it, see the [Stream History feature reference](../features/stream-history.md). ## Where to find it Open **Dashboard > Stream > History** from the sidebar. The nav item appears when the `feature:stream_history` feature is enabled for your account and your role holds the `history:read` permission. Stream History is a baseline feature - it is enabled on every plan, not a paid unlock. The **Dashboard home** shows recent sessions in its timeline, a 12-month stream calendar, and a **View all history** link into this page. With `history:read`, its offline metrics compare Today, 7 days or 30 days with the preceding period; while live, the **Now** card shows sampled viewer and support figures. The home history areas use `feature:stream_history` and `history:read`. The full list, filters, and reports live here on the History page. ## The history list The top of **Dashboard → Stream → History** summarizes the filtered sessions: stream count and duration, average and highest peak viewers, follower growth, messages, and estimated revenue when you have `history:revenue-read`. A currency switch beside revenue uses your display currency. Amounts are estimates; the assumptions behind them are in [Channel settings → History](./channel-settings.md). If a connected YouTube channel has not granted the optional Analytics revenue permission, the history list shows one announcement that you can dismiss for that account. Reconnect YouTube and leave Google's revenue checkbox selected to use its figures. The announcement is separate from the persistent hints on the revenue area. The **Streaming activity** calendar covers the last 371 days in your timezone. Each day shows its stream count and duration; hover, focus, or tap it for that day's streams, titles, platforms, times, peaks, and follower counts. Select a day to filter the list to it; select it again to clear that day. The calendar retains the full year even when the list has a shorter date range, and dims days outside that range. A sticky toolbar combines search, platform, category, stream type, and date range. The date picker has quick choices for 7, 30, and 90 days, this year, or all time. Its arrangement follows the width of the content area beside the sidebar: wide content keeps the controls on one line; medium content puts search, platform, category, and type on the first line, then date range, result count, sorting, **Export**, and sort direction on the second. In narrow content, including a phone or a tablet with the sidebar open, search sits beside **Filter (n)**, which opens the other controls in a dialog. The result count includes total hours (for example, **44 streams · 162 h**), and sorting shows **Sort: Date** with a separate direction button. Active filters appear as removable chips. Search, filters, and sorting live in the URL (`q`, `platform`, `category`, `type`, `from`, `to`, `sort`, `dir`), so you can bookmark a view. An older link with a `page` parameter still works: the parameter is ignored and the list starts at the top. Returning from a report restores the same list view, including on a phone: the same filters, the same number of loaded streams (up to 200), and the scroll position of the stream you opened. Above 200 loaded streams, the list starts at the top again. Sessions appear in a sortable table while the list card has at least about 46 rem of space beside the sidebar; at narrower content widths they become cards with their main numbers. Table columns depend on that content width. As it narrows, Chatters, Revenue, Subs, Average viewers, and Viewer trend disappear in that order. The **Viewer trend** column draws a line over a soft area with a dot at the peak; a live stream instead has a red dot at its latest sample. Each row shows its start and end time, or start time and **now** while live. When a stream ends on a later day, the end time carries its date as well (for example **22:10 – 07.10., 02:15**). Estimated amounts in the revenue column, month summary, and overview tiles carry `≈`. Select a column header to sort by it; selecting it again flips the direction. Every number column sorts, including Subs / Members and Revenue. Revenue sorting and the **Revenue** sort option need permission to see revenue and use the amounts shown in the list currency. Date sorting groups rows by month with totals for the **whole filtered set**. A live session has a live badge; the list refreshes while streams run. The list starts with your 25 most recent streams (in the chosen sort order) and loads 25 more as you scroll towards the end, on desktop and on a phone alike. A line under the list shows how many are loaded, for example **25 of 132 streams**, and **All 132 streams loaded** at the end. The **Load more** button under the list loads the next 25 without scrolling, for keyboard and screen-reader use, and screen readers announce each loaded batch. Changing a filter, the search, the sorting, the date range, or a heatmap day starts again with the first 25 and scrolls back to the top of the list. If loading more fails, the rows already loaded stay and a **Try again** row appears at the bottom. **Export** always covers every stream that matches the filters, not only the loaded ones. You can select a row or card to open its report. While a stream runs, its peak and average viewers, duration, messages, followers, paid Subs or Members, gifted Subs, unique chatters and available revenue are running values marked **so far**. The viewer trend follows the minute samples. YouTube subscriber growth appears **after the stream**, not as zero while live. If a value has no sample yet or its chat calculation is unavailable, the list shows **—** with an explanation instead of zero. The list's **Export** menu is headed **Filtered list · n streams**. **CSV (table)** downloads a ZIP of `streams.csv` (one row per stream), `months.csv`, and `totals.csv`; **JSON** downloads all fields in a machine-readable form. Both cover the currently filtered set, up to 5,000 sessions. The menu notes that amounts use the selected currency and the ECB rate for each stream day. Without `history:revenue-read`, it says revenue is omitted. With that permission, Streamer Mode adds a **Contains amounts** warning: downloaded files can contain amounts even when they are masked on screen. The page shows a layout-shaped skeleton while its first data load runs, with phone cards on small screens. Later filter and sort changes keep the current rows in place and show a progress bar; loading more streams shows three placeholder rows at the bottom of the list. An empty account and a filtered view with no matches have separate empty states. ## The report page The report starts with the session title, platforms, date, duration, and category. Next to **Export** and **Share** in that header, a currency switch sets your display currency; anyone who can open the report sees it, because it is a personal setting. Changing it updates every amount on the page: the revenue card and the donation, Super Chat and Super Sticker values in the strip. Four headline cards show peak viewers, average viewers, messages, and follower growth with small trends. The remaining metrics sit in a compact strip; a metric with no activity is left out, except each platform's own counter: Bits for Twitch, KICKs for Kick, and Super Chats and Super Stickers for YouTube appear with 0 whenever that platform was part of the stream. Donations and Super Chat and Super Sticker values appear in your display currency, converted at the ECB rate of the stream date. When an amount was converted, a small **≈ from …** line under it shows the original amounts, for example `≈ from €1.49`. When no rate exists for a currency, the value keeps its original amounts, listed side by side if viewers paid in several currencies, and is marked **No exchange rate — original amount**. Older sessions recorded before currencies were kept show the value marked **Currency unknown**. Streamer Mode masks these values and their original amounts. On desktop, the report uses a wider grid; on phones, cards stack. A Back link returns to the filtered list on either screen size. During a stream, duration runs continuously. Peak and average viewers, messages, follower growth, paid Subs or Members, gifted Subs, chatters, platform comparison, stream-table figures and top lists show running values **so far**. Sample-based figures update about every 60 seconds without a page reload. Chat counters can trail incoming messages by another 30 seconds while Lumio waits for late chat writes. A value that cannot be calculated yet shows **—** and a hint, not a zero. YouTube subscriber gain appears **after the stream**; it is calculated from the subscriber-count difference at the end. Final figures replace the running ones after the session is finalized. The **Streams** table lists the streams in start order. Select a column header (platform, title, category, start, duration, peak, average viewers, messages, or chatters) to sort by it; selecting it again flips the direction. Text columns start from A to Z, number columns from the highest value. Missing values stay at the end. The sort is not saved and also works on a shared report. When a session crosses midnight in your time zone, every time in its report carries its day and month (for example **07.10., 01:30**): KPI and chart tooltips, chart axes, moments, ad breaks, the stream table and chat insights. A session within one day shows plain times, because the header already names the date. Under a stream row, the **Streams** table can show more detail: - **Categories:** when the category changed during the stream, a timeline such as `19:15 Just Chatting → 20:02 Hollow Knight`. A stream with a single category shows it only in the **Category** column. - **Tags:** the stream tags as chips (Twitch and Kick; YouTube has none). - **Shared Chat** (Twitch): in **Whole session**, the stream-table row gives a one-line **Sum of all channels** peak and average. The **Shared Chat · whole session** card gives partner details once: channel, messages, viewers and participation times. The sum's double-counting hint sits below the chart. In **My channel**, your own values stay separate. Unknown names appear as **Unknown channel**; platform channel IDs are never shown. Long lists wrap, also on a phone. Streams recorded before these values were collected show what is known; partner viewers stay empty for them. The main viewer chart combines the total with platform lines, peak and median markers, and Twitch ad-break bands. During a Twitch Shared Chat, **Whole session** shows **Sum of all channels** and one line per channel, while **My channel** keeps your own viewer curve and markers. A partner line runs only while that channel was in the session. Follower and sub charts share its time grid. You can zoom into the viewer, follower and sub charts together: drag across a chart to zoom to that stretch, or hold Ctrl (⌘ on a Mac) and scroll, or pinch on a trackpad, to zoom around the pointer; a plain scroll keeps scrolling the page. While zoomed, the y axes follow the visible stretch and each chart shows **Reset zoom** (with the visible range where there is room); a double-click on a chart resets it as well. Hovering a chart, or tapping it on a phone, shows the crosshair line, the points and the tooltip box in that chart only; the other charts and the KPI trend lines stay still. Each chart names its series with legend chips in the line colours. A chip hides or reveals that series in every chart of the report at once, and its chips on the other cards show it struck through too. Follower, sub and message totals only ever rise: when one platform's stream ends a minute before another's, its last count stays in the total. The viewer total counts the platforms that are live at that minute. YouTube reports no individual subscriptions, so its subscriber gain is counted once at stream end: it is part of the follower figure on the card but not of the curve, and a line under the chart names it. While the session is still live, that gain does not exist yet: YouTube has no line in the follower chart or its legend, the total is the sum of the other platforms, and the line under the chart reads **YouTube subscribers come after the stream**. A YouTube-only live session shows no follower chart and no trend line at all; the follower card says once, in its chart area, that the subscribers come after the stream, and the KPI tile reads **after the stream**. A platform comparison appears for multistreams; a **Moments** panel highlights peak, low, median, and strongest follower rise. The report also shows the platform stream table, up to 10 top chatters, up to 20 emotes if the finalized report contains them, and up to 9 GIFs in a 3 × 3 grid. A report with only 10 frozen emote entries stays at 10. Hover over an emote or GIF tile, or tap it on a phone, to open a larger card with the full name or caption, a bigger image, the provider and how often it was used; for newer reports an emote card also shows its ID, whether it is animated and its channel. With reduced animations, a GIF in the card plays only after you press **Play**. Shared reports show the same cards. With `chat:userinfo` and Multichat on your plan, selecting a top chatter opens the same user info card as in Chat and Events; on a shared report and in older reports without platform user ids, the list is static. A chatter whose data was erased on request keeps their place and count but shows as **Deleted user**, without an avatar and without a user card; the same applies to the posters and postings under **Links**, **Commands** and **Keywords**. KPI trend lines mark the peak for viewers and messages and the latest point for follower growth. Hover over a KPI trend line, or tap and drag along it on a phone, to see the time and value of each step: viewers at that time, messages per minute, and followers gained since the start (with ≈ for YouTube subscribers). Only the trend line under the pointer shows its line, point and box; the other trend lines and the charts stay still. This also works on a shared report. **Chat bots are not counted.** Messages from accounts with the Twitch or Kick bot badge, from known chat bots (such as Nightbot or Fossabot) and from the channel's own bot account are left out of the message count, the messages-per-minute trend, unique chatters, the per-platform figures, the top chatters and the emote and GIF figures, in the report, the list and on a shared report. YouTube has no bot badge, so YouTube bots are recognised only from the known-bot list. Under the **Messages** card, a muted note such as **excl. 42 bot messages** says how many were left out; hover over it for what counts as a bot. The note follows the running chat count while a session is live. It is missing when no bot wrote, when the live chat calculation is unavailable, and for older sessions whose chat log was already gone when bot filtering arrived. Events and viewer counts are never affected. The full definition is in [Stream History](../features/stream-history.md#bot-messages). ### Chat insights: links, commands, keywords, announcements, pins and AutoMod The card also has **Announcements** and **Pins** tabs. Announcements list the time, colour and sender; pins list the sender and the person who pinned, planned and actual duration, changes and the end reason. People show avatar, name and a bot marker. Times observed from Twitch's pin polling are marked **≈**. For sessions begun before these lists were captured, the tab says **Not captured for this stream**. No message text is frozen in the report: you see it while the chat message or retained pin event has it, then **Text no longer stored**. Announcements count in the usual message and chatter figures and keyword insights. Below the stream table, the **Chat insights** card has three tabs for chat analysis. **Links** lists the links posted in chat; **Commands** lists the chat commands viewers typed, starting with `!` or `#` (`!discord` and `#discord` are separate rows). **Keywords** lists every term tracked under [Channel settings → Keywords](./channel-settings.md#keywords), including terms with zero hits; its tab count is the number of terms that came up. Links and Commands show their entry counts. Every link or command is one row, however often it was posted, with the time it first appeared, how often it was posted, the top three posters with their counts plus **+N more**, and, for commands, how many different people used it, a **Lumio command** chip when it matches one of your enabled bot commands, and the three most common arguments (**no arguments** when nothing followed the command). **By time** sorts rows by first appearance, **Most used** by count; each tab keeps its own choice. On a wide card, the **First** and **Count** column headers sort as well: selecting the active one flips the direction (newest first, or least used first). The card shows 10 rows, and **Show all** expands the rest in place. Links and Keywords include chat bots, marked with a dashed **BOT** chip. Commands count viewers only; messages from bots are left out. **Times** opens a row: a strip across the session marks every posting (bot postings in grey), and hovering over the strip shows the time and the poster below it and moves the KPI trend lines to that time. Under the strip, the first 15 postings are listed with time, platform and name (for commands also what was typed); **show all** lists the rest. Lumio keeps the first 100 postings of each link, command or keyword, so a busier one says **First 100 of N**. On the dashboard, selecting a link opens the external-link warning before it opens in a new tab. With `chat:userinfo` and Multichat on your plan, selecting a name opens the user info card. A session without links or commands says so; for older streams whose chat log was already gone, the tab says **Not available for older streams**. The full extraction rules are in [Stream History](../features/stream-history.md#links-and-commands). #### AutoMod log For a Twitch session, members with both `history:read` and `chat:automod-read` see a fourth **AutoMod · n** tab. It lists messages Twitch held for review, including messages later allowed, denied or expired. The tab is private to the signed-in dashboard: it is absent from share links, exports and popouts. It is read from the AutoMod log when opened, rather than stored in the finalized report. Each row shows the hold time (hover for date and seconds), sender avatar, display name and @login (initials when no avatar is available; no badges in this tab), held text with flagged spans, reason, outcome, who decided and whether in Lumio or on twitch.tv, and waiting time. A name opens the user card when you have `chat:userinfo` and Multichat. Links in held text cannot be clicked. Reasons include a blocked link, a blocked term and an AutoMod category with level. Outcomes are **Open**, **Allowed**, **Denied** and **Expired**. An expired hold with no moderator or waiting time means nobody decided before it expired; **Expired · on twitch.tv** means Twitch reported the expiry. Filter chips show counts for each outcome. Choose **Oldest first** or **Newest first**, or select the **Time** column heading to reverse the order. The footer shows how many messages are displayed out of the matching total; **Load more** fetches up to 100 more at a time. An empty session says AutoMod held no messages. **Not available for older streams** means the session has no Twitch broadcast or its log is outside chat retention. A load error offers **Try again**. Each entry, its held text, flagged spans and term text included, is deleted once the hold is older than the account's chat retention window (Free 365 days, Pro 1,095 days), for every outcome, including allowed messages. **Text not stored** means the message exceeded the storage size limit. Twitch emotes and Cheers in the held text show as images with the Cheer amount; 7TV, BTTV and FFZ emotes use the connected channels' emote sets. The sender's avatar is the one Lumio knew at hold time. An erasure your channel instructs deletes that participant's rows in your channel; holds after it are logged again. An objection made to Lumio does not change the log. See [Data & Privacy](./privacy-and-export.md). **Follower and sub labels depend on scope.** Twitch and Kick use Followers and Subs; YouTube uses Subscribers and Members. A mixed session uses **Followers & Subscribers** and **Subs & Members**. In German, paid subscriptions are **Subs** and YouTube followers are **Abonnenten**; YouTube's rounded subscriber counts carry an approximate marker (`≈`). Twitch tier counts stay separate from YouTube memberships. Prime is shown separately and does not count as a new paid sub. ### Estimated revenue and ads With `history:revenue-read`, the revenue card shows an estimated total and per-hour average. The total, an **in EUR**-style label naming its currency, and the **Change assumptions** link sit above the itemized breakdown; the currency itself is set in the report header. When several platforms have revenue items, tabs show **All** plus the applicable **Twitch**, **YouTube**, and **Kick** views. **All** has one summary row per platform; selecting a row opens that platform's tab. Donations belong to no platform and appear only under **All**. Each platform tab lists its items and a **Total** for that platform when the amounts can be summed in one currency. A single-platform stream shows its itemized rows without tabs. The card keeps the same height while you switch tabs. Streamer Mode masks the grand total, platform totals, item amounts, and unit prices in this card. The card also shows any amounts that cannot be converted. Twitch tiers, Prime, Bits, Kick subs and KICKs use fixed USD values; their shares, ad CPMs and ad-free viewer shares are editable. YouTube memberships use the level prices you enter; an unknown level uses the cheapest entered level (`assumed_lowest_level`), and without any level price memberships remain unpriced. Membership milestones count as paid months. Without the revenue permission, the card shows Twitch sub tiers as counts only. The assumptions live in [Channel settings → History](./channel-settings.md#history--revenue-assumptions) (`/dashboard/settings/channel/history`); **Change assumptions** opens that tab. With `history:revenue-edit` you set payout currency, shares, ad assumptions and YouTube levels there; with `history:revenue-read` alone the tab is read only. The revenue is an estimate, not a platform payout or tax statement. Twitch and Kick ad breaks appear in the report with break count, automatic/manual split, total and average time, minutes per hour, estimated impressions, and viewer change after a break. Their ad revenue lines use viewer spots from each break, minus ad-free viewers, × your CPM and share. Until you enter a CPM, the line contributes no money. YouTube has no ad-break feed: its ad line uses sampled viewer-hours and your mid-rolls-per-hour assumption, with no pre-rolls; without a mid-roll rate there is no line. For YouTube, the card names the source **Lumio estimate** until YouTube Analytics figures arrive, normally **2–3 days after the stream**. With figures it says **Estimated revenue according to YouTube Analytics**, marks them **provisional** or **final**, and shows their fetch date. The Analytics lines for ads, Premium and other transactions replace the YouTube ad, membership, Super Chat and Super Sticker estimates in the total; they are never added to them. The card shows one of three specific hints when needed: reconnect YouTube with the revenue checkbox selected, the channel is outside the YouTube Partner Program, or enable the YouTube Analytics API in your Google app. The last hint links to the [setup guide](/getting-started/oauth-setup#youtube-analytics-api). These hints do not appear as connection-card alerts. Everyone with `history:revenue-read` can see the Analytics amounts; the connection dialog says so before authorization. Amounts use your **display currency** (or a currency selected in the report) at the ECB reference rate for the stream's UTC date. A weekend or holiday uses the latest available rate within seven days. Tooltips show the original amount, rate, and rate date. If a rate is unavailable, Lumio keeps the original currency, marks that amount unconvertible, and excludes it from converted totals and averages. ECB reference rates are not bank settlement rates. ## Sharing a report With `history:share`, **Share** opens a dialog. On the left you choose how long the link is valid (1 hour to 30 days; 7 days by default) and which sections a public viewer receives: core report data (viewers, growth and moments) is always present; chat and ads are on by default. Top chatters, **Links**, **Commands** and **Keywords** are grouped under **Shows viewer names publicly** and are off by default: Links shows who posted a link and when, Commands also shows what viewers typed after the command, and Keywords shows who posted a tracked term and when, so switch them on only when you want that public. Revenue is off by default. On the right, under the link preview, you set an optional password, the link currency, and whether downloads are allowed, and you manage the active links. **Close** and **Create & copy link** stay visible at the bottom of the dialog, even on a phone where its content scrolls. Revenue sharing also requires `history:revenue-read`. The preview lists the sections without showing amounts. **Create & copy link** puts the new URL on your clipboard. Active links show their expiry and contents and can be extended or revoked. Lumio stores only a token prefix: you can copy a full URL again only for a link created while this dialog remains open. A password is needed to open a protected link, and the password is never part of its URL. Even when you share revenue, the public link and its download show **only the Lumio estimate** for YouTube. They do not include Analytics figures or totals calculated from them. ## Viewing a shared report A link opens a public, read-only report at `/share/history/{token}` in its own layout. It shows the channel and expiry only after access is granted, only the sections the owner shared (the **Chat insights** card appears only with **Links**, **Commands** or **Keywords** shared, with a tab for each one that is; there, links are plain text that cannot be clicked and names do not open a card), and a download action only when export was allowed. When revenue is shared, the page labels its currency, ECB rate date, and estimated items. A link that has expired or was revoked, and an unknown link, all show the same **This link is no longer available** page: it asks the viewer to request a new link from the person who shared it, links to the Lumio home page, and never asks for a password, even when the link had one. A still-active protected link shows the password page instead; a wrong password shows an **Incorrect password** message on the field. Neither page reveals a **title, date, channel, or expiry**. If a link expires while someone has the report open, their next download switches the page to the "no longer available" page. A connection or server problem shows a separate error with **Try again**. The social preview is the same neutral text for protected, expired, revoked, and unknown links. Every state includes the standard Lumio footer with legal and privacy links. The page asks for cookie consent: avatars, emotes, GIFs and Bits graphics come from other services (Twitch, Kick, YouTube, 7TV, BetterTTV, FrankerFaceZ, GIPHY), so they appear only after the visitor allows **Functional** cookies. Until then the report shows initials, emote names and a **GIF** label instead, and the emote and GIF cards do not open. Your own report in the dashboard always shows them. The header has a theme switcher (light, dark, or system; system by default) and a language switcher (English or German) on every state, including the password page. Both choices are remembered in that browser. Switching the language on an unlocked report keeps it open and shows the report, dates, and numbers in the new language; downloads then use that language too. The public page has no dashboard seasonal decoration. ## Exporting a report When a share link is created, **Announcements** and **Pins** are separate sections under the viewer-name disclosure choices and are off by default. Only selected sections appear on the public report and its export. A report CSV ZIP includes `announcements.csv` and `pins.csv` for present lists; PDF, TXT and JSON include the same information when allowed. With `history:export`, **Export** downloads a finalized report as PDF, CSV, TXT, or JSON. The menu names the currency in use and sends your language and timezone. Live sessions cannot be exported. The private report and filtered-list export menus warn that the downloaded file may include YouTube Analytics revenue. After download, the file is outside Lumio's control. A public export uses only the Lumio estimate. The PDF has up to four designed A4 pages: **Overview**, **Timeline**, **Revenue & Ads**, and **Chat & Streams**. Empty sections and their pages are omitted; long tables continue with repeated headings. Pages carry a header, footer, and page number. TXT follows the same sections. CSV is a ZIP of report sheets, including revenue and ad breaks where available; when the report has links, commands or keywords, the **Chat & Streams** page and the TXT carry their tables, and the ZIP adds `links.csv`, `link_posts.csv`, `commands.csv`, `command_posts.csv`, `keywords.csv` and `keyword_posts.csv` for lists present in the report; JSON keeps API field names. The report exports use English or German labels where applicable. Times in the PDF and TXT follow the same rule as the report page: plain times for a session within one day of your timezone, and day plus month (for example **07.10. 01:30**) on every time once the session crosses midnight. A public download includes only the sections that link permits, and revenue requires the owner to share it. ## Twitch Shared Chat in history The **Shared Chat** filter offers **All**, **With Shared Chat** and **Without Shared Chat** when your history includes Twitch streams. It filters the list, summary figures, trend, calendar heatmap and list export together. The choice appears as a removable chip and remains in the URL. A live stream enters **With** as soon as its session begins. A Twitch icon and up to three channel avatars mark a matching list row; the host comes first with a crown, then your channel and the partners. For a report with Shared Chat, choose **My channel** (the default) or **Whole session** near its header. The same switch appears on the Dashboard's **This stream** card while the session runs. This choice affects only the current view; list totals and exports keep your own-channel figures. **My channel** counts messages, chatters and insights from your room, even while partners are present. **Whole session** adds partner activity by channel. Its chart has the sum area and one line per channel with a legend; a channel that leaves keeps its earlier line. The Shared Chat card shows the partner breakdown once. Your Subs, Bits, gifts, ads and tips remain your own channel's revenue in either view. Shared report links and stream-summary emails show My channel. The timeline marks the exact start, joins, leaves and end from Twitch session events. It stays available after a report is finalized. Older reports without those events show a minute-sample reconstruction labelled **From minute samples (± 1 min)**. A stream whose chat log is already gone may still show **Including partner chat** on an old frozen message total; otherwise Lumio recomputes the own-channel count from retained chat. ### Zuschauerzahlen im Shared Chat Lumio currently uses Twitch's Helix `viewer_count` as each channel's own viewer count during Shared Chat. A live comparison has not verified that assumption. Twitch [describes a separate individual count](https://blog.twitch.tv/en/2024/12/05/shared-viewership), while its API and EventSub do not expose the unique Shared Viewership metric. **Sum of all channels** adds channel counts and can count people watching several channels more than once; the report explains this under the chart. ## Streamer Mode on History Streamer Mode replaces on-screen money amounts, unit prices, share URLs, token prefixes, and link passwords with placeholders. Shared Chat rows never display platform channel IDs, so there is no ID to mask there. An individual eye control can reveal a field; **Copy** still copies the actual new share URL without showing it on screen. Exported files can still contain authorized amounts, so check a file before showing it to viewers. Titles, categories, channel and chatter names, counts, and the internal session ID in the page URL or filename remain visible. Stream titles and names are already public; counts are the point of an analytics report. A session ID alone does not grant access to the report. Share tokens and passwords do grant access, which is why they are masked. ## Stream summary email After a stream ends and its session is finalized, Lumio emails the **account owner** a summary with average viewers, peak viewers, and live time. Where a previous completed stream exists, the viewer figures show the change from it. A sentence lists follower, subscriber/member, and chat-message counts, plus other non-zero stream activity. A chart shows viewer and chat trends; a platform table breaks out average and peak viewers. The email links to the full report, where the detailed charts and top lists remain available. Streams shorter than **5 minutes** are skipped. You control this under **Settings → Notifications**, on the **Stream summary** row in the **Stream** category: - **Off** - no summary at all. - **In-App** - a dashboard notification, no email. - **Email** - the summary email only, no dashboard notification. - **In-App + Email** - both (the default). ## Permissions at a glance | Permission | What it unlocks in the webapp | |-----------|-------------------------------| | `history:read` | The History nav item, the session list, and reports | | `history:share` | The Share button and share-link management | | `history:export` | Exporting a report's data | | `history:delete` | Deleting a stored session through the API | | `history:revenue-read` | Estimated amounts in the list, overview, report, and exports; option to share revenue | | `history:revenue-edit` | Editing revenue assumptions in Channel settings → History | | `history:keywords-edit` | Adding, changing and removing tracked terms in Channel settings → Keywords | Owner and Administrator hold all seven by default, including `history:revenue-read` and `history:revenue-edit`. Moderator holds `history:read` and `history:export`. --- ## User Guide / Public Stats Source: https://docs.lumio.vision/user-guide/public-stats # Public Stats Public Stats at `https://stats.lumio.vision` shows public channel and stream statistics without a Lumio login. You can browse rankings, games, emotes and partners, search for a channel, or open a channel's page and recent streams. Some pages include data for channels that do not use Lumio. ## Channel and stream pages A channel page shows the current profile, aggregate figures, recent streams, active broadcaster-marked ad panels and trends where data is available. It links to `/{channel}/history` for earlier profile values, removed panels and earlier panel artwork. A stream page shows the stream's public figures and can show a chatter leaderboard only when the broadcaster enabled it. The history route and stream pages remain accessible by link but are not indexed by search engines. The channel URL uses the current handle as its canonical address. An older handle can still resolve, but search engines are directed to the current one. YouTube and Kick channel pages remain available and are not indexed; Twitch channel pages can be indexed. A broadcaster's opt-out removes the channel from public reads and the sitemap. ## Search visibility The production home page, ranking pages, Games, Emotes and Partners may appear in search results. The sitemap lists those pages and eligible Twitch channel pages with a stream in the last 12 months. It contains no stream pages, profile or panel change history, search-result pages or personal watchlists. Search engines can take time to refresh an old result after a page changes or is removed. Staging and other non-production Stats hosts are excluded from indexing. The site's footer states that Lumio Stats is independent of Twitch, YouTube and Kick, and links to Lumio's legal pages. ## Sharing and privacy Page links have share images. Channel images show the current channel identity and aggregate figures, without chatter names, profile text or change history. An unknown or opted-out channel receives a generic image. Third-party apps may keep their own copy of a preview after the Lumio page changes. If you own a connected channel, you can manage its visibility from the dashboard's [channel settings](./channel-settings.md). A broadcaster without a Lumio account can opt out using the verified `!optout` command in their own channel chat. See [Public Stats](../features/public-stats.md) for the data and API rules behind these pages. --- ## User Guide / Streamer Mode Source: https://docs.lumio.vision/user-guide/streamer-mode # Streamer Mode Streamer Mode hides sensitive data across your Lumio dashboard - platform IDs, email addresses, credential hints, and similar identifiers are replaced with dots (`••••••••`). This keeps private information off-screen while you show your dashboard on a live stream. ## Where to find it There are three ways to toggle Streamer Mode: 1. **Header icon** - The **Shield** icon next to the notification bell in the top bar. One click toggles it on or off, and the icon is highlighted while it is active. The top bar is hidden on the full-screen pages (Chat, Events, and the overlay editor), so use one of the options below from there. 2. **Settings page** - Go to **Dashboard > Settings**. **Streamer Mode** is the first section, with an **Off** / **On** switch. 3. **Popout settings** - In the chat, events, and music popouts, open the gear icon and scroll to the **Streamer Mode** row in the settings panel. Your choice is saved to your user account, so it follows you to a new browser or device the next time you sign in. Tabs and popout windows already open in the same browser pick up the change immediately. Streamer Mode belongs to **you**, not to a channel. It is available no matter which entry is selected in the account switcher, including your personal profile, and the same value applies to every channel you switch to. ## What gets masked When Streamer Mode is active, the following data is replaced with dots: | Location | Data hidden | |----------|-------------| | **Connections** | Platform channel IDs (Twitch, YouTube, Kick) | | **Credentials** | Client ID hints in the credentials list; the Client ID and Client Secret input fields in the setup wizard | | **Bot connections** | Bot username | | **StreamElements** | Token hints and the token input field | | **Profile** | Your email address (both the header and the email input field) and platform provider account IDs | | **Accounts** | Provider account IDs shown in the login-assignment picker | | **Audit log / Activity** | The IP address (partly, e.g. `203.•••.•••.7`), the location (city and country), the user agent, and the raw event JSON, which starts closed. Each row has its own Eye. **Anonymised** and **—** stay visible because they reveal nothing | | **Sessions** | The IP address and the location of every session, in the table and in the stacked rows. **Unknown** stays visible | | **Security overview** | The place in the **Failed sign-ins** tile ("Latest date · place · device") and in the **Sessions** tile ("… longest device, city"). The rest of the sentence stays; the Eye reveals only the place and does not open the tile's area | | **Sign-in & 2FA** | Your email address in the **Email & password** row in every state (active, link pending, change pending, confirmed), in every step of the set-up dialog, and in its address field. The field stays an email field, so a password manager still saves the address as your user name | | **Members, tokens, extension teams** | Other people's email addresses: an invite without a display name in the channel's member list, a member without a display name in a token's assignment, and the address under each member of an extension team | | **User info card** | The platform user ID in the card header (opened from the chat and the event feed) | | **Stream History** | On-screen revenue and donation amounts, unit prices, share URLs, token prefixes, and share-link passwords | | **Dashboard home and Events** | Money, Bits and KICKs in activity rows (including tests), the live **Now** card, the Events feed and the Events popout | | **Billing** | Invoice amounts in the invoice list | | **Extension payouts** | Revenue balance, total earned and pending payout, the connected Stripe account ID, and the PayPal email, IBAN, BIC and account holder fields | | **Extension analytics** | The revenue figure | Public list prices stay visible: plan prices on the subscription page and in the account wizard, extension prices in the store, in the install dialog and in your own extension list, because anyone can see them on the public site. The user info card keeps its own public event detail; money, Bits and KICKs in feed rows and on the dashboard home are masked until you reveal them. Public information like stream titles, channel and chatter names, counts, and avatars remains visible. A Shared Chat source without a known name appears as **Unknown channel**; the report never displays its platform channel ID in either mode. Stream History session IDs remain visible in page URLs and export filenames because an ID alone does not grant report access. Downloads can contain authorized amounts even while the dashboard masks them, and the CSV/JSON exports of the audit log, the activity, and the sessions contain the full IP addresses and locations: a file does not appear on stream. The value is not merely blurred: it is swapped out for a placeholder, so the real value is not sitting in the page where a screen reader or a viewer could pull it out. ## Revealing individual fields Each masked field has an **Eye** icon next to it - for input fields, inside the field on the right. Click the icon to temporarily reveal that specific value (the icon switches to **EyeOff**). Click again to mask it again. Revealing a field does not turn off Streamer Mode - only that one field becomes visible. Password fields are always masked with the same Eye control, whether or not Streamer Mode is on. ## Permissions Streamer Mode is a personal user preference - no RBAC permission is required. Every user can toggle it on their own account regardless of their role. ## Tips - **Toggle it before going live.** The Shield icon in the top bar is the fastest way - one click, no page navigation needed. - **Popout windows follow automatically.** If you toggle Streamer Mode in the main dashboard, open popout windows (chat, events, music) update immediately. - **Credential setup is safe.** The setup wizard for app credentials and the StreamElements token field mask their inputs while Streamer Mode is active, so you can enter Client IDs and secrets on stream without exposing them. - **Revealing is per-field.** You can temporarily reveal one ID without exposing everything else. The Eye toggle only affects the field you click. --- ## User Guide / Cookie Preferences Source: https://docs.lumio.vision/user-guide/cookie-preferences # Cookie Preferences Lumio asks for your consent before setting non-essential cookies. The consent dialog opens by itself on your first visit; afterwards you can reopen it from the round cookie button in the bottom-right corner (tooltip: **Cookie settings**). ## Categories | Category | Required | Examples | |---|---|---| | **Essential** | Always on | Login and refresh tokens, pop-out session, language and other preference mirrors, Cloudflare security, Auth.js login cookies on the ID app | | **Functional** | Opt-in | Platform integrations (Twitch, YouTube, Kick, Discord), UI preferences, Sentry error tracking | | **Statistics** | Opt-in | Anonymous product analytics (PostHog, when enabled) | | **Marketing** | Opt-in | Reserved for future advertising integrations, currently unused | Only Essential is on by default - functional, statistics and marketing start switched off until you accept them. ## Preference cookies Lumio uses four necessary preference cookies for display settings: | Cookie | Category | Purpose | |---|---|---| | `locale` | Essential | Mirrors your selected UI language so server-rendered pages load the right message catalog. | | `lumio-tz` | Essential | Mirrors your resolved timezone so server-rendered pages can format times in the same zone as the dashboard. | | `lumio-season` | Essential | Mirrors your [Appearance](/user-guide/preferences#seasonal-look) settings (seasonal look, strength, animations, creature switches, closed seasonal banners) so the dashboard paints the right look before it has loaded. | | `lumio-motion` | Essential | Mirrors your [Animations in Lumio](/user-guide/preferences#animations-in-lumio) setting (System, Reduced, Off) so the dashboard starts without unwanted motion before it has loaded. | These cookies are preference state, not tracking. They are set from your [Region & Language](/user-guide/preferences) and Appearance settings and from the language switcher. The cookie-clearing dialog lists them under **Preferences** so you can remove them from the browser; your signed-in user preferences remain stored on your user profile and can repopulate the mirrors after the next dashboard render. A shared web or sign-in link with a valid `?lang=en` or `?lang=de` can also set the `locale` cookie when your browser does not have one yet. It leaves an existing cookie unchanged. See [Language](/user-guide/preferences#language). ## How to change Open the dialog via the cookie button. The first view offers **Essential only**, **Accept all** and **Customize**. Pick **Customize** to switch the three optional categories individually, then confirm with **Save preferences**. Each category can be expanded to list the cookies and browser-storage keys it covers, with their purpose and duration. Your choice is stored in your browser and read again on every page load, so it applies to new tabs and later visits in the same browser. ## Revoking Reopen the dialog and choose **Essential only** - statistics, functional and marketing cookies stop being set from that point on. Necessary preference cookies such as `locale`, `lumio-tz`, `lumio-season` and `lumio-motion` may still be set because they are required for rendering. Cookies that were already set are not removed by that choice. To clear them, use the **Cookies** link in the page footer (see below). ## Clearing cookies and dashboard data The **Cookies** link in the page footer opens the **Manage Cookies** dialog. Select one or more categories and confirm with **Clear**: | Category | What it clears | |---|---| | **Authentication** | `lumio-token`, `lumio-refresh`, `lumio-popout-token`, `lumio-popout-origin`. These cookies are httpOnly, so the dialog cannot count them and shows only an **httpOnly** badge. Clearing them signs you out in this browser and ends pop-out sessions opened in this browser; an OBS browser source is a separate browser and is not affected. | | **Preferences** | The four preference cookies above, plus the theme and sidebar state stored in this browser. Your profile settings stay on your user profile and repopulate the cookies on the next dashboard visit. | | **Dashboard data in this browser** | What the dashboard keeps in this browser's local storage: chat filters, keyword tabs, chat platform selections, chat alert settings, the viewer-list toggle, event filters, pop-out display settings, recent emotes and colours, and the emote cache. Chat filters, keyword tabs and alert settings saved only on this device are lost. The dialog shows how many entries are present. | Some entries are never cleared by the dialog: **streamer mode** stays as it is (clearing it would switch streamer mode off without notice and could reveal private data on stream), your **cookie consent choice** is changed only in the consent dialog, and developer preview settings stay untouched. The Auth.js login cookies of the ID app (`id.lumio.vision`) belong to that site; this dialog cannot read or clear them and does not list them. ## Where the dialog appears The consent dialog and its floating button are shown on Lumio's public pages - the marketing site, the legal section and the login flow. They are deliberately suppressed on the dashboard (`/dashboard`, `/account`, `/hub`) and on OBS browser-source routes (`/overlay/…`, `/popout/…`), where a consent dialog would cover a live capture. Your stored choice still applies on those routes - only the dialog itself is hidden. To change it while you are in the dashboard, open a public page such as `/legal/cookies` and use the cookie button there. The footer **Cookies** link for clearing cookies is available everywhere. ## Public Stats site The public Lumio Stats site (`stats.lumio.vision`) runs the same consent stack: the banner opens on the first visit, the floating cookie button reopens it, and your choice is remembered in that browser. Your consent choice is stored in your browser's local storage, and - like the rest of Lumio - the site uses the essential `locale` cookie to remember your language. No non-essential script (analytics, error tracking, embedded clips) runs before you accept. Emotes, avatars and panel artwork load from the Stats site itself, so they show without a consent choice; GIPHY GIFs wait for the **Functional** category, as on the share pages of `lumio.vision`. Legal and cookie-policy links from that site point back to the main Lumio site (`lumio.vision/legal/…`). --- ## User Guide / Legal Pages Source: https://docs.lumio.vision/user-guide/legal-pages # Legal Pages Lumio's legal section lives under `/legal` with six pages: | Slug | Purpose | |---|---| | `/legal/imprint` | Provider information (§5 TMG). | | `/legal/privacy` | Data processing and storage policy. | | `/legal/terms` | Terms of service. | | `/legal/cookies` | Which cookies + browser storage we use. | | `/legal/security` | Security practices and contact. | | `/legal/faq` | Common legal questions. | The overview at `/legal` shows the six legal texts and a separate **Contracts and contact** group with the contact, withdrawal, cancellation and abuse report forms. The abuse report appears when the public copyright directory is enabled. Every page is available in English and German, and the forms work without signing in. ## Navigating The legal pages and forms share one header, navigation and footer. The Lumio logo links back to the overview; the header includes theme and language controls and a route back to Lumio. Signed-in visitors can return to the dashboard. - **Desktop** - navigation under the header separates the legal texts from the forms. Content pages have an **On this page** table of contents where applicable. - **Mobile** - a breadcrumb sits under the header, and the menu opens a drawer for the legal texts and forms. Content pages carry a *last updated* badge under the title. The overview itself has no table of contents. ## Reporting copyright abuse [/legal/abuse](https://lumio.vision/legal/abuse) lets you report a song for abuse or copyright concerns when Lumio's public copyright directory is enabled. Enter the song and artist, your name and email address, and the reason. A Spotify track ID and an HTTP(S) proof link are optional. Lumio prefills your name and email when you are signed in. Complete the Cloudflare Turnstile check and submit the report. A successful submission enters an operator review queue; it does not immediately block the song. The form accepts at most five reports per IP address per hour. ## Contact form [/legal/contact](https://lumio.vision/legal/contact) hosts the contact form in the shared legal layout. The site footer also links to it. It needs no account; when you are signed in, Lumio prefills your name and email address. The page lists other ways to reach Lumio. | Field | Notes | |---|---| | Your name | Optional, up to 100 characters. | | Your e-mail address | Required. Lumio replies to this address. | | Subject | General question, Privacy and your data, Legal, Billing or Other. | | Message | Required, up to 5,000 characters. | A Cloudflare Turnstile check protects the form. Usually nothing is visible except the line *Protected by Cloudflare Turnstile*; when a challenge is needed, a checkbox unfolds above the **Send message** button. Lumio forwards the message by e-mail to its support inbox and does not store it. The form accepts at most five messages per hour from one IP address. Field errors appear by the affected field; send and security errors appear by the form action in the page language. The [withdrawal and cancellation guide](/user-guide/withdrawal-and-cancellation) explains the other two forms in this layout. ## Editing Legal pages are authored in MDX, one file per language. Each page carries its own last-updated date inside its content file, so the text and the date shown in the badge change together. See [MDX Content Pipeline](../developer-guide/mdx-content.md) for the technical details. --- ## API Reference / REST API Source: https://docs.lumio.vision/api-reference/rest-api # REST API ## Contract declarations All declaration endpoints have GraphQL twins at `POST /v1/gql`. JSON fields are `snake_case`. Public requests are rate limited (10 per IP per hour, 200 per hour globally) and require a Turnstile token with action `withdraw` or `cancel`. Repeating a `client_request_id` for the same declaration returns the first receipt. Reusing a `client_request_id` for a different declaration (another user, channel, kind, contract or purchase, or another e-mail address on the public forms) returns `409 IDEMPOTENCY_KEY_REUSED` and no receipt. | Method and path | Request or filter | Guard and result | |---|---|---| | `POST /v1/withdrawals` | `name`, `email`, `contract_reference`; optional `message`, `locale`, `turnstile_token`, `client_request_id` | Public withdrawal; receipt with `status: open`. | | `POST /v1/cancellations` | `name`, `email`, `contract_reference`; optional `kind`, `reason`, `effective`, `effective_date`, `locale`, `turnstile_token`, `client_request_id` | Public cancellation; receipt with `status: open`. | | `POST /v1/billing/withdrawal` | `contract` (`plan`, `extension_purchase`, `other`); optional `extension_purchase_id`, `contract_reference`, `message`, `name`, `receipt_email`, `client_request_id`, `account_id` | Interactive session. `plan` needs `plan:edit`; `extension_purchase` needs `extension-store:uninstall` in the target channel; `other` needs only the session. | | `GET /v1/billing/withdrawable-contracts` | — | Interactive session; eligible plan and paid extension contracts across manageable channels. | | `GET /v1/billing/cancellable-contracts` | — | Interactive session; plan and extension subscriptions across manageable channels, including cancellation state. | | `POST /v1/billing/cancel` | Optional `kind`, `reason`, `effective`, `effective_date`, `receipt_email`, `name`, `client_request_id`, `account_id` | `plan:edit` in the target channel and interactive session; stores a plan cancellation declaration. | | `POST /v1/extension-purchases/{id}/cancel` | Optional `kind`, `reason`, `effective`, `effective_date`, `name`, `receipt_email`, `client_request_id`, `account_id` | `extension-store:uninstall` in the target channel and interactive session; that channel must own the active subscription purchase. | | `GET /v1/admin/contract-declarations` | `status` (`open` default, `processed`, `rejected`, `all`), `kind` (`withdrawal`, `cancellation`), `first` (1–100, default 50), `after` | Admin `contract-declarations:read`; oldest first, `items` and `next_cursor`. | | `GET /v1/admin/contract-declarations/{id}` | — | Admin `contract-declarations:read`; one case. | | `POST /v1/admin/contract-declarations/{id}/close` | `status` (`processed` or `rejected`); optional `note`, `refund_reference`, `stripe_applied` | Admin `contract-declarations:edit` and interactive session; no automatic refund. | | `POST /v1/admin/contract-declarations/{id}/match` | `account_id`, `contract` (`plan` or `extension_purchase`), optional `extension_purchase_id` | Admin `contract-declarations:edit` and interactive session; match an open public case. | | `POST /v1/admin/contract-declarations/{id}/retention` | `retain_until` (`YYYY-MM-DD`, later than the current date) | Admin `contract-declarations:edit` and interactive session. | `kind` is `ordinary` by default or `extraordinary`, which requires `reason`. `effective` is `period_end` for ordinary cancellation or `immediate` by default for extraordinary cancellation; `date` needs `effective_date` (`YYYY-MM-DD`, today or later). Each `ContractOption` in the two list responses includes `account_id` and `account_name` as well as the plan or extension details; the active channel comes first, then other manageable channels by switcher name. Omit `account_id` on a signed-in write to use the active channel, or pass a listed option's `account_id` to check permission in that channel without switching the active channel. `account_id` is ignored for a withdrawal with `contract: other`. A declaration receipt carries `id`, eight-character `reference`, `kind`, `status`, `received_at`, `receipt_email`, and nullable `period_end`. Plan cancellation also returns `declaration_id` and the same case reference and status. An ordinary signed-in cancellation can be `processed` once Stripe accepts it; an outstanding Stripe operation still returns success with `open` and is retried. Extraordinary and public cancellations remain `open` for operator review. A missing permission in the target channel returns 403; a foreign extension purchase returns `404 PURCHASE_NOT_FOUND`; an already cancelled subscription returns `409 SUBSCRIPTION_ALREADY_CANCELLED`. Public validation and Turnstile failures return 400, rate limits 429, and a storage outage 503 `TRY_AGAIN`. Admin actions return `409 DECLARATION_CLOSED` for an already closed case. See [Withdrawal & Cancellation](/features/contract-declarations). ## Abuse reports `POST /v1/abuse-reports` is public and accepts `song_name`, `artist`, `reporter_name`, `reporter_email` and `reason`. Optional fields are `spotify_track_id`, `proof_url` (HTTP or HTTPS) and `turnstile_token`. A Turnstile token for action `abuse` is required when verification is enabled. The response is `201` with `data: { "id": "..." }`; the report enters the admin queue without immediately blocking the song. The limit is five reports per IP address per hour and 100 reports per hour globally. Validation and Turnstile errors return 400 with `MISSING_SONG_NAME`, `MISSING_ARTIST`, `MISSING_REPORTER_NAME`, `INVALID_EMAIL`, `MISSING_REASON`, `INVALID_PROOF_URL`, `INVALID_SPOTIFY_TRACK_ID`, `TURNSTILE_REQUIRED` or `TURNSTILE_FAILED`. Rate limiting returns `429 RATE_LIMITED`; an unavailable verification or storage dependency returns `503 TRY_AGAIN`. The [GraphQL mutation](/api-reference/graphql#abuse-reports) uses the same fields, checks and codes in `camelCase`. ## Developer balance release (admin) GraphQL twins: `adminReleaseFundsAndDeleteUser` / `adminReleaseFundsAndDeleteDeveloper` ([GraphQL](./graphql.md#developer-balance-release-admin)), with the same guard, validation, errors and audit events. | Method and path | Request body | Guard and result | |---|---|---| | `POST /v1/admin/users/{id}/release-funds-delete` | `request_reference` (1–64 characters), `reason` (1–500 characters) | Admin `developer-funds:release-deletion` **and** `users:delete`, interactive session; releases the user's developer balance as a liability, then deletes the user like `DELETE /v1/admin/users/{id}`. | | `POST /v1/admin/developers/{id}/release-funds-delete` | as above | Admin `developer-funds:release-deletion` **and** `developer-verification:edit`, interactive session; releases the balance, then deletes the profile like `DELETE /v1/admin/developers/{id}`. | The response `data` is `{ deleted, released_balance_id, amount, currency, release_case, mail_sent, claim_code }`. A release is only possible when the balance cannot be paid out (`release_case` `not_verified` or `no_payout_method`); a payable balance returns `409 DEVELOPER_FUNDS_PAYABLE`, an in-flight payout `409 DEVELOPER_FUNDS_OUTSTANDING`. `claim_code` is set only when the release mail could not be sent. Invalid input returns `400 INVALID_REQUEST_REFERENCE` or `400 INVALID_RELEASE_REASON`. ### Settling a released balance GraphQL twins: `adminStartReleasedBalanceClaim` / `adminSettleReleasedBalance` ([GraphQL](./graphql.md#settling-a-released-balance)), with the same guard, validation, errors and audit events. The claim code goes only in the request body. | Method and path | Request body | Guard and result | |---|---|---| | `POST /v1/admin/released-balances/claim` | `claim_code` | Admin `developer-funds:settle`, interactive session; records that the person presented the code (`claim_started_at`, set once). `data`: `{ released_balance_id, amount, currency, release_case, released_at, claim_started_at, newly_started }`. | | `POST /v1/admin/released-balances/settle` | `claim_code`, `developer_id` | Admin `developer-funds:settle`, interactive session; credits the amount to the verified profile `developer_id` and marks the record settled. `data`: `{ released_balance_id, amount, currency, developer_id, settled_at, new_balance }`. | Errors: `400 INVALID_CLAIM_CODE`, `403 DEVELOPER_VERIFICATION_REQUIRED` (the profile's identity is not verified), `404 RELEASED_BALANCE_NOT_FOUND` or developer profile not found, `409 RELEASED_BALANCE_SETTLED`. ## Profile security and data routes All paths below use the `/v1/` prefix. JSON fields use `snake_case`. Public sign-in routes have rate limits; profile routes require the caller's interactive session unless a narrower guard is stated. A fresh **step-up** is a successful identity confirmation recorded on that session within the last ten minutes. Where 2FA is enabled, step-up accepts a passkey, authenticator app code or recovery code. | Method and path | Purpose | |---|---| | `GET /v1/auth/turnstile-config` | Public runtime Turnstile site key and widget requirement. | | `POST /v1/auth/register` | Email/password registration; gives the same check-your-inbox response for existing addresses. | | `POST /v1/auth/register/resend` | Resend a registration confirmation. | | `POST /v1/auth/email/verify` | Confirm a link after a user clicks **Confirm**; never signs the user in. Public: works signed out, on another device and for every registration. A bearer is optional and only tells "same profile" (`same_profile: true`) from another signed-in profile (403 `LINK_OTHER_PROFILE`); an expired or revoked bearer counts as signed out. | | `POST /v1/auth/password/login` | Email/password sign-in; may return `mfa_required`, `mfa_token` and `mfa_methods` instead of tokens. | | `POST /v1/auth/authorize` | PKCE authorization; when a second step is needed, returns `mfa_required`, `mfa_token` and `mfa_methods` (`passkey` and/or `totp`) instead of a code. | | `POST /v1/auth/password/reset` | Request a reset link without disclosing account existence. | | `POST /v1/auth/password/reset/complete` | Set the password, end prior sessions, then continue sign-in and 2FA if enabled. | | `POST /v1/auth/mfa/verify` | Complete the pending 2FA challenge with an app/recovery code or passkey assertion before tokens or a PKCE code are issued. | | `POST /v1/auth/passkey/options` | Get a public, IP-limited passkey sign-in challenge. | | `POST /v1/auth/passkey/verify` | Sign in with a passkey; the passkey also completes 2FA. | | `POST /v1/auth/mfa/passkey/options` | Get a challenge for the profile named by a pending `mfa_token`. | | `POST /v1/auth/step-up/passkey/options` | Get a challenge to confirm a session or a 2FA change. | | `POST /v1/auth/step-up/passkey` | Confirm the interactive session with a passkey. | | `POST /v1/auth/step-up/oauth` | Confirm with an already linked platform account; requires an interactive session. | | `POST /v1/auth/step-up/password` | Confirm with the email password when 2FA is off. | | `POST /v1/auth/step-up/mfa` | Confirm with an app code, recovery code or passkey assertion. | Registration, password reset, resend, dashboard email setup and the [contact form](#contact-form) use a server-verified Turnstile token. Password login requires it after repeated failures or suspicion. The API checks the token action and the exact ID-app or web-app hostname. Login can proceed through its temporary lockout path if Turnstile cannot answer; other protected actions fail closed. | Method and path | Purpose | |---|---| | `GET /v1/users/me/email-login` | Current email sign-in and pending-link state. | | `POST /v1/users/me/email-login` | Request or resend the setup link; fresh step-up and Turnstile. | | `POST /v1/users/me/email-login/change` | Request confirmation of a replacement address. | | `POST /v1/users/me/email-login/password` | Set the password after address confirmation. | | `DELETE /v1/users/me/email-login/pending` | Cancel a pending setup or address change. | | `DELETE /v1/users/me/email-login` | Remove email sign-in; fresh step-up and at least one other sign-in method required. | | `POST /v1/users/me/password` | Change the password using the current password. | | `DELETE /v1/users/me/email` | Remove a profile email that came from a platform sign-in. The email sign-in address answers `409 EMAIL_FROM_SIGN_IN`; change or remove the email sign-in instead. | | `GET /v1/users/me/mfa` | 2FA status, recovery-code count, trusted devices and `factors` (apps and passkeys). | | `POST /v1/users/me/mfa/setup` | Begin setup; requires fresh step-up. | | `POST /v1/users/me/mfa/setup/confirm` | Check an authenticator code and return ten recovery codes. | | `POST /v1/users/me/mfa/enable` | Finish setup; requires fresh step-up. | | `POST /v1/users/me/mfa/disable` | Turn off all methods with a valid second factor. | | `POST /v1/users/me/mfa/recovery-codes` | Replace all unused recovery codes after a second-factor check. | | `PATCH /v1/users/me/mfa/factors/{id}` | Rename an app or passkey (`label`, 1–40 characters). | | `DELETE /v1/users/me/mfa/factors/{id}` | Remove an app or passkey with another second factor; passkeys return `credential_id` for the WebAuthn Signal API. | | `POST /v1/users/me/passkeys/options` | Begin registration; first-method response includes ten recovery codes. | | `POST /v1/users/me/passkeys` | Finish registration; the first method requires `recovery_codes_stored: true`. | | `DELETE /v1/users/me/mfa/trusted-devices/{id}` | Revoke one trusted device. | | `DELETE /v1/users/me/mfa/trusted-devices` | Revoke every trusted device. | | `GET /v1/users/me/step-up` | Available confirmation methods (`mfa_methods`) and freshness. Each entry of `connections` carries `connected_at` (RFC 3339) and `last_used_at` (RFC 3339, last platform sign-in or step-up through that connection; `null` when unknown). | | `GET /v1/users/me/support-code` | Current hourly support code; interactive session only. | | `GET /v1/users/me/flagged-connections` | Sign-in, channel and bot connections that need reconnecting. | `POST /v1/auth/link` requires fresh step-up when adding a platform sign-in, except a reconnect to the same provider account. `DELETE /v1/login-connections/{id}` requires it before removing a connection. Both reject operations that would leave the user with no sign-in method. A 403 `STEP_UP_REQUIRED` tells the dashboard to reopen its confirmation dialog. | Method and path | Purpose | |---|---| | `GET /v1/users/me/data-exports` | List the caller's export requests. | | `POST /v1/users/me/data-exports` | Request `user` or `user_channels` data; one active export per model and one request per model per 24 hours. | | `POST /v1/users/me/data-exports/{id}/cancel` | Cancel an own export while it is `pending` or `running`; no step-up. The row becomes `cancelled` and does not count towards the 24-hour limit as long as it had not started yet; once the job had started it (`running`), it counts, so a new request answers `429 EXPORT_COOLDOWN`. Another user's export returns `404 NOT_FOUND`, a finished one `409 EXPORT_NOT_CANCELLABLE`. GraphQL: `cancelDataExport`. | | `POST /v1/users/me/data-exports/{id}/download-link` | Issue a five-minute signed link after fresh step-up. | | `GET /v1/data-exports/{id}/download` | Stream the ZIP using that signed link; intentionally REST-only. | An export row carries `status` (`pending`, `running`, `ready`, `failed`, `expired`, `cancelled`) and `progress_percent` (0–100 while the archive is built, only ever rising; `null` while `pending`). `GET /v1/users/me` lists each membership in `accounts[]` with `platforms`: the distinct platforms of that channel's connections (channel connections, not sign-ins) in the order `twitch`, `youtube`, `kick`, then the rest alphabetically. A connection waiting for a reconnect counts, a removed one does not; no connection gives `[]`. Same values and order as GraphQL `AccountMembership.platforms`. | Method and path | Purpose | |---|---| | `POST /v1/users/me/deletion` | Schedule deletion after the chosen grace period, or delete at once; fresh step-up. See below. | | `DELETE /v1/users/me/deletion` | Cancel scheduled deletion, possible until the date. Response `{ "scheduled_for": null, "deleted": false }`. `409 DELETION_IN_PROGRESS` ("The deletion is already running") once the date has passed, `409 NO_DELETION_SCHEDULED` when nothing is scheduled. | | `GET /v1/me/audit-log/export` | Export personal activity as CSV or JSON. | | `GET /v1/account/audit-log/export` | Export account activity; requires account `audit-log:read`. | Audit exports accept the current filter or up to 1,000 selected `ids`. Their file responses are REST-only. `POST /v1/users/me/deletion` takes an optional JSON body `{ "grace_days": 0 | 7 | 30 }`. No body or no `grace_days` means 30. Any other value returns `400 INVALID_GRACE` ("grace_days must be 0, 7 or 30"); a malformed body returns `400`. The response is `{ "scheduled_for": string | null, "deleted": bool, "closing_payout_cents": number | null }`. `grace_days: 0` deletes the profile at once through the same path as the daily job (export files, uploaded avatar, the user row and with it every session, `system:user_deleted` with `self_service: true`) and answers `{ "scheduled_for": null, "deleted": true }`. Every value needs a fresh step-up (`403 STEP_UP_REQUIRED`). The grace period is stored per profile (`users.deletion_grace_days`); the reminder mail goes out 7 days before the end of a 30-day period and 1 day before the end of a 7-day period. GraphQL: `scheduleProfileDeletion(graceDays: Int)`, identical codes. Owning channels blocks scheduling with `409 OWNS_CHANNELS`, unless **every** owned channel is already scheduled for dissolution on or before the profile deletion date (`dissolution_scheduled_for <= scheduled_for`). `grace_days: 0` still requires owning no channel at all. A developer revenue balance above 0 is paid out with a grace period of 7 or 30 days: the request creates the closing payout for the whole balance, even below the minimum payout amount, and returns its amount in `closing_payout_cents` (`null` when nothing was owed). Scheduling fails with `409 DEVELOPER_FUNDS_OUTSTANDING` ("The developer profile still has a revenue balance or a payout in progress. It can be deleted once Lumio has paid it out") while a payout is `pending` or `processing`, with `grace_days: 0` while any money is owed, and when the balance cannot be paid out (no payout method, no verified developer identity); nothing is paid out or scheduled then. The closing payout carries its self-billed credit note like every payout, so incomplete tax details answer `422 TAX_DETAILS_REQUIRED` and an incomplete `[billing.issuer]` `503 CREDIT_NOTE_ISSUER_NOT_CONFIGURED` (same texts as `POST /v1/developer/payouts/request`), again without a payout or a schedule. `DELETE /v1/admin/users/{id}` and `DELETE /v1/admin/developers/{id}` answer the same code while money is owed. A due deletion is held by the job until the profile is paid out and retried every run. Revenue that arrives while the deletion is scheduled is paid out with `POST /v1/developer/payouts/request` `{ "closing": true }`. The daily job dissolves due channels first and deletes due profiles in the same run. `GET /v1/users/me` `accounts[]` (`AccountMembershipResponse`) and `GET /v1/accounts/{id}` (`AccountResponse`) carry `dissolution_scheduled_for`: an ISO-8601 timestamp while the channel is scheduled for dissolution, otherwise `null`. Same value as GraphQL `dissolutionScheduledFor`. The dashboard reads it from the membership list for the dissolution banner and the account-switcher label. Both also carry `identity`, the channel's resolved name and picture (`source_kind`, `source_connection_id`, `source_platform`, `name`, `avatar_url`); see [Channel name and picture](#channel-name-and-picture). The membership's `account_name` is the same name. `owner_avatar_url` on the membership is deprecated and equals `identity.avatar_url`. | Method and path | Purpose and guard | |---|---| | `POST /v1/admin/users/{id}/mfa/reset` | Reset a user's 2FA with a mandatory reason; admin `users:edit`. | | `POST /v1/admin/users/{id}/support-code/reveal` | Reveal the current support code; admin `support:verify`, audited. | | `POST /v1/admin/users/{id}/support-code/check` | Check a code; admin `support:verify`, limited to five attempts per profile per ten minutes and audited. | | `GET /v1/admin/users/{id}/support-code/checks` | Recent checks; admin `support:verify`. | Lumio exposes a RESTful API. Most resource endpoints wrap their payload in a HATEOAS envelope. ## Base URLs | Environment | URL | |-------------|-----| | Production | `https://api.lumio.vision/v1` | | Production Preview | `https://lumio.api.prod.zaflun.dev/v1` | | Staging | `https://lumio.api.staging.zaflun.dev/v1` | ## Response Format A single resource is returned in an envelope whose `_links.self` is a **string** URL, not a link object: ```json { "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: ```json { "data": [ … ], "_links": { "self": "/v1/events?page=2&limit=25", "first": "/v1/events?page=1&limit=25", "prev": "/v1/events?page=1&limit=25", "next": "/v1/events?page=3&limit=25", "last": "/v1/events?page=4&limit=25" }, "_meta": { "total": 100, "page": 2, "limit": 25, "pages": 4 } } ``` `GET /v1/events` and `GET /v1/chat/history` count at most 10 000 matching rows. When more match, `_meta.total` is `10000` and `_meta` adds `"total_capped": true` (show it as "10 000+"); `pages` covers the capped total, and a page past it returns an empty `data` array. Without `from`, both read only the 30 days before `to` (or now). Pass an explicit `from` to reach older history. Not every endpoint uses the envelope - action endpoints (playback control, toggles, deletes) commonly return a bare body such as `{"success": true}`, and webhook receivers return platform-mandated shapes. Request and response bodies use **snake_case**. ## Error Format Errors return a flat body with a stable machine-readable `error` code: ```json { "error": "VALIDATION_ERROR", "message": "expires_at cannot be in the past" } ``` An optional `details` object is present only when the handler supplies one. When an error says how long to wait (`details.retry_after_secs`, e.g. `RATE_LIMITED`, `AVATAR_BUSY`), the response also carries the same value as a `Retry-After` header. A missing permission returns `{ "error": "FORBIDDEN", "message": "Missing permission: chat:ban" }` — the same message, character for character, that GraphQL returns with `extensions.code: "FORBIDDEN"`. | `error` | HTTP | Meaning | |---------|------|---------| | `BAD_REQUEST` | 400 | Malformed request | | `VALIDATION_ERROR` | 400 | Request body failed validation | | `UNAUTHORIZED` | 401 | No or invalid credential | | `FORBIDDEN` | 403 | Authenticated but missing the required `resource:action` | | `PLAN_LIMIT_REACHED` | 403 | The account's plan quota for this resource is exhausted | | `FEATURE_DISABLED` | 403 | The feature flag gating this endpoint is off for the account | | `NOT_FOUND` | 404 | Resource does not exist or is not visible to the caller | | `CONFLICT` | 409 | Conflicts with current state (duplicate slug, live session, …) | | `BILLING_DISABLED` | 409 | Billing (Stripe) is not set up on this deployment (`stripe.enabled = false`); message `Billing is not enabled` | | `WRONG_PLATFORM_ACCOUNT` | 409 | A channel reconnect came back with a different platform account than the connected one; nothing was changed. `details.signed_in_as` names that account (login, else display name — never a token or ID) when the platform reported it. See [Connections](../features/connections.md#reconnecting-with-a-different-account) | | `ADMIN_MFA_REQUIRED` | 403 | The caller holds admin grants but has no 2FA factor (authenticator app or passkey), so every admin endpoint refuses them. Message: `Admin access requires two-factor authentication. Turn on an authenticator app or a passkey in Settings › Security`. See [Admin panel → Two-factor authentication](../admin/admin-roles.md#two-factor-authentication-required) | | `CHANNEL_DISSOLVING` | 403 | The channel is scheduled for dissolution and suspended until then; `details.dissolution_scheduled_for` names the date. See [Channels being dissolved](#channels-being-dissolved) | | `AVATAR_BUSY` | 503 | Picture upload only: all decode slots for profile and channel pictures are taken; retry after `Retry-After` seconds. See [`POST /users/me/avatar`](#post-usersmeavatar) | | `RATE_LIMIT_EXCEEDED` | 429 | Per-auth-context rate limit exceeded (see [Authentication](./authentication.md#rate-limits)), or Spotify throttled an upstream request (see [Spotify](../features/spotify.md#error-handling)) | | `DATABASE_ERROR`, `REDIS_ERROR`, `INTERNAL_SERVER_ERROR` | 500 | Server-side failure | 4xx `message` values are user-facing and returned verbatim. 5xx messages are scrubbed to a generic string (`An internal server error occurred`, `An internal database error occurred`, `An internal cache error occurred`) so no internal detail leaks; the full message is logged server-side only. GraphQL returns the identical `error` code and `message` for the same failure. When Spotify throttles a read or playback, queue, device, or playlist action under `/v1/spotify/*`, the response is HTTP `429` with `error: "RATE_LIMIT_EXCEEDED"`, `message: "Spotify rate limit reached. Try again shortly."`, `details.retry_after_secs`, and a `Retry-After` header containing the same number of seconds (at least 1). The wait is supplied by Spotify. It is separate from Lumio's per-auth-context API rate limit. The [GraphQL Spotify operations](./graphql.md#errors) carry the same code and message with `extensions.retryAfterSecs`. ## Interactive Documentation - **Swagger UI** - `/v1/swagger-ui/` when `docs.swagger_ui` is enabled in config - **OpenAPI spec** - `/v1/api-doc/openapi.json` (served with Swagger UI) or `/v1/openapi.json` when `docs.openapi_json` is enabled Both are config-gated so a deployment can lock them down. The generated spec covers the endpoints that carry `utoipa` annotations. | `[docs]` key | `default.toml` (dev, staging) | `production.toml` | ENV override | |---|---|---|---| | `swagger_ui` | `true` | **`false`** | `LUMIO__DOCS__SWAGGER_UI` | | `openapi_json` | `true` | `true` (inherited) | `LUMIO__DOCS__OPENAPI_JSON` | Production (`api.lumio.vision`) does not serve the interactive Swagger UI: it is an unauthenticated HTML/JS page on the API origin, so it stays off there. The raw spec at `/v1/openapi.json` stays available for SDK generators. Enabling `swagger_ui` implies `openapi_json` at runtime. The Swagger UI assets are compiled in from the checksum-pinned `utoipa-swagger-ui-vendored` crate (`vendored` feature) — the build never downloads them, so `SWAGGER_UI_DOWNLOAD_URL` has no effect. ### Spec completeness policy The spec is hand-enumerated in `apps/api/src/openapi.rs`, so historically live routes could silently drift out of it. A build-time test (`openapi::completeness`) now enforces that **every route module wired into the live router is either registered in the spec or on a documented exclusion allowlist** (`SPEC_EXCLUDED_MODULES`, with a per-module reason) - so the spec is a trustworthy completeness oracle for everything **not** on that list. The intentional exclusions are: - **`internal_extension_handlers`** - internal service-to-service endpoint (the V8 bot-module/automation workers fetch handler code), not a consumer-facing API operation. - **`webhooks_payments`** - inbound Stripe Connect / PayPal payout webhooks; a provider-called receiver, not a callable consumer operation. - **`/.well-known/security.txt`** - a static RFC 9116 resource, not a JSON API operation. The `sounds`, `widgets`, and `history` modules were previously excluded as an annotation gap; their handlers are now annotated and present in the spec. The entire extension / developer-portal / marketplace / payout surface has since graduated into the spec (ZAF-1107): `developer_teams`, `developer_applications`, `developer_store_profiles`, `developer_verifications`, `admin_developer_applications`, `admin_developer_teams`, `developer_extensions` (Developer Extensions - manage your extensions, versions, secrets, testers, analytics, payouts, and limit requests), `extension_actions`, `extension_purchases`, `extension_bundles`, `extension_storage`, `extension_uploads`, `extension_access` (Extension Access - overlay access grants and shared access links), `extension_reviews` (Extension Reviews - admin extension review queue and moderation), `extension_store` (Extension Store - browse, install, review, and serve store assets), `extension_functions` (Extension Functions - invoke extension query/mutate/execute functions), `bot_module_extensions` (Bot Module Extensions - bot-module extension configuration and control), `automation_nodes`, `stripe_connect`, and `marketplace` (Marketplace - public extension store browsing, listings, reviews, featured, categories). Only the two internal modules above (`internal_extension_handlers`, `webhooks_payments`) remain deliberately excluded, because they are not public consumer operations. Treat this page and the feature docs as authoritative for the excluded surfaces above. ## Endpoints Overview All paths are under `/v1`. Request and response bodies use **snake_case**. Permissions use the `resource:action` format. | Resource group | Path prefix | Description | |----------------|-------------|-------------| | Auth | `/v1/auth` | Token exchange, refresh, logout, OAuth link/authorize, popout-token exchange | | Users | `/v1/users/me` | Current-user profile, login connections, sessions | | Contact | `/v1/contact` | Public contact form: forwards a message to the Lumio inbox by e-mail, nothing stored (see [Contact form](#contact-form)) | | Accounts | `/v1/accounts` | Create/dissolve/leave accounts, channel name and picture, member invites, login assignments | | Login Connections | `/v1/login-connections/{id}` | Delete a login connection by UUID; `PUT /v1/login-connections/{id}/reconnect-flag` clears its reconnect prompt | | Members & Invites | `/v1/accounts/{id}/members`, `/v1/invites` | Team management | | Roles | `/v1/roles` | RBAC role CRUD and permission catalog | | Tokens | `/v1/tokens`, `/v1/api-keys` | Popout token and user API key management. `POST /v1/tokens` **requires a first-party session** (logged-in user or the user's own API key; ZAF-1017/ZAF-469) - a popout/overlay/widget/extension token is rejected with `403 FORBIDDEN`, and an omitted `user_id` binds to the **creator**. `PATCH /v1/tokens/{id}` with `"user_id": null` (unassign) binds the token to the **editor**, under the same first-party gate - a popout token is always bound to an explicit user and an unbound token never resolves (ZAF-1922). An explicit `user_id` (on create, or reassigned via `PATCH`) must be an account member whose role does not exceed the caller's own permissions; the owner counts as holding every account permission (ZAF-1740 A1). It accepts an optional `expires_at` (RFC3339) to set the token lifetime at creation - `null`/absent = never expires; a past value is rejected with `VALIDATION_ERROR` "expires_at cannot be in the past", identical to GraphQL `createPopoutToken` (the `PATCH` update path still allows a back-dated `expires_at` as a soft-revoke). The `/v1/api-keys` routes are also first-party gated (see [API Keys](#api-keys)). `/tokens/me` returns the caller's **effective** permissions - a scoped API key or popout token reports its own scope capped to its bound member's current role (ZAF-2430) and a logged-in user reports their account-role permissions; identical to GraphQL `myPermissions`, and never the `["*"]` wildcard. | | Overlays | `/v1/overlays` | Overlay configuration CRUD | | Sounds | `/v1/sounds` | Sound library management, upload, playback control, and streaming | | Widgets | `/v1/widgets` | Widget instance CRUD, access management, and duplication | | Setting Presets | `/v1/setting-presets` | Named snapshots of chat/events/music display settings, keyed `(user, account)`. Create/edit/delete are scoped to the user + active account (no RBAC, fail-closed on membership); sharing with your account is gated `presets:share`. Reads work with a popout token (own presets only); writes require a first-party session. Gated by `feature:setting_presets` | | Uploads | `/v1/uploads` | File uploads (multipart) and presigned download URLs | | Chat | `/v1/chat/*` | History (filter by platform, user, date, keyword `search`, YouTube `live_chat_id`), merged messages+events timeline (`/v1/chat/history/merged`), send, moderate, user profiles, user name-completion search, notes, raid/poll/prediction | | Events | `/v1/events` | Event history, single event, emit/test | | Emotes | `/v1/emotes` | Channel and user emote fetching | | YouTube Memberships | `/v1/youtube/memberships/tiers`, `/v1/admin/privacy/youtube/member/{id}` | Read observed YouTube member-tier badges (account-scoped); GDPR-Art-17 erasure of a member on one channel's instruction (`account_id`, `instruction_reference` and `initiated_by` = `channel` | `subject` query parameters, all required): Lumio's member cache across every account and the member's chat data in that account only (system_admin only) - see [Member Badges](../features/memberships.md) | | Storage Erasure | `/v1/admin/storage/orphaned-extension-uploads/sweep` | `POST` with `{"apply": false}` (default, dry run: counts extension upload files no `extension_uploads` row references) or `{"apply": true}` (queues them for deletion, audit `system:orphaned_storage_swept`); returns `applied`, `extensions_scanned`, `extensions_failed`, `orphaned_objects`, `queued_objects` (`admin:privacy-erase`). Mirrors GraphQL `adminSweepOrphanedExtensionUploads` | | Chat Privacy | `/v1/admin/privacy/chat/erase` | `POST` `{account_id, instruction_reference, reason, initiated_by, lumio_user_id}` or `{account_id, instruction_reference, reason, initiated_by, platform, user_id}`: GDPR-Art-17 erasure of a data subject in the one instructing account - chat messages, `platform_users` row, moderator notes, AutoMod log, moderation-log rows targeting them, bot-list entry, stream-history entries (announcements and pins included) and still-open AutoMod held-queue messages (events stay); records them in the account's `account_chatter_suppressions` with `initiated_by` (required: `channel` when the channel initiated the erasure, `subject` when the person asked for it); a `subject` entry is never downgraded by a later `channel` erasure, and its errors are `initiated_by is required` / `initiated_by must be one of: channel, subject`. Other accounts are not touched. Afterwards the channel owner gets a confirmation e-mail (counts and a case number from `case_id`, never the person). `reason` (why the subject is erased, trimmed, at most 500 characters) is required and goes into the audit event; errors `reason is required` / `reason must be at most 500 characters`, as on `records/erase`. 404 for an unknown account (`admin:privacy-erase`; only support operators call it). Returns `{account_id, case_id, deleted_message_count, deleted_platform_user_count, deleted_user_note_count, deleted_automod_message_count, deleted_automod_moderation_log_count, deleted_moderation_log_count, scrubbed_history_session_count, deleted_account_bot_count, removed_held_queue_entry_count, suppressed}`. Mirrors GraphQL `eraseChatSubjectData` | | Chat Privacy | `/v1/admin/privacy/chat/erase/withdraw` | `POST` `{account_id, platform, user_id, instruction_reference}` (all required): withdraws one channel's erasure instruction for one chatter - deletes the chatter's row in that account's `account_chatter_suppressions`, so the channel's stream reports frozen from then on name the chatter again. What the erasure deleted or blanked stays gone; other channels and the global `chatter_suppressions` list are not touched. Validation and error texts as the objection plus `account_id is required` / `instruction_reference is required`; 404 `Account not found` for an unknown account and `no channel erasure is recorded for this chatter` without an entry; only an erasure the channel initiated can be withdrawn - an entry the person asked for (and every entry recorded before the initiator was stored) is a 409 `this erasure was requested by the person, not initiated by the channel; it cannot be withdrawn on the channel's instruction` and stays (`admin:privacy-erase-withdraw`). Audit `privacy:channel_suppression_withdrawn` on success only (system scope, without the user id). Returns `{account_id, platform, suppressed}` (`suppressed` is `false`). Mirrors GraphQL `withdrawChannelChatterSuppression` | | Chat Privacy | `/v1/admin/privacy/lumio/erase` | `POST` `{platform, user_id, login?, instruction_reference, reason}`: erasure of a person on Lumio's own surfaces only - an `erasure` entry in the global `chatter_suppressions` list (Lumio Stats leaderboard) and, on YouTube, Lumio's member cache across every account. No channel's chat, profiles, logs or reports are touched. Idempotent; validation and error texts as the objection plus `instruction_reference is required` and, for the required `reason` (trimmed, at most 500 characters, in the audit event), `reason is required` / `reason must be at most 500 characters` (`admin:privacy-erase`; only support operators call it). Returns `{platform, suppressed, erased_key_count}`. Mirrors GraphQL `eraseChatSubjectAtLumio` | | Chat Privacy | `/v1/admin/privacy/records/erase` | `POST` `{kind, account_id, instruction_reference, reason, record_id?, created_at?, session_id?, platform?, user_id?, login?}`: erasure of **one record** in one channel on that channel's instruction. `kind` is `chat_message` (`record_id` + `created_at`, matched within ±1 s; also removes the message's text copy from the moderation log), `user_note`, `moderation_log`, `automod_log` (also its `automod_allow` / `automod_deny` moderation-log rows and a still-open held-queue entry), `bot_list` (each by `record_id`) or `stream_report` (`session_id` + `platform` + `user_id`, optional Twitch/Kick `login`: the person's entries in that report's top chatters, links, commands and keywords, and their slots in its announcements and pins, keep place and counts and lose the identity). `account_id`, `instruction_reference` and `reason` (at most 500 characters) are required. A record of another account is `404 Record not found`, like a missing one; an unknown account is `404 Account not found`; a stream report that is not finalized yet is `409`. No suppression entry is written. Errors: `kind must be one of: …`, `account_id is required`, `instruction_reference is required`, `reason is required`, `reason must be at most 500 characters`, `record_id is required`, `created_at is required for a chat_message`, `session_id is required for a stream_report`, plus the objection's chatter errors (`admin:privacy-erase`). Returns `{account_id, case_id, kind, deleted_record_count, moderation_log_text_removed_count, deleted_moderation_log_count, held_queue_entry_removed, scrubbed_entry_count}`. Mirrors GraphQL `eraseChatRecord` | | Chat Privacy | `/v1/admin/privacy/chat/objections` | `POST` `{platform, user_id, login?}` records a chatter's GDPR-Art-21 objection in the global `chatter_suppressions`: the public Stats leaderboard masks the chatter on read, also on streams published earlier; channel reports, exports, share links and AutoMod logs are not affected; nothing is erased; idempotent (`admin:privacy-objection-create`). Returns `{platform, suppressed, reason}`. Mirrors GraphQL `addChatterObjection` | | Chat Privacy | `/v1/admin/privacy/chat/objections/{platform}/{user_id}` | `DELETE` lifts the objection; the public Stats leaderboard names the chatter again; 404 when none is recorded, 400 for an erasure entry (`admin:privacy-objection-delete`). Mirrors GraphQL `removeChatterObjection` | | YouTube Streams | `/v1/youtube/active-streams` | Live and upcoming YouTube broadcasts with a resolved `live_chat_id` (reads the YouTube worker's Redis cache; an upcoming broadcast without a chat ID can still appear in dashboard channel status) | | Connections | `/v1/connections` | App credentials and channel OAuth flow | | Bot Connections | `/v1/bot-connections`, `/v1/bot-status`, `/v1/bot-toggle`, `/v1/bot-rejoin` | Custom bot identity OAuth and control. `GET /v1/bot-connections/discord/authorize` (`bot-connections:create`, `feature:bots`, session-only) returns the verified Discord server invite of the global Lumio bot; Discord completes on the unified callback (`POST /v1/connections/oauth/discord/exchange`), never on `POST /v1/bot-connections/discord/exchange` | | Bot Commands | `/v1/bot-commands` | Cross-platform command CRUD and global overrides | | Bot Modules | `/v1/bot-modules` | Moderation module configs (link/spam/word/timed), extension bot module kill switch | | Bot Module Triggers | `/v1/internal/bot-modules` | Internal trigger resolution, handler-bundle feed and handler-result actions for the bots and the Bot Module Worker (System key with `bots:internal`) | | Extension Handler Feeds | `/v1/internal/{bot-modules,automation-nodes}/handlers` | Internal handler-code bundle + context feed for the workers' InstallRegistry (System key with `bots:internal`) | | Automations | `/v1/automations` | Visual automation CRUD and manual execution | | Automation Extension Nodes | `/v1/automation/extension-nodes`, `/v1/automation/webhooks` | Extension node listing and webhook receiver | | Channel Status | `/v1/channel-status`, `/v1/channels/schedule`, `/v1/spotify/manual-connect` | Live status and the stream plan of the connected channels (`?from=&to=`, default now…+30 days, at most 60 days; both `connections:read`), Spotify manual polling (`spotify:read`) | | Spotify | `/v1/spotify/*` | State, playback, queue, devices, playlists, search | | Copyright | `/v1/copyright/*` | Safe/blocked songs, playlist imports, community voting | | Notifications | `/v1/notifications` | In-app notifications, actions, and delivery preferences | | Ideas Hub | `/v1/ideas` | Community ideas, votes, comments, @mention autocomplete, categories, tags. Moderation actions live on the same paths and are gated on the `ideas:moderate_*` admin permissions | | OBS | `/v1/integrations/obs`, `/v1/obs-remote/*` | OBS config + remote stream/recording/scene control | | SE Tokens | `/v1/se-tokens` | StreamElements JWT token storage | | Discord Guilds | `GET /v1/discord-guilds`, `DELETE /v1/discord-guilds/{guild_id}` | List the account's Discord servers (`bot-connections:read`; fields as GraphQL `discordGuildConnections`, incl. `bot_removed` and `missing_permissions`) and disconnect one (`bot-connections:delete`, returns `{ success }`). Connecting a server is the Discord branch of the bot connections above | | Abuse Reports | `/v1/abuse-reports` | User-submitted abuse reports | | Webhooks | `/v1/webhooks/*` | Platform webhooks (Twitch, YouTube, Kick, Shopify, Stripe) | | Billing | `/v1/billing/*` | Stripe checkout/portal/status/invoices/coupon; resolved account limits (`account-limits`). `POST /checkout`, `POST /portal` and `POST /cancel` are **session-only** - an API key gets `403` (see [Authentication → Session-only Surfaces](./authentication.md#session-only-surfaces)). `GET /status` carries `billing_enabled`; when it is `false` (no Stripe on this deployment), checkout, portal, cancel, invoices and coupon validation answer `409 BILLING_DISABLED` after their auth check, while `GET /status` itself still succeeds. `POST /checkout` requires `accept_withdrawal_waiver: true` in its body (the buyer ticked the withdrawal-waiver checkbox); a missing or `false` value answers `400 VALIDATION_ERROR` "You must accept the withdrawal waiver (accept_withdrawal_waiver) before checkout" before any Stripe call. `POST /portal` opens the customer portal without plan changes (see [Billing → Withdrawal waiver and order confirmation](../features/billing.md#withdrawal-waiver-and-order-confirmation)) | | Playlists | `/v1/playlists`, `/v1/playlists/{id}` | Read-only system-curated playlists. Public, gated by `feature:copyright_detection` (`403` when off), see [Copyright → Public REST endpoints](../features/copyright.md#public-rest-endpoints) | | Songs | `/v1/songs`, `/v1/songs/check`, `/v1/songs/sitemap`, `/v1/songs/{id}` | Read-only global song metadata, copyright status and the sitemap id enumeration (`after`, `limit` ≤ 5000). Public, gated by `feature:copyright_detection` (`403` when off) | | Platforms | `/v1/platforms/metadata` | Static platform metadata (name, icon, features, scopes) | | Features | `/v1/features/enabled`, `/v1/providers/enabled` | Public feature/provider flag reads | | Extension Bundles | `/v1/extension-bundles` | Serve extension bundle files (JS, CSS, assets) | | Extension Version Files | `/v1/extensions/{id}/versions/{version}/files` | List files in an extension version bundle | | Extension Uploads | `/v1/extensions/{id}/uploads` | Upload/serve files from extension editors (icons, images) | | Fonts | `/v1/fonts/{family}/css`, `/v1/fonts/{family}/{weight}.woff2` | DSGVO-compliant font proxy (CSS + woff2). Public scope, no auth, permissive CORS. Inputs are allowlist-validated on both routes (`400` otherwise): `family` is 1–64 characters of `A-Z a-z 0-9`, space, `+` or `-`; a weight is a multiple of 100 between 100 and 900 (on `/css`, invalid `weights` entries are ignored and duplicates collapse, `400` when none remain); `display` is one of `auto`, `block`, `swap`, `fallback`, `optional` (default `swap`). Responses carry `X-Content-Type-Options: nosniff` | | Developer Applications | `/v1/developer/application` | Submit and view developer applications | | Developer Verification | `/v1/developer/verification` | Submit and view own KYC verification (self-service) | | Developer Credit Notes | `/v1/developer/credit-notes`, `/v1/admin/credit-notes`, `/v1/admin/developer-payouts` | Self-billed credit notes (Gutschrift) of developer payouts: own list and PDF; operator payout list and settlement (mark paid / failed); operator list, PDF and cancellation | | Developer Limits | `/v1/developer/limits` | View resolved limits and request increases | | Developer Teams | `/v1/developer/teams` | Team CRUD, members, invites, roles, permissions | | Developer Store Profiles | `/v1/developer/profiles/{slug}`, `/v1/developer/teams/{slug}/profile` | Public developer and team profile pages | | Admin Developer Applications | `/v1/admin/developer-applications` | Review, approve, reject developer applications | | Admin Limit Requests | `/v1/admin/limit-requests`, `/v1/admin/developers/{id}/limit-requests` | Review developer limit increase requests | | Admin Extension Limits | `/v1/admin/extensions/{id}/limits` | Set per-extension limit overrides | | Admin Extension Purchases | `/v1/admin/extension-purchases/undelivered` | `GET ?first=&after=`: paid extension checkouts without delivery (still `completed`, never installed, older than 1 h), newest first. Returns `{items, next_cursor}`; each item has `id`, `extension_id`, `extension_name`, `account_id`, `account_name` (the four are `null` once deleted), `stripe_checkout_session_id`, `stripe_payment_id`, `amount` (cents), `currency`, `undelivered_reason` (`detached`, `not_installable`, `install_failed`, `refund_failed` or `null`) and `created_at`. `first` is 1-100 (default 50); `after` is the previous page's `next_cursor`. Admin `extension-purchases:read`; twin of GraphQL `adminUndeliveredExtensionPurchases`. See [Admin › Extension Purchases](../admin/extension-purchases.md) | | Overlay Folders | `/v1/overlay-folders` | Overlay folder CRUD and move | | Overlay Shared Links | `/v1/overlay-shared-links` | Time-limited shared overlay links (`lm_share_*`) | | Channel Connections | `/v1/connections/channel`, `/v1/connections/status`, `/v1/connections/credential-modes`, `/v1/connections/oauth/{platform}/exchange`, `/v1/channel-connections/{id}/reconnect-flag` | Channel OAuth, reconnect prompts, the channel list (`/channel` - each row carries `channel_name` the display name (may be localized) and `channel_login` the URL-safe login/handle; GraphQL parity `channelConnections.channelLogin`, ZAF-1461; plus `missing_scopes` - the required base scopes the stored grant lacks, `[]` when nothing is missing or the grant was never recorded (`scopes` is `null`), a non-empty list means reconnect; GraphQL parity `channelConnections.missingScopes`, same `connections:read` guard as `scopes`), the per-platform status overview (`/status` - health-aware `is_connected` + `reconnect_required` + `expires_at`, plus the acting user's personal login-grant health `login_connected` + `login_reconnect_required` reported separately from the channel (ZAF-1045, GraphQL parity `connectionStatuses`), `connections:read`), the credential mode per `(platform, kind)` (`/credential-modes` - `platform`, `kind` `"channel"`/`"bot"`, `mode` `"system"`/`"account"`, `system_configured`; twin of GraphQL `platformCredentialModes`, `connections:read` + `feature:connections`), and the **unified OAuth exchange** (`/oauth/{platform}/exchange` - the single web-host callback shared by the channel, account-bot and admin global-bot system flows; redeems the structured state handle, dispatches on `kind`, binds `channel` / `bot` handles to the session of the user who started the authorize step (re-checking account access + the connect permission; the admin `global_bot` handle needs no session), and validates `return_to` fail-closed against `[web, admin]`). The channel and bot `authorize` steps are **session-only** - see [Authentication → Session-only Surfaces](./authentication.md#session-only-surfaces); `?include=follower_count` fills the optional cached `follower_count` and `?include=paid_subscriber_count` the optional cached `paid_subscriber_count` - current paid subs, Twitch Helix `total` / Kick `active_subscribers_count`, always `null` on YouTube and without `history:read` (field guard, no error); both combine as `?include=follower_count,paid_subscriber_count` (otherwise `null`, unknown include = 400; GraphQL parity `channelConnections.followerCount` / `paidSubscriberCount`) | | Channel Info | `/v1/channel-info` | Live channel metadata for the active account | | Stream History | `/v1/history` | Session list, reports, stats, exports, and shared report links | | Public Stats | `/v1/public-stats` | Per-channel publicness (opt-out) settings + the unauthenticated public read model (channel + stream stats) | | Public Stats browse | `/v1/public-stats/{browse,games}` | Public, unauthenticated cross-channel browse/ranking (metric nav) + Games directory + channel search. No auth; gated by the `system:public_stats` kill-switch; edge-cacheable | | Emote Directory | `/v1/public-stats/emotes` | Public, unauthenticated emote-catalog browse (filter + name search + pagination). No auth; gated by the `system:public_stats` kill-switch; edge-cacheable | | Watchlist | `/v1/watchlist` | Per-user favourite-channel list for the public Stats app. Authenticated viewer (JWT); no account/RBAC permission, no plan gate. GraphQL + REST (no WS) | | Audit Log | `/v1/me/audit-log`, `/v1/account/audit-log` | Self-scope personal-security log and account-scope tenant audit log; the operator audit surface lives under `/v1/admin/audit-log` | | Marketplace | `/v1/marketplace`, `/v1/store`, `/v1/categories`, `/v1/featured` | Public extension store browsing, listings, reviews, icons (`/v1/store/extensions/{id}/icon-data` serves the bytes), screenshots | | Extension Installs | `/v1/extension-installs` | Install lifecycle, per-install storage, actions, and query/mutate/execute function calls | | Extension Purchases | `/v1/extension-purchases` | Paid-extension checkout. Body `{ extension_id, accept_withdrawal_waiver }`: a paid checkout requires `accept_withdrawal_waiver: true` (`400 VALIDATION_ERROR`, same message as `POST /v1/billing/checkout`) and a signed-in person (`403` for tokens without one); a free-access install needs neither. `409 Extension already purchased` when the account owns it; for a subscription extension also `409 Active subscription already exists` while an earlier subscription is still running, even if its first payment was refunded | | Extension Access | `/v1/extension-access-invites` | Private-extension access invites | | User Roles | `/v1/user-roles` | User-scoped role CRUD (admin) | | Well-Known | `/v1/.well-known/*` | Service discovery documents | | Health | `/v1`, `/v1/health` | Liveness probes; `/v1/health` also carries service-dependency status and live WebSocket gauges | | Metrics | `/metrics` | Prometheus scrape endpoint (origin root, **not** under `/v1`) | | Admin | `/v1/admin/*` | System-admin-only endpoints: `admin-roles`, `admin-permissions`, `feature-flags`, `plans`, `coupons`, `users`, `accounts`, `api-keys` (operator management of tenant/user keys), `oauth-clients`, `system-keys`, `system-connections`, `providers`, `bot-control`, `bot-commands`, `bot-connections`, `discord-guilds`, `se-tokens`, `audit-log`, `abuse-reports`, `privacy` | This page and the feature docs cover the endpoints in day-to-day use; the API serves more, and the OpenAPI document intentionally omits the surfaces listed under [Spec completeness policy](#spec-completeness-policy) above (everything else wired into the router is guaranteed present by a build-time test). For the exhaustive parameter list of the annotated endpoints, download `/v1/openapi.json` (or use the Swagger UI at `/v1/swagger-ui/` on deployments that enable it — not production). Feature-specific docs under **Features** document the most commonly used endpoints with their `resource:action` permissions. ## Public Songs and Playlists All six public `GET /v1/songs*` and `GET /v1/playlists*` routes work without authentication and apply the fail-closed `feature:copyright_detection` gate. With an authenticated caller the gate resolves for that account; otherwise it resolves globally. A disabled feature returns `403 FEATURE_DISABLED`. Song reads expose global safe and blocked rows only; playlist reads expose system-curated playlists. Their [GraphQL twins](./graphql.md#public-songs-and-playlists) use the same feature gate. | Method and path | Parameters | Result | |---|---|---| | `GET /v1/songs` | `page` (default 1), `limit` (default 25, maximum 100), optional `search`, `status` (`safe` or `blocked`), `source` | Paginated global songs. | | `GET /v1/songs/check` | `spotify_track_id`, or a song `title` and `artist` | Global status for a song. | | `GET /v1/songs/sitemap` | Optional `after` (exclusive song UUID cursor), `limit` (default and maximum 5,000) | `data.items[]` with `id` and nullable `lastmod`, plus `data.has_more`. Pass the last ID as `after` while `has_more` is true. | | `GET /v1/songs/{id}` | Song UUID | One global song; 404 when unknown or not global. | | `GET /v1/playlists` | None | System-curated playlist summaries. | | `GET /v1/playlists/{id}` | Playlist UUID | One system-curated playlist with tracks; 404 when unknown. | The sitemap song date is the row's `created_at` or `null`. Which global song lists are included is fixed on the server (safe and blocked); clients cannot change it with a query parameter. REST fields use `snake_case`, including `has_more`; GraphQL uses `camelCase`. The public marketplace reviews (`GET /v1/marketplace/extensions/{slug}/reviews`, GraphQL `marketplaceExtensionReviews`) return `id`, `rating`, `comment`, `created_at` and `updated_at` per review. They contain no reviewer identity, neither a user ID nor a name. The store pages do not show reviewers either. `GET /v1/connections/oauth-scopes/{platform}` returns `data: { platform, required, optional }` with full provider scope names. `required` contains base scopes and `optional` contains scopes requested on every authorization but excluded from `missing_scopes`. YouTube's optional list contains `https://www.googleapis.com/auth/yt-analytics-monetary.readonly`; Kick's required list includes `ads:read`. The route requires `feature:connections` and `connections:read`, as does its GraphQL twin `channelOAuthScopes(platform)`. An unsupported platform returns `400` with `Unsupported platform: X` (the GraphQL twin reports the same message). ## Health ### `GET /v1/health` Public, unauthenticated. Returns the API version, uptime, per-dependency health (PostgreSQL, TimescaleDB, Redis), and live WebSocket-server gauges. The `websocket` block reports aggregate operational counters only - total active sessions and broadcast channels, with no per-user or per-channel detail - and is read lock-free, so scraping it never contends with the WebSocket broadcast path. ```json { "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](../developer-guide/metrics.md) 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 | ```text # 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](./authentication.md#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:** ```json { "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): ```json { "data": { "token": "lm_...", "expires_at": "2026-08-12T12:15:00Z", "account_id": "…" } } ``` Validates the token (prefix + `revoked_at` / `expires_at`) and mints a 15-minute session JWT carrying the token's permission subset. The session resolves to the **same** popout auth context as the raw token (identical account, permission subset, OBS credential gate) - never a full user session. A revoked, expired, unknown, or non-popout token returns `401` with `Invalid or expired popout token`. ### `GET /v1/auth/popout/session` Read the `(account_id, user_id)` pair the current popout session represents - the REST twin of GraphQL `popoutSession`. See [Authentication → Popout Tokens & Popout Sessions](./authentication.md#popout-tokens--popout-sessions) for the full model. **Auth:** any authenticated context, including a popout token / session (the `?token=lm_pop_*` query param or the httpOnly `lumio-popout-token` cookie). It is **not** first-party gated. Anonymous is rejected with `401 Authentication required` - the same message the GraphQL `AuthGuard` emits. **Response:** ```json { "data": { "account_id": "…", "user_id": "…", "locale": "auto", "timezone": "auto", "date_format": "auto", "time_format": "auto", "season_look_enabled": true, "season_strength": "full", "season_motion": "auto", "season_creatures": {}, "season_banner_dismissed": [], "motion_preference": "system" } } ``` The `account_id` / `user_id` pair is read straight from the auth context (`auth.account_id()` / `auth.user_id()`) - never a client parameter, so the scope cannot be forged, and the response carries only the caller's **own** session ids. `user_id` is always the user the popout token is bound to - every popout token is bound to an explicit user and there is no owner fallback (ZAF-1922). No permission guard and no audit event (a read of the caller's own session). The four display-preference fields (`locale`, `timezone`, `date_format`, `time_format`) are the represented user's stored preferences (same value space as [`GET /users/me`](#get-usersme)), so a popout - which cannot reach the first-party-gated `me` - can render language, timezone and date/time format. The five `season_*` fields carry the seasonal-look preferences and `motion_preference` the "Animations in Lumio" preference the same way (written via `PATCH /users/me`, which a popout cannot reach; a popout session's only two writes are the one-way season routes below). They carry the represented user's values (assigned member, or account owner for an unassigned token). ### `POST /v1/auth/popout/season-banner/dismiss` Dismiss the season banner for one season instance on behalf of the current **popout session** - the REST twin of GraphQL `popoutDismissSeasonBanner`. One of exactly two one-way profile writes a popout may make (see [Authentication → Popout Tokens & Popout Sessions](./authentication.md#popout-tokens--popout-sessions)). **Auth:** popout-only - a popout token or popout session. Anonymous → `401 Authentication required`; any other credential (user session, API key, overlay, shared-overlay, widget, extension) → `403 This endpoint requires a popout session`. A logged-in user writes the same list through [`PATCH /users/me`](#patch-usersme). The guard runs before validation. **Request:** ```json { "instance": "halloween:2026" } ``` `instance` is `:`. The season id must be a known season - the server-side list `user_preferences::SEASON_IDS` is `halloween`, `nicholas`, `christmas`, `new-year`, `easter` - every season of the web registry (`apps/web/src/lib/seasons/registry.ts`) plus the planned ones; a season outside it extends both. The year must be within ±1 of the current year. Anything else is a `400` validation error - `Invalid season instance: must be ':'`, `Invalid season instance: unknown season id (allowed: halloween, nicholas, christmas, new-year, easter)` or `Invalid season instance: year must be between and `. Free text is never echoed back. **Response:** ```json { "data": { "season_look_enabled": true, "season_banner_dismissed": ["halloween:2026"] } } ``` Appends `instance` to the represented user's `season_banner_dismissed` and changes nothing else. Idempotent: an instance already in the list is a no-op (no write). The same dedupe and 50-entry cap as `PATCH /users/me` apply - a full list drops its oldest entry. No audit event (UI bookkeeping) and no WebSocket push (the account events channel would leak one user's preference to every viewer). ### `POST /v1/auth/popout/season-look/off` Switch the seasonal look **off** on behalf of the current **popout session** - the REST twin of GraphQL `popoutSwitchOffSeasonLook`. Same auth, request body, validation and error text as [`POST /v1/auth/popout/season-banner/dismiss`](#post-v1authpopoutseason-bannerdismiss). Sets the represented user's `season_look_enabled = false` and, in the same write, appends `instance` to `season_banner_dismissed`, so switching the look back on later does not bring this season's banner back. `season_look_enabled` is the **global** switch (all seasons), the same one the Settings toggle writes. **Off only:** no popout route can set it to `true` or change `season_strength`, `season_motion` or `season_creatures` - turning the look on again is a dashboard Settings action ([`PATCH /users/me`](#patch-usersme)). Idempotent (look already off + instance present = no write). Response shape as above, with `"season_look_enabled": false`. No audit event, no WebSocket push. ### `POST /v1/auth/popout/ws-token` Mint a **WebSocket-scoped** token for the current **popout session** - the REST twin of GraphQL `issuePopoutWsToken`, and the popout counterpart of `POST /v1/auth/ws-token`. **Auth:** a popout session (the httpOnly `lumio-popout-token` cookie, or a popout session bearer). This is **popout-only**: `POST /v1/auth/ws-token` is session-only (see the note above) and returns `403` for a popout, so a popout cannot mint there. A non-popout caller here is rejected with `401 UNAUTHORIZED` (no credentials) or `403 FORBIDDEN` (any non-popout credential). **Response:** ```json { "data": { "ws_token": "lm_..." } } ``` The minted token carries the popout's **own** permission subset (never the account owner's), a WebSocket-only use tag, and **no `session_id`**. The API accepts it **only** on the `/v1/ws` upgrade path - a leak of the `?token=` WS URL cannot be replayed against a REST/GraphQL mutation. It is short-lived (`auth.ws_token_expiration`, ~15 min); the popout re-mints on reconnect. ## Contact form ### `POST /v1/contact` Public, no authentication. Forwards one message to the Lumio inbox (`[contact] recipient`, default `hey@lumio.vision`) as an e-mail whose `Reply-To` is the sender's address. Nothing is stored: there is no table and no retention job. Logs record only the outcome and the subject category, never the name, address or message. The GraphQL twin `submitContactMessage` calls the same service, so validation, limits and error codes and texts are identical. | Field | Type | Rule | |---|---|---| | `name` | string, optional | At most 100 characters, no control characters; blank counts as absent. | | `email` | string | A valid e-mail address; becomes the `Reply-To`. | | `subject` | string | One of `general`, `privacy`, `legal`, `billing`, `other`. | | `message` | string | 1 to 5000 characters after trimming. | | `turnstile_token` | string | Cloudflare Turnstile token for action `contact`, issued on a web-app hostname (`turnstile.web_hostnames`). | The checks run in this order: fields, then 5 messages per IP per hour, then Turnstile, then 100 forwarded messages per hour in total (only requests that passed Turnstile count). A field error therefore never uses up the single-use Turnstile token. The generic per-IP limit of the `/v1` scope applies as well. **Response** `200`: `{ "data": { "status": "sent" } }`. The mail is sent in the background. | Status | `error` | `message` | |---|---|---| | 400 | `INVALID_EMAIL` | `Enter a valid email address` | | 400 | `INVALID_CONTACT_NAME` | `The name can have at most 100 characters` | | 400 | `INVALID_CONTACT_SUBJECT` | `Choose a subject: general, privacy, legal, billing or other` | | 400 | `INVALID_CONTACT_MESSAGE` | `The message needs 1 to 5000 characters` | | 400 | `TURNSTILE_REQUIRED` | `Please complete the security check` | | 400 | `TURNSTILE_FAILED` | `The security check failed. Please try again` | | 429 | `RATE_LIMITED` | `Too many requests. Try again later` (`details.retry_after_secs` and a `Retry-After` header) | | 503 | `TRY_AGAIN` | `Please try again in a moment` (Turnstile or the limit store unavailable) | ## API Keys User API keys are personal bearer credentials with the `lm_usr_` prefix. Each key is bound to the current `(user_id, account_id)` and can only carry permissions the creator already has. The dashboard uses these endpoints for **Settings → Channel → Access → API Keys**; the full key is returned once from the create response and cannot be retrieved later. All paths live under `/v1/api-keys` and are gated on `feature:apikeys`. Bodies use snake_case. > **First-party session required (ZAF-1017 / ZAF-469):** personal API keys are a first-party surface - every path below requires a logged-in user session or a user's own API key. A popout/overlay/widget/extension token, whose `user_id` is bound at token-create time and could be the account owner, is rejected with `403 FORBIDDEN` on both REST and GraphQL. This stops a permission-capped member from listing/minting/renaming/revoking a key **bound to the owner** (an owner-bound `lm_usr_` key would otherwise launder past this same gate - e.g. `PATCH /v1/users/me` email change → owner takeover). | Method | Path | Permission | Description | |--------|------|------------|-------------| | `GET` | `/v1/api-keys/permissions?kind=personal\|service` | `apikeys:read` | List the scopes assignable to an API key of the given kind | | `GET` | `/v1/api-keys` | `apikeys:read` | List the caller's own user API keys for the active account | | `POST` | `/v1/api-keys` | `apikeys:create` | Create a user API key; returns the full `lm_usr_` key once. **Session-only** - an API key cannot mint keys | | `PATCH` | `/v1/api-keys/{id}` | `apikeys:edit` | Rename one of the caller's own keys (label only) | | `POST` | `/v1/api-keys/{id}/rotate` | `apikeys:edit` | Rotate one of the caller's own keys - regenerate the secret in place; returns the new full `lm_usr_` key once. **Session-only** - an API key cannot rotate keys | | `DELETE` | `/v1/api-keys/{id}` | `apikeys:delete` | Revoke one of the caller's own keys by hard-deleting it | ### Assignable scopes An API key may carry **any permission the account/user behind it can** - not the deliberately narrow popout-token list. `GET /v1/api-keys/permissions?kind=personal|service` returns the assignable-scope registry (key + label + category), derived from the RBAC registries minus a small deny-list; it is the source of truth for the picker (parity with GraphQL `apiKeyAssignablePermissions`). `kind` is required - an unknown value returns `400 VALIDATION_ERROR`. - **Personal keys** (`kind=personal`, `lm_usr_`) carry the **129** account permissions left after the four-key API-key deny-list plus the **10** user-scoped `ideas:*` permissions (**139** assignable scopes) - a personal key acts with its member's user identity, so `ideas:*` are reachable via the key (ZAF-1169). - **Service keys** (`kind=service`, `lm_svc_`) carry those **129** assignable account permissions only - an account-owned key has no user identity, so the user-scoped family is not offered. - **Deny-list (both kinds):** `apikeys:create`, `apikeys:edit`, `tokens:create` (credential minting - a key that mints credentials would outlive the human it belongs to) and `account:delete` (tenant destruction). These are never assignable. On create, each requested scope is validated against this registry **first** - an unknown or deny-listed string is rejected with `400 VALIDATION_ERROR` "Invalid permission: `{perm}`" (identical to role writes) - and then against the subset rule (a key can never exceed its creator; over-reach → `403 FORBIDDEN`). Wildcards (`resource:*`) are never offered in the picker but are accepted from a principal that legitimately holds them. Scopes are **immutable after creation** - a too-narrow key must be re-minted. **Create request:** ```json { "label": "CI/CD pipeline", "permissions": ["events:read", "overlays:read"], "expires_at": "2026-09-01T00:00:00Z" } ``` `label` is required. `expires_at` is optional; omit it or send `null` for a non-expiring key. Every key object (list, create, rename, rotate) carries `permissions`, the stored scope list, and `effective_permissions`, the stored scopes the owner's current role still grants. Only `effective_permissions` authorize requests; a scope missing from it is dormant and returns when the owner regains it. `/v1/tokens` objects carry the same pair, capped to the token's bound member. If the owner's permissions cannot be read, the request fails instead of reporting the stored list. Parity with GraphQL `UserApiKey.effectivePermissions` / `PopoutToken.effectivePermissions`. **Create response:** ```json { "data": { "key": "lm_usr_xxxxxxxxxxxx", "id": "uuid", "user_id": "uuid", "account_id": "uuid", "key_prefix": "lm_usr_ab", "label": "CI/CD pipeline", "permissions": ["events:read", "overlays:read"], "effective_permissions": ["events:read", "overlays:read"], "created_at": "2026-08-16T10:00:00Z", "expires_at": "2026-09-01T00:00:00Z", "last_used_at": null }, "_links": { "self": { "href": "/v1/api-keys/uuid" } } } ``` **Rotate.** `POST /v1/api-keys/{id}/rotate` (gated `apikeys:edit` + `feature:apikeys`) regenerates the secret of one of the caller's own keys in place - the key id, label and scopes are unchanged, the old secret stops working immediately, and the response returns the new full `lm_usr_` `key` **once** (same shape as the create response). It emits the `user:api_key_rotated` audit event (metadata `api_key_id`, `old_key_prefix`, `new_key_prefix`). Parity with GraphQL `rotateUserApiKey`. ## Service Keys Account-owned service keys are bearer credentials with the `lm_svc_` prefix, for CI/CD pipelines and long-lived integrations. Unlike personal `lm_usr_` API keys they are owned by the **account** and **keep authenticating after the member who created them leaves** - they carry no member identity. They share the `feature:apikeys` flag and the `apikeys:*` permission family with personal keys, and the create path enforces the same permission-subset guard (a key can only carry permissions the creator holds). The full key is returned once from the create response and cannot be retrieved later. All paths live under `/v1/service-keys`. Bodies use snake_case. Every endpoint is gated on `feature:apikeys`. Create and rotate mint a credential, so they additionally require an **interactive sign-in session** (a logged-in user only - a popout/overlay/widget/extension token, a personal `lm_usr_` key, a service `lm_svc_` key or a system key is rejected with `FORBIDDEN` "This action requires an interactive sign-in session"; ZAF-1742 rule, applied to service keys by ZAF-1740 C3). | Method | Path | Permission | Description | |--------|------|------------|-------------| | `GET` | `/v1/service-keys` | `apikeys:read` | List the account's service keys | | `POST` | `/v1/service-keys` | `apikeys:create` + interactive session | Create a service key; returns the full `lm_svc_` key once | | `PATCH` | `/v1/service-keys/{id}` | `apikeys:edit` | Rename a service key (label only) | | `POST` | `/v1/service-keys/{id}/rotate` | `apikeys:edit` + interactive session; caller must hold every scope on the key | Rotate a service key - regenerate the secret in place; returns the new full `lm_svc_` key once | | `DELETE` | `/v1/service-keys/{id}` | `apikeys:delete` | Revoke a service key by hard-deleting it | **Create request:** ```json { "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:** ```json { "data": { "key": "lm_svc_xxxxxxxxxxxx", "id": "uuid", "account_id": "uuid", "created_by": "uuid", "key_prefix": "lm_svc_ab", "label": "CI/CD deploy", "permissions": ["events:read", "overlays:read"], "created_at": "2026-09-04T10:00:00Z", "updated_at": "2026-09-04T10:00:00Z", "expires_at": null, "last_used_at": null }, "_links": { "self": { "href": "/v1/service-keys/uuid" } } } ``` `created_by` records the member who minted the key; it becomes `null` once that member is removed from the account (the key itself keeps working). Lifecycle changes emit the account-scoped audit events `account:service_key_created` / `account:service_key_updated` / `account:service_key_revoked` / `account:service_key_rotated`. Parity with GraphQL `accountServiceKeys` / `createAccountServiceKey` / `updateAccountServiceKey` / `deleteAccountServiceKey` / `rotateAccountServiceKey`. **Rotate.** `POST /v1/service-keys/{id}/rotate` (gated `apikeys:edit` + `feature:apikeys` + an **interactive sign-in session**, checked before the key is looked up) regenerates a service key's secret in place - id, label and scopes unchanged, the old secret invalidated immediately - and returns the new full `lm_svc_` `key` **once** (same shape as the create response). The caller must hold every scope on the key - rotating hands out a fresh secret with those scopes, so it is held to the same ceiling as create and refused with `403 FORBIDDEN` "Cannot grant permission '…'" otherwise (ZAF-1740 M5). It emits `account:service_key_rotated` (metadata `service_key_id`, `old_key_prefix`, `new_key_prefix`). Parity with GraphQL `rotateAccountServiceKey`. ## Developer API Keys Developer API keys (`lm_dev_` prefix) are the publishing credential for extension developers - the key `lumio deploy` and CI use instead of a personal `lm_usr_` key. Unlike service keys they are **not** owned by an account: a developer key is bound to a developer **profile** or a developer **team**, carries **no account** and **no member identity**, and (like a service key) keeps authenticating after the member who minted it leaves the team. A developer key can carry **only** the `extension-dev:*` permission family, and - unlike every other key - is **decoupled from the plan gate**: eligibility is approved-developer status, not the Pro plan, so it is **not** gated on `feature:apikeys`. The full key is returned once from the create response and cannot be retrieved later. All paths live under `/v1/developer/api-keys`. Bodies use snake_case. Management is **first-party only** - these endpoints require a logged-in user session, so a `lm_dev_` key can never mint or revoke another developer key. Minting (`POST`) is **session-only**: a user's own `lm_usr_` key is refused too (ZAF-1742, see [Authentication → Session-only Surfaces](./authentication.md#session-only-surfaces)). | Method | Path | Permission | Description | |--------|------|------------|-------------| | `GET` | `/v1/developer/api-keys` | Auth (developer) | List the caller's developer keys (own profile + member teams) | | `POST` | `/v1/developer/api-keys` | Auth (developer) + `team-secrets:edit` for a team key | Create a developer key; returns the full `lm_dev_` key once | | `PATCH` | `/v1/developer/api-keys/{id}` | Auth (developer) + `team-secrets:edit` for a team key | Rename a developer key (label only) | | `DELETE` | `/v1/developer/api-keys/{id}` | Auth (developer) + `team-secrets:edit` for a team key | Revoke (soft-delete) a developer key | **Create request:** ```json { "label": "CI deploy", "permissions": ["extension-dev:edit", "extension-dev:publish"], "team_id": null, "expires_at": null } ``` `label` and `permissions` are required; every permission must be in the `extension-dev:*` family and cannot exceed what the creator holds. `team_id` binds the key to a developer team you belong to (requires the team's `team-secrets:edit` right) - omit it or send `null` to bind the key to your own developer profile. `expires_at` is optional (RFC3339; `null` = never). **Rename / revoke authorization.** Both are scoped to the caller's developer identity: a key that does not exist or belongs to a profile or team the caller is not part of returns **404** `Not found: Developer key not found` (key existence is never disclosed). A **profile** key can be renamed or revoked by its owner. A **team** key additionally requires the caller's *current* developer-team right `team-secrets:edit` on that team - the same right that creating it requires. A member without it (e.g. the default **Member** role, which only holds `team-secrets:read`) can see the key but gets **403** `Missing team permission: team-secrets:edit` for both rename and revoke; there is no membership-only revoke - a member who suspects a leak asks a `team-secrets:edit` holder to revoke it. Re-revoking an already-revoked key is an authorized no-op `204`. Lifecycle changes emit the **user-scoped** audit events `user:developer_api_key_created` / `user:developer_api_key_updated` / `user:developer_api_key_revoked` (there is no owning account to hang an account-scoped event on). Parity with GraphQL `developerApiKeys` / `createDeveloperApiKey` / `updateDeveloperApiKey` / `revokeDeveloperApiKey`. ## Sounds All sound endpoints require the `feature:sounds` feature flag to be enabled for the account. ### `GET /v1/sounds` List sounds in the account's library. **Permission:** `sounds:read` **Query parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `limit` | integer | Page size (default 20, max 100) | | `offset` | integer | Pagination offset | | `search` | string | Filter by name (partial match) | **Response** - the list also carries the account's resolved sound limits and current usage: ```json { "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:** - `400` `BAD_REQUEST` / `VALIDATION_ERROR` - Unsupported file type or file exceeds size limit - `403` `PLAN_LIMIT_REACHED` - Account has reached the `max_sounds` or storage plan limit **Response:** 201 Created with the new sound object. ### `PATCH /v1/sounds/{id}` Update sound metadata. **Permission:** `sounds:edit` **Body:** all fields optional. | 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](./graphql.md#sounds). ## Widgets ### Instances #### `GET /v1/widgets` List the widget instances of the active account. **Permission:** `widgets:read` **Visibility:** per-widget grants apply. A widget on which the caller has the role `none` is left out; a widget without an entry for the caller, or with `viewer`/`editor`, is listed. A caller with `widgets:access-read` sees every widget of the account. Same rule as `GET /v1/overlays` and GraphQL `widgetInstances`. **Widget object:** `id`, `account_id`, `name`, `widget_type`, `extension_id`, `install_id`, `config`, `width`, `height`, `created_at`, `updated_at` and `extension`. `extension` is `null` for a built-in widget. For an extension widget it is the GraphQL [`WidgetExtensionRef`](./graphql.md#widgetextensionref) in snake_case: `id`, `slug`, `name`, `visibility`, `icon_key`, `developer_name`, `version`, `targets`, `config_schema`, `designer_schema`, `permissions`. `permissions` holds the manifest permissions of that version, `[]` when it declares none. Every endpoint that returns a widget (`GET /v1/widgets`, `GET /v1/widgets/{id}`, `POST /v1/widgets`, `PATCH /v1/widgets/{id}`, `POST /v1/widgets/{id}/duplicate`) uses this shape. #### `POST /v1/widgets` Create a widget instance with a one-time access token and OBS URL. **Permission:** `widgets:create` **Body:** `name`, `widget_type`, optional `install_id`, `extension_id`, `config`, `width`, `height`. **Extension binding:** `extension_id` is never stored as sent. With an `install_id`, the install must be a live install of the active account (not soft-uninstalled), and the widget takes that install's extension. The check runs in the same transaction as the insert. | Condition | Response | |-----------|----------| | `install_id` unknown, from another account, or soft-uninstalled | `404` `Extension install not found` | | `extension_id` differs from the install's extension, or `widget_type` names another extension (its slug, `ext:`, or a built-in type of another system extension) | `400` `Widget extension does not match the install` | | `extension_id` or an `ext:` `widget_type` without `install_id` | `400` `An extension widget requires an install_id` | | Plan widget limit reached | `403` `Widget limit reached (…). Upgrade your plan for more widgets.` | **Response:** 201 Created with the widget object, `access_token` and `obs_url`. ### Access Management #### `GET /v1/widgets/{id}/access` List access entries for a widget. **Permission:** `widgets:access-read` **Response:** List of access entries with `id`, `widget_id`, `user_id`, `user_name`, `user_avatar`, `role`, `granted_by`, and `created_at`. #### `GET /v1/widgets/{id}/access/candidates` List account members eligible for the widget access dialog. **Permission:** `widgets:access-read` **Response:** List of account members with `id` (the user ID), `display_name`, and `avatar_url`. #### `PUT /v1/widgets/{id}/access/{user_id}` Set a user's access role on a widget. **Permission:** `widgets:access-grant` **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `role` | string | Yes | `"viewer"`, `"editor"`, or `"none"`. Any other value is rejected with `400 BAD_REQUEST` (`Invalid role`). | **Response:** 200 with the updated access entry. #### `DELETE /v1/widgets/{id}/access/{user_id}` Remove a user's access entry from a widget. **Permission:** `widgets:access-revoke` **Response:** 204 No Content. ### Duplication #### `POST /v1/widgets/{id}/duplicate` Duplicate a widget instance. Creates a copy of the widget with its configuration and returns the new widget with an access token. **Permission:** `widgets:create`, plus **Editor** access on the source widget (a copy carries the full configuration). `widgets:access-grant` bypasses the per-widget check; `widgets:access-read` does not. A source the caller cannot edit answers `404` `Widget not found`, the same as a missing one. **Limits:** the plan widget limit is checked inside the insert, so concurrent creates and duplicates cannot exceed it (`403` `Widget limit reached (…). Upgrade your plan for more widgets.`). A source bound to an extension install that is no longer live is not copied (`404` `Extension install not found`). **Response:** 201 Created with the new widget object including `access_token`. > **Protocol parity:** All endpoints have matching GraphQL queries/mutations - see [GraphQL](./graphql.md#widgets). ## Setting Presets Named snapshots of chat/events/music display settings, keyed to the pair `(user, account)` (ZAF-622 §52c; see [Setting Presets](../features/setting-presets.md)). All endpoints require the `feature:setting_presets` feature flag. Create/edit/delete are scoped to *(the authenticated user, their active account)* with no `resource:action` permission and fail-closed on membership; a preset owned by another user, living in another account, or in an account you have left is never visible or mutable (it reads as `404`). The **one** RBAC gate is `presets:share` on the share/un-share endpoints. **Popout access:** the read endpoints (`GET`) accept a **popout token** so the OBS-source popout can list and load presets. The write endpoints (`POST`/`PATCH`/`DELETE`, incl. share/un-share) require a **first-party user session** (a logged-in user or the user's own API key) - a popout/overlay/widget/extension token is rejected with `403 FORBIDDEN`. This is deliberate: a popout token authenticates as the member it is bound to, not as the person holding it, so allowing writes there would let whoever holds the token act as that member (ZAF-450/469). **Account sharing (ZAF-622 §19):** a user can share one of their own presets with the members of their active account. `GET /v1/setting-presets` for a **first-party** caller then also returns presets shared into that account by a still-member owner - each such foreign row is marked `shared: true` with `owner_display_name`. Over a **popout token the list stays restricted to your own presets** (no shared branch). Sharing is a flag on the existing row (no copy) and costs no quota; loading a shared preset writes nothing server-side. See the `.../share` endpoints below. The quota is one number spanning all kinds combined, resolved **per account** from that account's own plan (`plans.max_setting_presets`, overridable per account via `account_limits.max_setting_presets`; `0 = unlimited`) - a full account never blocks a different one. The effective limit is also readable without creating a preset - as `maxSettingPresets` on the GraphQL `accountLimits` query and its REST twin `GET /v1/billing/account-limits` (both gated on `plan:read`) - so a UI can render "N of M used". ### `GET /v1/setting-presets` List the current user's presets. **Query parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `kind` | string | Optional filter: `chat` \| `events` \| `music` | **Response:** `data` is an array of preset objects (`id`, `user_id`, `kind`, `name`, `payload`, `is_default`, `shared_account_id`, `shared`, `owner_display_name`, `created_at`, `updated_at`). Own rows sort first (default-first, then most-recently-changed) with `shared: false`; foreign rows shared into your active account follow, with `shared: true` + `owner_display_name`. A popout token sees only own rows. ### `GET /v1/setting-presets/quota` Your own preset quota in the active account. Per-account (no `resource:action` permission, popout-token readable) - deliberately **not** `plan:read`, so every member sees their limit. **Response:** `data` is `{ used, limit }` - `used` counts your own presets in the active account across all kinds (foreign rows shared into your account never count), `limit` is the account's resolved cap with `0` meaning unlimited. Both come from the functions the create path gates on, so this read and the create rejection can never disagree. ### `GET /v1/setting-presets/{id}` Get a single preset by ID. Returns `404` if it does not exist or belongs to another user. ### `POST /v1/setting-presets` Create a preset. **First-party session only.** **Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `kind` | string | Yes | `chat` \| `events` \| `music` | | `name` | string | Yes | Display name (non-empty, ≤ 255 chars). Duplicate names are allowed. | | `payload` | object | Yes | The settings snapshot (the ZAF-622 export format, `{ "format": 1, ... }`). Opaque to the backend. | | `is_default` | boolean | No | Mark as this kind's default (unsets the previous default of the same kind) | **Errors:** - `400` `BAD_REQUEST` - invalid `kind`, empty name, or non-object/oversized payload - `403` `FORBIDDEN` - wrong token class (not a first-party session) / feature disabled - `409` `PLAN_LIMIT_REACHED` - the user is at their preset quota **Response:** the created preset object. ### `PATCH /v1/setting-presets/{id}` Rename and/or overwrite a preset (same route serves both; moves `updated_at`). **First-party session only.** **Body:** at least one of: | Field | Type | Description | |-------|------|-------------| | `name` | string | New name (rename) | | `payload` | object | New settings snapshot (overwrite) | **Response:** the updated preset object; `404` if not found or foreign. ### `POST /v1/setting-presets/{id}/default` Mark the preset as its kind's default (unsets the previous default of that kind). **First-party session only.** **Response:** the updated preset object; `404` if not found or foreign. ### `DELETE /v1/setting-presets/{id}` Hard-delete a preset (no trash). **First-party session only.** **Response:** `200 { "success": true }`; `404` if not found or foreign. ### `POST /v1/setting-presets/{id}/share` Share one of your own presets with the members of your **active account**. **First-party session + `presets:share`.** The target account is always the caller's active account; a second share re-points the same row (no copy) and costs no quota. Emits `account:setting_preset_shared`. **Errors:** - `403` `FORBIDDEN` - `Missing permission: presets:share`, wrong token class, or feature disabled - `404` `NOT_FOUND` - `Setting preset not found` (missing or not owned by the caller) **Response:** the updated preset object (`shared_account_id` set). ### `DELETE /v1/setting-presets/{id}/share` Un-share one of your own presets (make it private again). **First-party session + `presets:share`.** Idempotent - un-sharing an already-private preset succeeds and emits no event; otherwise emits `account:setting_preset_unshared` keyed to the account it was shared into. **Response:** the updated preset object (`shared_account_id` cleared); `404` if not found or foreign. > **Protocol parity:** All endpoints have matching GraphQL queries/mutations - see [GraphQL](./graphql.md#setting-presets). ## Automation Extension Nodes ### `GET /v1/automation/extension-nodes` List all installed automation node extensions for the current account. **Permission:** `automations:read` **Response:** ```json { "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:** ```json { "data": { "webhook_url": "https://api.lumio.vision/v1/automation/webhooks/{extension_id}/{install_id}", "webhook_secret": "64-char-hex-string" } } ``` ### `POST /v1/automation/webhooks/{extension_id}/{install_id}` Receive an external webhook for a trigger node. The external service must include the `X-Webhook-Secret` header matching the stored secret. **Auth:** `X-Webhook-Secret` header (not JWT). **Request body:** Arbitrary JSON payload. Forwarded to the extension handler as `ctx.webhookBody`. **Response:** 200 on success, 401 if the secret is invalid, 404 if the extension/install is not found. > **Protocol parity:** `installedAutomationNodes` query and `automationWebhookUrl` query in GraphQL -- see [GraphQL](./graphql.md#automation-extension-nodes). ## Extension Actions ### `POST /v1/extension-installs/{install_id}/actions/{action}` Run an action on behalf of an extension install - the backend of `useLumioAction()`. Body: `{ "params": { ... } }`. `200` carries `{ "success": true, "data": { ... } }`, or `{ "success": false, "error": "..." }` when the action itself fails. `400` for `Unknown action: …` / `Invalid params: …`, `401` `Authentication required`, `403` for every token, permission or manifest denial, `404` `Extension install not found`. Messages are character-for-character those of GraphQL. **Auth** (identical on both protocols, one shared resolver): | Credential | May act on | |------------|-----------| | Extension token (`lm_ext_*`) | Only its own install; any other id → `Extension token install_id does not match the requested install` | | User session / personal API key | A live install of the caller's account, with `extension-store:configure` (`Missing permission: extension-store:configure`; another account's install → `Extension install belongs to a different account`; unknown or uninstalled → `Extension install not found`) | | Overlay token (`lm_overlay_*`) | Only a live install of the token's account that an extension layer (`ext:{slug}`) of **that** overlay renders; anything else → `Extension install belongs to a different overlay` | | Widget token (`lm_widget_*`) | Only the widget's own install; anything else → `Extension install belongs to a different widget` | | Shared overlay link (`lm_share_*`), popout token, anything else | Never → `Authentication required` | Overlay and widget tokens never run a `chat:*` action (`chat:send`, `chat:delete`, `chat:ban`, … and any future `chat:` action): their links are permanent and leak through stream captures and shared screenshots, and a chat action runs as the account's bot. Such a call is refused with `403` before the install is looked up: `Permission denied: chat actions require a signed-in session, not an overlay or widget token`. Spotify, OBS and the other actions stay available to them. Chat actions need a user session, an API key or the extension token. The extension identity is always read from the token or the install row, never from the request. The install's manifest must then declare the permission the action needs (`Permission denied: Extension does not declare the required permission: …`). Feature: `feature:extensions`. Actions run on behalf of the install. Only the chat moderation actions `chat:delete`, `chat:ban`, `chat:unban` and `chat:timeout` emit an audit event (`account:extension_moderation`, see [Audit Log](../features/audit-log.md)); every other action emits none. > **Protocol parity:** GraphQL `executeExtensionAction` - see [GraphQL](./graphql.md#extension-actions). ## Shopify The account connection routes need `integration:shopify` plus the listed permission; hiding a stored order name needs `events:edit` without the plan flag. GraphQL twins in [GraphQL → Shopify](./graphql.md#shopify) use the same guards, fields and messages. Link flow, errors and storage: [Shopify](/features/shopify). | Method | Path | Permission | Notes | |--------|------|-----------|-------| | `GET` | `/v1/connections/shopify` | `connections:read` | `{ configured, app_store_url, connection }`; `connection` is `null` or `{ shop_host, shop_name, status ("connected" \| "needs_reconnect"), customer_display, show_items, show_price, scopes, enabled, connected_at }`. Never a token | | `POST` | `/v1/connections/shopify/link/inspect` | `connections:create`, interactive session | Body `{ "handle": "" }`; returns `{ shop_host, state ("pending" \| "confirmed") }`, the store a link request comes from. `409` when another user already confirmed it: the request is then deleted and must be started again in Shopify; `422` when the request has expired | | `POST` | `/v1/connections/shopify/link/confirm` | `connections:create`, interactive session | Body `{ "handle": "" }`; binds the request to the active account (`{ shop_host, state: "confirmed" }`). Refuses a browser request whose `Sec-Fetch-Site` is not `same-origin` (`403`). `409` when the store belongs to another account, the account holds another store, the request is being completed, or it was confirmed for another account (it is then deleted and must be started again in Shopify); `422` when expired; `429` after 10 per user in 10 min | | `PUT` | `/v1/connections/shopify/settings` | `connections:edit` | Body `{ "customer_display"?: "none" \| "first_name" \| "first_initial" \| "first_name_last_initial" \| "custom_name", "show_items"?: bool, "show_price"?: bool }`, at least one field; returns the connection. `400` for another form or an empty body, `404` when not connected. Each changed value emits `account:shopify_settings_updated` | | `POST` | `/v1/connections/shopify/events/{event_id}/hide-name` | `events:edit` | Body `{ "created_at": "" }` (the event's timestamp, ±1 s); removes `raw.customer_display` from the `shopify:order` / `shopify:order_paid` event and from the other event of the same order. Returns `{ hidden }` (`false` = nothing left to hide). `404` when it is not a Shopify order event of the account, `400` for an unreadable `created_at`. Not gated on `integration:shopify`. A hide emits `account:shopify_order_name_hidden` | | `DELETE` | `/v1/connections/shopify` | `connections:delete` | `204`; clears the storefront metafields (best effort), deletes the row and writes the `app/uninstalled` tombstone. Shopify keeps sending order webhooks while the app is installed; they are dropped. `404` when not connected | `customer_display` defaults to `none`; `show_items` defaults to `true` and `show_price` to `false`. These settings affect orders received after the update. When items are off, order event `raw` has no `line_items`; when price is off, it has no `total_price`, `currency` or item `price`. Absent details do not reach automations or widgets. The handle travels in the request body, never in a URL. ### Embedded app (`/v1/shopify/embedded/*`) The endpoints the embedded Shopify app (inside the Shopify admin) calls through its own server. **Auth: Shopify ID token** — the App Bridge session token as `Authorization: Bearer ` (OpenAPI scheme `shopify_id_token`). HS256 with the app's client secret (or `client_secret_previous` during a rotation), `aud` = the client id, `exp`/`nbf` checked with 5 s leeway, `iss`/`dest` must name the same `*.myshopify.com` store, and `sub` (the staff member) is required. A Lumio token (`lm_*`, JWT or API key) is refused here, and an ID token is refused everywhere else. The store comes **only** from the token's `dest`; no route takes a store or an account id. A linked store acts on its own account's Shopify connection and nothing else, with no Lumio permission check (any staff member with app access). Every refused token, and a token exchange Shopify answers with `400`, is `401` with `X-Shopify-Retry-Invalid-Session-Request: 1` and the body `invalid session`, so App Bridge fetches a fresh token and retries once. **REST-only:** the caller is not a Lumio principal, so there is no GraphQL twin. | Method | Path | Notes | |--------|------|-------| | `GET` | `/v1/shopify/embedded/status` | `{ linked, account_name, connection }`; `account_name` is the linked account's display name (the only account data), `connection` as above or `null`. Also renews a dead token by token exchange and re-pushes lagging storefront metafields | | `POST` | `/v1/shopify/embedded/link` | Starts a link: `{ handle, url, expires_in: 600 }`; `url` is the Lumio page with the handle in its fragment (`…/connections/shopify#link=`). A new request replaces the store's open one. `409` when the store is already linked; `429` after 5 per store in 10 min | | `GET` | `/v1/shopify/embedded/link/{handle}` | `{ state ("pending" \| "confirmed" \| "completing"), account_name, confirmed_by }` — once confirmed, the Lumio account and user. Reads only. `422` when expired or not this store's | | `DELETE` | `/v1/shopify/embedded/link/{handle}` | `204`; cancels the request (also when it no longer exists). `409` while it is being completed | | `POST` | `/v1/shopify/embedded/link/complete` | Body `{ "handle" }`; completes a confirmed request with the token exchange and returns the connection. Only the staff member who started it (`sub`). `409` when not confirmed yet, being completed, or the store/account is taken; `403` for another staff member or a plan without Shopify; `422` when expired; `429` after 10 per store per minute. A failure leaves the request confirmed, so a retry works. Emits `account:integration_connected` and notifies the owner and the confirming user | | `PUT` | `/v1/shopify/embedded/settings` | Body as `PUT /v1/connections/shopify/settings`, same validation and messages; `404` when not linked. Emits `account:shopify_settings_updated` (`source: shopify_admin`) per changed value and sends the account owner an in-app notice | | `POST` | `/v1/shopify/embedded/disconnect` | `204`; as `DELETE /v1/connections/shopify`, audit `reason: shopify_admin`, in-app notice to the account owner. `404` when not linked | Reads and writes of the embedded routes are limited to 120 per store per minute. Shopify's mandatory compliance webhooks (`customers/data_request`, `customers/redact`, `shop/redact`) arrive at the public `POST /v1/webhooks/shopify/compliance` (signed with the app secret; `401` on a bad `hmac`, `200` once queued). They have no GraphQL twin: Shopify calls them, not a client. See [Webhooks → Shopify compliance topics](/features/webhooks#shopify-compliance-topics). ## Discord Bot Connections `GET /v1/bot-connections/discord/authorize` returns a Discord invite URL for the one global Lumio bot. It requires an interactive session, the active account's `bot-connections:create` permission, and `feature:bots`. The invite uses scopes `bot applications.commands identify`, `integration_type=0`, and targeted permissions. The browser returns to `/api/connections/oauth/discord/callback`; the unified exchange verifies the code and uses the server ID in Discord's token response, not an untrusted browser parameter. `POST /v1/bot-connections/discord/exchange` rejects Discord because that legacy exchange handles account-source bot OAuth only. `GET /v1/discord-guilds` requires `bot-connections:read` and lists the active account's servers. Each row includes `bot_removed` and `missing_permissions` booleans. `DELETE /v1/discord-guilds/{guild_id}` requires `bot-connections:delete`, removes that account's server mapping and settings, and returns `{"success":true}` when a row was deleted. The GraphQL twins use the same guards for authorize, list and disconnect. See [Bot Connections](/features/bot-connections#discord-servers). ## Login Assignments Login assignments link a user's assignable login connection (e.g. their Twitch login) to a specific Lumio account. A single user can own multiple accounts; login assignments control which platform identity is associated with which account. Discord is sign-in-only and cannot be assigned. > **Own assignments are always allowed without permissions.** The `login-assignments:*` permissions only apply when managing another user's assignments. > **Which account the write targets.** The create and delete endpoints take an optional `account_id` and fall back to the caller's **active** account when it is omitted — so a user-centric surface such as the Profile page must always send the account the user picked, never rely on the account switcher. A supplied `account_id` must be an account the caller belongs to: acting on one's **own** login connection, any account in the caller's membership list is accepted; acting on **another user's** connection (`user_id` supplied) the target is pinned to the active account, because `login-assignments:*` is resolved against the active account's roles. Anything else is rejected with `403` and `You do not have access to the target account`. ### `GET /v1/accounts/login-assignments` Returns all login assignments for the caller's active account. **Permission:** `login-assignments:read` (or own account - no permission required for the account owner). **Response:** ```json { "data": [ { "provider": "twitch", "login_connection_id": "00000000-0000-0000-0000-000000000001", "user_id": "00000000-0000-0000-0000-000000000002", "assigned_at": "2026-01-15T10:00:00Z" } ] } ``` ### `POST /v1/accounts/login-assignments` Assign a login connection to an account. **Permission:** `login-assignments:create` (or own account). **Request body:** | Field | Type | Required | Notes | |-------|------|----------|-------| | `login_connection_id` | UUID | Yes | ID of the login connection to assign | | `provider` | string | Yes | Platform slug, e.g. `"twitch"`, `"google"` | | `user_id` | UUID | No | Defaults to the authenticated user | | `account_id` | UUID | No | Target account. Defaults to the active account | **Response:** 200 with the created or updated assignment object. **Audit:** emits `account:login_connection_assigned` (Account scope) with `provider`, `target_user_id` and `connection_id`. **Discord:** if either the requested provider or the stored connection's provider is Discord, the API rejects the assignment with `400` and `This sign-in method is login-only and cannot be assigned to a channel`. GraphQL `assignLoginConnection` uses the same check and message. ### `DELETE /v1/accounts/login-assignments/:provider` Remove the login assignment for a given provider from an account. **Permission:** `login-assignments:delete` (or own account). **Path parameter:** `:provider` - platform slug (e.g. `twitch`, `google`). **Query parameters:** `user_id` (UUID, defaults to the caller), `account_id` (UUID, defaults to the active account). **Response:** 200 with `{"data":{"success":true}}`. **Audit:** emits `account:login_connection_removed` (Account scope) when a row was actually removed. ### `DELETE /v1/login-connections/:id` Delete a login connection by its UUID. The connection must belong to the authenticated user. **Permission:** None (own connections only). **Path parameter:** `:id` - UUID of the login connection. **Step-up:** after the "last sign-in method" check, the calling session needs a step-up in the last 10 minutes; otherwise `403` with code `STEP_UP_REQUIRED` ("Please confirm that it's you first") and nothing is deleted. Same code and message as GraphQL `disconnectLoginConnection`. `POST /v1/auth/link` (a new link, not a reconnect of the same connection), `POST /v1/users/me/mfa/setup` and `POST /v1/users/me/mfa/enable` need the same step-up. See [Authentication → Step-up before sign-in changes](../features/auth.md#step-up-before-sign-in-changes). **Response:** 204 No Content. > **Session-only (ZAF-1742):** the create, delete and login-connection delete endpoints require an interactive sign-in session - a user's own API key is refused with `403 This action requires an interactive sign-in session`. The two list endpoints keep the first-party gate. See [Authentication → Session-only Surfaces](./authentication.md#session-only-surfaces). > **Protocol parity:** All four endpoints have matching GraphQL operations - see [GraphQL](./graphql.md#login-assignments). ## Feature Flags & Status ### `GET /users/me` Returns the authenticated user's profile, account memberships, permissions, feature statuses, login connections, and preferences. The response includes `streamer_mode` (boolean) - the user's Streamer Mode preference. Use `PATCH /users/me` with `{ "streamer_mode": true }` to toggle it. The `UserResponse` also includes `admin_permissions` (admin-scope permission strings), `admin_mfa_required` (boolean - `true` when the user holds admin grants but has no 2FA factor, so admin endpoints answer `403 ADMIN_MFA_REQUIRED`; GraphQL twin `MeResult.adminMfaRequired`), `user_permissions` (user-scoped, cross-account non-admin permission strings - e.g. Ideas Hub `ideas:moderate_*`; the exact set the backend enforces via `AuthContext::require_user_permission`, and the GraphQL twin of `MeResult.userPermissions`), `enabled_features` (list of enabled feature keys for the active account), `login_connections` (OAuth login identities linked to the user, filtered by enabled providers), `is_developer` (boolean - `true` when the user has a developer profile or the `extension_dev_mode` admin override), and `extension_dev_mode` (boolean - when `true`, extension bundle serving loads the latest draft/testing version instead of the published version for extensions the user develops). The `UserResponse` also carries the user-global **display preferences** (all `string`, stored on `users`): | Field | Values | Meaning | |-------|--------|---------| | `locale` | `auto` · `en` · `de` | UI language; `auto` uses the request-time fallback (`locale` cookie if valid, otherwise default language) | | `timezone` | `auto` or an IANA name (`Europe/Berlin`, …) | `auto` uses the browser-detected zone mirrored into `lumio-tz`; server render falls back to `UTC` until that mirror exists | | `date_format` | `auto` · `dmy_dot` · `dmy_slash` · `mdy_slash` · `ymd_dash` | Numeric date format: automatic language-locale format, `31.12.2026`, `31/12/2026`, `12/31/2026`, or `2026-12-31` | | `time_format` | `auto` · `h12` · `h24` | Numeric time format: automatic format-locale hour cycle, `11:30 PM`, or `23:30` | These are the user's own preferences, applied across the webapp and every account the user can access. Set them with `PATCH /users/me`; an invalid value is rejected with `400`, never coerced back to `auto`. Changing them emits **no audit event** (a display preference is neither security- nor lifecycle-relevant). It also carries the user-global **seasonal-look preferences** (the seasonal dashboard look, e.g. Halloween). They live on the profile so they follow the user to every device and into popouts (also inside OBS): | Field | Type | Default | Values | |-------|------|---------|--------| | `season_look_enabled` | `bool` | `true` | One switch for every season | | `season_strength` | `string` | `full` | `full` · `subtle` | | `season_motion` | `string` | `auto` | `auto` (animations play unless the OS asks for reduced motion) · `off` | | `season_creatures` | `object` | `{}` | `{ "": bool }`; an absent key means the creature's registry default (spider off, bat on). Keys `[a-z_]{1,32}`, at most 32 entries | | `season_banner_dismissed` | `string[]` | `[]` | Dismissed season banners as `:` (season id `[a-z0-9-]{1,32}`, the year the season window starts in), oldest first | GraphQL parity: `me { seasonLookEnabled seasonStrength seasonMotion seasonCreatures seasonBannerDismissed }`. No audit event on change (a display preference). And the user-global **"Animations in Lumio"** preference, also read by popouts via [`GET /v1/auth/popout/session`](#get-v1authpopoutsession): | Field | Type | Default | Values | |-------|------|---------|--------| | `motion_preference` | `string` | `system` | `system` (full motion unless the OS asks for reduced motion) · `reduced` · `off` | There is no "always on" (the OS reduced-motion request is never overridden); it is independent of `season_motion`, and the stricter of the two applies to the seasonal decoration. GraphQL parity: `me { motionPreference }`. No audit event on change (a display preference). The `feature_statuses` field is a merged list of account-scope feature statuses **and** the user-scope `system:account_creation` status. ```json { "data": { "id": "...", "feature_statuses": [ { "key": "feature:bots", "enabled": true, "reason": null }, { "key": "feature:music", "enabled": false, "reason": "plan_locked" }, { "key": "system:account_creation", "enabled": true, "reason": null } ], ... } } ``` The `reason` field is one of: `"global_off"`, `"plan_locked"`, `"account_override"`, `"user_override"`, or `null` (when enabled). ### `PATCH /users/me` Updates the authenticated user's profile, active account, or preferences. All fields are optional - at least one must be provided. > **First-party session required (ZAF-450 S1 / ZAF-469):** this endpoint - and the whole first-party user surface keyed on the acting user's own identity (`GET /users/me`, the session endpoints under `/users/me/sessions`, login-connection and login-assignment management, `PUT /users/me/name-source-connection`, the `/notifications` endpoints, `POST`/`GET /developer/application`, `POST`/`GET /developer/verification`, account create/dissolve/leave, invite accept (`POST /invites/{code}/accept`, ZAF-1740 A6), and the JWT-minting `POST /auth/ws-token`) - apply the first-party gate. It admits a logged-in user session or the user's own API key, except for the identity-changing operations and `POST /auth/ws-token` listed as session-only below. A popout/overlay/widget/extension token, whose `user_id` is bound at token-create time and could be set to the account owner, is rejected with `403 FORBIDDEN` on both REST and GraphQL. This closes the profile-edit (email → password-reset) account-takeover path, the owner-bound-JWT minting path, and the broader authorization/disclosure gap. The same first-party bar extends to the **extension-developer REST surface** keyed on the developer identity (ZAF-471): the `developer/profile`, `developer/revenue`, `developer/payout-settings`, `developer/payout-history`, `developer/limits*` reads, the `developer/payouts` (update) / `developer/payouts/request` (request) writes, extension create/submit/delete and tester invites, the developer-team endpoints, and extension-access grants/invites - all reject a popout/overlay/widget/extension token with `403 FORBIDDEN`. The payout writes require an interactive session (ZAF-1750); ownership-scoped extension operations, including the extension secret store, may also admit developer keys under their separate guard. > **What happens to a requested payout (ZAF-1257):** the payout is `pending` and the balance is debited. The payout cycle sends Stripe and PayPal payouts within about 15 minutes (Stripe `completed`, PayPal `processing` until its webhook); SEPA payouts and payouts with an unknown provider answer are settled by an operator. Every request emits `developer:payout_requested` (user scope) on both protocols. See [Developer Payouts](../features/developer-payouts.md). > **Payouts need a verified developer (ZAF-2804):** `POST /developer/payouts/request` answers `403 DEVELOPER_VERIFICATION_REQUIRED` ("Payouts require a verified developer identity. Complete the verification in the developer settings first") until the developer verification is `verified`; the automatic payout cycle skips unverified developers. GraphQL twin: `requestPayout`, same code and text. > **Closing payout (ZAF-2825):** `POST /developer/payouts/request` takes an optional JSON body `{ "closing": true }`. It pays out the whole balance even below the method's minimum payout amount, but only while the user has a profile deletion scheduled (grace period running, or held by the deletion job); otherwise `409 CLOSING_PAYOUT_REQUIRES_DELETION` ("A closing payout below the minimum payout amount is only possible while a profile deletion is scheduled"). A balance of 0 still fails with the below-minimum `400`. Payout method, verified identity and the interactive session stay required. No body means a regular payout. GraphQL twin: `requestPayout(closing: true)`, same code and text. > **Every payout carries a self-billed credit note (ZAF-2807):** `POST /developer/payouts/request` issues the payout's credit note (Gutschrift) in the same transaction. The payout `amount` is the note's gross amount: for a German developer who charges VAT, 19 % VAT is added on top of the balance (the balance is debited by the net amount). It answers `422 TAX_DETAILS_REQUIRED` while the tax details of the verification are incomplete (`Tax details required: enter your tax number (Steuernummer) or VAT ID`, `… state whether you are a small business under section 19 UStG`, `… enter your VAT ID`) and `503 CREDIT_NOTE_ISSUER_NOT_CONFIGURED` while `[billing.issuer]` is incomplete; nothing is booked in either case. See [Developer Credit Notes](#developer-credit-notes). GraphQL twin: `requestPayout`, same codes and texts. > **Money-moving writes are session-only (ZAF-1750):** `POST /developer/stripe-connect/onboard` (creates the Stripe Connect account and so sets the payout destination), `POST /developer/payouts` (payout settings), `POST /developer/payouts/request`, and the billing `POST /billing/checkout`, `POST /billing/portal` and `POST /billing/cancel` require an **interactive sign-in session**. A user's own API key is refused with `403 This action requires an interactive sign-in session`, whatever its scopes, before any row is written or Stripe is called; every token type is refused the same way. The reads (`GET /developer/stripe-connect/status`, `developer/payout-settings`, `developer/payout-history`, `developer/revenue`, billing status and invoices) keep the first-party gate, so a scripted key can still read them. GraphQL twins identical. See [Authentication → Session-only Surfaces](./authentication.md#session-only-surfaces). > **`email` is not settable:** the profile email is set by the email sign-in only. A request with `email` is refused for every caller with `400 EMAIL_MANAGED_BY_LOGIN` (`Set your e-mail under Security › Sign-in › E-mail & password`) before anything is written (GraphQL `updateMe` identical). With an email sign-in the profile email is always the confirmed sign-in address (`email_source: "email_login"`); without one it is a platform sign-in's address (`platform:`, filled only when empty), otherwise `null`. Removing the email sign-in brings back the platform address it replaced, or leaves none. > **Session-only fields (ZAF-1922):** the account switch / clear (`active_account_id`, `clear_active_account`) is session-only too (ZAF-1922): both re-mint the session JWT, and a JWT is only minted for a caller that already holds a session - a user API key or a session-less JWT gets the same `403`. Every other field keeps the first-party gate above. Session-only as a whole: `POST /auth/link` (linking / reconnecting a login provider), the login-assignment create/delete, `DELETE /login-connections/{id}`, `PUT /users/me/name-source-connection`, `PATCH /users/me/display-name`, `POST`/`DELETE /users/me/avatar` (they rearrange, rename or remove the identities you log in with), the channel name/picture writes `PUT /accounts/{id}/identity-source`, `PATCH /accounts/{id}/name`, `POST`/`DELETE /accounts/{id}/avatar` (see [Channel name and picture](#channel-name-and-picture)) and `POST /auth/ws-token` (the minted JWT carries the full user permission set). See [Authentication → Session-only Surfaces](./authentication.md#session-only-surfaces). | Field | Type | Description | |-------|------|-------------| | `email` | `string` | Deprecated. Always refused with `400 EMAIL_MANAGED_BY_LOGIN` | | `active_account_id` | `UUID` | Switch active account (must be a member; interactive sign-in session only) | | `clear_active_account` | `bool` | Clear active account (go to user-only mode; interactive sign-in session only) | | `streamer_mode` | `bool` | Toggle Streamer Mode on/off | | `extension_dev_mode` | `bool` | Toggle Extension Developer Mode - loads draft/testing versions in editors | | `locale` | `string` | UI language: `auto` · `en` · `de` | | `timezone` | `string` | `auto` or a valid IANA timezone name | | `date_format` | `string` | `auto` · `dmy_dot` · `dmy_slash` · `mdy_slash` · `ymd_dash` | | `time_format` | `string` | `auto` · `h12` · `h24` | | `display_currency` | `string` | `auto`, EUR, or an ECB reference currency; `auto` resolves to EUR for German and USD otherwise | | `season_look_enabled` | `bool` | Seasonal look on/off | | `season_strength` | `string` | `full` · `subtle` | | `season_motion` | `string` | `auto` · `off` | | `season_creatures` | `object` | Replaces the creature map: `{ "": bool }`, keys `[a-z_]{1,32}`, at most 32 entries; `{}` resets every creature to its default | | `season_banner_dismissed` | `string[]` | Replaces the dismissed-banner list: `:` entries; duplicates are dropped and beyond 50 the oldest entries are dropped | | `motion_preference` | `string` | "Animations in Lumio": `system` · `reduced` · `off` | An invalid `locale`, `timezone`, `date_format`, or `time_format` is rejected with `400` (fail-closed, identical message on GraphQL and REST) - never silently coerced to `auto`. The same holds for the seasonal fields: an unknown `season_strength` / `season_motion`, a non-object or non-boolean `season_creatures`, a bad creature key, more than 32 creatures, or a malformed `season_banner_dismissed` entry returns `400` with the message the GraphQL twin returns (e.g. `Invalid season_strength 'loud': allowed values are full, subtle`). An unknown `motion_preference` likewise returns `400` with the GraphQL twin's message (`Invalid motion_preference 'auto': allowed values are system, reduced, off`). `display_name` is **no longer accepted** here (the field was removed): the Lumio display name + avatar follow the name source. Change the source via [`PUT /users/me/name-source-connection`](#put-usersmename-source-connection); with own details, set the name via [`PATCH /users/me/display-name`](#patch-usersmedisplay-name). `GET /users/me` exposes the current source as `name_source_kind` + `name_source_connection_id`. Response: full `UserResponse` (same shape as `GET /users/me`), including `display_currency` and resolved `effective_display_currency`. When switching accounts, a `token` field with a fresh JWT bound to the caller's session is included; a non-session caller never receives a `token`. #### Stale-membership filtering on `active_account_id` If the JWT carries an `accountId` the user is no longer a member of (e.g. the account owner removed them after the token was issued), the resolver returns `active_account_id: null` rather than the stale claim. Permissions are computed against the corrected scope. The dashboard shell reads this signal and routes the user to onboarding instead of rendering an account context they can no longer access. The REST `DELETE /v1/accounts/{id}/members/{membership_id}` (admin kick) and `POST /v1/accounts/{id}/leave` (self-leave) endpoints invalidate the Redis permission cache for the removed user, matching the existing GraphQL `removeMember` mutation behaviour - two-protocol parity for cache invalidation. ### `PUT /users/me/name-source-connection` Chooses where the current user's Lumio display name + avatar come from (`name_source_kind` on `GET /users/me`): - **`connection`** - mirrored on every login from a single *name-source* login connection, so a login via a secondary platform never rewrites them. - **`own`** - the user's own details. Sign-ins never overwrite them. Set the name with [`PATCH /users/me/display-name`](#patch-usersmedisplay-name) and the picture with [`POST /users/me/avatar`](#post-usersmeavatar). Session-only (ZAF-469 / ZAF-1742): rejected with `403` for an API key or a popout/overlay/widget/extension token. GraphQL twin: `setNameSourceConnection(loginConnectionId, kind)`. | Field | Type | Description | |-------|------|-------------| | `kind` | `string` / absent | `connection` (default) or `own`. Anything else returns `400 INVALID_NAME_SOURCE`. | | `login_connection_id` | `UUID` / `null` | With `connection`: the connection to source from. `null` clears the pin, so resolution falls back to the first-ever-connected login (`created_at` ascending). Ignored with `own`. | With `connection`, the connection must belong to the caller; a foreign or unknown id returns `400` (same message as the GraphQL twin). A profile without any platform sign-in returns `409 NO_LOGIN_CONNECTION`. The name/avatar are re-materialized immediately, and an uploaded own picture is deleted from storage. With `own`, the current name and picture stay as the starting values. Response (also returned by the display-name and avatar endpoints below): ```json { "data": { "display_name": "…", "avatar_url": "…", "name_source_connection_id": "…", "name_source_kind": "connection" } } ``` Audit: switching between `own` and `connection` writes `user:profile_identity_changed` (`changed: ["source"]`, plus `"avatar"` when an uploaded picture was deleted, and `source`). Choosing among one's own connections is not audited (a display preference). ### `PATCH /users/me/display-name` Sets the own display name. Only while `name_source_kind` is `own`, otherwise `409 NAME_SOURCE_NOT_OWN`. Session-only. GraphQL twin: `updateDisplayName(displayName)`. ```json { "display_name": "René" } ``` The name is trimmed and NFC-normalised, then needs 2-32 characters without control or invisible characters (zero-width spaces/joiners, bidi controls, BOM). Errors: `400 DISPLAY_NAME_TOO_SHORT`, `400 DISPLAY_NAME_TOO_LONG`, `400 DISPLAY_NAME_INVALID_CHARACTERS` - the same codes and messages as on GraphQL. Writes `user:profile_identity_changed` (`changed: ["display_name"]`) when the name actually changes. ### `POST /users/me/avatar` Uploads the own profile picture as `multipart/form-data` with one field `file`. Only while `name_source_kind` is `own`, otherwise `409 NAME_SOURCE_NOT_OWN`. Session-only. **REST-only by design** - the picture is binary data and the API has no GraphQL file uploads. GraphQL has the counterpart `removeAvatar`, and the result shape is the same `NameSourceResponse` / `NameSourceResult`. The server validates the file itself and never relies on the browser's crop: | Check | Error | |-------|-------| | At most 5 MB | `413 AVATAR_TOO_LARGE` | | Type by magic bytes: PNG, JPEG or WebP (no SVG, no GIF, no renamed files) | `415 AVATAR_UNSUPPORTED_TYPE` | | At most 16 megapixels, checked from the header before decoding (decompression bombs) | `400 AVATAR_TOO_MANY_PIXELS` | | The image decodes completely | `400 AVATAR_UNREADABLE` | | At most 10 uploads per profile and hour (every attempt counts; counted before the body is read) | `429 RATE_LIMITED` with `retry_after_secs` | | No `file` field | `400 AVATAR_MISSING` | | File storage is not configured | `503 AVATAR_UNAVAILABLE` | | All picture decode slots are taken (at most 2 profile and channel pictures are decoded at a time across the API) | `503 AVATAR_BUSY` with a `Retry-After` header and `retry_after_secs` (5 s) | An accepted picture is turned upright (EXIF orientation), cropped to a centred square and re-encoded as **512 × 512** and **128 × 128** lossless WebP. The re-encode keeps pixels only, so EXIF, GPS, ICC and XMP metadata are gone. Both files are stored through lo-storage under `users/{user_id}/avatar/{uuid}-512.webp` and `…-128.webp`. `avatar_url` becomes `{storage.public_url}/users/{user_id}/avatar/{uuid}-512.webp`; with the default `storage.public_url` (`{api}/v1/uploads/file`) that is the [public picture route](#get-uploadsfileusersuser_idavatarfile). An earlier upload is deleted. Writes `user:profile_identity_changed` (`changed: ["avatar"]`). ### `DELETE /users/me/avatar` Removes the own profile picture: `avatar_url` becomes `null` (initials) and an uploaded file is deleted. Only while `name_source_kind` is `own`, otherwise `409 NAME_SOURCE_NOT_OWN`. Session-only. GraphQL twin: `removeAvatar`. Writes `user:profile_identity_changed` (`changed: ["avatar"]`) when there was a picture. The uploaded picture is part of the "user data only" GDPR export (`profile_picture.webp`) and is deleted together with the profile. ### `GET /uploads/file/users/{user_id}/avatar/{file}` Serves an uploaded profile picture. Public, no sign-in, like platform avatars: the picture is shown to anyone who sees the profile. The path is the default `storage.public_url` (`{api}/v1/uploads/file`) plus the storage key, so the `avatar_url` of an upload lands here; the files in storage never have to be public. Not audited (read). No GraphQL twin (binary download, like the upload); GraphQL `me` and REST `GET /users/me` return the same `avatar_url`. `{file}` is `{uuid}-512.webp` or `{uuid}-128.webp` (lower-case UUID). Only the user's **current** upload is served, never a replaced or removed one. | Case | Response | |------|----------| | Current upload | `200`, `Content-Type: image/webp`, `Cache-Control: public, max-age=31536000, immutable` (the name is new per upload), `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox` | | `{file}` does not match the pattern | `400 Invalid picture file name` | | Not the user's current upload (replaced, removed, another user's id) | `404 Picture not found` | | File storage is not configured | `503 AVATAR_UNAVAILABLE` | ### `GET /v1/accounts/{id}/enabled-features` Returns the list of enabled feature keys for the given account. The caller's active account must match `{id}`. Works with popout-token auth (no `account:read` permission required). Excludes `system:*` flags. **Response:** ```json { "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:** ```json { "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: ```json HTTP 403 { "error": "Account creation is currently disabled", "error_code": "account_creation_disabled" } ``` This mirrors the GraphQL error `extensions.code: "ACCOUNT_CREATION_DISABLED"` with message `"Account creation is currently disabled"`. ### `POST /v1/accounts` / `DELETE /v1/accounts/:id` - `token` in the response Account creation returns the new account plus a `token`: a fresh JWT carrying the new account, bound to the caller's existing session. Dissolution returns a fresh JWT without an account the same way. A JWT is only minted for a caller that already holds an interactive session (ZAF-1922) - for any other first-party caller (e.g. a user API key) account creation proceeds under its usual guards and `token` is `null`; dissolution refuses such a caller (step-up, below). GraphQL twins: `createAccount` (`CreateAccountResult.token` is a nullable `String`) and `dissolveAccount` (returns `{ success }`, no token). ### `DELETE /v1/accounts/:id` - step-up After the `account:delete`, owner, active-account and `confirm_name` checks, the calling session needs a step-up in the last 10 minutes; otherwise `403` with code `STEP_UP_REQUIRED` ("Please confirm that it's you first") and nothing is deleted. Only an interactive sign-in session can hold a step-up, so a user API key, service key or popout token is refused even with `account:delete`. Same code and message as GraphQL `dissolveAccount`. See [Authentication → Step-up before sign-in changes](../features/auth.md#step-up-before-sign-in-changes). ### `POST /v1/accounts/:id/dissolution` - dissolve after a grace period Schedules the dissolution of a channel, or dissolves it at once. Guard: `require_permission("account:delete")`, the same as GraphQL `scheduleAccountDissolution(accountId, graceDays)`. Optional body `{ "grace_days": 0 | 7 | 30 }`, default 30. The checks run in this order, with the same codes on both protocols: | Check | Response | |---|---| | First-party user (popout, overlay, widget and extension tokens refused) | `403 FORBIDDEN` | | `grace_days` is 0, 7 or 30 | `400 INVALID_GRACE` | | `:id` is the caller's active account | `403` | | Caller is the owner | `403` "Only the account owner can dissolve an account" | | No dissolution already scheduled | `409 DISSOLUTION_ALREADY_SCHEDULED` | | No running Stripe subscription that is not set to cancel at period end | `409 ACTIVE_SUBSCRIPTION` ("Cancel it in the billing settings first") | | Step-up in the last 10 minutes (only an interactive session holds one, so user API keys are refused) | `403 STEP_UP_REQUIRED` | Response: `{ "dissolution_scheduled_for": string | null, "dissolved": bool }`. `grace_days: 0` dissolves the channel at once through the same path as `DELETE /v1/accounts/:id` and returns `{ "dissolution_scheduled_for": null, "dissolved": true }`. A scheduled dissolution writes `account:dissolution_scheduled` and mails the owner and every other member. The account security worker sends the owner a reminder (7 days before the end of a 30-day period, 1 day before the end of a 7-day period) and dissolves the channel when the date is reached (`account:dissolved` with `via: "scheduled"`). ### `DELETE /v1/accounts/:id/dissolution` - cancel a scheduled dissolution Owner only, interactive session only (no user API key, no popout token), no step-up - cancelling is the safe direction. Works while the channel is suspended, up to the end of the grace period. Returns `409 PROFILE_DELETION_SCHEDULED` while the owner's own profile is scheduled for deletion (cancel the profile deletion first), `409 DISSOLUTION_IN_PROGRESS` ("The dissolution is already running") once the date has passed, and `409 NO_DISSOLUTION_SCHEDULED` when nothing is scheduled. Writes `account:dissolution_cancelled` and mails the owner and the other members. GraphQL: `cancelAccountDissolution(accountId)`. `DELETE /v1/accounts/:id` (immediate dissolve) refuses with `409 ACTIVE_SUBSCRIPTION` too; that check runs before the step-up. ### Channel name and picture A channel's name and picture come from one **source**: `connection` (one of the channel's own Twitch, YouTube or Kick channel connections - never Spotify - whose channel name and platform profile picture are used) or `own` (an editable name and an optional uploaded picture; without one, `avatar_url` is `null` and the initial is shown). New channels start with `own`. Background: [Accounts › Channel name and picture](../features/accounts.md#channel-name-and-picture). `identity` on `GET /v1/accounts/{id}`, `POST /v1/accounts` and every `accounts[]` entry of `GET /v1/users/me` (`AccountIdentityResponse`), also the response of the four writes below: ```json { "source_kind": "connection", "source_connection_id": "…", "source_platform": "twitch", "name": "…", "avatar_url": "https://static-cdn.jtvnw.net/…" } ``` **Guard (all four writes):** `require_permission("account:edit")` (owner and administrator by default), an interactive sign-in session (a user API key or popout token gets `403 This action requires an interactive sign-in session`), and the path `{id}` must be the session's active channel (`403 Account id does not match the active account`). While the channel is scheduled for dissolution the refusal is `403 CHANNEL_DISSOLVING`. GraphQL twins use the same guard, validation and error codes. **Audit:** an effective change writes `account:identity_changed` (scope `account`, actor = caller) with metadata `changed` (list of `source`, `name`, `avatar`), `source_kind` and `source_platform` - never a name or URL. Nothing is written when nothing changed. #### `PUT /v1/accounts/{id}/identity-source` Chooses the source. GraphQL twin: `setAccountIdentitySource(accountId, kind, connectionId)`. ```json { "kind": "connection", "connection_id": "uuid-of-channel-connection" } ``` | Case | Error | |------|-------| | `kind` is not `connection` / `own`, `connection` without `connection_id`, `own` with a `connection_id`, or a Spotify connection | `400 INVALID_SOURCE` | | The connection does not exist or belongs to another channel | `404 Connection not found` | Switching to `own` keeps the current name as the starting value; the picture starts as the initial. Switching back to a connection deletes an uploaded picture (`changed: ["source", "avatar"]`). While a connection is the source, the channel's stored name mirrors its channel name. #### `PATCH /v1/accounts/{id}/name` Sets the own channel name. Only with source `own`, otherwise `409 SOURCE_NOT_OWN`. GraphQL twin: `updateAccountName(accountId, name)`. ```json { "name": "My Channel" } ``` The name is trimmed and NFC-normalised, then needs 2-50 characters without control or invisible characters: `400 NAME_TOO_SHORT`, `400 NAME_TOO_LONG`, `400 NAME_INVALID_CHARACTERS`. #### `POST /v1/accounts/{id}/avatar` Uploads the own channel picture as `multipart/form-data` with one field `file`. Only with source `own`, otherwise `409 SOURCE_NOT_OWN`. **REST-only by design** (no GraphQL file uploads); the GraphQL counterpart is `removeAccountAvatar`. Same pipeline as [`POST /users/me/avatar`](#post-usersmeavatar): | Check | Error | |-------|-------| | At most 5 MB | `413 AVATAR_TOO_LARGE` | | Type by magic bytes: PNG, JPEG or WebP | `415 AVATAR_UNSUPPORTED_TYPE` | | At most 16 megapixels | `400 AVATAR_TOO_MANY_PIXELS` | | The image decodes completely | `400 AVATAR_UNREADABLE` | | At most 10 uploads per channel and hour (every attempt counts; counted before the body is read) | `429 RATE_LIMITED` with `retry_after_secs` | | No `file` field | `400 AVATAR_MISSING` | | File storage is not configured | `503 AVATAR_UNAVAILABLE` | | All picture decode slots are taken (at most 2 profile and channel pictures are decoded at a time across the API) | `503 AVATAR_BUSY` with a `Retry-After` header and `retry_after_secs` (5 s) | An accepted picture is re-encoded as **512 × 512** and **128 × 128** WebP without metadata and stored through lo-storage under `accounts/{id}/avatar/{uuid}-512.webp` and `…-128.webp`. `avatar_url` becomes `{storage.public_url}/accounts/{id}/avatar/{uuid}-512.webp`. An earlier upload is deleted. #### `DELETE /v1/accounts/{id}/avatar` Removes the own channel picture (`avatar_url` becomes `null`, the initial is shown) and deletes the files. Only with source `own`, otherwise `409 SOURCE_NOT_OWN`. GraphQL twin: `removeAccountAvatar(accountId)`. #### `GET /v1/uploads/file/accounts/{id}/avatar/{file}` Serves an uploaded channel picture. Public, no sign-in: the picture is shown wherever the channel is. The files in storage are never public. Not audited (read). No GraphQL twin (binary download). `{file}` is `{uuid}-512.webp` or `{uuid}-128.webp`. Only the channel's **current** upload is served, never a replaced or removed one. | Case | Response | |------|----------| | Current upload | `200`, `Content-Type: image/webp`, `Cache-Control: public, max-age=31536000, immutable`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox` | | `{file}` does not match the pattern | `400 Invalid picture file name` | | Not the channel's current upload | `404 Picture not found` | | File storage is not configured | `503 AVATAR_UNAVAILABLE` | Disconnecting the channel connection that is the source switches the channel to `own` with the connection's last channel name and the initial; the owner gets an in-app notification (`channel_identity_source_removed`). Dissolving the channel or deleting it from the admin app deletes the uploaded files. ### Channels being dissolved While a channel has `dissolution_scheduled_for` set, one check in the API auth middleware, right after the credential is resolved, suspends it: - **Credentials bound to the channel** - user API keys (`lm_usr_`), service keys (`lm_svc_`), popout tokens, overlay (`lm_overlay_`), shared overlay (`lm_share_`), widget (`lm_widget_`) and extension (`lm_ext_`) tokens - are refused: ```json HTTP 403 { "error": "CHANNEL_DISSOLVING", "message": "This channel is being dissolved. Only cancelling the dissolution is possible", "details": { "dissolution_scheduled_for": "2026-11-08T03:00:00Z" } } ``` They are not revoked and work again after the dissolution is cancelled. - **A member's dashboard session** keeps its identity but holds no account permission: every permission-gated route answers `403 CHANNEL_DISSOLVING`. User-level routes keep working - the profile, `GET /v1/users/me` with the account list and `dissolution_scheduled_for`, switching and leaving accounts, and the owner's `DELETE /v1/accounts/:id/dissolution`. - **Feature flags** all resolve off for the account, which stops automations and feature-gated routes. - **Bots** - the channel drops out of the bot channel and Discord guild lists; bots leave on their next poll and rejoin by themselves after a cancel. Bot-module and automation-node handler feeds report the account's installs as disabled. - **Webhook ingest** (YouTube PubSubHubbub, Kick) drops deliveries for the channel. - **Checkout** - `POST /v1/billing/checkout` answers `403 CHANNEL_DISSOLVING`. The WebSocket upgrade is refused the same way; see [WebSocket](./websocket.md). ### `PATCH /v1/admin/users/{id}` - account creation override The request body accepts an optional `account_creation_override` field: ```json { "account_creation_override": "default" } ``` Valid values: `"default"` (inherit global flag), `"allow"` (always permitted), `"deny"` (always blocked). The override is cached in Redis and invalidated immediately on save. Requires `users:edit` admin permission. The `account_creation_override` field is also present in `GET /v1/admin/users/{id}` and the user-list response. ### Platform-filtered connection lists `GET /users/me/login-connections`, `GET /connections/channel`, `GET /bot-connections`, and `GET /admin/providers` filter by platform flags: - Login connections are filtered by `platform:{x}:login` - platforms with the login sub-flag disabled are excluded. - Channel connections are filtered by `platform:{x}:channel`. - Bot connections are filtered by `platform:{x}:bot`. - Admin providers exclude integration-only entries (e.g., Shopify does not appear - its flag is `integration:shopify` in the Feature Flags page, not a platform provider). > **Protocol parity:** All endpoints above have matching GraphQL queries/mutations - see [GraphQL](./graphql.md#feature-flags--status). ## Admin API Keys Platform-operator management of **any** account's or user's API keys, for support and incident response. All paths are under `/v1/admin` and check **admin-scope permissions** (the `account-apikeys:*` family, `require_admin_permission`) - distinct from the account-scope `apikeys:*` a member uses on their own keys under [`/v1/api-keys`](#api-keys). `{kind}` is `personal` (`lm_usr_`, in `api_keys`) or `service` (`lm_svc_`, in `account_service_keys`). Bodies use snake_case; no response ever includes a key's secret except the one-time rotate result. | Method | Path | Permission | Description | |--------|------|------------|-------------| | `GET` | `/v1/admin/accounts/{account_id}/api-keys` | `account-apikeys:read` | List an account's keys - **both** kinds (its members' personal `lm_usr_` keys and the account's `lm_svc_` service keys) | | `GET` | `/v1/admin/users/{user_id}/api-keys` | `account-apikeys:read` | List a user's personal `lm_usr_` keys (service keys have no owning user) | | `PATCH` | `/v1/admin/api-keys/{kind}/{id}` | `account-apikeys:edit` | Edit a key's `label` and `permissions` (scopes) | | `POST` | `/v1/admin/api-keys/{kind}/{id}/rotate` | `account-apikeys:rotate` | Rotate a key - regenerate its secret in place; returns the new full key **once** | | `DELETE` | `/v1/admin/api-keys/{kind}/{id}` | `account-apikeys:delete` | Revoke (delete) a key; **idempotent** - an already-gone key resolves to the same not-found result | **Scope-edit rule.** `PATCH` validates each requested scope against the assignable-scope registry for the key's kind (ZAF-1170) - an unknown, deny-listed, or user-scoped-on-a-service-key scope is rejected. For a **personal** key the scopes are additionally capped to the key **owner's** own grantable permissions; for a **service** key the account is the principal, so the registry minus the deny-list is the ceiling. An operator can never push a key above its own principal. **Audit.** Each write emits a **system-scope** audit event - `system:api_key_updated` (PATCH), `system:api_key_rotated` (rotate), `system:api_key_revoked` (delete) - with the operator as the actor (`user_id`) and the target `kind` / `api_key_id` / `account_id` / (personal only) `user_id` in metadata; rotate/update additionally carry `old_key_prefix`+`new_key_prefix` / `permissions`. These rows land only in the operator log, never in the target account's or user's own audit log. Parity with the GraphQL `adminAccountApiKeys` / `adminUserApiKeys` queries and `adminUpdateApiKey` / `adminRotateApiKey` / `adminDeleteApiKey` mutations - see [GraphQL](./graphql.md#admin-api-key-management). ## Admin Account Membership These endpoints check admin-scope permissions and mirror `adminAccountRoles` and `adminAddAccountMember` in GraphQL. ### `GET /v1/admin/accounts/{id}/roles` Requires `accounts:read`. Returns the account's roles in `sort_order` order in the standard `data` envelope; each item has `id`, `slug`, `name`, nullable `color`, `is_system`, `is_default`, and `sort_order`. Returns 404 `"Not found: Account not found"` for an unknown account. The API includes the Owner role; the admin dialog excludes it from its choices. ### `POST /v1/admin/accounts/{id}/members` Requires `accounts:edit`. The JSON body requires both `user_id` and `role_id`: ```json { "user_id": "", "role_id": "" } ``` The role must belong to the target account. Missing fields fail request validation (400); an unknown role or one belonging to another account returns 404 `"Not found: Role not found for this account"`. On success, returns the updated account detail and emits `system:account_member_added` with the selected `role_id`; adding a user who is already a member changes nothing and emits no event. ## Admin Account Limits `PUT /v1/admin/accounts/{id}/limits` requires `accounts:edit`. Its optional `chat_retention_days` override must be 0–1095 days; values outside that range return 400 with `"chat_retention_days must be between 0 and 1095 days (the published maximum)"`. Omit the field to keep the existing override. `GET /v1/admin/accounts/{id}/limits` requires `accounts:read`. The retention sweeps use the account override when set, otherwise the plan value. ## Admin Role Management All endpoints below are under the `/v1/admin` scope and check **admin-scope permissions** (not account permissions). The caller must have the admin permission listed for each endpoint. ### `GET /v1/admin/admin-roles` Requires `admin-roles:read`. Returns `[AdminRoleResponse]` - the full list of admin roles with their permissions and member counts. ### `POST /v1/admin/admin-roles` Requires `admin-roles:create`. Body: `CreateAdminRoleRequest`. Returns 201 + `AdminRoleResponse`. Validation errors (400): - `"Name is required"` - empty name - `"Name must be 100 characters or less"` - name too long - `"Invalid permission: "` - unknown permission string - `"Role name already in use"` - duplicate name `admin:access` is auto-injected if not included in the permissions list. ### `GET /v1/admin/admin-roles/{id}` Requires `admin-roles:read`. Returns `AdminRoleResponse`. Returns 404 `"Admin role not found"` if not found. ### `PATCH /v1/admin/admin-roles/{id}` Requires `admin-roles:edit`. Body: `UpdateAdminRoleRequest` (all fields optional). Returns `AdminRoleResponse`. - Omit a field to leave it unchanged - Set `description` to `null` to clear it - Permissions are applied as a diff; unknown/legacy permissions are preserved - Same validation error wording as `POST` ### `DELETE /v1/admin/admin-roles/{id}` Requires `admin-roles:delete`. Returns 204 on success. - 400 `"Cannot delete system admin role"` - if `is_system = true` - 404 `"Admin role not found"` - if not found ### `GET /v1/admin/admin-roles/{id}/members` Requires `admin-roles:read`. Returns `[AdminRoleMemberResponse]` - all users assigned to the role. ### `PUT /v1/admin/admin-roles/{id}/members/{userId}` Requires `admin-roles:edit`. Assigns the given user to the role. Idempotent. Returns 204. - 404 `"Admin role not found"` - if the role does not exist ### `DELETE /v1/admin/admin-roles/{id}/members/{userId}` Requires `admin-roles:edit`. Removes the user's role assignment. Returns 204. ### `GET /v1/admin/admin-permissions` Requires `admin-roles:read`. Returns `[AdminPermissionInfoResponse]` - the full catalog of admin-scope permissions with category labels. Source: `lo_auth::rbac::all_admin_permissions()`. ### `GET /v1/admin/system-key-permissions` Requires `system-keys:read`. Returns `[AdminPermissionInfoResponse]` - every scope assignable to a [System key](../admin/system-keys.md), each with its category. Source: `lo_auth::rbac::system_key::get_assignable_permission_infos()` - the derived registry (admin ∪ account ∪ user minus the credential-minting / tenant-destruction deny-list, ZAF-1213). This is the same registry `POST /v1/admin/system-keys` validates against, so the create picker can never drift from what the mint accepts. Guarded on `system-keys:read` (not `admin-roles:read`), so the operator who mints keys can read it. Parity: GraphQL `adminSystemKeyPermissions`. ### Request / Response Types **`CreateAdminRoleRequest`** | Field | Type | Required | |-------|------|----------| | `name` | `string` | Yes | | `description` | `string \| null` | No | | `permissions` | `string[]` | Yes (may be empty) | **`UpdateAdminRoleRequest`** | Field | Type | Notes | |-------|------|-------| | `name` | `string` | Optional; trimmed | | `description` | `string \| null` | `null` = clear; omit = leave unchanged | | `permissions` | `string[]` | Optional; replaces via diff | **`AdminRoleResponse`** | Field | Type | |-------|------| | `id` | UUID | | `name` | string | | `description` | string or null | | `is_system` | boolean | | `permissions` | string[] | | `member_count` | integer | | `created_at` | ISO-8601 string | | `updated_at` | ISO-8601 string | **`AdminRoleMemberResponse`** | Field | Type | |-------|------| | `user_id` | UUID | | `display_name` | string | | `email` | string or null | | `avatar_url` | string or null | | `assigned_at` | ISO-8601 string | **`AdminPermissionInfoResponse`** | Field | Type | |-------|------| | `permission` | string | | `category` | string | > **Protocol parity:** All 9 endpoints have matching GraphQL queries/mutations - see [GraphQL](./graphql.md#admin-role-management). ## Admin Plan Management All endpoints below are under the `/v1/admin` scope and check **admin-scope permissions**. Request and response bodies use snake_case. Reading the plan list is GraphQL-only - use the `adminPlans` query. There is no `GET /v1/admin/plans`; the REST surface covers create, update, delete, and the feature mapping. ### `POST /v1/admin/plans` Requires `plans:create`. Body: `CreatePlanRequest`. Returns 201 + `AdminPlanResponse`. Validation errors (400): - `"Invalid slug format"` - slug does not match `^[a-z0-9]+(?:-[a-z0-9]+)*$` or is outside 2–40 chars - `"Price cannot be negative"` - monthly or yearly price is negative - `"Limit cannot be negative"` - a numeric limit is negative - `"chat_retention_days must be between 0 and 1095 days (the published maximum)"` - retention is outside the published range Conflict errors (409): - `"Plan slug already in use"` - another plan already uses this slug Auth errors: `401 UNAUTHORIZED` for a missing or invalid JWT; `403 FORBIDDEN` (`Missing permission: plans:create`) when the admin role lacks `plans:create`. Fields of `CreatePlanRequest`: | Field | Type | Required | Notes | |-------|------|----------|-------| | `slug` | `string` | Yes | Regex `^[a-z0-9]+(?:-[a-z0-9]+)*$`, length 2–40, immutable after creation | | `name` | `string` | Yes | Display name | | `description` | `string \| null` | No | | | `price_monthly` | `integer` | Yes | Cents (or the minor unit of `currency`) | | `price_yearly` | `integer` | Yes | Cents | | `currency` | `string \| null` | No | ISO-4217 code; defaults to `"USD"` | | `is_public` | `boolean` | Yes | Whether the plan is visible on the public pricing page | | `sort_order` | `integer` | Yes | Sort position in pricing pages | | `max_overlays` | `integer` | Yes | | | `max_storage_bytes` | `integer` | Yes | | | `max_upload_size_bytes` | `integer` | Yes | | | `max_integrations` | `integer` | Yes | | | `chat_retention_days` | `integer` | Yes | 0–1095 days. `0` keeps chat and events indefinitely; chatter and moderation data are still deleted after 1095 days. A value outside this range returns 400. | | `max_sounds` | `integer` | Yes | Maximum number of sounds per account | | `max_sound_file_size` | `integer` | Yes | Maximum size per sound file in bytes | | `max_sound_storage_bytes` | `integer` | Yes | Total sound storage quota in bytes | | `max_setting_presets` | `integer` | Yes | Setting-preset quota across all kinds; `0` = unlimited (defaults: Free 5, Pro 20) | | `stripe_product_id` | `string \| null` | No | Paste from Stripe dashboard | | `stripe_monthly_price_id` | `string \| null` | No | Paste from Stripe dashboard | | `stripe_yearly_price_id` | `string \| null` | No | Paste from Stripe dashboard | ### `PATCH /v1/admin/plans/{id}` Requires `plans:edit`. Body: `UpdatePlanRequest`. Returns 200 + `AdminPlanResponse`. - The `slug` is **immutable** and is therefore not part of `UpdatePlanRequest`. - All fields are required on update - the handler rewrites the full editable field set. - On success, the feature cache is invalidated for every account currently on the plan. Errors: - 400 `"Price cannot be negative"` / `"Limit cannot be negative"` / `"chat_retention_days must be between 0 and 1095 days (the published maximum)"` - validation failures - 403 `FORBIDDEN` - missing `plans:edit` (`Missing permission: plans:edit`) - 404 `"Plan not found"` - no plan with that ID Fields of `UpdatePlanRequest`: | Field | Type | Notes | |-------|------|-------| | `name` | `string` | | | `description` | `string \| null` | | | `price_monthly` | `integer` | | | `price_yearly` | `integer` | | | `currency` | `string` | | | `is_public` | `boolean` | | | `sort_order` | `integer` | | | `max_overlays` | `integer` | | | `max_storage_bytes` | `integer` | | | `max_upload_size_bytes` | `integer` | | | `max_integrations` | `integer` | | | `chat_retention_days` | `integer` | 0–1095 days; outside this range returns 400. `0` keeps chat and events indefinitely, while chatter and moderation data have a 1095-day ceiling. | | `max_sounds` | `integer` | | | `max_sound_file_size` | `integer` | | | `max_sound_storage_bytes` | `integer` | | | `stripe_product_id` | `string \| null` | | | `stripe_monthly_price_id` | `string \| null` | | | `stripe_yearly_price_id` | `string \| null` | | ### `DELETE /v1/admin/plans/{id}` Requires `plans:delete`. Returns 204 No Content on success. - 403 `FORBIDDEN` - missing `plans:delete` (`Missing permission: plans:delete`) - 404 `"Plan not found"` - no plan with that ID - 409 `"Cannot delete plan: N account(s) still reference it. Migrate them to a different plan first."` - one or more accounts still point at this plan; migrate them before retrying Deletion cascades to `plan_features` via the foreign key constraint. No other data is affected. ### Request / Response Types **`AdminPlanResponse`** | Field | Type | |-------|------| | `id` | UUID | | `slug` | string | | `name` | string | | `description` | string or null | | `price_monthly` | integer | | `price_yearly` | integer | | `currency` | string | | `is_public` | boolean | | `sort_order` | integer | | `max_overlays` | integer | | `max_storage_bytes` | integer | | `max_upload_size_bytes` | integer | | `max_integrations` | integer | | `chat_retention_days` | integer | | `max_sounds` | integer | | `max_sound_file_size` | integer | | `max_sound_storage_bytes` | integer | | `max_setting_presets` | integer | | `stripe_product_id` | string or null | | `stripe_monthly_price_id` | string or null | | `stripe_yearly_price_id` | string or null | | `features` | `[AdminPlanFeatureResponse]` | | `accounts_using` | integer | **`AdminPlanFeatureResponse`** | Field | Type | |-------|------| | `feature_id` | UUID | | `feature_key` | string | | `label` | string | | `enabled` | boolean | > **Protocol parity:** The three write endpoints have matching GraphQL mutations, plus `PUT /v1/admin/plans/{id}/features` for the feature mapping; the plan list is GraphQL-only (`adminPlans`) - see [GraphQL](./graphql.md#plan-management). ## Admin - Setting Presets Operator view/delete of a user's setting presets (ZAF-1282; see [Setting Presets](../features/setting-presets.md#deleting-presets-as-an-operator)). Presets are user-bound, so an operator manages them by owning user; deleting one removes it for that user across every account they belong to. The `payload` is never exposed. ### `GET /v1/admin/users/{id}/setting-presets` Requires `users:read`. Returns a list of `AdminSettingPresetResponse` (`id`, `user_id`, `kind`, `name`, `is_default`, `created_at`, `updated_at`). ### `DELETE /v1/admin/setting-presets/{id}` Requires `users:edit`. Returns 204 No Content on success, 404 `"Setting preset not found"` if no such preset. Emits `system:user_setting_preset_deleted`. > **Protocol parity:** Matching GraphQL `adminUserSettingPresets` (query) / `adminDeleteUserSettingPreset` (mutation) - see [GraphQL](./graphql.md#setting-presets). ## Chat History ### `GET /v1/chat/shared-chat` Reads the account's current Twitch Shared Chat session with `chat:read` and `feature:multichat`. A popout token with those grants can use it. The body is snake_case `SharedChatSessionView`: `status` (`session`, `none`, `unknown`), `revision`, `session_id`, `host_broadcaster_id`, `own_broadcaster_id`, `started_at`, `updated_at`, `participants` and `total_viewers`. Participants carry broadcaster identity, `joined_at`, optional viewer/category/title/ follower data and `inferred`, `is_host`, `is_own_channel`. `unknown` means the state could not be determined and must not clear a previously known session; its `revision` is null. A newer revision wins over an older read or WebSocket frame. GraphQL twin: `sharedChatSession`. Twitch announcements are chat rows with `message_type: "announcement"` and `announcement_color: "primary" | "blue" | "green" | "orange" | "purple"`. Other messages have `announcement_color: null`. A missing or unrecognised announcement colour resolves to `primary`. The same fields appear in `GET /v1/chat/history` and the message part of the merged history read. ### Current pins and pin actions `GET /v1/chat/pins` returns `{twitch, kick, available}` for the active account. It requires `chat:read` and `feature:multichat` and accepts a popout token. `available: false` means the state could not be read; `null` platform pins then mean unknown. A pin has `platform`, `channel_id`, `message_id`, `sender`, `pinned_by`, `text`, `emotes`, `sent_at`, `starts_at`, `ends_at` and `updated_at`. `sent_at` is the message's write time, or `null` when unknown, not the pin time. Each person has `platform`, `user_id`, `login`, `display_name`, `avatar_url`, `color` and `badges`. | Method | Path | Body | Effect | |--------|------|------|--------| | `POST` | `/v1/chat/pins` | `platform`, `message_id`, `duration_seconds` | Pin a Twitch message, replacing the current pin. | | `PATCH` | `/v1/chat/pins` | Same | Change the current pin's duration from now. | | `DELETE` | `/v1/chat/pins/{platform}/{message_id}` | None | Unpin the current Twitch message. | All actions require `chat:pin` and `feature:multichat`. The duration is 30–1,800 seconds or `null` / absent for until stream end. The caller's own Twitch login must moderate the channel and grant `moderator:manage:chat_messages`. Kick actions return a result with `success: false` and `error_code: "pin_platform_unsupported"`. Every result has `success`, `platform`, `message_id`, `action`, `error_code` and `details`. Invalid platform, message ID or duration gives a 400; a refused platform action is represented in the result. The GraphQL twins have the same guard, validation and outcome fields in camelCase. ### `GET /v1/chat/history` Returns paginated account chat history. Query parameters use snake_case and match the GraphQL `chatHistory(filter: ChatFilterInput)` surface, except that REST encodes keyword search as one comma-separated string. | Query parameter | Type | Description | |-----------------|------|-------------| | `platform` | string | Optional platform filter: `twitch`, `youtube`, or `kick` | | `platform_user_id` | string | Optional platform-native user filter | | `live_chat_id` | string | Optional YouTube live-chat/broadcast filter | | `from` | ISO 8601 timestamp | Optional inclusive lower time bound. When omitted, the read covers the 30 days before `to` (or now) | | `to` | ISO 8601 timestamp | Optional inclusive upper time bound | | `search` | comma-separated string | Optional keyword search over message text, for example `?search=hello,world`. Matching is case-insensitive and uses the same validation as GraphQL: at most 20 keywords, each at most 100 characters after trimming and dropping blanks. A keyword search always has a lower time bound: the supplied `from`, or the last 12 hours when `from` is omitted. A keyword that itself contains a comma cannot be expressed through REST; use GraphQL `ChatFilterInput.search: [String!]` for that case. | | `page` | integer | 1-based page number, default 1 | | `limit` | integer | Items per page, default 50, max 100 | `_meta.total` is capped at 10 000; `_meta.total_capped: true` means more messages match (see [Response format](#response-format) for the paging rules). Each returned message is a snake_case chat row. A reply carries the parent author under two fields: `reply_parent_username` (the **display name**) and `reply_parent_login` (the **login/handle** twin, ZAF-1452 F1) — `reply_parent_login` is `null` for platforms/messages without a distinct login (Kick's username already is the handle; YouTube carries no reply handle) and for pre-existing rows (no backfill). For a Twitch **Shared Chat** message that originated in a different channel's room, the row additionally carries `is_shared_chat: true` and `source_channel: { platform_channel_id, login, display_name, avatar_url }` (the origin channel). For normal and host-origin messages `is_shared_chat` is `false` and `source_channel` is `null`. Attribution is by channel (public broadcaster identity), never by viewer. > **Protocol parity:** mirror at GraphQL `chatHistory(filter: ChatFilterInput)`. GraphQL accepts `ChatFilterInput.search` as a real `[String!]` list; REST accepts the same logical terms through the comma-separated `search` query parameter. The `replyParentLogin`, `isSharedChat` / `sourceChannel` fields are present on both protocols and on the `chat:message` WebSocket payload. ### `GET /v1/chat/history/merged` Returns **one timestamp-ordered page of chat messages, platform events and Twitch AutoMod holds interleaved**, for the multichat history view. It replaces the client-side merge of the two separate `/v1/chat/history` and `/v1/events` windows (which drift on a chat-quiet, event-loud channel): messages and events for the same time span always arrive together, in `created_at` order, with nothing capped or hidden. Pagination is **keyset**, not page/offset: pass the opaque `before` cursor from the previous response to fetch the strictly-older page. A single cursor spans both sources so the two streams can never page apart. One request reads at most **30 days** below its cursor, or down to the account's retention cutoff when retention is enforced and shorter. The history ends 7 days before the account was created. A page can therefore be **short or empty while `has_more` is `true`**: the window was exhausted and older history still exists below it, so `next_cursor` points at the window edge. Keep paging until `has_more` is `false`; only that marks the end of the history (see [Chat → Server-merged history read](../features/chat.md#server-merged-history-read-mergedchathistory)). | Query parameter | Type | Description | |-----------------|------|-------------| | `platform` | string | Optional platform-tab filter (`twitch`, `youtube`, `kick`); applied to **every** source; a non-Twitch value skips AutoMod holds | | `live_chat_id` | string | Optional YouTube live-chat/broadcast filter; **messages only**; skips AutoMod holds | | `type` | comma-separated string | Optional enabled event-type set (e.g. `?type=twitch:follower,kick:subscribe`); **events only**. Maps to GraphQL `types: [String!]` | | `before` | string | Opaque keyset cursor from a prior page's `next_cursor` (or any item's `cursor`); omit for the newest page | | `limit` | integer | Total items across all sources, default 50, max 100 | Response body: ```jsonc { "items": [ { "kind": "message", "id": "…", "created_at": "…", "cursor": "…", "message": { /* ChatMessageResponse */ }, "event": null }, { "kind": "event", "id": "…", "created_at": "…", "cursor": "…", "message": null, "event": { /* EventResponse */ } }, { "kind": "automod", "id": "…", "created_at": "…", "cursor": "…", "message": null, "event": null, "automod": { /* AutoMod log entry */ } } ], "next_cursor": "…", // cursor for the next (older) page; null when has_more is false "has_more": true } ``` Each item is tagged `kind: "message" | "event" | "automod"` and carries the full row in the matching field — `message` reuses the [`GET /v1/chat/history`](#get-v1chathistory) shape, `event` reuses the `GET /v1/events` shape (see [Events](../features/events.md)), `automod` reuses the entry shape of `GET /v1/history/reports/{id}/automod` (see [AutoMod log](../features/stream-history.md#automod-log)) (outcome `open`/`approved`/`denied`/`expired`, `resolved_via`, moderator, reason, text, `emotes`, sender look), so no field drifts from the standalone endpoints. An AutoMod item's `id` is the log row id and its `created_at` is `held_at`. Event `raw` is returned as stored on both endpoints, including the Twitch Shared Chat partner-event fields `is_shared_chat`, `source_channel` and `shared_chat_notice_type` ([Events → Twitch Shared Chat partner events](../features/events.md#twitch-shared-chat-partner-events)). A chat message sent through a channel-points reward with text input carries `message_type: "channel_points_custom_reward"`. **Gates are server-side and independent.** Messages are included only with `feature:multichat` **and** `chat:read`; events only with `feature:events` **and** `events:read`; AutoMod holds only with `feature:multichat` **and** `chat:read` **and** `chat:automod-read` — with `history:read` across the account's full chat window, without it for the last 24 hours. The AutoMod source always applies the plan-window floor and ends the history instead of paging empty windows. A member missing a gate receives only the streams they may read — no error, no empty state; a member holding none receives an empty page. Popout tokens (`lm_pop_*`) are accepted and scoped exactly like a member; overlay, shared-overlay, widget, extension, developer and system keys and anonymous callers get no AutoMod items. A malformed `before` cursor is a `400`. > **Protocol parity:** mirror at GraphQL `mergedChatHistory(filter: MergedChatHistoryFilterInput): ChatHistoryPage!` — identical fields, filters, independent gates, and errors. WebSocket live-append is unchanged; this is the history read path only. ## Chatter List ### `GET /v1/chat/chatters` Who is in the chat right now, per platform — see [Chatter List](../features/chatter-list.md). Requires `chat:read` and the feature `feature:chat-viewer-list` (same guard as GraphQL `chatters` and the `chatters:{account_id}` WebSocket channel); popout tokens with `chat:read` are accepted. | Query | Notes | |---|---| | `platform` | Optional, comma-separated (e.g. `twitch,kick`). Omitted = every supported platform. An unknown platform is `400 BAD_REQUEST` `Bad request: Unsupported chatter platform: `. Kick without a usable channel connection is omitted even when requested explicitly | ```json { "data": { "supported_platforms": ["twitch"], "platforms": [ { "platform": "twitch", "available": true, "reason": null, "total": 12400, "truncated": true, "updated_at": "2026-10-02T18:00:00Z" } ], "chatters": [ { "platform": "twitch", "user_id": "141981764", "login": "nightbot", "display_name": "Nightbot", "avatar_url": "https://…", "color": null, "role": "bot", "last_message_at": null, "present": true, "first_seen_at": "2026-10-02T17:12:00Z", "last_seen_at": "2026-10-02T18:00:00Z" }, { "platform": "twitch", "user_id": "22484632", "login": "lena_plays", "display_name": "lena_plays", "avatar_url": null, "color": null, "role": "viewer", "last_message_at": null, "present": false, "first_seen_at": "2026-10-02T17:40:00Z", "last_seen_at": "2026-10-02T17:56:00Z" } ] }, "links": { "self": "…/v1/chat/chatters" } } ``` `chatters` lists present people first, then the recently-left entries (`present: false`, kept 30 min after `last_seen_at`). `first_seen_at` is when the person (re)entered the chat in the current stream; `last_seen_at` is the later of the last snapshot that listed them and their last message, frozen once they left. `total` never counts recently-left entries. See [GraphQL → Chatter List](./graphql.md#chatter-list) for the field semantics. `supported_platforms` contains Twitch and, when the account has a usable Kick channel connection, Kick. A platform status has `available: false` with `reason: "source_unavailable"` when its list source fails. Kick’s undocumented web source can return that reason; recent writers remain listed without a reconnect. In this state `total` is `null` and `truncated` is `false`. Kick’s ordinary chatter group is otherwise capped at 100, so `truncated: true` can accompany a larger platform `total`. Same fields as the GraphQL type (snake_case), same errors: `403` `Missing permission: chat:read`, `403` `Feature 'feature:chat-viewer-list' is not available`. ## Chat User Search ### `GET /v1/chat/users/search` Prefix-search the account's known chatters (the `platform_users` table) by username or display name, for the multichat filter name-completion. Requires `chat:userinfo` and the `feature:multichat` feature - without `chat:userinfo` the search returns nothing, so it never leaks user data. The account is derived server-side from the token (cookie **or** popout `lm_pop_*`), never from a client parameter. | Query parameter | Type | Description | |-----------------|------|-------------| | `q` | string | Case-insensitive prefix over `username` **and** `display_name`. Shorter than 2 characters (after trimming) returns an empty list, not an error. `%`, `_`, `\` match literally. | | `platform` | comma-separated string | Optional platform filter, for example `?platform=twitch,youtube`. Omit for all platforms. Maps to GraphQL `platforms: [String!]`. | | `limit` | integer | Optional, default 10, **hard-capped at 25**. | Each result is the slim shape `{ platform, platform_user_id, username, display_name, avatar_url, last_seen_at, message_count }`, ordered `last_seen_at DESC, message_count DESC` (most-recently-active first). > **Protocol parity:** mirror at GraphQL `searchPlatformUsers(query, platforms, limit)` - same fields, same validation, same empty-below-2-characters behaviour. See [GraphQL](./graphql.md#searchplatformusersquery-string-platforms-string-limit-int-gqlplatformusersearchresult) and [Chat](../features/chat.md#name-completion-searchplatformusers). ## Chat Ingest (internal) `POST /v1/chat/message` is the bot fleet's ingest path for chat messages read from the platforms (buffer + broadcast). It is internal: it requires a System key carrying [`bots:internal`](./permissions.md#system-key-only-permissions) (non-System → `403 System access required`; System key without the grant → `403 Missing permission: bots:internal`), an explicit `account_id` in the body (`400` `account_id is required` when absent), and `feature:multichat` for that account. Tenant credentials - including a `chat:write` grant - cannot call it. GraphQL twin: `sendChatMessage(input: { accountId, … })`, same guard, validation and messages. Sending a message **to** a platform is [`POST /v1/chat/send`](#chat-send). ## 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): ```jsonc { "sent": true, "message_id": "abc", "platform": "youtube" } { "sent": false, "error_code": "login_reconnect_required", "platform": "youtube" } { "sent": false, "error": "…platform message…", "error_code": "send_failed", "platform": "twitch" } ``` `error_code` is machine-readable so the client renders a translated message instead of parsing `error`: `login_reconnect_required` (the sender's login grant is dead - flagged `reconnect_required`; **no** crypto/DB internals in `error`), `internal_error` (scrubbed internal failure), or `send_failed` (the platform rejected the send). GraphQL parity: `GqlSendResult.errorCode` + `platform`. **Kick emotes on send.** For `platform: "kick"` every whitespace-separated word that exactly matches a known Kick emote (channel, Kick `Global` and `Emojis` sets from the emote catalog) is sent as Kick's `[emote:ID:NAME]` markup, which is the only form Kick renders as an emote. Same conversion on GraphQL `sendChatToPlatform` (one shared send function). ## Emotes ### `GET /v1/emotes/info` Returns hover-card details for up to 50 emotes. This read requires authentication but no RBAC permission or plan feature. Dashboard sessions, API / service / developer keys, popout tokens and System keys may call it; overlay, shared overlay, widget and extension tokens are refused with `403` `Emote details are not available to overlay, widget or extension tokens`. `platform` is required and accepts `twitch`, `youtube`, or `kick`. `items` is a URL-encoded JSON array of objects with required `emote_id` and optional `owner_id` and `url`; omitting `items` returns an empty array. ```text GET /v1/emotes/info?platform=youtube&items=%5B%7B%22emote_id%22%3A%22UC...%2Femote%22%2C%22url%22%3A%22https%3A%2F%2Fyt3.ggpht.com%2F...%22%7D%5D ``` The response envelope's `data` is an array in request order. Each element has `emote_id`, nullable `animated`, and nullable `owner` (`owner_id`, nullable `login`, `display_name`, nullable `avatar_url`). An unknown owner stays `null`; without an account context all owners are `null`. Twitch animation is always `null`. Kick animation is checked from a server-built `files.kick.com` URL for a numeric ID, ignoring any client URL. YouTube animation requires an HTTPS `yt3.ggpht.com` URL in the item. The server reads at most 512 KiB with a 2-second deadline and at most three same-origin redirects; an undecidable result is `null`. One request resolves at most 10 distinct owners and probes at most 10 images; items beyond those caps come back with `owner: null` / `animated: null`. Probe results are cached by the fetched image URL (hashed), not by the client's emote id - decided results for 30 days, undecidable results for one hour. Unsupported platforms, more than 50 items, an empty or over-200-character `emote_id`, an `owner_id` over 200 characters, or a `url` over 2048 characters return a validation error. Malformed `items` JSON returns `Validation error: items must be a JSON array of {emote_id, owner_id?, url?}`. The GraphQL twin is [`emoteInfo`](./graphql.md#emoteinfo) and uses the same resolver, guard, and validation. `GET /v1/emotes/{platform}/{channel_id}` (authenticated) and GraphQL `emotes(platform, channelId)` (`feature:multichat` + `chat:read`) return the same sets through one shared resolver. For `platform = kick` the response includes Kick's native sets — one `EmoteSet` per section with `provider: "kick"` and `section` = `channel` | `global` | `emojis` — and each Kick emote carries `subscribers_only` (omitted when `false`). GraphQL exposes them as `GqlEmoteSet.section` and `GqlEmote.subscribersOnly`. Kick channel emotes carry `owner_id = "kick:"`, and the response's `owners` map (keyed by that id) carries the channel owner's `display_name` and `avatar_url`; GraphQL returns the same entries as `owners: [{ id, displayName, avatarUrl }]`. `owners` is `null` when no set has an owner. See [Emotes](../features/emotes.md#kick-native-sets). Each emote also has nullable `owner_name` (a 7TV, FFZ, or BTTV creator name) and nullable `scope` (`channel` or `global` for a third-party set; `null` when unknown or for native sets). Their GraphQL names are `ownerName` and `scope`. Chat-message badge JSON on REST history rows can carry `image_url_hd` for Twitch's Helix 4x badge image when available; older rows and badges without that image retain `image_url` alone. The GraphQL chat row and WebSocket payload carry the same badge JSON. ## Chat Moderation ### Twitch AutoMod held messages | Route | Permission | Response | |-------|------------|----------| | `GET /v1/chat/automod/held` | `chat:automod-read` | Open holds, oldest first, as `messages`; `queue_available: false` means the queue could not be read. | | `POST /v1/chat/automod/held/{message_id}/allow` | `chat:automod-allow` | Decision result; Twitch publishes an allowed message with a new ID. | | `POST /v1/chat/automod/held/{message_id}/deny` | `chat:automod-deny` | Decision result; Twitch drops a denied message. | All three require `feature:multichat` and mirror `heldChatMessages`, `allowHeldChatMessage` and `denyHeldChatMessage` on GraphQL. A hold carries `platform`, `message_id`, `sender` (with optional `avatar_url`, `color` and `badges`: the sender's look at hold time from Lumio's chatter record, absent for a chatter Lumio has not seen), `text`, optional `fragments`, optional `emotes` (Twitch emotes and cheermotes in the chat-message `emotes` shape; absent or `null` when there are none or the fragments were dropped for size), `reason_kind`, `reason`, `flagged_spans`, `held_at` and optional `expires_at`. Spans use Unicode code-point offsets and an inclusive `end`. A decision uses the acting member's Twitch login and returns `success`, `message_id`, `decision`, `error_code` and `details`; a provider or queue refusal is a 200 result with `success: false`. When the acting user's stored Twitch login scopes are known and lack `moderator:manage:automod`, the decision returns `error_code: login_scope_missing` ("Your Twitch login does not grant `moderator:manage:automod`. Log in with Twitch again to allow AutoMod actions.") without a Helix call; an empty stored scope set counts as unknown and Helix decides, and a Helix 401 maps to the same code. Same on GraphQL. An unavailable queue is distinct from an empty queue. The history log uses a separate route and requires `history:read` as well. `POST /v1/chat/moderate` is the REST counterpart of the GraphQL `moderateChat` mutation and behaves identically - same fields, same per-platform support, same `chat:clear_user` broadcast on ban/timeout. See [Chat](../features/chat.md) for the moderation-permission matrix and [GraphQL](./graphql.md#moderatechatinput-moderationinput-gqlmoderationresult) for the field semantics. Body (snake_case): ```jsonc { "action": "ban" | "timeout" | "delete", "platform": "twitch" | "youtube" | "kick", "user_id": "", // required for ban / timeout "message_id": "", // required for delete "duration_secs": 300, // required for timeout; YouTube 1..86400, Kick rounded up to minutes, max 604800 (7 days) "reason": "spam", // optional, written to moderation_log "live_chat_id": "" // YouTube only, optional — auto-resolved from Redis when omitted } ``` Provider ids (`message_id`, `user_id`, `live_chat_id`) must be 1–512 printable ASCII characters without whitespace; anything else is a validation error (`message_id contains invalid characters`, `user_id contains invalid characters`, `live_chat_id contains invalid characters`), identical on GraphQL. The server percent-encodes them (or sends them as query parameters) when building provider URLs. Permission required matches the action: `chat:ban`, `chat:timeout`, or `chat:delete`. All three platforms support all three actions. Kick uses the official public API (`POST /public/v1/moderation/bans` for ban/timeout, `DELETE /public/v1/chat/{message_id}` for delete). Request errors (validation, unsupported platform, non-numeric Kick `user_id`, Kick timeout above 7 days, missing login/channel connection) return the HTTP status with the standard error body. A refused action returns `200` with `data.success = false`, a readable `data.details`, and a stable `data.error_code`: `login_scope_missing`, `login_reconnect_required`, `moderation_forbidden`, `moderation_failed`, or `internal_error`. The codes are identical to GraphQL `errorCode`; see the table there for their meaning. > **Protocol parity:** mirror at GraphQL `moderateChat(input: ModerationInput!)` - see [GraphQL](./graphql.md#chat-moderation). ## Chat Profile Refresh ### `POST /v1/chat/users/{platform}/{platform_user_id}/refresh` Force a fresh enrichment of a platform user's profile, bypassing the normal staleness interval. Returns the refreshed profile in the same shape as `GET /v1/chat/users/{platform}/{platform_user_id}`. **Permission:** `chat:refresh_user` **Path parameters:** | Parameter | Description | |-----------|-------------| | `platform` | Platform slug: `twitch`, `youtube`, or `kick` | | `platform_user_id` | The platform-native user identifier | **Responses:** | Status | Body | Description | |--------|------|-------------| | `200` | Refreshed `UnifiedProfile` (snake_case) | Enrichment succeeded; profile is updated in DB and Redis | | `429` | `{ "error": "REFRESH_COOLDOWN", "retry_after_seconds": N }` | Cooldown active; retry after `N` seconds (max 600) | The cooldown is 10 minutes per (account, platform, user) triple, enforced via a Redis SETNX key. Both `GET /v1/chat/users/{platform}/{platform_user_id}` and this refresh return the enrichment fields `description`, `broadcaster_type`, `is_follower`, `followed_at`, `account_created_at`, `follower_count` and `enriched_at`, all nullable. `follower_count` (integer) is the user's **own** follower count - filled on Twitch, best-effort on Kick (undocumented Kick endpoint, may be `null`), `null` on YouTube; it matches GraphQL `followerCount`. > **Protocol parity:** mirror at GraphQL `refreshPlatformUserProfile(platform, platformUserId)` - see [GraphQL](./graphql.md#chat-moderation). ## Notifications User-scoped in-app notifications. See [Notifications](../features/notifications.md) for the full endpoint reference. `POST /v1/notifications/{id}/action` takes `{ "action": "accept_team_invite" }` (or another dispatched action) and returns the acted-on notification. The first-party caller must own it; no account RBAC permission is required. It executes `accept_invite` and `decline_invite` for account invites, or `accept_team_invite` and `decline_team_invite` for developer-team invites. Account acceptance joins the inviting account with the invite's role. Team acceptance joins with the team invite's role, checks `feature:extension_development`, expiry, use count and membership, and notifies the team owner. Declining an account invite deletes it when present. Declining a team invite deletes it only when it is unused and single-use; multi-use or already-used invites remain, and a missing invite is a successful no-op. The notification is marked acted on success. Actions carrying a `url` open a page in the bell. Posting one of them, including `view_idea`, `view_developer_application`, `view_developer`, `view_extension`, or `install_extension`, returns `400 NOTIFICATION_ACTION_NAVIGATES`. The same validation and invite handling backs [`executeNotificationAction`](./graphql.md#notifications). | HTTP status | `code` | Meaning | |-------------|--------|---------| | 400 | `NOTIFICATION_NO_ACTIONS` | The notification has no actions. | | 400 | `NOTIFICATION_ACTION_NOT_AVAILABLE` | The requested action is absent from this notification. | | 400 | `NOTIFICATION_ACTION_NAVIGATES` | The action opens a page; there is nothing to execute. | | 400 | `NOTIFICATION_ALREADY_HANDLED` | The notification has already been acted on. | | 400 | `NOTIFICATION_UNKNOWN_ACTION` | The action is present but has no dispatcher. | | 400 | `INVALID_INVITE_ID` | A required invite ID is missing or invalid. | | 400 | `INVITE_EXPIRED` / `INVITE_FULL` | The invite has expired or reached its use limit. | | 400 / 409 | `ALREADY_MEMBER` | The caller is already in the account (400) or developer team (409). | | 403 | `FEATURE_DISABLED` | Team invite acceptance requires `feature:extension_development`. | | 404 | `NOTIFICATION_NOT_FOUND` | The notification does not exist or belongs to another user. | | 404 | `INVITE_NOT_FOUND` | The invite to accept no longer exists. | | 409 | `INVITE_NO_LONGER_VALID` | The invite became invalid during acceptance. | The REST error body carries a plain `error` message and the stable `code`. The GraphQL twin returns the same message with `extensions.code`. ### Notification preferences | Method | Path | Permission | Description | |--------|------|-----------|-------------| | `GET` | `/v1/notifications/preferences` | Auth | List all delivery-channel preferences for the current user | | `PATCH` | `/v1/notifications/preferences/{type}` | Auth | Set the delivery channel for a notification type | `PATCH` body: `{ "channel": "off" | "in_app" | "email" | "in_app_email" }`. Returns 400 for unknown channel values or locked types (e.g. `invite`). > **Protocol parity:** `notificationPreferences` query and `updateNotificationPreference` mutation in GraphQL - see [GraphQL](./graphql.md#notifications). ## Ideas Hub Community idea board with voting, comments, moderation, categories, and tags. All endpoints require the `system:ideas_hub` feature flag to be enabled. GET endpoints are public (no auth required). Mutation endpoints require authentication and the permissions noted below. **Field visibility.** Some fields depend on who is calling. The same rules apply to the GraphQL twins: - `login` on an `author` or timeline `actor` is returned only to Ideas staff (`ideas:moderate_read` on the user or admin role) and to the user themself. Everyone else gets `null`, including anonymous callers and other signed-in users. The public pages and the dashboard show only `display_name` and `avatar_url`. - In the `timeline` of `GET /v1/ideas/:id`, the `old_value` / `new_value` of `title_changed` and `description_changed` entries hold the previous and new text. Only the idea's author and Ideas staff see them. Everyone else gets `null` for both, and `action` is still returned. `status_changed` (status key) and `category_changed` (category label) values are returned to everyone, because the public timeline shows them. ### Ideas | Method | Path | Description | Permission | |--------|------|-------------|------------| | `GET` | `/v1/ideas` | List ideas (filter, sort, paginate) | Public | | `GET` | `/v1/ideas/:id` | Get idea with timeline | Public | | `POST` | `/v1/ideas` | Create idea | `ideas:create` | | `PATCH` | `/v1/ideas/:id` | Update idea | `ideas:edit` / `ideas:moderate_edit` | | `DELETE` | `/v1/ideas/:id` | Delete idea | `ideas:delete` / `ideas:moderate_delete` | Deleting an idea that other people commented on, or a comment that has replies, keeps it as a tombstone so the other people's posts stay. A tombstone reads as `"deleted": true` with `"author": null` and an empty `title`/`description` (idea) or `body` (comment); every live idea and comment carries `"deleted": false`. Writes to a tombstone return `409` with `Idea was deleted` or `Comment was deleted`. See [Ideas Hub → Deletion and tombstones](../features/ideas-hub.md#deletion-and-tombstones). **Query parameters for `GET /v1/ideas`:** | Parameter | Type | Description | |-----------|------|-------------| | `status` | string | Filter by status slug | | `category_id` | UUID | Filter by category | | `tag_ids` | string | Comma-separated list of tag UUIDs | | `author_id` | UUID | Filter by author | | `search` | string | Full-text search on title and description | | `sort` | string | `newest`, `most_voted`, `most_commented`, `recently_updated` | | `limit` | integer | Page size | | `offset` | integer | Page offset | ### Voting | Method | Path | Description | Permission | |--------|------|-------------|------------| | `POST` | `/v1/ideas/:id/vote` | Vote on an idea | `ideas:vote` | | `DELETE` | `/v1/ideas/:id/vote` | Remove vote | `ideas:vote` | `POST` body: `{ "vote_type": "up" | "down" }`. ### Comments | Method | Path | Description | Permission | |--------|------|-------------|------------| | `GET` | `/v1/ideas/:id/comments` | List comments (nested) | Public | | `POST` | `/v1/ideas/:id/comments` | Create comment | `ideas:comment_create` | | `PATCH` | `/v1/ideas/comments/:id` | Update comment | `ideas:comment_edit` | | `DELETE` | `/v1/ideas/comments/:id` | Delete comment | `ideas:comment_delete` / `ideas:moderate_comment` | | `POST` | `/v1/ideas/comments/:id/vote` | Vote on a comment | `ideas:comment_vote` | | `DELETE` | `/v1/ideas/comments/:id/vote` | Remove comment vote | `ideas:comment_vote` | Comment voting mirrors idea voting exactly: one vote per user per comment, and voting the active direction again removes the vote (toggle-off). `POST` body: `{ "vote_type": "up" | "down" }`. Both endpoints return the updated comment with recounted `vote_count_up` / `vote_count_down` and the caller's `my_vote`. Comment listings include those three fields per comment and reply. Comment bodies contain sanitized HTML from a rich text editor. @mentions are stored as `@Name`. ### Participants | Method | Path | Description | Permission | |--------|------|-------------|------------| | `GET` | `/v1/ideas/:id/participants` | List participants for @mention autocomplete | Signed-in user only (session or `lm_usr_*`; embed, popout, service, developer and system credentials get `403`) | Returns the union of the idea author, voters, and commenters. Supports an optional `search` query parameter. ### Voters | Method | Path | Description | Permission | |--------|------|-------------|------------| | `GET` | `/v1/ideas/:id/voters` | List voters for an idea | Public | ### Categories | Method | Path | Description | Permission | |--------|------|-------------|------------| | `GET` | `/v1/ideas/categories` | List all categories | Public | | `POST` | `/v1/ideas/categories` | Create category | Admin `ideas:edit` | | `PATCH` | `/v1/ideas/categories/:id` | Update category | Admin `ideas:edit` | | `DELETE` | `/v1/ideas/categories/:id` | Delete category | Admin `ideas:delete` | ### Tags | Method | Path | Description | Permission | |--------|------|-------------|------------| | `GET` | `/v1/ideas/tags` | List / search tags | Public | | `POST` | `/v1/ideas/tags` | Create tag | `ideas:create` | | `DELETE` | `/v1/ideas/tags/:id` | Delete tag | Admin `ideas:delete` | `GET /v1/ideas/tags` supports an optional `search` query parameter. ### Status (Moderation) | Method | Path | Description | Permission | |--------|------|-------------|------------| | `PATCH` | `/v1/ideas/:id/status` | Change idea status | `ideas:moderate_status` | `PATCH` body: `{ "status": "" }` (e.g. `open`, `in_progress`, `done`, `declined`). > **Protocol parity:** All endpoints above have matching GraphQL queries/mutations - see [GraphQL](./graphql.md#ideas-hub). ## 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:** `myDeveloperApplication` query and `submitDeveloperApplication` mutation in GraphQL - see [GraphQL](./graphql.md#developer-applications). ## Developer Verification (self-service) KYC verification an **approved developer** submits about themselves (individual or company) to unlock paid-extension publishing. This is the developer-facing counterpart to the admin review endpoints under `/v1/admin/developer-verifications`. **Auth:** first-party user session or the user's own API key. A popout/overlay/widget/extension token is rejected with `403 FORBIDDEN` (ZAF-469). The caller must be an **approved developer** (have a `developer_profiles` row - created when a developer application is approved). > These endpoints are deliberately **not** gated on `feature:extension_development`: that feature is *granted by* verification approval, so gating submission on it would be circular. ### `GET /v1/developer/verification` Returns the authenticated developer's own verification record, or `null` if none has been submitted (or the caller is not yet an approved developer). Returns `200` with a nullable `data` - **not** a `404` - mirroring the nullable GraphQL query. **Response `data` (or `null`):** | Field | Type | Description | |-------|------|-------------| | `id` | UUID | Verification ID | | `developer_id` | UUID | The developer profile this record belongs to | | `legal_name` | string | Legal name | | `company_name` | string or null | Company name (company type only) | | `address_line1` | string | Address line 1 | | `address_line2` | string or null | Address line 2 | | `postal_code` | string | Postal code | | `city` | string | City | | `country` | string | ISO-3166-1 alpha-2 country code | | `tax_id` | string or null | Tax ID (legacy free text) | | `tax_number` | string or null | Steuernummer | | `vat_id` | string or null | USt-IdNr. / foreign VAT ID (stored upper-case, without spaces) | | `small_business_declared` | boolean or null | Small business under § 19 UStG; `null` = not answered | | `tax_details_updated_at` | ISO-8601 or null | When the tax details were last entered | | `trade_register_id` | string or null | Trade register ID (company type only) | | `document_key` | string or null | Upload key for a supporting document | | `status` | string | `"pending"`, `"verified"`, or `"rejected"` | | `review_notes` | string or null | Notes from the reviewer (e.g. rejection reason) | | `reviewed_by` | UUID or null | Reviewer's user ID | | `reviewed_at` | ISO-8601 or null | Review timestamp | | `created_at` | ISO-8601 | First submission timestamp | | `updated_at` | ISO-8601 | Last update timestamp | ### `POST /v1/developer/verification` Submit (or resubmit) the developer's own KYC verification. **Idempotent upsert** - one record per developer; a resubmit re-enters `pending` and clears any prior review verdict, so a rejected developer can correct and resubmit. An already-`verified` record is terminal via self-service and cannot be resubmitted. **Request body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `legal_name` | string | Yes | Legal name | | `company_name` | string | No | Company name - its presence infers the `company` developer type | | `address_line1` | string | Yes | Address line 1 | | `address_line2` | string | No | Address line 2 | | `postal_code` | string | Yes | Postal code | | `city` | string | Yes | City | | `country` | string | Yes | ISO-3166-1 alpha-2 country code (exactly 2 letters) | | `tax_id` | string | No | Tax ID | | `trade_register_id` | string | No | Trade register ID | | `document_key` | string | No | Upload key for a supporting document | | `tax_number` | string | No | Steuernummer (max. 50 characters) | | `vat_id` | string | No | USt-IdNr. / foreign VAT ID (max. 50 characters) | | `small_business_declared` | boolean | No | Small business under § 19 UStG | A submit without any of the three tax fields keeps the stored tax details. **Validation errors (400):** `"Legal name is required"`, `"Address line 1 is required"`, `"Postal code is required"`, `"City is required"`, `"Country is required"`, `"Country must be a 2-letter ISO code"`, `"Tax number must be at most 50 characters"`, `"VAT ID must be at most 50 characters"`. **Other errors:** `403` `"You must be an approved developer to submit verification"` (caller has no developer profile); `409` `"Your developer verification is already approved"` (record is already `verified`). **Response:** `201 Created` with the verification object (same shape as the GET response). **Audit:** emits `developer:verification_submitted` (user scope; no KYC/PII in metadata). ### `PUT /v1/developer/verification/tax-details` Set the tax details a payout's credit note is derived from, in **any** verification status (also once `verified`). They are self-declared: an update does not reset the review and never changes a credit note that was already issued. **Request body:** `tax_number`, `vat_id` (strings, max. 50 characters, blank = cleared), `small_business_declared` (boolean or omitted = not answered). The body replaces all three. Which details a payout needs: | Developer | Needed | Credit note | |-----------|--------|-------------| | Germany, small business | `tax_number` or `vat_id`, `small_business_declared: true` | No VAT, § 19 UStG note | | Germany, charges VAT | `tax_number` or `vat_id`, `small_business_declared: false` | 19 % VAT on top of the revenue share | | Other EU country | `vat_id` | No VAT, reverse-charge note (§ 13b UStG) | | Outside the EU | — | No VAT, reverse-charge note | **Errors:** `400` validation (as above); `403` `"You must be an approved developer to submit verification"`; `404` `"Submit your developer verification before entering tax details"`. **Response:** `200 OK` with the verification object. **Audit:** emits `developer:tax_details_updated` (user scope; metadata says which details are present, never their values). > **Protocol parity:** `developerVerificationStatus` query and the `submitDeveloperVerification` / `updateDeveloperTaxDetails` mutations in GraphQL - see [GraphQL](./graphql.md#developer-verification-self-service). ## Developer Credit Notes Every developer payout is documented by a **self-billed credit note** (Gutschrift, § 14 Abs. 2 S. 2 UStG), issued in the payout's own transaction, numbered `GS--<6 digits>` without gaps per calendar year (Europe/Berlin). A failed payout is cancelled by a **cancellation note** (Stornierung) with its own number and negated amounts. The PDF is generated once after the payout and mailed to the developer with the PDF attached; until then `pdf_status` is `pending` and `download_url` is `null`. See [Developer credit notes](../features/developer-credit-notes.md). ### `GET /v1/developer/credit-notes` The caller's own credit notes and cancellations, newest first. Query: `limit` (default 20, max 100), `offset`. **Auth:** `extension-dev:payouts` + `feature:extension_development`, first-party user (a popout token or developer key is refused, as for the payout history). **Response `data`:** an array of: | Field | Type | Description | |-------|------|-------------| | `id` | UUID | Credit note ID | | `kind` | string | `credit_note` or `cancellation` | | `number` | string | `GS-2026-000001` | | `issued_at` | ISO-8601 | Date of issue | | `payout_id` | UUID or null | The payout it documents (credit notes only) | | `cancels_id` | UUID or null | The cancelled credit note (cancellations only) | | `cancelled_at` | ISO-8601 or null | When this credit note was cancelled | | `cancellation_reason` | string or null | `payout_failed`, `developer_objection` or `correction` | | `period_start`, `period_end` | ISO-8601 | Service period | | `currency` | string | `EUR` | | `net_amount`, `vat_amount`, `gross_amount` | integer | Cents; negative on a cancellation | | `vat_rate_bp` | integer | VAT rate in basis points (`1900` = 19 %) | | `vat_treatment` | string | `domestic_standard`, `domestic_small_business`, `reverse_charge_eu` or `reverse_charge_third_country` | | `pdf_status` | string | `pending` (being generated) or `stored` | | `download_url` | string or null | `GET` URL of the PDF (`null` while pending) | | `delivered_at` | ISO-8601 or null | When the mail with the PDF was sent | ### `GET /v1/developer/credit-notes/{id}` One of the caller's own notes. Another developer's note answers `404 "Credit note not found"`. Same auth. ### `GET /v1/developer/credit-notes/{id}/pdf` The PDF as issued (`Content-Type: application/pdf`, `Content-Disposition: attachment`, `Cache-Control: private, no-store`). The bytes are checked against the stored SHA-256 before they are served. `404` for another developer's note; `409 CREDIT_NOTE_PDF_PENDING` while the PDF is being generated. Same auth. ### `GET /v1/admin/credit-notes` Operators. Query: `developer_id` (optional), `limit`, `offset`. Includes notes whose developer profile was deleted (`developer_id: null`). **Permission:** `billing:read` (admin-scope). Each item is the developer view plus `developer_id`, `supplier_legal_name`, `supplier_country`, `objection_received_at`, `recorded_by`; `download_url` points at the admin PDF route. ### `GET /v1/admin/credit-notes/{id}` and `GET /v1/admin/credit-notes/{id}/pdf` One note / its PDF. **Permission:** `billing:read` (admin-scope). ### `POST /v1/admin/credit-notes/{id}/cancel` Record a developer's objection to a credit note (§ 14 Abs. 2 S. 3 UStG) or correct it. Issues the cancellation note and, with `reissue: true`, a corrected credit note for the same payout and service period from the developer's current verification, in one transaction. **Permission:** `billing:edit` (admin-scope). | Field | Type | Required | Description | |-------|------|----------|-------------| | `reason` | string | Yes | `developer_objection` or `correction` | | `objection_received_at` | ISO-8601 | For an objection | When the objection arrived; not allowed for a correction | | `reissue` | boolean | No | Issue a corrected credit note (default `false`) | **Errors:** `400` `"Reason must be developer_objection or correction"`, `"An objection needs the date it was received"`, `"Only an objection carries a received date"`; `404` `"Credit note not found"`; `409` `"Only an effective credit note can be cancelled"`, `"This credit note has no payout to re-issue it for"`, `409 CREDIT_NOTE_AMOUNT_MISMATCH` (the corrected note would total a different amount than was paid, e.g. the VAT status changed; settle the difference first); `422 TAX_DETAILS_REQUIRED` for the re-issue. **Response:** `{ "cancellation": …, "reissued": … | null }` (admin view). **Audit:** emits `system:developer_credit_note_cancelled` (system scope; ids, numbers and reason only). ### `GET /v1/admin/developer-payouts` Developer payouts for operators, newest first, including payouts of deleted profiles. **Permission:** `billing:read` (admin-scope). Query: `status` (`pending`, `processing`, `completed`, `failed`), `limit` (default 20, max 100), `offset`. | Field | Type | Description | |-------|------|-------------| | `id` | UUID | Payout id | | `developer_id` | UUID \| null | `null` once the profile was deleted | | `amount` / `currency` | integer / string | Cents, `EUR` (the credit note's gross amount) | | `method` | string | `stripe`, `paypal` or `bank` | | `status` | string | `pending`, `processing`, `completed` or `failed` | | `stripe_transfer_id` / `paypal_batch_id` | string \| null | Provider ids | | `payout_reference` | string \| null | Masked destination | | `created_at` / `completed_at` / `execution_started_at` | ISO-8601 | `execution_started_at`: when the payout cycle sent it | | `needs_operator` | boolean | `pending` SEPA payout, or a provider payout whose answer the cycle did not receive | **Errors:** `400` `"Status must be one of pending, processing, completed, failed"`. ### `POST /v1/admin/developer-payouts/{id}/complete` and `POST /v1/admin/developer-payouts/{id}/fail` Mark a `pending` or `processing` payout paid, or failed. Failed credits the debited amount back to the balance and cancels the credit note. **Permission:** `billing:edit` (admin-scope). No body. **Response:** `{ "payout": …, "refunded": | null, "cancellation_number": "GS-…" | null }` (`refunded` and `cancellation_number` are only set by `/fail`). **Errors:** `404` `"Payout not found"`; `409 PAYOUT_NOT_IN_FLIGHT` (`"Only a pending or processing payout can be settled"`); `/fail` only: `409 PAYOUT_BEING_SENT` (`"The payout cycle is sending this payout right now; try again in 15 minutes"`). **Audit:** emits `system:developer_payout_completed` or `system:developer_payout_failed` (system scope). See [Developer Payouts](../features/developer-payouts.md). > **Protocol parity:** `developerCreditNotes`, `developerCreditNote`, `adminCreditNotes`, `adminCreditNote`, `cancelCreditNote`, `adminDeveloperPayouts`, `completeDeveloperPayout` and `failDeveloperPayout` in GraphQL - see [GraphQL](./graphql.md#developer-credit-notes). ## 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](./graphql.md#admin-developer-applications). ## 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](./graphql.md#admin---developer-limits). ## Developer Teams Team management endpoints with team-scoped RBAC. All team operations (except create and list) check `require_team_permission()` against the caller's team role. Every endpoint except the public invite lookup requires a first-party user (session or the user's own API key). Creating a team and accepting an invite additionally require the `feature:extension_development` plan feature on the caller's account; when it is off they fail with `Feature 'feature:extension_development' is not available`. The first-party check runs first. ### Team CRUD | Method | Path | Permission | Description | |--------|------|-----------|-------------| | `POST` | `/v1/developer/teams` | Auth (developer) + `feature:extension_development` | Create a new team | | `GET` | `/v1/developer/teams` | Auth (developer) | List teams the user belongs to | | `GET` | `/v1/developer/teams/{id}` | Team member | Get team details | | `PATCH` | `/v1/developer/teams/{id}` | `team-settings:edit` | Update team name/description/URLs | | `DELETE` | `/v1/developer/teams/{id}` | Team owner only | Delete a team and its extensions (uninstalled from every channel; see [Accounts](../features/accounts.md)) | ### Team Members | Method | Path | Permission | Description | |--------|------|-----------|-------------| | `GET` | `/v1/developer/teams/{id}/members` | `team-members:read` | List team members | | `PATCH` | `/v1/developer/teams/{id}/members/{member_id}/role` | `team-members:edit` | Change a member's role | | `DELETE` | `/v1/developer/teams/{id}/members/{member_id}` | `team-members:remove` | Remove a member | ### Team Invites | Method | Path | Permission | Description | |--------|------|-----------|-------------| | `POST` | `/v1/developer/teams/{id}/invites` | `team-members:invite` | Create an invite link | | `GET` | `/v1/developer/teams/{id}/invites` | `team-members:read` | List active invites | | `DELETE` | `/v1/developer/teams/{id}/invites/{invite_id}` | `team-members:invite` | Revoke an invite | | `GET` | `/v1/developer/team-invites/{code}` | Public | Look up an invite by code | | `POST` | `/v1/developer/team-invites/{code}/accept` | Auth + `feature:extension_development` | Accept an invite | An addressed invite email links to `https://lumio.vision/invite/dev/{code}` (or the configured web app origin in another environment). This page accepts by code. The bell accepts the same invite by its stored invite ID; the notification never contains the secret code. Both paths apply the same feature, expiry, use-limit and membership checks. Acceptance errors use `INVITE_NOT_FOUND` (404), `INVITE_EXPIRED` / `INVITE_FULL` (400), and `ALREADY_MEMBER` / `INVITE_NO_LONGER_VALID` (409). The bell also offers Decline: it removes an unused single-use invite, retains shared or used invites, and treats a missing invite as handled. ### Team Roles (RBAC) | Method | Path | Permission | Description | |--------|------|-----------|-------------| | `GET` | `/v1/developer/teams/{id}/roles` | `team-members:read` | List roles for a team | | `POST` | `/v1/developer/teams/{id}/roles` | `team-settings:edit` | Create a custom role | | `PATCH` | `/v1/developer/teams/{id}/roles/{rid}` | `team-settings:edit` | Update a role | | `DELETE` | `/v1/developer/teams/{id}/roles/{rid}` | `team-settings:edit` | Delete a role | | `GET` | `/v1/developer/teams/permissions` | Auth (first-party user) | List all available team permissions | > **Protocol parity:** All endpoints have matching GraphQL queries/mutations - see [GraphQL](./graphql.md#developer-teams). ## 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](./graphql.md#developer-store-profiles). ## Extension Store Icons ### `GET /v1/store/extensions/{slug}/icon` Returns icon metadata for a store extension: `extension_id`, `icon_key` and `icon_url` (the absolute URL of the byte route below, or `null` when the extension has no icon). Returns JSON, not the image. **Auth:** None (public). `404 Extension not found` unless the extension is visible on the store detail page (status `published` or `deprecated`, visibility `public`, `paid`, `unlisted` or `system`). ### `GET /v1/store/extensions/{id}/icon-data` Serves the icon image of a store extension by its UUID. **Auth:** None (public). Same visibility as the store detail page. **Response:** the stored bytes with `Content-Type` `image/png`, `image/jpeg` or `image/webp` (from the stored key), `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox` and `Cache-Control: public, max-age=3600`. | Status | When | |--------|------| | `404 Extension icon not found` | Unknown extension, not store-visible, no icon, a stored key that is not PNG/JPEG/WebP, or the object is missing from storage | | `503 ICON_UNAVAILABLE` | File storage is not configured | REST only: raw image bytes have no GraphQL representation. Every `icon_url` / `iconUrl` the API returns for an extension points at this route — the store icon metadata and the developer icon upload as an absolute URL, the marketplace lists and detail (REST `/v1/marketplace/*` and the GraphQL `marketplace*` queries) as the API-relative path `/v1/store/extensions/{id}/icon-data`. ## Extension Bot Module Endpoints ### `GET /v1/internal/bot-modules/{account_id}/triggers` Returns the resolved bot-module trigger set for the given account. This is an internal endpoint the platform bots call on connect (and on `trigger_sync`) to build their in-memory matcher. Per install, the effective triggers are resolved by layering per-install overrides from `extension_installs.config.triggers` over the version manifest's `triggers` (COALESCE: config wins, manifest is the default). **Auth:** System key carrying `bots:internal` only. A non-System caller gets `403 System key required for internal endpoints`; a System key without the grant gets `403 Missing permission: bots:internal`. **Path parameter:** `account_id` - UUID of the account. **Response:** the raw body (no `data` envelope) - the bots deserialize it directly. Each trigger's `handler` is the `function_name` the bot sends to the Worker's `/v1/execute` (command → command name, keyword → keyword text, pattern → regex source). `killed` is the account's stored kill switch (`account_extension_kill_switch`), so a restarted bot starts killed; live changes arrive over Redis pub/sub on top. ```json { "installs": [ { "install_id": "uuid", "extension_id": "uuid", "extension_name": "My Bot Module", "commands": [ { "name": "rank", "alias": null, "cooldown_global": 5, "cooldown_user": 10, "min_role": "everyone", "enabled": true, "handler": "rank" } ], "keywords": [ { "value": "gg", "enabled": true, "handler": "gg" } ], "patterns": [ { "regex": "^!\\w+$", "enabled": true, "handler": "^!\\w+$" } ], "has_moderate": false, "platforms": ["twitch", "youtube"] } ], "killed": false } ``` ### `GET /v1/internal/bot-modules/handlers` Returns the resolved handler-code bundle plus trusted context for every installed bot-module extension. The Bot Module Worker consumes this to populate its in-memory `InstallRegistry` at startup and to refresh it incrementally on install / uninstall / update. Where the trigger feed above delivers only trigger *metadata*, this feed delivers the executable handler code. **Auth:** System key carrying `bots:internal` only. A non-System caller gets `403 System key required for internal endpoints`; a System key without the grant gets `403 Missing permission: bots:internal`. **Query parameters (all optional):** | Field | Type | Description | |-------|------|-------------| | `account_id` | UUID | Scope to a single account (incremental account refresh). | | `install_id` | UUID | Scope to a single install (incremental install/update refresh). | | `bundles` | `by_version` | Bundle layout. `by_version` ships each distinct bundle once in a top-level `bundles` map and enables the `ETag` / `304` change check. Omitted: every install carries its own `handler_code`. Any other value is a `400`. | Omitting `account_id` and `install_id` returns a full cross-account snapshot (initial registry load). Disabled installs are included (with `enabled: false`) so the worker can short-circuit them. **Response:** the raw body (no `data` envelope) - the worker deserializes it directly. `handlers` lists the valid `function_name`s the bots dispatch (command name / keyword text / regex source / `"moderate"` / `"timer"`, plus subscribed event types and timer names); every name maps to the same bundle. `command_min_roles` is each command's effective `min_role`, `events` the subscribed event types (manifest `triggers.events`), `timers` the timers the worker schedules (`[{ "name", "interval_secs" }]`), and `killed` whether the account's extension kill switch is on. `timers` covers the SDK form `triggers.timers[{ name, interval }]` (entries under 60 s or without a name are skipped) and the legacy `triggers.timer: true` form, which yields one timer named `"timer"` whose interval is the install's `intervalSeconds` setting, clamped to 60..86400, default 300. Where the bundle lives depends on `bundles`: - **`bundles=by_version`** (what the Bot Module Worker sends): every install's `handler_code` is `null`, and `bundles` maps each install's `version_id` to the compiled `server.js` bundle, or to `null` when it cannot be resolved (missing file / storage unavailable). Installs sharing a version share one entry, so each bundle is downloaded from storage and sent at most once per response. - **omitted**: `handler_code` is the bundle (one string per install) or `null` when unresolvable; there is no `bundles` key. **Change check (`bundles=by_version` only):** the response carries an `ETag` over the install metadata (everything above except the bundles; `version_id` identifies the bundle). Send it back as `If-None-Match` and the API answers `304 Not Modified` with no body while nothing has changed. It decides that before downloading any bundle. The `ETag` is omitted while any bundle is unresolvable, so the next request downloads again. ```json { "installs": [ { "install_id": "uuid", "extension_id": "uuid", "account_id": "uuid", "extension_name": "Link Protection", "version": "1.0.2", "version_id": "uuid", "install_config": { "action": "delete" }, "enabled": true, "platforms": ["twitch", "youtube", "kick"], "has_moderate": true, "handlers": ["moderate"], "handler_code": null, "command_min_roles": {}, "events": [], "timers": [], "killed": false } ], "bundles": { "uuid": "async (ctx, args) => { /* compiled server.js */ }" } } ``` ### `POST /v1/internal/bot-modules/{install_id}/actions` Runs the `actions` of one bot-module handler result. The Bot Module Worker (keyword, pattern, event, timer handlers) and the chat bots (command handlers, moderate decisions) call it after the reply, at most once - there is no retry, because a retried `chat:ban` is not idempotent at the platform. Not part of `openapi.json`, like the other internal feeds. **Auth:** System key carrying `bots:internal` only. A non-System caller gets `403 System key required for internal endpoints`; a System key without the grant gets `403 Missing permission: bots:internal`. **Path parameter:** `install_id` - UUID of the bot-module install. The account always comes from the install row, never from the request. **Request body:** ```json { "trigger_type": "moderate", "handler": "moderate", "platform": "twitch", "actions": [ { "action": "chat:timeout", "params": { "platform": "twitch", "user_id": "123", "duration_secs": 600 } } ] } ``` | Field | Type | Description | |-------|------|-------------| | `trigger_type` | string | `command`, `keyword`, `pattern`, `event`, `timer` or `moderate` | | `handler` | string | Handler function name (command name, event type, timer name, …), 1-200 characters | | `platform` | string, optional | Platform the trigger came from | | `actions` | array | `{ "action", "params" }` items, at most 20; `params` defaults to `{}` | **Errors:** | Status | When | |--------|------| | `400` | Unknown `trigger_type`, empty or over-long `handler`, or more than 20 actions | | `404` | `Bot module install not found or not runnable` - the install is uninstalled, disabled, not a bot module, its extension is suspended, its channel is in the dissolution grace period, or the account's kill switch is on | **Response `200`:** the raw body, one result per action in request order. One failing action does not stop the next. ```json { "results": [ { "action": "chat:timeout", "success": true }, { "action": "obs:set_scene", "success": false, "error": "Permission denied: Extension does not declare the required permission: obs:set_scene", "error_code": "denied" } ] } ``` Each action runs through the same dispatcher and manifest-permission gate as widget and app actions (the 21 supported actions): the manifest must declare the action name as a permission, the account's plan must include the action's feature (`spotify:*` → `feature:music`, `obs:*` → `feature:obs_remote`, `chat:*` → `feature:multichat`), and the params must be valid. | `error_code` | Meaning | |--------------|---------| | `denied` | Refused before dispatch: unknown action, invalid params, permission not declared, plan feature off, or `params` not an object. The message is the one the widget path returns | | `rate_limited` | The install exceeded `[extensions] bot_module_action_rate_per_minute` (default 120) in the current minute | | `rate_limit_unavailable` | Redis could not count the action; refused (fail-closed) | | dispatcher codes (`bot_not_connected`, `bot_scope_missing`, `moderation_forbidden`, `platform_not_supported`, …) or none | The action failed during execution | The rate cap is counted per install in Redis `lumio:botmod:act_rl:{install_id}:{epoch_minute}` (TTL 120 s). Every outcome is written to the install's log (`extension_install_logs`, level `info` on success, `warn` otherwise), at most one row per second and install per API process; after the cap is hit only the first refused action of the minute is logged. A row's args hold only the action name and, for `chat:*`, `platform` / `user_id` / `message_id` - never message text or other params. `chat:ban`, `chat:unban` and `chat:timeout` emit the audit event `account:extension_moderation` with `source: "bot_module"` and an empty origin (service-to-service call). A bot module's `chat:delete` is not audited; it appears in the install log only. See [Audit Log](../features/audit-log.md). ### `GET /v1/internal/automation-nodes/handlers` The automation-node counterpart of the bot-module handler feed - resolves the handler bundle + context for every installed `automation_node` extension for the Automation Worker's `InstallRegistry`. **Auth:** System key carrying `bots:internal` only. A non-System caller gets `403 System key required for internal endpoints`; a System key without the grant gets `403 Missing permission: bots:internal`. **Query parameters:** identical to the bot-module handler feed (`account_id`, `install_id`, both optional). **Response:** raw body. `node_type` is the node's kind (`trigger` / `action` / `logic`); `handler_code` is the single compiled `server.js` bundle (automation nodes have exactly one handler), or `null` when unresolvable. ```json { "installs": [ { "install_id": "uuid", "extension_id": "uuid", "account_id": "uuid", "extension_name": "HTTP Request", "version": "1.0.0", "install_config": { "url": "https://example.com" }, "enabled": true, "node_type": "action", "handler_code": "async (ctx, args) => { /* compiled server.js */ }" } ] } ``` ### `POST /v1/bot-modules/kill` Sets the extension kill switch for the caller's account: while `killed` is `true`, the bots match no extension bot-module trigger for the account, and every handler server function (`query()` / `mutation()` / `action()`, [`/execute`](#post-v1extension-installsinstall_idexecutefunction_name)) of the account's extensions of any category, widgets included, is refused with `403 EXTENSIONS_KILLED`. Declarative server functions (`/query`, `/mutate`) keep working. The state is stored in Postgres (`account_extension_kill_switch`), so it survives a bot restart, and is pushed live over `lumio:botmod:kill:{account_id}`. Install configuration is untouched. **Auth:** Account auth (JWT, API key, or popout token). **Permission:** `bot-modules:edit` **Request body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `account_id` | UUID | Yes | Must be the caller's active account (`403 Cannot control kill switch for another account` otherwise) | | `killed` | boolean | Yes | `true` stops extension triggers, `false` resumes them | **Response:** `200 {"ok": true}`. Emits the account-scoped audit event `account:extension_kill_switch_set` (metadata: `killed`). **GraphQL twin:** `setBotModuleKillSwitch(accountId: UUID!, killed: Boolean!): Boolean!` (same permission, check, error and audit event). ## Developer Extension Endpoints Create and update requests accept only `private`, `unlisted`, `public`, or `paid` visibility. `system` is reserved for Lumio, and a developer slug beginning with `system-` (ignoring case and surrounding whitespace) is rejected. REST returns `400` with `Invalid visibility: . Allowed: private, unlisted, public, paid` or `The slug prefix 'system-' is reserved for Lumio system extensions`. GraphQL applies the same validators. ### Extension secrets Secrets are the extension author's credentials (for example a third-party API key). Each extension has **one** set, shared by every install: bot-module handlers read it with `ctx.secrets.get(key)`. The developer's active account plays no part, so a developer key (`lm_dev_`) manages secrets like a logged-in developer. Values are encrypted with AES-256-GCM before they are stored and are never returned. | Method | Path | Permission | Description | |---|---|---|---| | `GET` | `/v1/developer/extensions/{id}/secrets` | `extension-dev:read` (+ `team-secrets:read` on a team extension) | List secret key names | | `PUT` | `/v1/developer/extensions/{id}/secrets/{key}` | `extension-dev:edit` (+ `team-secrets:edit` on a team extension) | Create or replace a secret. Body `{"value": "..."}` | | `DELETE` | `/v1/developer/extensions/{id}/secrets/{key}` | `extension-dev:edit` (+ `team-secrets:edit` on a team extension) | Delete a secret (`204`, also when no secret had that key) | - **Order of checks:** ownership first (`404 Extension not found` for an extension the caller's profile or teams do not own), then the team right (`403 Missing team permission: team-secrets:edit`), then validation. - **Validation:** the key must match `^[A-Z0-9_]{1,64}$` (`400 Secret key must match ^[A-Z0-9_]{1,64}$`); the value must be 1 to 4096 bytes (`400 Secret value must be between 1 and 4096 bytes`). - **Response row:** `id`, `extension_id`, `account_id` (always `null`), `key`, `created_at`, `updated_at`. - **Audit:** set emits `developer:extension_secret_set`, a delete that removed a row emits `developer:extension_secret_deleted` (user scope, metadata `extension_id`, `key`, and `developer_key_id` for a developer key; never the value). - **Runtime:** a changed or deleted secret reaches running bot-module handlers within 60 seconds. **GraphQL twins:** `developerExtensionSecrets(extensionId)`, `setExtensionSecret(extensionId, key, value)`, `deleteExtensionSecret(extensionId, key): Boolean!` (same permissions, checks, messages and audit events). ### Version creation, upload, and submission | Method and path | Request | Validation | |---|---|---| | `POST /v1/developer/extensions/{id}/versions` | JSON `version`, `manifest`, optional `data_schema` | Manifest schema, extension ID, and manifest version must match the request; `data_schema` is validated and checked to be additive. | | `POST /v1/developer/extensions/{id}/versions/upload` | Multipart `files`, `source`, `manifest`, optional `data_schema` (JSON), and version metadata | Manifest schema and extension ID are validated; bundle storage keys are server-derived; `data_schema` as above. Each `files` filename is a relative `/`-separated path: `..`, absolute paths, backslashes, empty or `.` segments, NUL bytes and file types outside the allow-list return `400`. | | `POST /v1/developer/extensions/{id}/versions/{vid}/submit` | Draft version ID in the path | Stored manifest is revalidated before the version enters review. | `data_schema` is the `defineSchema` result of `server/schema.ts` (`{"tables": {"": {"columns": {"": {"type": "string|number|boolean|array|object", "isOptional": bool}}, "storageScope": "instance"}}}`). It is stored once, normalised, with the version; omitted or `null` means the version declares no data. An invalid schema (name rule `^[a-z][a-z0-9_]{0,39}$` without `pg_*` / `ext_*`, at most 32 tables, 64 columns per table, 64 KiB, storage scope `instance` only) returns `422 INVALID_DATA_SCHEMA`. A change that is not additive against the extension's tables (type change, new required column on an existing table, optional → required) returns `422 DATA_SCHEMA_NOT_ADDITIVE` with every violation in `details.violations` (`table`, `column`, `rule`, `applied`, `declared`). The tables are created at approval, not at upload: `POST /v1/extensions/reviews/{id}/approve` (GraphQL `approveExtensionVersion`) provisions the version's schema on the extensions database before the status changes. A version that is no longer additive is not approved: `422 DATA_SCHEMA_NOT_ADDITIVE` with the violation list, which is also stored as the version's review notes. When live queries hold the tables for longer than the lock timeout (three retries), approval returns `503 EXTENSION_TABLES_BUSY` and can be retried. The first install of an approved version re-creates tables an earlier last uninstall removed. See the developer docs → Server Functions → Schema. Manifest failures return `400 Manifest validation failed: ...`; a version mismatch returns `400 Version '' does not match the manifest version ''`. Submission leaves the draft unchanged when validation fails. For a team-owned extension, create and upload require `team-versions:create`, and submit requires `team-extensions:publish`. A member without the role gets `403 Missing team permission: `; a caller without ownership gets `404` first. The same team-role check applies to editing, deleting, analytics, and tester/access management as described in [Team-Scoped Permissions](./permissions.md#team-scoped-permissions-developer-teams). > **Developer API keys (`lm_dev_`):** > These publishing routes accept a **developer API key** (`lm_dev_`) as well as a logged-in user session or account API key. A developer key is bound to a developer profile or team, is **decoupled from the `feature:extension_development` plan gate**, and reaches exactly the ownership-scoped publishing operations here - its own extensions, versions (create / upload / list), analytics, limits, purchases, testers (list / remove), screenshots and the extension's **secrets** (see [Extension secrets](#extension-secrets)). It is **refused** (fail-closed) from: > > - the **first-party** routes - the developer profile / payout / revenue / limit-request surface, plus `POST …/extensions` (create), `…/versions/{vid}/submit`, `…/testers` (invite) and `…/deletion-request`, which require a logged-in user. > > See [Authentication → Developer API Keys](./authentication.md#developer-api-keys). > **Telemetry retention & availability:** > Extension telemetry lives in TimescaleDB hypertables with bounded retention policies, so the tables cannot grow without bound - aged chunks are dropped automatically: > > - **Runtime errors** (`extension_errors`, surfaced by `/errors`): **90 days**. > - **Per-install execution logs** (`extension_install_logs`): **30 days**. > - **Install/uninstall/config-change analytics** (`extension_events`, feeding `/metrics`): **180 days**. > > A query window wider than a table's retention returns only the rows still inside the window. > > An `extension_errors` row keeps no viewer or account data: account and install IDs are HMAC-hashed, the error message and stack trace are scrubbed of UUIDs, @-mentions, tokens and IPs, the install config is reduced to its key names, and the handler args are stored as **shape only** - their keys and value types (`""`, `""`, `""`), never the values. The `/errors` endpoint does not return the args. > > The retention policies are active today. `extension_errors` and `extension_install_logs` now have their **writers wired**: the bot-module and automation workers record one row per handler execution (an `extension_errors` row on failure, an `extension_install_logs` row on every run), fail-open, once a worker has a TimescaleDB pool configured. Rows only begin to flow when the workers actually execute handlers against live installs - i.e. once the worker `InstallRegistry` is populated at runtime (tracked separately); until then these tables stay empty even though the writers are present. `extension_events` (install/uninstall/config-change analytics feeding `/metrics`) is still unwired. Where a table has no rows yet, its endpoint returns an **empty result set** - the example payloads below illustrate the response shape, not live data. ### `GET /v1/developer/extensions/{id}/errors` Returns recent runtime errors for an extension owned by the authenticated developer. Useful for debugging server function failures and trigger handler exceptions. **Auth:** Account auth (JWT or API key). The caller must be the extension owner or a member of the owning developer team. **Permission:** `extensions:read` (developer scope) **Path parameter:** `id` - UUID of the extension. **Query parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `limit` | integer | Max results (default 50, max 200) | | `offset` | integer | Pagination offset | | `since` | ISO-8601 | Only errors after this timestamp | **Response:** ```json { "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:** ```json { "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:** ```json { "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:** ```json { "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:** ```json { "data": { "max_sounds": 50, "max_sounds_source": "default" } } ``` `max_sounds_source` is one of: `"default"` (platform default 50), `"developer_override"` (admin set on developer), `"extension_override"` (admin set on this extension). ### `GET /v1/developer/payout-settings` Returns the authenticated developer's saved payout settings - payout method, Stripe Connect id, PayPal email, and SEPA bank IBAN/BIC/name - for prefilling the payouts form. Companion read to the `POST /v1/developer/payouts` update (same private `DeveloperProfile` shape, gated on the payout permission). REST twin of the GraphQL `developerPayoutSettings` query. The update (`POST /v1/developer/payouts`) bounds the plaintext with the same rule and error text as GraphQL `updateDeveloperPayoutSettings`: IBAN at most 34 characters not counting spaces (50 with spaces), BIC at most 11, PayPal email and bank name at most 255 each. A longer value is refused with `400` (`IBAN must be at most 34 characters, not counting spaces`, `BIC must be at most 11 characters`, `PayPal email must be at most 255 characters`, `Bank name must be at most 255 characters`) before anything is stored. The four fields are stored encrypted. **Auth:** Account auth (JWT or API key). The caller must be a developer; a popout/overlay/widget/extension token is rejected with `403 FORBIDDEN` (ZAF-471 first-party gate). **Permission:** `extension-dev:payouts` **Feature:** `feature:extension_development` **Response:** ```json { "data": { "id": "uuid", "user_id": "uuid", "stripe_connect_id": null, "paypal_email": "dev@example.com", "bank_iban": null, "bank_bic": null, "bank_name": null, "payout_method": "paypal", "revenue_balance": 12500, "total_earned": 48000, "created_at": "2026-08-01T10:00:00Z", "updated_at": "2026-09-05T12:00:00Z" } } ``` Returns `404 NOT FOUND` (`Developer profile not found`) if the caller has not applied as a developer. ## Extension Bundle Serving ### `GET /v1/extension-bundles/{extension_id}/{version}/{path}` Serve a file from an extension version's bundle. The `path` is a catch-all that resolves to a file in the version's `extension_version_files` table. **Auth:** Published versions are publicly accessible (no auth required). Draft and testing versions require developer authentication (JWT or API key of a developer of the extension, its own extension token, or a system key carrying `bots:internal` - a system key without the grant gets `403 Missing permission: bots:internal`). **Path parameters:** | 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.gz` is never served (returns 403) - Bundle and source storage keys are derived by the server. Legacy bundle and file records are served only when their key belongs to that extension version's storage namespace. **Response:** The raw file bytes with the stored `Content-Type` header. Cache headers depend on whether the resolved version is published: - **Published** - auth-independent bytes, identical for every caller: `Cache-Control: public, max-age=86400, immutable`. - **Draft / testing** - auth-dependent bytes, resolved only for a developer (via `extension_dev_mode` or an extension token) or a system key carrying `bots:internal`: `Cache-Control: private, no-store` plus `Vary: Authorization, Cookie`. They must never sit in a shared/URL-keyed cache that could serve draft bytes to a non-developer request without re-running the draft-access check. Every successful bundle response includes `X-Content-Type-Options: nosniff`. HTML, SVG, JSON, source maps, Lottie, and other content outside JavaScript, CSS, raster images, fonts, audio, and video also receive `Content-Security-Policy: sandbox` and `Content-Disposition: attachment`. The dashboard bundle proxy applies the same content-handling headers; SVG remains usable as an image resource. **Legacy compatibility:** `GET /v1/extension-bundles/{extension_id}/{version}/bundle.js` is preserved as a dedicated route and falls back to the `bundle_key` column for pre-migration versions. ### `GET /v1/extensions/{id}/versions/{version}/files` List all files in an extension version's bundle. **Auth:** Published versions are publicly accessible. Draft and testing versions require developer authentication. **Path parameters:** | Parameter | Description | |-----------|-------------| | `id` | UUID of the extension | | `version` | Semver version string | **Response:** ```json { "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](./graphql.md#extension-version-files). ## 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:** ```json { "data": { "url": "https://api.lumio.vision/v1/extensions/{extension_id}/uploads/{hash}.png" } } ``` ### `GET /v1/extensions/{extension_id}/uploads/{filename}` Serve an uploaded file. Public access (URLs are unguessable due to content-hash filenames). **Response:** Raw file bytes with correct `Content-Type` and `Cache-Control: public, max-age=86400, immutable`. ## Extension Install Endpoints Every install object (`GET /v1/extension-installs`, the install, configure, auto-update and pin responses, and GraphQL `InstalledExtension`) carries `targets`: the manifest `targets` of the installed version (`editor`, `layer`), or `[]` when the manifest lists none. The overlay editor offers every install with a `layer` target as a layer, a bot module with a layer included. `POST /v1/extension-installs` accepts a published extension with `public`, `paid`, `unlisted`, or `system` visibility. A published private extension also needs an access grant for the account or a tester invitation for the acting user. Other targets return `404 Extension not found`, including after a private access grant is revoked. The GraphQL `installExtension` mutation uses the same eligibility check. `POST /v1/extension-purchases` applies it before it creates a Stripe Checkout session or installs through a free-access grant (`404 Extension not found` otherwise), and the payment webhook applies it again before its install. A payment that completes after access was revoked, or after the extension or its version was deleted or unpublished, is recorded without an install and refunded in full in the same webhook pass (a subscription is cancelled at once first); the purchase turns `refunded` when Stripe reports the refund. Purchase has no GraphQL twin. Installing an extension the account already has returns `409 Extension is already installed`. `DELETE /v1/extension-installs/{id}` removes the install from every install read at once: lists, lookups, the per-install function/action/storage endpoints (`404 Extension install not found`) and the internal bot/automation handler feeds. Unless `delete_data_immediately=true` is passed, the extension's stored data is kept for 30 days. Either way only this account's data is deleted: the extension's database schema `ext_{extension_id}` is shared by every account that installs it, so the account's rows are deleted from it and the schema is dropped only with the extension's last install. The uninstall emits the account-scoped audit event `account:extension_uninstalled` (`extension_id`, `install_id`, `delete_data_immediately`). a `POST /v1/extension-installs` for the same extension within that window restores the install on the latest published version with its saved configuration and returns `201`. GraphQL `installExtension` / `uninstallExtension` behave the same. ### `GET /v1/extension-installs/{install_id}/storage` All key/value entries of an install's extension storage (`useExtensionStorage()`), ordered by key. Auth: the install's own extension token, or a user session / personal key / service key of the owning account with `extension-store:read`. An overlay token whose overlay renders the install in an extension layer (`ext:{slug}`), or a widget token whose widget renders it, may read too; the install must be runnable (its extension and version not suspended, the install enabled, the account not dissolving), else `404 Extension install not found`. A token of another overlay or widget gets `403 Extension install belongs to a different overlay` / `… widget`; shared overlay links and popout tokens get `401`. Feature: `feature:extensions`. An uninstalled install (pending its 30-day data deletion) answers `404`. GraphQL twin: `extensionStorage(installId)`. ```json { "data": [ { "key": "volume", "value": 0.8 } ] } ``` ### `POST /v1/extension-installs/{install_id}/query/{function_name}` and `/mutate/{function_name}` Declarative server functions on the extension's tables, called by name. `function_name` is an export of the extension's `server/functions.ts`; the API looks it up in the functions manifest (`functions.json`) of the version the install runs (its pinned version, else its current one). A version's manifest never changes after upload, so the API parses it once per version and process and keeps it in memory (16 MiB budget); a call costs one database lookup, the install check that also names the version. The body is `{"params": {...}}`: the values for the function's `arg()` references. - **Resolution:** `query` runs a `queryRows` definition, `mutate` an `insertRow` / `patchRow` / `deleteRow` definition. The definition supplies the table, filters (`=`, `!=`, `>`, `>=`, `<`, `<=`, `like`, `in`), order (default `created_at` ascending), `limit` (default 100, clamped to 1000), `offset` (clamped to 100 000) and row values. `arg()` values are bound as typed SQL parameters, never as SQL text; a value the definition fixes cannot be overridden by `params`. `identity()` is the calling user's id (`null` for token callers). A missing `arg()` is `null` in values and filters, "not set" for `limit` / `offset`, and ``400 missing argument `id` `` for a row id. - **Errors:** `404 Unknown server function: {name}` (also when the version ships no functions); `400 Server function '{name}' is a mutation, not a query` (and the reverse); `400 Server function '{name}' is a handler; call it through execute`; `400 invalid argument ...` / `row id must be a UUID`. - **Editor-only:** a definition with `editorOnly: true` runs only for the editor surface. The surface comes from the credential, never from the request: a dashboard session, personal API key or System key is the editor; an extension, overlay or widget token is a rendered extension and gets `403 Editor-only function called outside editor`. - **Raw operations:** dashboard sessions, personal API keys and System keys may instead send the operation itself. `query`: `table`, `filters` (`[{field, op, value}]`, `op` ∈ `eq|neq|gt|gte|lt|lte|like|in`) and `limit` (newest first). `mutate`: `table`, `function_type` (`insert_row` | `patch_row` | `delete_row`), `args` (the column values) and `row_id` (patch/delete). A request with `table` is a raw operation and `params` is ignored. Extension, overlay and widget tokens get `403 Raw data operations need a signed-in session or an API key; call the server function by name`. - **Response:** `{rows, affected_count}`; every row is the full row as JSON including `_id`, `_account_id`, `_created_at` and `_updated_at`. - **Scope:** every statement is limited to the install's account; another account's `_id` matches nothing. `_`-prefixed columns cannot be written or filtered. - **Validation:** unknown tables/columns and values of the wrong type return `400` (`unknown table: ...`, `unknown column ...`, `invalid value for column ...: expected ...`, `column ... is not nullable`, `cannot order by column ...`). An extension whose installed versions declare no data returns `404 NO_DATA_SCHEMA` (`this extension has no data schema`). - **Limits:** per install and minute, 100 reads (`query`) and 200 writes (`mutate`), shared with the extension's bot-module `ctx.db`; over the limit `429 RATE_LIMIT_EXCEEDED`. When the rate-limit store is unreachable, reads run and writes return `503 EXTENSION_DATA_UNAVAILABLE` (fail-closed). A statement over 2 s returns `504 EXTENSION_QUERY_TIMEOUT`; no free extensions-database connection within 2 s returns `503 EXTENSION_DATA_UNAVAILABLE` (`db busy`). - **Callers:** `User` / `ApiKey` of the install's account need `extension-data:read` (query) or `extension-data:edit` (mutate). System keys need `bots:internal`. An extension token works only for its own install; an overlay token only for an install that an extension layer (`ext:{slug}`) of its overlay renders; a widget token only for its widget's install. Shared overlay links and popout tokens are refused. The manifest is checked when the version is uploaded (`POST /v1/developer/extensions/{id}/versions/upload` with the `functions.json` file, or GraphQL `uploadExtensionVersion` with `functionsManifest`): format version, names, operators, fixed row ids, and every table against the version's data schema. A failed check returns `400` with the reason, e.g. `` functions.json: `getRules` uses table `missing`, which the version's data schema does not declare ``. **GraphQL twins:** `extensionQuery(input)` and `extensionMutate(input)` (`functionName` + `params`, or the raw fields): same callers, resolution, validation, limits, messages and codes. ### `POST /v1/extension-installs/{install_id}/execute/{function_name}` Handler server functions: a `query()`, `mutation()` or `action()` export of the extension's `server/functions.ts`. Body: `{"args": {...}}`, passed to the handler as its `args`. The API looks `function_name` up in the functions manifest of the version the install runs; the definition must be a handler (`400 Server function '{name}' is not a handler; call it through query or mutate`). The handler code is that version's `server.js`, never taken from the request. The API sends the call to the Bot Module Worker (`POST {bot_module_worker.url}/v1/server-functions/execute`, `Authorization: SystemKey`), which runs it in a V8 isolate. V8 does not run in the API process. - **Callers and checks:** as for `query` / `mutate`; an install whose code may not run (its extension or the version it runs suspended, the install disabled, the account in its dissolution grace period) is `404 Extension install not found` for every caller and never reaches the worker. `User` / `ApiKey` need `extension-data:read` for a `query()` handler and `extension-data:edit` for `mutation()` / `action()`. `editorOnly` comes from the definition; tokens get `403 Editor-only function called outside editor`. While the account's extension kill switch is on, every call gets `403 EXTENSIONS_KILLED`. - **Handler context:** `ctx.db` is the shared data executor on the install's account (same limits as above); a `query()` handler is read-only - its `ctx.db` writes, `ctx.cache` writes and `ctx.defer` jobs are refused inside the handler, so `extension-data:read` never causes a stored write (it can still trigger the handler's own `ctx.realtime.push` broadcasts, under the install's realtime cap); `ctx.auth` is `{userId, accountId, role}` (`userId` is `null` for token callers); `ctx.config` the install's config; `ctx.cache`, `ctx.secrets` and `ctx.defer` as for bot modules; `ctx.fetch` refuses every host (deny-all egress). - **Limits:** 5 s wall-clock and 256 MB heap per call; per install and minute 100 `query()` calls (shared with `/query`), 50 `mutation()` and 20 `action()` calls (`429 RATE_LIMIT_EXCEEDED`; when the worker's rate-limit Redis fails, calls are refused the same way rather than let through); per worker replica at most 16 handlers at a time and 4 per account (`[server_functions]` in the worker config) - a call that finds no free slot gets `429 RATE_LIMIT_EXCEEDED` at once instead of waiting; `server.js` at most 5 MiB. - **Realtime:** the worker returns the handler's `ctx.realtime.push(topic, payload)` calls with its value. The API validates them (topic 1-64 characters of `A-Z a-z 0-9 _ . : -` starting with a letter or digit, payload at most 16 KiB serialized, at most 32 pushes per call; the isolate applies the same rules at `ctx.realtime.push`, so a bad push throws inside the handler, and a batch that still arrives invalid is dropped while the call succeeds, since the handler's writes are already committed) and publishes them in order on `lumio:extension_install:{install_id}`, which reaches WebSocket subscribers of `extension_install:{install_id}` (see [WebSocket](./websocket.md)). At most 300 pushes per install and minute (shared with the install's bot-module handlers); pushes over that are dropped and the call still succeeds. - **Response:** `{"data": }`. - **Errors:** `404 Unknown server function: {name}` / `The installed version has no server bundle`; `404 Extension install not found` (see callers above); `422 HANDLER_ERROR` (the first line of the message the handler threw, at most 500 characters, without its stack trace; `Handler execution failed` for a failure outside the handler's code, whose text never reaches the caller); `422 HANDLER_MEMORY_LIMIT`; `504 HANDLER_TIMEOUT`; `503 EXECUTOR_UNAVAILABLE` (`Server-function executor is unavailable; try again later`) when `[bot_module_worker]` has no `system_key`, the worker is unreachable or fails, or the bundle cannot be read from storage. **GraphQL twin:** `extensionExecuteHandler(input: {installId, functionName, args})`: same callers, checks, limits, messages and codes. ### `POST /v1/extension-installs/{install_id}/storage` Set one key. Body: `{ "key": "volume", "value": 0.8 }` (`value` is any JSON). Returns the stored entry and broadcasts `storage:update` on the WebSocket channel `ext-storage:{install_id}`. Auth as above, with `extension-store:configure`. Overlay and widget tokens never write storage: `403 Overlay and widget tokens cannot write extension storage`. A rendered layer that has to change state calls a server function instead. GraphQL twin: `setExtensionStorage`. Limits per install, checked on every write: key at most 256 bytes (UTF-8, not empty, no `:`; surrounding whitespace is trimmed), value at most 64 KiB of JSON, at most 1 000 keys and 1 MiB in total (keys + JSON values). Overwriting an existing key with a smaller value is always allowed. A rejected write stores nothing and returns `400 BAD_REQUEST` with the same message as GraphQL, e.g. `Bad request: Extension storage is limited to 1000 keys per install`. The data lives in PostgreSQL (`extension_storage`) and is deleted with the install: immediately on uninstall with `delete_data_immediately`, after the 30-day retention window otherwise, and on account dissolution. ### `GET /v1/extension-installs/{id}/logs` Returns the newest handler-execution logs (up to 100, newest first) of one bot-module or automation-node install of the caller's active account. **Auth:** Account auth (JWT, API key, or popout token). **Permission:** follows the install's extension category — `bot-modules:read` for a bot module, `automations:read` for an automation node. An install of another account, an unknown install, an install of any other category, and a caller without the category's permission all get the same `404` `Install not found`, so the endpoint reveals neither install ids nor categories. **Path parameter:** `id` - UUID of the extension install. **Query parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `since` | RFC 3339 timestamp | Only entries at or after this time | | `level` | string | Filter by log level: `info`, `warn`, `error` | **Redaction:** `message` and `stack_trace` are scrubbed of UUIDs, @-mentions, tokens and IPv4 addresses. `args` depends on the category: - **Bot module** — the positional command arguments, string values scrubbed the same way. - **Automation node** — `nodeConfig` keeps its values, with every secret-looking key (`key`, `token`, `secret`, `password`, `auth`) masked as `"***"` at any depth; `inputData` (the triggering event: viewer, message, tip, Shopify or custom data) is returned as **shape only** — keys and nesting are kept, every value becomes `""`, `""` or `""` (`null` stays `null`). The workers store rows already redacted; rows written before that are redacted when read. **Response:** ```json { "data": { "entries": [ { "time": "2026-10-09T10:30:00+00:00", "handler": "onAction", "trigger_type": "action", "platform": null, "level": "info", "message": null, "stack_trace": null, "args": { "nodeConfig": { "label": "Tip alert", "webhook_secret": "***" }, "inputData": { "user": { "name": "" }, "amount": "" } }, "user_name": null, "user_role": null, "execution_ms": 34, "success": true } ] } } ``` > **Protocol parity:** GraphQL twin `extensionInstallLogs` -- same guard, redaction and error (both call `db::extension_install_logs::read_install_logs`). See [GraphQL](./graphql.md#extension-install-logs). ## Account Bots The active account's bot list supports `twitch`, `kick` and `youtube`. Responses use the usual `data` envelope and snake_case fields. The [GraphQL twins](./graphql.md#account-bots) enforce the same account-scoped guards and validation. | Endpoint | Permission | Result | |----------|------------|--------| | `GET /v1/account-bots` | `chat-bots:read` | Account entries, newest first: `platform`, `platform_user_id`, `login`, `display_name`, `source`, `added_by`, `added_by_name`, `added_at`. | | `GET /v1/account-bots/auto-detected` | `chat-bots:read` | Read-only badge, curated-list and own-connection entries with `platform`, `source`, `identity`, `name`. | | `POST /v1/account-bots` | `chat-bots:edit` | Body: `platform` and exactly one of `platform_user_id` or `name`. A name or `@handle` must resolve before saving. Returns `{ bot, created }` with `201` for an add or `200` and `created: false` for an existing entry. | | `DELETE /v1/account-bots/{platform}/{platform_user_id}` | `chat-bots:edit` | Returns `{ removed }`; `false` when no entry existed. | A real add starts background recalculation of retained history sessions. Removing a mark does not restore already recalculated figures. Real changes emit `account:bot_list_updated`; no-op writes do not. ## Stream History All owner routes require `feature:stream_history` and the permission shown below. The public share routes are anonymous and IP-rate-limited. Structured routes mirror [GraphQL Stream History](./graphql.md#stream-history); the filtered-list file download is REST-only by design. ### `GET /v1/history` List sessions with `history:read`. `page` defaults to 1; `per_page` defaults to 25 (max 100). Filters `from`, `to` (RFC3339), `session_type` (`single`/`multi`), `platform`, `platforms`, `category`, and `search` combine. `platforms=twitch,youtube` keeps sessions with a broadcast on at least one listed platform; it cannot be combined with `platform`, and `include=sparkline` then sums only the listed platforms. `sort_by` accepts `started_at`, `duration_secs`, `peak_viewers`, `avg_viewers`, `total_messages`, `unique_chatters`, `new_followers`, `new_paid_subs`, or `estimated_revenue`; `sort_dir` is `asc` or `desc` (default date descending). Nullable stats sort last; ties sort newest first. `estimated_revenue` orders by the revenue a row shows (the converted total in `currency`, else the raw estimate), computed over the whole filtered set before paging. Without `history:revenue-read` it is accepted but keeps the default order, so the order never reveals revenue. `include` is a comma-separated set of `sparkline`, `revenue`, `milestones`, `platform_stats`, `shared_chat`; unknown values return `400`. `include=shared_chat` fills `shared_chat` for a session that ran Twitch Shared Chat and omits it otherwise: `host_broadcaster_id` (the host of the newest or running Shared Chat, `null` when unknown), `active` (a Shared Chat of the live session is running now), `channel_count` and `channels` (`platform_channel_id`, `login`, `display_name`, `avatar_url`, `is_host`, `is_own_channel`; host first, then the own channel, then join order). Sessions stored before the Shared Chat timeline list only the partner channels and have no host. `include=platform_stats` fills `platform_stats`: each platform's part of the session (`platform`, `duration_secs`, `peak_viewers`, `avg_viewers`, `new_followers`, `messages`, `paid_subs`, `gifted_subs`). `include=sparkline` fills `viewer_sparkline` (at most 32 total-viewer points, or `null`). `include=revenue` fills `estimated_revenue` only with `history:revenue-read`; otherwise the field is omitted. `include=milestones` fills each session's persisted milestones. Optional `currency` chooses converted revenue currency, defaulting to the user's display currency. A session summary in the list, report, public share or `latest_session.session` carries `live` for an unfinalized session started within the last 48 hours. The object has `computed_at`, nullable `sampled_at`, `samples`, `chat`, `top_chatters` and nullable `youtube_subscribers`. The state values are `live`, `unavailable` and `after_stream`. An `unavailable` group may contain zero or empty-list placeholders; clients must display it as unknown. YouTube subscriber gain is `after_stream` during a YouTube broadcast. The session `live` is `null` after finalization or beyond the 48-hour window. It is distinct from `latest_session.live`, the dashboard snapshot. GraphQL twin: `HistorySessionSummary.live`. Every session object (list, report, public share, JSON export) carries `bot_messages` (integer or `null`): the chat messages left out of `total_messages`, `unique_chatters`, the per-platform counts, the top lists and the emote/GIF counts because a bot wrote them. `null` means unknown: the live chat calculation is unavailable, or the session was finalized before bot filtering with its chat log already gone. A live session with a completed chat calculation has a running count. See [Stream History → Bot messages](../features/stream-history.md#bot-messages). GraphQL twin: `botMessages`. `?shared_chat=with|without` filters sessions that have or never had Twitch Shared Chat, including live sessions. It combines with the other history filters and applies to the overview, trend, heatmap and list export. Invalid values return the same bad-request message as GraphQL `sharedChat`; filter options expose `has_shared_chat`. No additional permission is required. ### `GET /v1/history/overview` Return totals, platform counts, month summaries, and activity days with `history:read`. Uses the list filters and optional `time_zone` (IANA; UTC default) and `currency`. A session is grouped by its start in that zone. `months` spans the filtered set; `days` contains today plus the preceding 370 days and ignores `from`/`to` while honoring other filters. An invalid zone is `400`. `estimated_revenue` and `months[].revenue` require `history:revenue-read` and are otherwise absent. GraphQL twin: `channelHistoryOverview`. `range=today|7d|30d` uses local day boundaries in `time_zone` and fills `range` (`key`, `from`, `to`, `previous_from`, `previous_to`), `current` and `previous` KPIs (`sessions`, `total_duration_secs`, `avg_viewers`, `new_followers`, `messages`, `new_paid_subs`, `gifted_subs`), plus a zero-filled daily `kpi_series`. The overview's existing totals and estimated revenue cover the selected range; `days` still spans 371 days. Combining `range` with `from` or `to` returns 400. `latest_session` always refers to the account's newest session, ignoring filters, and includes `session`, up to 60 `viewer_series` points per platform, `live` sampled totals and `milestones`; it is `null` without sessions. GraphQL computes it only if selected. `platforms=twitch,youtube` (comma-separated `twitch`, `youtube`, `kick`) is the share filter. It keeps only sessions with a selected platform and counts only the selected platforms' share in the totals, `months`, `days`, `current`, `previous` and `kpi_series`. A session whose every platform is selected keeps its own values, so listing all platforms gives the unfiltered result. Otherwise the stream time is the time at least one selected platform was live. Ø viewers are the selected platforms' stream averages weighted by their live time and divided by that stream time, so concurrent platforms add up. The peak is the sum of their stream peaks. Followers, messages, Subs + Members and gifted Subs come from their `platform_breakdown` entries, and YouTube followers are the subscriber delta. `platforms` in the response lists only the selected ones. `estimated_revenue` and `months[].revenue` cover the whole sessions. `latest_session` becomes the newest session with a selected platform. Its `viewer_series`, `viewer_total` (the combined curve: per bucket the highest per-minute cross-platform sum, on the `viewer_series` bucket times), `live` and follower marks cover only those platforms, while tips stay session-wide, and its `session` carries `platform_stats`. An empty value means no filter. Combining it with `platform` returns 400 `` `platforms` cannot be combined with `platform` ``. An unknown platform returns 400 `` `platforms` must only contain: kick, twitch, youtube ``. Both messages are identical on GraphQL. `latest_session.shared_chat_series` contains `total` (the highest concurrent own-plus-partner sum per viewer-series bucket) and `channels` (one partner curve each, with channel identity and points). A missing partner count makes a gap, not zero. It is null without Shared Chat, when Twitch is excluded or after raw samples age out. `latest_session.live.shared_chat` gives `host_broadcaster_id`, host-first `participants`, `partner_viewers`, `total_viewers`, `partner_messages`, `session_chat_per_min` and `source` (`state` or `sample`). The session chat rate adds own and partner messages over the two latest Twitch samples. It is null until the second sample, never the own `chat_per_min` as fallback. GraphQL twins: `latestSession.sharedChatSeries` and `latestSession.live.sharedChat`. `latest_session.live` has `sampled_at`, `currency`, `viewer_count`, `peak_viewers`, `messages_total`, `chat_per_min`, `new_followers`, `paid_subs`, `gifted_subs`, `bits`, `kicks`, `superchats`, `superstickers`, `tips` and `platforms`. Each money kind has `count`, `by_currency`, `unknown_cents` and `converted_cents`; conversion is `null` if a part has no currency or rate. Tips are session-wide. `chat_per_min` uses the newest two probes; `live` is `null` before the first sample. ### `GET /v1/history/milestones` Return up to 1,000 persisted milestones under `history:read` and `feature:stream_history`, oldest first. RFC3339 `from` is inclusive and `to` exclusive; reversed bounds return 400. `platforms=twitch,youtube` keeps only those platforms' follower marks; the cross-platform records (`platform: null`) stay, and an unknown platform returns 400. Each item has `id`, `session_id`, `kind` (`viewer_record`, `sub_record`, `biggest_raid`, `follower_mark`), `platform`, `value`, `previous`, and `achieved_at` (session start). The report's `session.milestones` is always present. GraphQL twin: `channelHistoryMilestones(from, to, platforms)`. ### `GET /v1/history/filter-options` Return the account's distinct `platforms` and `categories` with `history:read`. GraphQL twin: `channelHistoryFilterOptions`. ### Tracked keywords All keyword endpoints require `feature:stream_history`. Reads and pattern tests need `history:read`; writes need `history:keywords-edit`. Their [GraphQL twins](./graphql.md#stream-history) use the same account-scoped guards, matcher and errors. | Endpoint | Result | |----------|--------| | `GET /v1/history/keywords` | `keywords` (oldest first), `last_session_id`, `last_session_started_at`, `max_keywords` (50). Each entry includes `id`, `keyword`, `match`, `case_insensitive`, `added_by`, `added_by_name`, `added_at`, `last_stream_count`; the last count is `null` when unknown. | | `POST /v1/history/keywords/test` | Body: `pattern`, `match`, optional `case_insensitive`, `sample` (at most 500 characters). Returns `valid`, `error`, and `matches` with UTF-16 `start`/`end` offsets and `text`; saves nothing. More than 30 calls per minute returns `429`. | | `POST /v1/history/keywords` | Body: `keyword`, `match`, optional `case_insensitive`. Returns `keyword`, `created`, `changed`; `201` for a new entry, `200` with `created: false` for a duplicate term and mode. | | `PUT /v1/history/keywords/{id}` | Same body and payload; `changed: false` for an unchanged entry, `404` for an unknown id, `409` for a duplicate. | | `DELETE /v1/history/keywords/{id}` | Returns `removed: false` when the entry was absent. | `match` is `word`, `contains` or `regex`; matching ignores case by default. Text modes permit 100 characters. Regex permits 200 characters, rejects empty-text matches and compiles with a 1 MiB size limit. At most 50 entries may be stored. Real writes emit `account:history_keywords_updated` and recount finalized sessions with complete chat logs in the background. ### `GET /v1/history/revenue-settings` and `PUT /v1/history/revenue-settings` Read the account's revenue assumptions with `history:revenue-read`; when none are saved, the read returns defaults with `updated_at`/`updated_by` null. The response includes `fixed_values`, a read-only list of USD `item`, `amount_cents`, `currency`, `share`, `source`, `source_url` and `note` for Twitch tiers, Prime, Bits, Kick subs and KICKs. Unit prices for these items are not writeable. Both operations also require `feature:stream_history`. The `PUT` requires `history:revenue-edit`. Send `currency`, `twitch_share_bps`, `kick_share_bps`, `twitch_ad_cpm_cents`, `twitch_ad_share_bps`, and `twitch_ad_free_viewer_bps`. Optional fields are `youtube_share_bps` (Super Chats), `youtube_supersticker_share_bps`, `youtube_membership_share_bps`, `youtube_member_levels` (`[{"name":"Fan","price_cents":499}]`), `kick_ad_cpm_cents`, `kick_ad_share_bps`, `kick_ad_free_viewer_bps`, `youtube_ad_cpm_cents`, `youtube_midrolls_per_hour`, `youtube_ad_share_bps` and `youtube_ad_free_viewer_bps`. All shares are 0–10,000 basis points; money inputs are 0–1,000,000 cents; the mid-roll rate is 0–60 per hour; currency is EUR or an ECB reference currency. The three YouTube creator shares default to 7,000 basis points when omitted. Omitting `youtube_member_levels` keeps the saved list; an empty list clears it. Omitting a Kick or YouTube ad field keeps its saved value; explicit `null` clears a nullable CPM or the mid-roll rate. `twitch_ad_cpm_cents: null` leaves Twitch ads without an amount. At most six membership levels are accepted, with trimmed, non-empty names of at most 100 characters that are unique ignoring case. GraphQL twins: `channelHistoryRevenueSettings` and `updateChannelHistoryRevenueSettings`, with the same validation. ### `GET /v1/history/youtube-revenue/status` Return the account's YouTube Analytics state without amounts. The response data has `connected` (boolean), `status` (`not_connected`, `not_granted`, `not_in_ypp`, `api_disabled`, `unchecked`, or `ok`), nullable `ypp_checked_at`, and nullable `revenue_auth_verified_at`. It requires `history:revenue-read` and `feature:stream_history`; GraphQL twin: `channelHistoryYoutubeRevenueStatus` with camelCase fields. ### `GET /v1/history/revenue-settings/youtube-member-level-suggestions` Return up to 50 level names from retained YouTube membership events, most frequent first. Each item has `name`, `events`, `last_seen_at` and `entered` (whether that name is already priced, ignoring case). The account-scoped read requires `history:revenue-read` and `feature:stream_history`. GraphQL twin: `channelHistoryYoutubeMemberLevelSuggestions`. ### `GET /v1/history/reports/{id}` Return one owned report with `history:read`; optional `currency` controls `session.display_money` and converted revenue. `session.display_money` (`currency`, `donations_cents`, `superchats_cents`, `superstickers_cents`, `rate_date`) converts donations, Super Chats and Super Stickers at the stream-date rate and needs no revenue permission; an amount without a rate (or a known currency) is `null`, and the source amounts stay in `donations_total_cents` / `donations_currency` and `platform_metrics`. On the public share payload its amounts are `null` and `currency` is the link currency. The response includes the session, per-platform streams, chart markers, top lists (`top_emotes` holds up to 20 entries, `top_chatters` and `top_gifs` up to 10; a report keeps the list length it was finalized with), and Twitch `ad_breaks` (`started_at`, `duration_secs`, `automatic`, `viewers_start`, `viewers_after`) plus `ad_stats` (break counts, total/average/min/max seconds, minutes per hour, threshold, estimated impressions, viewer change). `session.estimated_revenue` requires `history:revenue-read` and is otherwise absent. The revenue object contains `currency`, `total_cents`, `lines`, `unpriced`, `actuals`, `grand_total`, and `averages`. A line's `converted` value has `amount_cents`, `currency`, `source_amount_cents`, `source_currency`, `rate`, `rate_date`, and `convertible`. `grand_total` separates estimated and actual cents and lists `not_convertible` amounts. Missing ECB rates within seven days leave an amount in its original currency, outside converted totals. Each stream carries its metadata timeline: `tags` (string list, the tags at stream end, deduplicated, in platform order), `category_changes` (`[{ at, category, category_id }]`, ascending; the first entry is the category at stream start with `at` = the stream's `started_at`, then one entry per category change inside the stream) and `title_changes` (`[{ at, title }]`, same rules). Twitch and Kick fill them from their `stream_update` events and the live tag polls; YouTube returns empty lists, as does any stream whose values were never observed. The finalizer freezes the timeline into the report, so it survives the event-log retention; a live stream derives it on read. A Twitch stream with a Shared Chat also has `shared_viewers_peak` / `shared_viewers_avg` (peak and average of the per-bucket total of own viewers plus partners; `null` without a Shared Chat) and `shared_chat_sources`: `[{ platform_channel_id, message_count, login, display_name, avatar_url, peak_viewers, avg_viewers, active_from, active_to }]`, ordered by `active_from`, then `message_count` descending; `null` without a Shared Chat. The report also carries `links`, `commands` and `keywords` (at most 50 entries each; `null` = unknown, when neither a frozen list nor the complete chat log exists). A link has `url` (normalized), `display` (as first posted), `domain`, `count`, `first_seen_at`, `last_seen_at`, `posters` (top 5: `platform`, `user_id`, `login`, `display_name`, `count`, `is_bot`), `other_posters`, `occurrences` (first 100, oldest first: `at`, `platform`, `user_id`, `login`, `display_name`, `is_bot`) and `occurrences_truncated`; bot posters are included and flagged. A poster or occurrence of an erased chatter (and an erased `top_chatters` entry) carries `erased: true` with `user_id` `null` and empty `login` / name; the key is absent on every other entry ([Stream History → Erased chatters](../features/stream-history.md#links-and-commands)). A command has `name` (prefix + lower-cased name, e.g. `!discord`), `prefix`, `count`, `unique_users`, `first_seen_at`, `last_seen_at`, `posters`, `other_posters`, `top_args` (`[{ args, count }]`, `""` = no arguments), `occurrences` (as for links, plus `args`), `occurrences_truncated` and `known`; bot authors are left out. See [Stream History → Links and commands](../features/stream-history.md#links-and-commands). The public share payload carries the same stream fields and samples. `keywords` contains one row for every tracked keyword, including count zero: `keyword_id`, `keyword`, `match`, `case_insensitive`, `count`, `bot_count`, `unique_users`, `first_seen_at`, `last_seen_at`, `posters`, `other_posters`, `occurrences` and `occurrences_truncated`. Bot posts count and are flagged. A leading command token that matches a keyword counts as a keyword rather than a command. `null` means no frozen list and no complete chat log. GraphQL twin: `channelHistoryReport`. For a Twitch stream, `shared_chat_timeline` contains the host and the event-exact session/channel spans, frozen with `shared_chat_sources` at finalization. Older streams without transition events use minute-sample boundaries. `shared_chat_insights` contains frozen per-partner activity; partner keyword, link and command entries hold term and count only, without poster identities or occurrences. Own-channel chat and all revenue fields remain scoped to the own channel. The public share omits Whole session insights and timeline viewer figures. On an owned report, `session.estimated_revenue.youtube_analytics` contains `status` (`not_granted`, `not_in_ypp`, `api_disabled`, `pending`, `provisional`, or `final`) and nullable `fetched_at`. It is absent or `null` without a YouTube stream and omitted from public shares. `pending` retains the Lumio estimate. With `provisional` or `final`, the lines `youtube_analytics_ads`, `youtube_premium`, and `youtube_transactions` replace YouTube ad and membership estimates and Super Chat/Sticker amounts; `youtube_transactions` is derived from YouTube's reported total. The `note` is `youtube_analytics` or `derived_from_analytics`. The account read needs `history:revenue-read`; the public share report and export use only Lumio's estimate, including the total. The report also returns `announcements` and `pins` (at most 500 and 200, oldest first), with `announcements_captured`, `pins_captured`, `announcements_truncated` and `pins_truncated`. The capture flags distinguish a session that began before collection from one with zero entries. `null` lists mean unknown or not shared. A pin carries `sender`, `pinned_by`, `planned`, `changes`, `duration_seconds`, `approx`, and `end_reason` (`expired`, `unpinned`, `replaced`, `message_deleted`, `stream_end`, `ran_past_stream_end`, `unknown`, `active`). The lists retain person, time and message ID but do not freeze message text. `text_stored: false` calls for **Text no longer stored**. The GraphQL twin uses camelCase. ### `GET /v1/history/reports/{id}/automod` The messages Twitch AutoMod held during the session, oldest first, with counts per outcome. Requires `history:read` **and** `chat:automod-read` (a missing one is `403 Missing permission: …`, like GraphQL) and `feature:stream_history`; popout tokens get `403 Popout tokens cannot read the AutoMod log`. Query: `outcome` (`open`, `approved`, `denied`, `expired`; default all), `after` (the previous page's `next_cursor`), `first` (1–100, default 50); anything else is `400`. `data: null` means unavailable: the session has no Twitch broadcast, or it started before the chat retention window. A foreign or unknown session is `404 History session not found`. `text_retention_days` is the account's effective chat window in days: the entry, text included, is deleted that many days after the hold. Each entry carries `message_id`, `sender` (`user_id`, `login`, `display_name`, and the look at hold time `avatar_url`, `color`, `badges`; `null` = unknown), `text` and `flagged_spans` (code points, `end` inclusive), `emotes` (JSON array in the chat-message `emotes` shape: Twitch emotes `{id, name, url, provider: "twitch", animated}` and cheermotes `{kind: "cheermote", …}`; `null` when none; a cheer the channel's cheermote catalog does not know stays plain text), `text_state` (`stored`, or `not_stored` above the 4 KiB size cap), `reason_kind` and `reason` (`category`, `level`, `terms[{term_id, text}]`), `outcome`, `resolved_via` (`lumio`/`twitch`), `moderator_login`, `moderator_name`, `held_at`, `resolved_at`. A hold nobody decided reads `expired` once the queue TTL has passed, with no moderator and no `resolved_at`. `counts` (`total`, `open`, `approved`, `denied`, `expired`) cover the whole session, independent of `outcome`. The log is never part of the report, share links or exports. GraphQL twin: `automodLog`. ### `GET /v1/history/reports/{id}/stats` Time-series samples for a session, optionally restricted by `platform`. Requires `history:read`. `live_counters` separates cumulative `gifted_subs`, `bits`, `kicks`, `superchats`, `superstickers` and `tips`; money kinds carry `count`, `by_currency` and `unknown_cents`. Older samples have `live_counters: null`. Tips are session-wide, repeated on each platform sample of the same tick and never summed across platforms. A Twitch sample during a Shared Chat also carries `shared_viewers` (own Twitch plus all partners in that bucket; `null` otherwise) and `shared_chat_viewers` (`[{ platform_channel_id, viewer_count }]`, `[]` outside a Shared Chat). A partner missing from a bucket was not in the Shared Chat then; `viewer_count: null` means it was in it but not live. GraphQL twin: `channelHistoryStats` (`liveCounters`, `sharedViewers`, `sharedChatViewers { platformChannelId viewerCount }`). ### `GET /v1/history/export` Download the **filtered list** with `history:export`. `format=csv|json`; accepts the same list filters and sorting, plus `time_zone`, `currency`, and `locale`. Maximum 5,000 sessions. CSV is a ZIP containing `streams.csv`, `months.csv`, and `totals.csv`. Revenue columns appear only with `history:revenue-read`. This file-download endpoint intentionally has no GraphQL twin. ### `GET /v1/history/reports/{id}/export` Download a finalized report with `history:export`. `format=pdf|csv|txt|json`; optional `locale`, `time_zone`, and `currency` set language, dates, and converted amounts. PDF has up to four A4 sections (Overview, Timeline, Revenue & Ads, Chat & Streams) with page headers/footers; empty sections drop out. TXT has the same sections. CSV is a ZIP of report tables, including `revenue.csv` and `ad_breaks.csv` when applicable, and `links.csv` / `link_posts.csv`, `commands.csv` / `command_posts.csv`, and `keywords.csv` / `keyword_posts.csv` when the report carries those lists, plus `announcements.csv` and `pins.csv` when present (times in `time_zone`). `pins.csv` keeps `planned_seconds` and `duration_seconds` as numbers and adds `duration`, the real duration worded in the export language. PDF and TXT word pin durations the way the dashboard does: seconds or minutes under an hour (`12:04 Min.`), hours and minutes from one hour (`1 Std. 8 Min.`), days and hours from one day (`2 Tage 20 Std.`), rounded down. JSON keeps API field names and includes export metadata. Donations, Super Chats and Super Stickers are in the export currency without a revenue permission: the PDF overview lists them, CSV `summary.csv` adds `display_currency`, `display_rate_date`, `*_display_cents` and `*_source` (source amounts per currency), and JSON carries `session.display_money`. Revenue requires `history:revenue-read`. A live session returns `409`. GraphQL `exportHistoryReport` validates the same request and returns this download URL. ### `DELETE /v1/history/reports/{id}` Delete a stored, finalized session and its share links with `history:delete`. Returns `204`. A session that is not finalized yet (live, in its grace window or waiting for the finalizer) is refused with `409` `This stream session is still live; it can be deleted once it is finalized` and nothing is deleted; GraphQL `deleteChannelHistory` returns the same code and text. ### `GET /v1/history/reports/{id}/shared-links` List link metadata with `history:share`: expiry, active state, `options`, and `legacy_options`, but never the full token. ### `POST /v1/history/reports/{id}/shared-links` Create a link with `history:share`. Body has `duration_secs` (3600, 21600, 86400, 604800, or 2592000), optional `password`, `allow_export`, and `options`. `options` accepts `sections` (`chat`, `top_chatters`, `ads`, `revenue`, `links`, `commands`, `keywords`, `announcements`, `pins`), `include_revenue`, and `currency`. `links`, `commands`, `keywords`, `announcements` and `pins` are off unless listed; without them the corresponding public report lists are `null` and the public export leaves those sections out. A revenue option also requires `history:revenue-read`; otherwise both protocols reject it. Omitted options give the legacy view (chat and top chatters; no ads, revenue, links, commands, keywords, announcements or pins). New links with options default to chat and ads, no revenue, links, commands, keywords, announcements or pins. Maximum 20 active links per report. The response returns the plaintext token and URL once. ### `PATCH /v1/history/shared-links/{id}` and `DELETE /v1/history/shared-links/{id}` Extend a link with `{ "duration_secs": … }` or revoke it, respectively. Both require `history:share`; extending a link that shares revenue also requires `history:revenue-read` (`403`, same message as GraphQL). Delete returns `204`. ### `GET /v1/history/shared/{token}` Resolve a public link. The response contains the permitted report sections, time-series samples, `options`, `channel` (`display_name`, `avatar_url`, `platform`), `expires_at`, and `allow_export`. A shared sample carries only its time, platform, viewers (`shared_viewers` / `shared_chat_viewers` included), message, follower and sub totals and the money-free `platform_metrics` counts: `donations_total_cents` is `0` and `live_counters` is `null`, with or without `include_revenue`. The shared `top_chatters` and the posters and occurrences of `links`, `commands` and `keywords` are served as frozen, without a read-time chatter mask: chatters the channel had erased appear blanked (`erased: true`, counts kept) because the instructed erasure scrubbed the stored sessions and the channel's suppression list (`account_chatter_suppressions`) blanks them in sessions frozen afterwards. Lumio's global list (`chatter_suppressions`, objections) does not apply to share links. Send a protected link's password in `X-Share-Password`, never the URL. A locked link answers with exactly one of two fixed responses, identical within each class (status, body and headers): | Case | Status | `error` | `message` | |------|--------|---------|-----------| | Malformed, unknown, expired or revoked token | `410` | `SHARE_UNAVAILABLE` | `This share link is no longer available` | | Active link with a password; password missing or wrong | `401` | `SHARE_PASSWORD_REQUIRED` | `A valid password is required to open this share link` | The body is `{ "error": …, "message": … }` with nothing about the link: no title, channel, date or expiry. Expiry and revocation are checked before the password, so a dead protected link returns `410` and never asks for a password. After 10 wrong passwords on one link, or 20 from one client IP, within 15 minutes, a protected link returns `429 RATE_LIMIT_EXCEEDED` to every password attempt until the window ends, including the correct one; a correct password never counts. Attempts are counted before the password is checked, so simultaneous requests cannot exceed the limit, and an IPv6 client is counted per `/64` network. The endpoint is also rate-limited per end-user IP, including when the web app proxies it (see [Authentication → Rate Limits](./authentication.md#rate-limits)), and returns `503` rather than going unthrottled while the rate limiter is unavailable. The public page at `/share/history/{token}` reveals no report metadata until unlocked. ### `GET /v1/history/shared/{token}/export` Download a shared report when `allow_export` is true, with the same four formats, link section restrictions and frozen chatter lists as the shared read. A protected link needs `X-Share-Password`; optional `locale` and `time_zone` choose output language and date zone (default from `Accept-Language` and UTC). A locked link gets the same `410 SHARE_UNAVAILABLE` / `401 SHARE_PASSWORD_REQUIRED` / `429` responses as the shared read, and shares its password lockout; an open link with `allow_export` off returns `403`. ## Public Stats Per-channel publicness for the public Stats app. All endpoints require the `feature:public_stats_page` flag and mirror the GraphQL `channelPublicSettings` query / `setChannelPublicSettings` mutation exactly (same fields, guards, validation and errors). Publicness is **opt-out / public-by-default**: a channel with no settings row is public. ### `GET /v1/public-stats/channels` List the account's connected channels with their current publicness settings. Permission: `public-stats:read`. Each entry: `platform`, `platform_channel_id`, `channel_name`, `is_public`, `show_top_chatters`, `opted_out_via`, `updated_at`. Channels default to `is_public: true` (opt-out) but `show_top_chatters: false` (the chatter leaderboard is opt-in - hidden until the broadcaster enables it) until changed. ### `PUT /v1/public-stats/channels/{platform}` Toggle a channel's publicness (channel listing opt-out; chatter leaderboard opt-in). Permission: `public-stats:edit`. Body: `{ "is_public": bool, "show_top_chatters": bool }`. The channel is resolved to the caller's own connected channel for `platform` (the channel id is never taken from the client), so an account can only change a channel it has connected. A missing connection → `404`; an unsupported platform or a connection whose channel identity is not yet resolved → `422`. Emits a `public_stats:channel_opted_{out,in}` audit event. ### Public read model (unauthenticated) The read surface the public Stats app consumes. **No token required** (anonymous, `OptionalAuth`) and **no per-user data**, so responses are cache-friendly. Gated globally by the `system:public_stats` kill-switch flag - disabled → `403`. Mirrors the GraphQL `publicChannelStats` / `publicStreamStats` queries exactly. Serves **only** the §5 public-safe field set: there is no field for donations, subscriber/gifter identities, revenue splits, or the raw `platform_metrics` / `platform_breakdown` JSONB. First-party and crawled rows use the same response shape, distinguished only by the `source` badge (`first_party` / `external_crawler`). #### `GET /v1/public-stats/channels/{platform}/{channel}` A channel's public aggregate, matched by the stable `(platform, platform_channel_id)` and addressed by the URL handle (`channel` = `channel_login`, case-insensitive). Returns `platform_channel_id`, `channel_display_name`, `source`, `stream_count`, `total_duration_secs`, `peak_viewers`, `avg_viewers`, `total_messages`, `last_streamed_at`, `broadcaster_type` (Twitch channel type: `"partner"` / `"affiliate"`, else `null` - no badge; ZAF-841), and `recent_streams` (public stream summaries). A channel that has **opted out** (`is_public = false`) or has no addressable streams → `404`. An unsupported platform → `400`. #### `GET /v1/public-stats/channels/{platform}/{channel}/profile-history` A channel's **About-box change timeline** (ZAF-836) - bio / social-link / team changes over time, most-recent first. Query params `limit` (default 50, max 200) and `offset` (default 0) paginate. Each entry: `change_type` (`bio` / `link` / `team`), `old_value` / `new_value` (opaque JSON - a string for `bio`, an array for `link` / `team`; `null` old = initial capture, `null` `bio` new = deletion), and `changed_at`. Mirrors the GraphQL `publicChannelProfileHistory` query exactly. A channel that has **opted out** or has no addressable streams → `404`; unsupported platform → `400`. Public data (founder ruling ZAF-791): no §5 minimisation, no retention cap. The separate `/{channel}/history` page reads this via `publicGql` (SSR) and is `noindex, follow`; there is no WebSocket surface. #### `GET /v1/public-stats/channels/{platform}/{channel}/trends` A channel's **"Stats"-tab trends** (ZAF-891) - the follower-growth line and the average-viewers-over-time line. Required query param `range` ∈ `DAYS_30 | DAYS_90 | DAYS_180 | DAYS_365` selects the rolling window; an unknown value → `400`. Returns `follower_series` (dated follower-total growth) and `avg_viewer_series` (dated average viewers), each an oldest-first list of day points: `follower_series` items are `{ date, follower_count }`, `avg_viewer_series` items are `{ date, avg_viewers }`, with `date` = `YYYY-MM-DD` (UTC). Follower points are the last recorded follower total per day (dated crawler snapshots); avg-viewer points are the exact sample-weighted daily mean from the `channel_history_stats_1h` aggregate scoped to the channel's own sessions. Mirrors the GraphQL `publicChannelTrends` query exactly. A channel that has **opted out** or has no addressable streams → `404`; unsupported platform → `400`. Either series is an empty list (`[]`) when the channel has no history in the window. Aggregate counts only - no viewer identities. Read via `publicGql` (SSR); no WebSocket surface. #### `GET /v1/public-stats/channels/{platform}/{channel}/panel-history` A channel's **ad-panel lifecycle + image-change history** (ZAF-892) for the "Placements" tab - the ad-marked panels grouped by panel, most-recently-active first. Each entry: `panel_id`, latest content (`title` / `description` / `image_url` / `link_url`), full span (`first_seen_at` / `last_seen_at`), lifecycle (`status` = `"active"` / `"removed"`, `removed_at`), and `images: [{ image_url, first_seen_at, last_seen_at }]` - the distinct image versions over time, oldest-first (the slideshow). Mirrors the GraphQL `publicChannelPanelHistory` query exactly. A channel that has **opted out** or has no addressable streams → `404`; unsupported platform → `400`; a channel with no ad-marked panels → `200` with an empty list. `image_url` is the Twitch CDN URL; the Stats page shows it through its [image proxy](../developer-guide/image-proxy.md). The channel page renders current active panels only; the `noindex, follow` `/{channel}/history` page renders removed panels and earlier artwork. Public data: no §5 minimisation, no retention cap; read via `publicGql` (SSR), no WebSocket surface. ### Sitemap and share-card reads (unauthenticated) These routes mirror the GraphQL `publicSitemapChannelPageCount`, `publicSitemapChannels` and `publicChannelCard` queries. No token or RBAC permission is required. The account-less `system:public_stats` kill switch is their only gate. When it is off, the count is `0`, a page is `[]`, and the card is `404 Channel not found`, the REST equivalent of GraphQL `null`. | Route | `data` in the REST envelope | |-------|-----------------------------| | `GET /v1/public-stats/sitemap/channels` | `{ "page_count": number }`, `ceil(candidate channels / 45000)`. Opt-outs are not subtracted from the count, so the final file can be short or empty. | | `GET /v1/public-stats/sitemap/channels/{page}` | Up to 45,000 `{ platform, channel_login, lastmod }` entries. `page` starts at 0; past the end returns `[]`, while a negative page returns `400` with `page must not be negative`. `lastmod` is the most recent stream-start date, UTC `YYYY-MM-DD`. | | `GET /v1/public-stats/channels/{platform}/{platform_channel_id}/card` | One card by stable platform channel ID, not by handle. Unknown, opted-out and disabled all return `404 Channel not found`. Unsupported platform returns `400 Unsupported platform:

`; an ID outside 1–64 ASCII letters, digits, `_` or `-` returns `400 Invalid platform channel id`. | The sitemap includes only channel pages on `public_stats.sitemap_platforms` (default `twitch,kick`) with a login and a stream start in the last 12 months. It checks opt-outs at read time; clients cannot expand the platform allowlist. The card checks visibility on each hit. Both sitemap routes return `Cache-Control: public, max-age=3600, s-maxage=86400` on `200`; the card returns `public, max-age=600, s-maxage=900, stale-while-revalidate=300` on `200`. The card has exactly 13 snake_case fields: `platform`, `platform_channel_id`, `channel_login` (current canonical handle), `channel_display_name`, `is_live`, `current_viewers`, `follower_count`, `stream_count`, `avg_viewers`, `peak_viewers`, `watch_seconds` (viewer-seconds), `last_category`, and `last_streamed_at` (RFC 3339). Unavailable figures are `null`, never zero. No chatter identity, profile text or change history is present. #### `GET /v1/public-stats/streams/{platform}/{broadcast_id}` One stream by its public key `(platform, broadcast_id)`. Returns the stream `summary`, `emote_count`, `top_emotes`, `gif_count` **and** `top_gifs` (ZAF-968 — `top_gifs` is `[{ id, url, provider, alt, count }]`, the GIF twin of `top_emotes`; on the public-stats crawler path `provider`/`alt` are `null`, and both fields are erased after the 90-day GIF-content retention), de-identified `new_paid_subs` / `gifted_subs` **counts**, `new_followers`, the `viewer_curve` timeseries, and `top_chatters` **only** when the channel opted the leaderboard in (`show_top_chatters = true`) - otherwise `null`. Chatters the channel had erased appear blanked as frozen; chatters on Lumio's global list (`chatter_suppressions`: Lumio-level erasures and objections) are blanked in `top_chatters` on read (`erased: true`, count kept), also on streams frozen before the objection; if that lookup fails, `top_chatters` is `null` and the rest of the page is still served. An unknown stream or an opted-out channel → `404`; unsupported platform → `400`. Every public stream `summary` (both here and in the channel endpoint's `recent_streams`) carries `shared_chat_sources` (ZAF-819): a list of source-channel message counts. It also carries `shared_chat_sessions`: the public host and participation timeline for a first-party stream. Crawled streams without EventSub session events return an empty list. No viewer counts or individual viewer identities occur in that timeline. GraphQL twin: `PublicStreamSummary.sharedChatSessions`. `shared_chat_sources` is a list of `{ platform_channel_id, message_count, display_name, avatar_url }` naming which **source channels** contributed Twitch **Shared Chat** messages to that broadcast, and how many each did - so the stream page can show "who these stats are coming from". `display_name` + `avatar_url` (ZAF-868) are the source channel's public identity, resolved from the crawler channel-profile store; both are `null` when the anonymous crawler never captured that channel's identity (fail-open - the client falls back to the id). Shared-chat messages are attributed to their source channel rather than counted toward the host, so the host's `message_count` / `unique_chatters` reflect only its own channel's chat. The list is empty for a normal (non-shared) broadcast. Attribution is by **channel** (public broadcaster identity), never by viewer - aggregates only. #### `GET /v1/public-stats/streams/{platform}/{broadcast_id}/metadata-history` A broadcast's **stream-metadata change timeline** (ZAF-890) - title / category / tags changes over time, most-recent first. Query params `limit` (default 50, max 200) and `offset` (default 0) paginate. Each entry: `change_type` (`title` / `category` / `tags`), `old_value` / `new_value` (opaque JSON - a string for `title` / `category`, an array of tag strings for `tags`; `null` old = initial capture, `null` new = emptied field / all tags removed), and `changed_at`. Mirrors the GraphQL `publicStreamMetadataHistory` query exactly. An unknown stream or an opted-out channel → `404`; unsupported platform → `400`. Public data (founder ruling ZAF-791/ZAF-794): no §5 minimisation, no retention cap. ### Cross-channel browse (Form B) Browse/rank **across all channels** - the metric nav, a Games directory, and channel search. **No auth**; gated by the account-less `system:public_stats` kill-switch; edge-cacheable (`Cache-Control` on the 200). Opted-out channels are excluded. Mirrors the GraphQL `publicChannelBrowse` / `publicGames` queries exactly. No WebSocket surface (a browse/search directory has no live-push consumer). #### `GET /v1/public-stats/browse` Ranked channel list. Query parameters (all optional): `platform`, `category` (Games filter), `metric` (`watchtime` (default) / `viewers` / `peak` / `follower_gain` / `messages` / `recent`), `live` (bool - `true` = only live, `false` = only offline), `search` (case-insensitive substring on the channel handle + display name), `page` (1-based, default 1), `limit` (clamped `1..=100`, default 50). An unknown `metric` → `400`. Response `data`: `{ "channels": [ { platform, platform_channel_id, channel_login, channel_display_name, source, last_category, is_live, stream_count, watch_seconds, avg_viewers, peak_viewers, total_messages, follower_gain, last_started_at, broadcaster_type } ], "pagination": { page, limit, total, total_pages, total_capped } }`. `watch_seconds` = Σ avg-viewers·duration; `follower_gain` = Σ new-followers (absolute follower total is not a browse metric - see the feature doc); `broadcaster_type` is the Twitch channel type (`"partner"` / `"affiliate"`, else `null`; ZAF-841). Timestamps are RFC-3339. #### `GET /v1/public-stats/games` Games directory. Query parameters (all optional): `platform`, `search` (case-insensitive substring on the category name), `page`, `limit` (same clamping). Response `data`: `{ "games": [ { category, channel_count, stream_count, live_stream_count, peak_viewers, watch_seconds, last_started_at } ], "pagination": { page, limit, total, total_pages, total_capped } }`. **Depth cap (both endpoints).** A browse pages at most 10 000 rows deep: `page` is clamped so the page starts before row 10 000, and the returned `page` is the clamped value. `total` counts at most 10 000; `total_capped` is `true` when more rows match (render `total` as "10 000+"). Same cap and fields on GraphQL. ### Cross-channel placements (`/partners`) #### `GET /v1/public-stats/placements` Active, broadcaster-marked advertising panels of every public channel, grouped by advertiser (the panel link's host, lower-cased, leading `www.` stripped). **No auth**; gated by the `system:public_stats` kill-switch; edge-cacheable (`Cache-Control` on the 200). Opted-out channels are excluded. Mirrors the GraphQL `publicPlacementsBrowse` query exactly - same fields, validation and error messages. No WebSocket surface. Query parameters (all optional): `platform` (default and only supported value `twitch`), `domain` (one advertiser), `sort` (`channel_count` (default) / `last_seen`), `after` (the previous page's `next_cursor`), `limit` (advertisers, clamped `1..=100`, default 25), `placements_limit` (per advertiser, clamped `1..=50`, default 10). `400` for an unknown `sort`, an unsupported `platform` (`Unsupported platform: …`) or an invalid / other-sort cursor (`Invalid cursor`). Response `data`: `{ "advertisers": [ { domain, channel_count, placement_count, first_seen_at, last_seen_at, "placements": [ { platform, platform_channel_id, channel_login, channel_display_name, avatar_url, panel_id, title, image_url, link_url, first_seen_at, last_seen_at } ] } ], "next_cursor", "has_more" }`. Timestamps are RFC-3339. ## Watchlist Per-user **favourite-channel** list for the public Stats app. Unlike the public read model above, this is an **authenticated per-user** surface - scoped to the signed-in Lumio-ID viewer (JWT `me`), with **no account/RBAC permission** and **no plan gate** (a favourite is a personal preference). Mirrors the GraphQL `myWatchlist` query / `addToWatchlist` / `removeFromWatchlist` mutations exactly (same fields, validation and errors). There is **no WebSocket surface** (a user's own single list has no live-push consumer) and **no audit event** (personal bookkeeping with no security consequence). ### `GET /v1/watchlist` The viewer's watchlist, newest-first. Response `data`: `{ "items": [ { platform, platform_channel_id, channel_login, channel_display_name, added_at } ] }` — `[]` when empty. Not signed in → `401`. ### `POST /v1/watchlist` Favourite a channel. Body: `{ "platform": string, "channel": string }` where `channel` is the URL handle/login. The handle is resolved to the stable `platform_channel_id` server-side (the same resolution the public channel page uses), and `channel_login` / `channel_display_name` are denormalized. Idempotent upsert; a per-user cap of **500** channels is enforced. Response `data`: the stored item. Not signed in → `401`; unsupported platform or cap reached → `400`; handle that resolves to no addressable channel → `404`. ### `DELETE /v1/watchlist` Remove a channel by its stable id. Body: `{ "platform": string, "platform_channel_id": string }`. Idempotent - returns `{ "success": true }` even when the row was already absent. Not signed in → `401`. ## Emote Directory Public, **unauthenticated** browse of the cross-platform emote catalog (`chat_emotes`) for the public Stats app. Read-only and edge-cacheable, gated **only** by the account-less `system:public_stats` operator kill-switch - no auth and no RBAC permission. Mirrors the GraphQL `publicEmotes` query exactly (same fields, filters, pagination and errors). No WebSocket surface (a static browse / search page has no live-push consumer). ### `GET /v1/public-stats/emotes` Paginated, filterable, name-searchable browse of the emote catalog. **No auth.** Query parameters (all optional): `platform` (`twitch`/`youtube`/`kick`), `provider` (the platforms plus `7tv`/`bttv`/`ffz`), `channel_id`, `animated` (bool), `search` (case-insensitive substring on the emote `name`), `page` (1-based, default 1), and `limit` (clamped to `1..=100`, default 50). Empty-string filters are treated as absent. Response `data`: `{ "emotes": [ { id, platform, provider, channel_id, name, url, animated, owner_id, source, first_seen_at, last_seen_at } ], "pagination": { page, limit, total, total_pages } }`. `channel_id` is `null` for a global (platform-wide) set; `source` is `api_fetch` | `observed`; timestamps are RFC-3339. The internal `provider_emote_id` is **not** exposed. Rate-limiting: the anonymous-tier per-IP limiter that wraps every `/v1` request. Caching: `Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600` on the `200` - public reference data, safe to edge-cache. When the kill-switch is off the endpoint returns `403 FEATURE_DISABLED` with `Feature 'system:public_stats' is not available` (parity with the GraphQL guard error). ## Audit Log Scoped read surfaces over the `audit_events` table - one endpoint per `scope` column value. They mirror the GraphQL `myAuditLog` / `accountAuditLog` operations exactly (same fields, permission, validation and errors). The operator audit surface stays at `GET /v1/admin/audit-log` and can optionally filter by `scope`. Both endpoints share the same query parameters and paginated response shape: | Query param | Type | Effect | |-------------|------|--------| | `page` | integer | Page number (default 1). | | `limit` | integer | Page size (default 50, clamped `1..=200`). | | `type` | string | Exact event-type match. | | `from` | RFC-3339 timestamp | Only events at/after this instant. An unparseable value is ignored. | | `to` | RFC-3339 timestamp | Only events at/before this instant. An unparseable value is ignored. | Response: `{ "items": [...], "total": N, "page": N, "limit": N, "total_pages": N }`. ### `GET /v1/me/audit-log` The caller's own `scope=user` rows. **Self-only**, no RBAC permission - but a **first-party principal** is required (a logged-in session or the user's own API key); a popout/overlay/widget/extension token gets `401`. Returns only the caller's rows. Current writers record successful logins, failed login attempts tied to a known user, and OAuth grant consent. Mirrors GraphQL `myAuditLog`. ### `GET /v1/account/audit-log` The active account's `scope=account` events. Current emitters write role/permission, member-role, channel-connection, and account-scoped GDPR erasure rows. Permission: the **account-scope** `audit-log:read` (`403` otherwise; `401` if no account is active). Returns only the caller's account's rows. Mirrors GraphQL `accountAuditLog`. ### `GET /v1/me/audit-log/export` and `GET /v1/account/audit-log/export` Download the personal or the account log as a file, with the same guard as the matching read above. `format=csv|json` (default `csv`); `columns` is a comma list of `time, event, ip, device, location, details` (default all); `type`, `from` and `to` filter like the read. `ids` selects rows: a comma list of at most **1000** event ids. More ids, an unknown column or format, or a malformed id returns `400`. A file holds at most 10,000 rows. CSV cells that a spreadsheet would run as a formula start with `'`. This file-download endpoint intentionally has no GraphQL twin. Every audit response item (here, on `GET /v1/me/audit-log`, and on the admin `GET /v1/admin/audit-log`) carries a `resolved_names` array of `{ id, kind, display_name }` (`kind` ∈ `user` | `account` | `role`) - read-time resolutions of the entity UUIDs the event references (actor + an allowlist of metadata ids), computed once per page. Misses (deleted/unknown entities) are omitted; the raw UUIDs are never removed. This mirrors the GraphQL `resolvedNames` field exactly (ZAF-1233). ### `POST /v1/internal/audit-ingest` Server-to-server ingest for **user-scope** login / 2FA / OAuth-grant security events. These originate in **apps/id** (NextAuth, TypeScript) at the auth boundary, but the Rust API is the single audit writer, so apps/id posts them here after the auth event instead of writing `audit_events` itself. **Auth: System key only, carrying the narrow `audit:ingest` grant.** Every non-System principal (session, user API key, popout/overlay/widget/extension token) is rejected `403`, and an anonymous request `401` - no public/user route can forge audit rows. A System key without `audit:ingest` (or `audit:*`) is also `403`; a bare `*:*` does **not** grant it. Provision apps/id's key with `audit:ingest`. Request body: | Field | Type | Required | Notes | |-------|------|----------|-------| | `user_id` | UUID | yes | The subject user the event is about. | | `type` | string | yes | One of `user:login`, `user:login_failed`, `user:mfa_enabled`, `user:mfa_disabled`, `user:oauth_granted`. Any other value (including an `account:*` / `system:*` type) → `400`. | | `ip_address` | string | no | The **end-user's** client IP at the auth boundary (this is a server-to-server call, so the request peer is the id service - forward the real client IP here). GeoIP-resolved fail-open into `country`/`city`. | | `user_agent` | string | no | The end-user's user-agent. | | `metadata` | object | no | Free-form JSON **object** (defaults to `{}`; a non-object value → `400`). | The handler **forces** `scope = user`, `user_id = `, 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": "" }` with the new row id. ### Passkey and 2FA request fields Passkey sign-in and assertion challenge responses contain `challenge_id` and browser `options`. Registration options contain browser `options` and, for the first method, `recovery_codes`; the finish call takes `response` without a separate challenge ID. Send the browser's `PublicKeyCredentialJSON` as `response` with that challenge ID to the matching verify route. A passkey second-factor answer uses `passkey_challenge_id` and `passkey_response`; alternatives are `code` and `recovery_code`. The `mfa_methods` list on pending sign-in and step-up responses contains `passkey` and/or `totp` after the first factor. `GET /v1/users/me/mfa` returns `factors[]` with `id`, `kind`, `label`, `created_at`, `last_used_at`, and, for passkeys, `provider`, `backup_eligible`, `backup_state`. The API limits a profile to five apps and ten passkeys. Admin user results report `mfa_app_count` and `mfa_passkey_count` without method names. Invalid labels return `INVALID_MFA_FACTOR_LABEL`; first-passkey completion without the recovery-code acknowledgment returns `RECOVERY_CODES_NOT_STORED`; a missing passkey method returns `PASSKEY_NOT_SET_UP`. Passkey failures include `PASSKEY_INVALID`, `PASSKEY_CHALLENGE_EXPIRED` and `PASSKEY_ALREADY_REGISTERED`; the per-kind limit returns `MFA_FACTOR_LIMIT`. --- ## API Reference / GraphQL Source: https://docs.lumio.vision/api-reference/graphql # GraphQL ## Contract declarations These operations are available at `POST /v1/gql`. GraphQL fields and arguments are `camelCase`; errors carry the same `extensions.code` as the REST twins. Public mutations require a Turnstile token for action `withdraw` or `cancel`, with the same per-IP and global limits as REST. | Query or mutation | Input / arguments | Guard | |---|---|---| | `declarePublicWithdrawal(input: PublicWithdrawalInput!)` | `name`, `email`, `contractReference`; optional `message`, `locale`, `turnstileToken`, `clientRequestId` | Public. | | `declarePublicCancellation(input: PublicCancellationInput!)` | `name`, `email`, `contractReference`; optional `kind`, `reason`, `effective`, `effectiveDate`, `locale`, `turnstileToken`, `clientRequestId` | Public. | | `declareWithdrawal(input: WithdrawalInput!)` | `contract` (`plan`, `extension_purchase`, `other`); optional `extensionPurchaseId`, `contractReference`, `message`, `name`, `receiptEmail`, `clientRequestId`, `accountId` | Interactive session; `plan:edit` for plan, `extension-store:uninstall` for purchase, session only for other. The target channel is checked. | | `withdrawableContracts`, `cancellableContracts` | No arguments; return `[ContractOption!]!` across manageable channels | Interactive session; options require the relevant permission in each channel. | | `cancelSubscription(kind, reason, effective, effectiveDate, receiptEmail, name, clientRequestId, accountId)` | Plan cancellation; all arguments optional | `plan:edit` in the target channel and interactive session. | | `cancelExtensionSubscription(purchaseId: UUID!, input: ExtensionCancelInput)` | Extension subscription cancellation; `input.accountId` is optional | `extension-store:uninstall` in the target channel and interactive session; that channel must own the purchase. | | `adminContractDeclarations(status, kind, first, after)` | Oldest-first `AdminDeclarationPage` (`items`, `nextCursor`); defaults to open and 50 items | Admin `contract-declarations:read`. | | `adminContractDeclaration(id: UUID!)` | One `AdminDeclaration` | Admin `contract-declarations:read`. | | `adminCloseContractDeclaration(id: UUID!, input: CloseContractDeclarationInput!)` | `status` (`processed` or `rejected`), optional `note`, `refundReference`, `stripeApplied` | Admin `contract-declarations:edit` and interactive session. | | `adminMatchContractDeclaration(id: UUID!, input: MatchContractDeclarationInput!)` | `accountId`, `contract`, optional `extensionPurchaseId` | Admin `contract-declarations:edit` and interactive session. | | `adminExtendContractDeclarationRetention(id: UUID!, retainUntil: String!)` | Later `YYYY-MM-DD` | Admin `contract-declarations:edit` and interactive session. | `DeclarationReceipt` contains `id`, `reference`, `kind`, `status`, `receivedAt`, `receiptEmail` and nullable `periodEnd`. `ContractOption` identifies a plan or extension purchase and carries `accountId`, `accountName`, the plan or extension name, contract time, paid-period end and `cancelAtPeriodEnd`. The lists put the active channel first, then other manageable channels by switcher name. For signed-in writes, omit `accountId` to use the active channel; supplying it checks the required permission in that channel without switching the active channel. `accountId` is ignored when withdrawing from `other`. A missing permission returns 403; a purchase outside the target channel returns `PURCHASE_NOT_FOUND` (404). `AdminDeclaration` includes the stored declarant and contract snapshot, cancellation terms, receipt status, processing result and `retainUntil`. The `kind` and `effective` values match [REST](/api-reference/rest-api#contract-declarations). Repeating `clientRequestId` for the same declaration returns the first receipt; reusing it for a different declaration (another user, channel, kind, contract or purchase, or another e-mail address on the public forms) fails with code `IDEMPOTENCY_KEY_REUSED` and returns no receipt. An ordinary signed-in cancellation can return `processed` after Stripe accepts it or `open` while Stripe is retried. Public and extraordinary requests remain open for review. ## Abuse reports `submitAbuseReport(input: AbuseReportInput!)` is public. The input requires `songName`, `artist`, `reporterName`, `reporterEmail` and `reason`; `spotifyTrackId`, `proofUrl` and `turnstileToken` are optional schema fields. The action `abuse` Turnstile token is required when verification is enabled. The mutation returns `AbuseReportResult { id }` and queues the report for operator review without immediately blocking the song. It shares validation, rate limits and errors with [REST](/api-reference/rest-api#abuse-reports): `MISSING_SONG_NAME`, `MISSING_ARTIST`, `MISSING_REPORTER_NAME`, `INVALID_EMAIL`, `MISSING_REASON`, `INVALID_PROOF_URL`, `INVALID_SPOTIFY_TRACK_ID`, `TURNSTILE_REQUIRED`, `TURNSTILE_FAILED`, `RATE_LIMITED` and `TRY_AGAIN` in `extensions.code`. ## Developer balance release (admin) A developer balance that Lumio cannot pay out — the profile has **no payout method**, or the identity is **not verified** — blocks every delete (`409 DEVELOPER_FUNDS_OUTSTANDING`). On the person's request, an operator releases it as a liability and deletes in one transaction. A payable balance is never released: it goes through the closing payout. | Mutation | Input / arguments | Guard | |---|---|---| | `adminReleaseFundsAndDeleteUser(userId: UUID!, input: AdminReleaseFundsInput!)` | `requestReference` (1–64 characters), `reason` (1–500 characters) | Admin `developer-funds:release-deletion` **and** `users:delete`, interactive session. | | `adminReleaseFundsAndDeleteDeveloper(id: UUID!, input: AdminReleaseFundsInput!)` | as above | Admin `developer-funds:release-deletion` **and** `developer-verification:edit`, interactive session. | Both return `AdminReleasedFundsDeletion { deleted, releasedBalanceId, amount, currency, releaseCase, mailSent, claimCode }`. `releaseCase` is `not_verified` (checked first) or `no_payout_method`; the server decides it, never the client. A zero balance deletes without a release (`releasedBalanceId: null`). The person receives the claim code by e-mail after the commit; `claimCode` is returned **only** when that mail could not be sent (`mailSent: false`) and cannot be shown again. `requestReference` and `reason` must not contain names, e-mail addresses or contact data. Errors (same codes and messages as REST): `INVALID_REQUEST_REFERENCE` / `INVALID_RELEASE_REASON` (400), `DEVELOPER_FUNDS_PAYABLE` (409, use the closing payout), `DEVELOPER_FUNDS_OUTSTANDING` (409, a payout is `pending` or `processing`), not found (404), `Missing permission: …` (403). Each successful call writes `developer:funds_deletion_released` (when a balance was released) and `system:user_deleted` / `system:developer_deleted`. ### Settling a released balance When the person comes back with the claim code from the release mail: | Mutation | Input | Guard | |---|---|---| | `adminStartReleasedBalanceClaim(input: AdminReleasedBalanceClaimInput!)` | `claimCode` (separators, spaces and case are ignored) | Admin `developer-funds:settle`, interactive session. | | `adminSettleReleasedBalance(input: AdminSettleReleasedBalanceInput!)` | `claimCode`, `developerId` (the person's new profile) | Admin `developer-funds:settle`, interactive session. | `adminStartReleasedBalanceClaim` sets `claim_started_at` once and returns `AdminReleasedBalanceClaim { releasedBalanceId, amount, currency, releaseCase, releasedAt, claimStartedAt, newlyStarted }`; from then on the record is kept until the claim is settled. `adminSettleReleasedBalance` requires a verified identity on the target profile, adds the amount to its balance (not to `totalEarned`) and marks the record settled, in one transaction; it returns `AdminReleasedBalanceSettlement { releasedBalanceId, amount, currency, developerId, settledAt, newBalance }`. Errors (same codes and messages as REST): `INVALID_CLAIM_CODE` (400), `DEVELOPER_VERIFICATION_REQUIRED` (403), `RELEASED_BALANCE_NOT_FOUND` / developer profile not found (404), `RELEASED_BALANCE_SETTLED` (409), `Missing permission: …` (403). They write `developer:funds_released_claim_started` (only the call that starts the claim) and `developer:funds_released_settled`. ## Profile security and data operations These operations use `POST /v1/gql`; GraphQL names and fields are `camelCase`. Public sign-in operations can return a pending 2FA challenge. The API issues no session token or PKCE code for a pending challenge until `verifyMfaChallenge` succeeds; `verifyPasskeySignIn` completes both steps directly. A fresh step-up means a successful confirmation on the same interactive session within ten minutes; 2FA-enabled users confirm with a passkey, authenticator app code or recovery code. | Query | Returns | Guard | |---|---|---| | `turnstileConfig` | Runtime site key and widget requirement | Public | | `emailLoginStatus` | Email sign-in state | Interactive session | | `mfaStatus` | 2FA state, recovery-code count, trusted devices and `factors` (apps and passkeys) | Interactive session | | `stepUpOptions` | Confirmation methods and freshness | Interactive session | | `supportCode` | Current hourly support code | Interactive session | | `dataExports` | Personal export requests | Interactive session | | `adminSupportCodeChecks(userId)` | Recent code checks | Admin `support:verify` | | Mutation | Purpose | |---|---| | `register`, `resendRegistration`, `verifyEmailLogin` | Email registration, resend and clicked-link confirmation. Confirmation does not sign in and needs no session; a link opened while signed in as another profile fails with `LINK_OTHER_PROFILE`. | | `passwordLogin`, `requestPasswordReset`, `completePasswordReset` | Email sign-in and password recovery. A completed reset ends earlier sessions. | | `verifyMfaChallenge` | Finish a pending sign-in with an app code, recovery code or passkey assertion before a token or PKCE code is issued. | | `passkeySignInOptions`, `verifyPasskeySignIn` | Get a public, rate-limited challenge and sign in without a password. | | `passkeyMfaOptions`, `stepUpPasskeyOptions`, `stepUpPasskey` | Get a passkey challenge for a pending second step or session confirmation, then confirm the session. | | `stepUpOauth`, `stepUpPassword`, `stepUpMfa` | Confirm identity on an interactive session. | | `requestEmailLogin`, `changeEmailLogin`, `setEmailLoginPassword`, `cancelEmailLogin`, `removeEmailLogin` | Set up, change or remove email sign-in. Request/resend needs Turnstile; method changes need a fresh step-up. | | `changePassword`, `clearProfileEmail` | Change the password; remove a profile email that came from a platform sign-in. The email sign-in address cannot be removed here (`EMAIL_FROM_SIGN_IN`); change or remove the email sign-in instead. | | `startMfaSetup`, `confirmMfaSetup`, `enableMfa`, `disableMfa` | Set up an authenticator app, or turn off all methods; a passkey can confirm disable. Start and enable require fresh step-up. | | `startPasskeyRegistration`, `finishPasskeyRegistration` | Create a passkey; first-method options include ten recovery codes and finish requires `recoveryCodesStored: true`. | | `renameMfaFactor`, `removeMfaFactor` | Rename or remove an app or passkey; removal accepts a passkey assertion, app code or recovery code. | | `regenerateRecoveryCodes`, `revokeTrustedDevice`, `revokeAllTrustedDevices` | Replace recovery codes or revoke trusted devices. | | `requestDataExport`, `cancelDataExport(exportId)`, `dataExportDownloadLink` | Request `user` or `user_channels` data; cancel an own export while it is `pending` or `running` (no step-up, status becomes `cancelled`, a cancelled export does not count towards the 24-hour limit as long as it had not started yet - once the job had started it (`running`), it counts; another user's export answers `NOT_FOUND`, a finished one `EXPORT_NOT_CANCELLABLE`); issue a five-minute file link after fresh step-up. The ZIP download itself is REST-only. | | `scheduleProfileDeletion(graceDays: Int)`, `cancelProfileDeletion` | Schedule deletion after `graceDays` (0, 7 or 30; default 30) or cancel it. See below. | | `scheduleAccountDissolution(accountId, graceDays)`, `cancelAccountDissolution(accountId)` | Dissolve a channel after a grace period (or at once) or cancel the scheduled dissolution. See **Scheduled dissolution** below. | | `setAccountIdentitySource(accountId, kind, connectionId)`, `updateAccountName(accountId, name)`, `removeAccountAvatar(accountId)` | Choose where the channel's name and picture come from, set the own name, remove the own picture. See **Channel name and picture** below. | | `adminResetUserMfa(userId, reason)` | Reset 2FA; mandatory reason and admin `users:edit`. | | `adminRevealSupportCode`, `adminCheckSupportCode` | Reveal or verify a code; admin `support:verify`, audited. | | `submitContactMessage(input: ContactMessageInput!)` | Public contact form; see **Contact form** below. | `disconnectLoginConnection` and platform sign-in linking require fresh step-up, with an exception for reconnecting the same provider account. The REST twins call the same security service and use the same guards and error codes. A stale confirmation returns `STEP_UP_REQUIRED`. Account audit exports and ZIP file downloads have REST file endpoints rather than GraphQL file fields. `StepUpConnection` (in `stepUpOptions.connections`) carries `connectedAt` (RFC 3339, when the sign-in was added) and `lastUsedAt` (RFC 3339, the last platform sign-in or step-up through exactly this connection; `null` when unknown, e.g. a connection that was only linked). `DataExport` carries `status` (`pending`, `running`, `ready`, `failed`, `expired`, `cancelled`) and `progressPercent` (0–100 while the archive is built, only ever rising; `null` while `pending`). `MeResult.accounts[].platforms` (`AccountMembership.platforms: [String!]!`) lists the distinct platforms of the channel's connections - channel connections, not sign-ins - in the order `twitch`, `youtube`, `kick`, then the rest alphabetically; a connection waiting for a reconnect counts, a removed one does not, and a channel without connections has `[]`. `scheduleProfileDeletion` returns `ProfileDeletion { scheduledFor, deleted, closingPayoutCents }`. `graceDays` other than 0, 7 or 30 fails with `INVALID_GRACE` ("grace_days must be 0, 7 or 30"); every value needs a fresh step-up (`STEP_UP_REQUIRED`). `graceDays: 0` deletes the profile at once (export files, uploaded avatar, the user row with its sessions, `system:user_deleted` with `self_service: true`) and returns `scheduledFor: null, deleted: true`. Owning a channel fails with `OWNS_CHANNELS` unless every owned channel is scheduled for dissolution on or before the profile deletion date; `graceDays: 0` requires owning no channel. With `graceDays` 7 or 30 a developer revenue balance above 0 is paid out as the closing payout for the whole balance, even below the minimum payout amount; `closingPayoutCents` carries the amount (`null` when nothing was owed). `DEVELOPER_FUNDS_OUTSTANDING` ("The developer profile still has a revenue balance or a payout in progress. It can be deleted once Lumio has paid it out") refuses every value while a payout is `pending` or `processing`, `graceDays: 0` while any money is owed, and a balance that cannot be paid out (no payout method, no verified developer identity). The closing payout carries its self-billed credit note like every payout, so incomplete tax details fail with `TAX_DETAILS_REQUIRED` and an incomplete `[billing.issuer]` with `CREDIT_NOTE_ISSUER_NOT_CONFIGURED` (same texts as `requestPayout`); nothing is paid out or scheduled then. `adminDeleteUser` and `adminDeleteDeveloper` fail with the same code while money is owed. The job holds a due deletion until the profile is paid out and retries it every run. The reminder goes out 7 days before the end of a 30-day period and 1 day before the end of a 7-day period. `cancelProfileDeletion` returns `deleted: false`; it works only before the date and fails with `DELETION_IN_PROGRESS` ("The deletion is already running") once the date has passed, and with `NO_DELETION_SCHEDULED` when nothing is scheduled. REST twin: `POST`/`DELETE /v1/users/me/deletion`, same codes. `Account.dissolutionScheduledFor` and `AccountMembership.dissolutionScheduledFor` (in `me.accounts[]`) are an ISO-8601 string while the channel is scheduled for dissolution, otherwise `null`. The dashboard reads the membership value for the dissolution banner and the account-switcher label. **Profile email.** It is set in one place only, the email sign-in. With an email sign-in, `me.email` is always the confirmed sign-in address (`emailSource: "email_login"`), and it follows a changed sign-in address. Without one it is the address of a platform sign-in (`platform:`, filled only when empty; `platform:unknown` for older addresses), otherwise `null`. Removing the email sign-in brings back the platform address it replaced, or leaves none. `setEmailLoginPassword` returns `EmailLoginActivated { email }`. `SignInResult` carries `mfaRequired`, `mfaMethods` (`passkey` and/or `totp`) and a one-use `mfaToken` while a 2FA challenge is pending; it carries session tokens or a PKCE code only after completion. `Session` includes `authMethod`, `mfaVerifiedAt`, `lastUsedAt`, `country`, and `city`. `MeResult` exposes `emailSource`, `nameSourceKind`, and scheduled-deletion state. `billingStatus` reports `billingEnabled`, `billingInterval` and `cancelAtPeriodEnd`. Admin user results expose `mfaAppCount` and `mfaPasskeyCount` without method names. The corresponding REST fields use `snake_case`. **Contact form.** `submitContactMessage(input: { name, email, subject, message, turnstileToken })` is public and returns `ContactMessageResult { status }` (`"sent"`). It forwards the message to the Lumio inbox (`[contact] recipient`) as an e-mail with `Reply-To` set to `email`; nothing is stored. `name` is optional (at most 100 characters), `subject` is one of `general`, `privacy`, `legal`, `billing`, `other`, `message` has 1 to 5000 characters, and `turnstileToken` is a Turnstile token for action `contact` from a web-app hostname. Errors (`extensions.code`, message): `INVALID_EMAIL` "Enter a valid email address", `INVALID_CONTACT_NAME` "The name can have at most 100 characters", `INVALID_CONTACT_SUBJECT` "Choose a subject: general, privacy, legal, billing or other", `INVALID_CONTACT_MESSAGE` "The message needs 1 to 5000 characters", `TURNSTILE_REQUIRED`, `TURNSTILE_FAILED`, `RATE_LIMITED` (5 per IP per hour, 100 per hour in total, with `retryAfterSecs`) and `TRY_AGAIN`. REST twin: `POST /v1/contact`, same codes and texts. Lumio provides a GraphQL endpoint for flexible data querying. ## Endpoint ``` POST /v1/gql ``` ## Base URLs | Environment | URL | |-------------|-----| | Production | `https://api.lumio.vision/v1/gql` | | Production Preview | `https://lumio.api.prod.zaflun.dev/v1/gql` | | Staging | `https://lumio.api.staging.zaflun.dev/v1/gql` | ## Interactive Explorer **GraphiQL** is available at `/v1/graphiql` when the API is running (gated behind `graphql.playground = true` in config). Use it to explore the schema, build queries, and test mutations. ## Schema Download Download the GraphQL SDL schema: ```bash curl -o schema.graphql https://api.lumio.vision/v1/schema ``` ## Example Query ```graphql query { overlays { id name key width height } } ``` ## Errors Resolver errors carry a machine-readable `extensions.code` that mirrors the REST [`error` codes](./rest-api.md#error-format) for the same failure (`UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `RATE_LIMIT_EXCEEDED`, `VALIDATION_ERROR`, …) with an identical client-facing `message` (5xx detail scrubbed, 4xx verbatim). Both protocols route through the shared `ApiError`, so a given failure returns the same `code` + `message` on GraphQL and REST — including upstream-integration resolvers such as Spotify, whose rate limits surface as `RATE_LIMIT_EXCEEDED` and expired tokens as `UNAUTHORIZED`. The web dashboard's proxy layer translates these codes back into HTTP status classes (e.g. `RATE_LIMIT_EXCEEDED` → `429`) for the browser. Extra error data rides in further extensions that mirror REST `details`: `retryAfterSecs` (REST `details.retry_after_secs`) and `signedInAs` (REST `details.signed_in_as`, the platform account a `WRONG_PLATFORM_ACCOUNT` OAuth flow came back with). For `spotifyState`, `spotifySearch`, and Spotify mutations that call Spotify, an upstream 429 returns `message: "Spotify rate limit reached. Try again shortly."`, `extensions.code: "RATE_LIMIT_EXCEEDED"`, and `extensions.retryAfterSecs` with Spotify's wait in seconds (at least 1). The [REST Spotify routes](./rest-api.md#error-format) return HTTP 429 with the same code and message, plus `details.retry_after_secs` and a `Retry-After` header. The dashboard and popout proxy map the GraphQL code to browser HTTP 429 so the [music player](/user-guide/music) can wait and resume automatically. `refreshToken` returns the same codes and messages as REST `POST /v1/auth/refresh`: an empty token is `BAD_REQUEST` (`Bad request: refresh_token is required`); a bad signature or expired token, a token that is not a refresh token, a missing or expired session (logged out, revoked, or the token was already rotated) and a session/user mismatch are all `UNAUTHORIZED` (`Unauthorized: Invalid or expired refresh token`, `Unauthorized: Not a refresh token`, `Unauthorized: Session not found or expired - please log in again`, `Unauthorized: Session user mismatch`, `Unauthorized: Session already refreshed - please use the latest refresh token`). Only database or signing faults return `DATABASE_ERROR` / `INTERNAL_SERVER_ERROR`. A client treats `UNAUTHORIZED` as "sign in again". The API logs expected client errors (`NOT_FOUND`, `UNAUTHORIZED`, …) at `info`, not `warn`; see [Error tracking → GraphQL request log levels](../developer-guide/error-tracking.md#graphql-request-log-levels). Billing without Stripe (`stripe.enabled = false`): `invoices`, `validateCoupon`, `createCheckout`, `createPortal` and `cancelSubscription` fail with `extensions.code: "BILLING_DISABLED"` and the message `Billing is not enabled` (REST: `409` with the same `error` code and message), after their permission or session guard. `billingStatus` never fails for this reason; it reports `billingEnabled: false`, so a client checks that first and does not ask for invoices. `createCheckout(planSlug, interval, couponCode, acceptWithdrawalWaiver: Boolean!)` creates a Stripe session only when `acceptWithdrawalWaiver` is `true` (the buyer ticked the withdrawal-waiver checkbox). `false` fails with `VALIDATION_ERROR` "You must accept the withdrawal waiver (accept_withdrawal_waiver) before checkout", the same contract as REST `POST /v1/billing/checkout`; omitting the argument is a schema error. `createPortal` opens the customer portal without plan changes. Details: [Billing → Withdrawal waiver and order confirmation](../features/billing.md#withdrawal-waiver-and-order-confirmation). Scrubbing a 5xx is a wire-level rule, not a reason to discard the cause: `graphql::errors::to_graphql_error` logs the full `ApiError` server-side for every 5xx, mirroring what REST's `ResponseError::error_response` already did. Both protocols therefore leave the same record in the API log, and an `INTERNAL_SERVER_ERROR` the browser sees as `An internal server error occurred` is always diagnosable from that log alone. ## Feature Flags & Status ### Queries #### `me { featureStatuses }` Returns merged feature-flag statuses for the current user. Includes account-scope flags (all non-`system:*` categories) **and** the user-scope `system:account_creation` flag. Available on the `MeResult` type returned by `me { ... }`. #### `accountFeatures { enabledFeatures featureStatuses }` Returns feature flags scoped to the caller's active account. Requires authentication (no `account:read` permission needed - works with popout-token auth). - `enabledFeatures: [String!]!` - list of flag keys that are enabled for this account (excludes `system:*` flags). - `featureStatuses: [FeatureStatusGql!]!` - full status list with `key`, `enabled`, and `reason` for each flag (excludes `system:*` flags). ```graphql query { accountFeatures { enabledFeatures featureStatuses { key enabled reason } } } ``` #### `me { ownedAccountCount }` and `me { maxAccounts }` - multi-account limits The `MeResult` type exposes two fields for multi-account management: - `ownedAccountCount: Int!` - the number of Lumio accounts the authenticated user owns (i.e. where they hold the Owner role). - `maxAccounts: Int!` - the maximum number of accounts this user may own, derived from the user's plan and any admin overrides. These fields are used by the dashboard onboarding flow and account-creation gate to surface upgrade prompts when the user is at their account limit. #### `me { adminMfaRequired }` - admin access needs 2FA `adminMfaRequired: Boolean!` is `true` when the user holds admin grants (`adminPermissions` is not empty) but has no 2FA factor (authenticator app or passkey). Such a session keeps its account and user-level permissions, but every admin resolver refuses it with `extensions.code = "ADMIN_MFA_REQUIRED"` and the message `Admin access requires two-factor authentication. Turn on an authenticator app or a passkey in Settings › Security`. `adminPermissions` still lists the granted admin permissions. The REST equivalent is the `admin_mfa_required` field on `GET /users/me`, and REST admin routes answer `403 ADMIN_MFA_REQUIRED`. Details: [Admin panel → Two-factor authentication](../admin/admin-roles.md#two-factor-authentication-required). #### `me { isDeveloper }` - developer status `isDeveloper: Boolean!` is `true` when the authenticated user has a row in the `developer_profiles` table OR has the `extension_dev_mode` admin override enabled. This field drives access to the developer dashboard and extension management features in the web app. The REST equivalent is the `is_developer` field on `GET /users/me`. #### `me { extensionDevMode }` - Extension Developer Mode `extensionDevMode: Boolean!` - when `true`, extension bundle serving loads the latest draft/testing version instead of the published version for extensions the user develops. Toggle via `updateMe(input: { extensionDevMode: true })`. The REST equivalent is the `extension_dev_mode` field on `GET/PATCH /users/me`. #### `me { locale timezone dateFormat timeFormat }` - display preferences The user-global display preferences (all `String!`), applied across the webapp (multichat, events, music, …): | Field | Values | Meaning | |-------|--------|---------| | `locale` | `auto` · `en` · `de` | UI language; `auto` uses the request-time fallback (`locale` cookie if valid, otherwise default language) | | `timezone` | `auto` or an IANA name (`Europe/Berlin`, …) | `auto` uses the browser-detected zone mirrored into `lumio-tz`; server render falls back to `UTC` until that mirror exists | | `dateFormat` | `auto` · `dmy_dot` · `dmy_slash` · `mdy_slash` · `ymd_dash` | Numeric date format: automatic language-locale format, `31.12.2026`, `31/12/2026`, `12/31/2026`, or `2026-12-31` | | `timeFormat` | `auto` · `h12` · `h24` | Numeric time format: automatic format-locale hour cycle, `11:30 PM`, or `23:30` | Set them with `updateMe(input: { locale: "de", timezone: "Europe/Berlin", dateFormat: "dmy_dot", timeFormat: "h24" })`. An invalid value is rejected with a GraphQL error (fail-closed, byte-identical message to the REST twin), never silently coerced to `auto`. Changing them emits **no audit event** (a display preference is neither security- nor lifecycle-relevant). The REST equivalents are the `locale` / `timezone` / `date_format` / `time_format` fields on `GET/PATCH /users/me`. #### `me { displayCurrency effectiveDisplayCurrency }` - history display currency `displayCurrency` is `auto` or EUR/one of the ECB reference currencies. `auto` resolves to EUR for German and USD otherwise (an automatic language uses the detected locale); `effectiveDisplayCurrency` is the resolved code. Set it with `updateMe(input: { displayCurrency: "EUR" })`. It is a personal display preference with no RBAC permission or audit event. REST exposes `display_currency` and `effective_display_currency` on `GET/PATCH /v1/users/me`. #### `me { seasonLookEnabled seasonStrength seasonMotion seasonCreatures seasonBannerDismissed }` - seasonal look The user-global preferences for the seasonal dashboard look (e.g. Halloween). They live on the user profile, so they follow the user to every device and into popouts (also inside OBS): | Field | Type | Default | Values | |-------|------|---------|--------| | `seasonLookEnabled` | `Boolean!` | `true` | One switch for every season | | `seasonStrength` | `String!` | `full` | `full` · `subtle` | | `seasonMotion` | `String!` | `auto` | `auto` (animations play unless the OS asks for reduced motion) · `off` | | `seasonCreatures` | `JSON!` | `{}` | Object `{ "": Boolean }`; an absent key means the creature's registry default (spider off, bat on). Keys `[a-z_]{1,32}`, at most 32 entries | | `seasonBannerDismissed` | `[String!]!` | `[]` | Dismissed season banners as `:` (season id `[a-z0-9-]{1,32}`, the year the season window starts in), oldest first | Set them with `updateMe(input: { seasonLookEnabled: true, seasonStrength: "subtle", seasonMotion: "off", seasonCreatures: { spider: true }, seasonBannerDismissed: ["halloween:2026"] })`. `seasonCreatures` and `seasonBannerDismissed` **replace** the stored value (send `{}` / `[]` to reset). An invalid preset, a non-object or non-boolean creature map, a bad creature key, more than 32 creatures, or a malformed banner entry is rejected with a GraphQL error (fail-closed, byte-identical message to the REST twin, never coerced). The banner list is de-duplicated and capped at 50 entries; beyond that the **oldest** entries are dropped rather than the request rejected. Changing them emits **no audit event** (a display preference). The REST equivalents are the `season_*` fields on `GET/PATCH /users/me`; popouts read them via `popoutSession`. #### `me { motionPreference }` - animations in Lumio The user-global **"Animations in Lumio"** preference. It lives on the user profile, so it follows the user to every device and into popouts (also inside OBS), which read it via `popoutSession`: | Field | Type | Default | Values | |-------|------|---------|--------| | `motionPreference` | `String!` | `system` | `system` (full motion unless the OS asks for reduced motion) · `reduced` · `off` | There is deliberately no "always on": the OS reduced-motion request is never overridden. It is independent of `seasonMotion`; the stricter of the two applies to the seasonal decoration. It covers the dashboard and its popouts, never widgets or overlays. Set it with `updateMe(input: { motionPreference: "reduced" })`. Any other value is rejected with a GraphQL error (fail-closed, byte-identical message to the REST twin, never coerced), e.g. `Invalid motion_preference 'auto': allowed values are system, reduced, off`. A popout session cannot change it (`updateMe` is first-party gated). Changing it emits **no audit event** (a display preference). The REST equivalent is the `motion_preference` field on `GET/PATCH /users/me`. #### `me { nameSourceConnectionId nameSourceKind }`, `setNameSourceConnection`, `updateDisplayName`, `removeAvatar` - name/avatar source A user's Lumio `displayName` + `avatarUrl` come from one of two sources, exposed as `me { nameSourceKind }`: - **`connection`** - mirrored on every login from a single *name-source* login connection: the one the user explicitly picks, or, when none is picked, their first-ever-connected login (`login_connections.createdAt` ascending). A login via a *secondary* platform therefore never rewrites the name or avatar. - **`own`** - the user's own details. Sign-ins never overwrite them; the name is set with `updateDisplayName`, the picture is uploaded over REST. E-mail-only profiles always use `own`. Operations (all session-only; the REST twins share the service code, the validation, the error codes and messages, and the audit event): - `me { nameSourceConnectionId }` (`UUID`) exposes the currently-pinned source connection, or `null` when it follows the first-connected login (always `null` with `own`). REST parity: `name_source_connection_id` / `name_source_kind` on `GET /users/me`. - `setNameSourceConnection(loginConnectionId: UUID, kind: String): NameSourceResult!` - `kind` is `"connection"` (default) or `"own"`; anything else returns `INVALID_NAME_SOURCE`. - `connection` pins (or, with `loginConnectionId: null`, clears) the source and immediately re-materializes the name/avatar from it. An uploaded own picture is deleted from storage. The connection must belong to the caller (ownership-checked; a foreign id is rejected with the same message as the REST twin). A profile without any platform sign-in gets `NO_LOGIN_CONNECTION`. - `own` keeps the current name and picture as the starting values. - `updateDisplayName(displayName: String!): NameSourceResult!` - only with `own` (otherwise `NAME_SOURCE_NOT_OWN`). The name is trimmed and NFC-normalised and needs 2-32 characters without control or invisible characters (zero-width spaces/joiners, bidi controls, BOM): `DISPLAY_NAME_TOO_SHORT`, `DISPLAY_NAME_TOO_LONG`, `DISPLAY_NAME_INVALID_CHARACTERS`. - `removeAvatar: NameSourceResult!` - only with `own` (otherwise `NAME_SOURCE_NOT_OWN`). Shows initials instead and deletes an uploaded file. - **No GraphQL upload.** The picture is binary multipart data, so the upload is REST-only: [`POST /v1/users/me/avatar`](./rest-api.md#post-usersmeavatar). This is the one deliberately single-protocol operation of the group. `NameSourceResult` is `{ displayName, avatarUrl, nameSourceConnectionId, nameSourceKind }` (REST twin `NameSourceResponse`). Audit: switching between `own` and `connection`, changing the own name, and uploading or removing the own picture write `user:profile_identity_changed` (scope `user`). Metadata is `changed` (a list of `source`, `display_name`, `avatar`) and, for a source switch, `source` = `own` / `connection`. It never contains a name or a picture URL. Choosing among one's own connections (`connection` → `connection`) is a display preference and is not audited. `displayName` is **not** settable through `updateMe` - the field was removed. Use `updateDisplayName` with own details. #### `me { loginConnections }` / `account { channelConnections }` / `account { botConnections }` - platform filtering Login connections, channel connections, and bot connections filter by the corresponding platform flag (`platform:{x}:login`, `platform:{x}:channel`, `platform:{x}:bot`). A platform whose flag is disabled globally is excluded from these lists. This filtering also applies to the REST equivalents (`GET /users/me/login-connections`, `GET /connections/channel`, `GET /bot-connections`). Each `channelConnections` row carries its `id: UUID!` — the value `setAccountIdentitySource(connectionId:)` takes; the REST twin `GET /v1/connections/channel` carries the same `id`. Each `channelConnections` row exposes both `channelName` — the platform **display name**, which may be localized (`こさむん`) — and `channelLogin` — the URL-safe **login / handle** (Twitch `login`, Kick slug; `null` for YouTube/Spotify and legacy rows not yet backfilled). Build a profile URL and any IRC-facing name from `channelLogin`, not `channelName` (ZAF-1461). The REST twin `GET /v1/connections/channel` carries the same `channel_login`. Each `channelConnections` row also carries `missingScopes: [String!]!` — the **base** scopes the stored grant lacks (e.g. `["kicks:read", "channel:rewards:read"]` for a Kick connection made before those scopes were required). `[]` when nothing is missing **and** when the grant was never recorded (`scopes` is `null`). YouTube's optional `yt-analytics-monetary.readonly` never appears in `missingScopes`; its revenue area checks the grant separately. A non-empty list means the owner should reconnect via `authorizeChannel`. Same guard as `scopes` (`feature:connections` + `connections:read`); the REST twin carries the identical `missing_scopes`, computed by the same helper. `channelOAuthScopes(platform: String!): ChannelOAuthScopes!` returns `{ platform required optional }` with full provider scope names for Twitch, YouTube, Kick or Spotify. `required` contains base scopes; `optional` contains scopes requested on every authorization but not counted by `missingScopes`. YouTube's optional list contains `https://www.googleapis.com/auth/yt-analytics-monetary.readonly`. It requires `feature:connections` and `connections:read`, like REST `GET /v1/connections/oauth-scopes/{platform}`. An unknown platform returns `Unsupported platform: X` on both protocols. Kick's required list includes `ads:read` for ad-break status. `channelConnections { followerCount }` returns an optional `Int` for each channel. It is fetched only when selected, under `feature:connections` and `connections:read`. Twitch uses its follower total, YouTube returns an approximately rounded subscriber count, Kick accepts its string-valued follower count, and Spotify returns `null`. Counts are cached for 10 minutes, failed lookups for 2 minutes; stored public metrics provide a fallback. REST selects the same field with `GET /v1/connections/channel?include=follower_count`. `channelConnections { paidSubscriberCount }` returns an optional `Int`: the channel's **current paid subscribers**. It is fetched only when selected and only for callers holding `history:read` (the permission of the Stream History "Subs & Members" metric). Without it the field is `null`, not an error, so a role with only `connections:read` still lists its connections. Twitch returns Helix `GET /subscriptions` `total` (gifted subs included; needs the channel scope `channel:read:subscriptions`, and an older grant without it returns `null` until the owner reconnects). Twitch does not document whether the broadcaster's own channel sub is part of `total`, so the number is passed through unchanged. Kick returns `active_subscribers_count` from the official `GET /public/v1/channels`. YouTube always returns `null`: channel memberships are not readable (Data API `members.list` is restricted to approved partners, InnerTube has no member count), and YouTube "subscribers" are its followers, reported in `followerCount`. Spotify returns `null`. The number is never estimated from events. Counts are cached for 10 minutes, unavailable ones for 2 minutes. The field is named *paid* because `subscriberCount` elsewhere in this API (`PublicChannelStats.subscriberCount`) means YouTube subscribers. REST selects the same field with `GET /v1/connections/channel?include=paid_subscriber_count`. `channelStatus` requires `connections:read`, as does `GET /v1/channel-status`. The separate `spotifyManualStatus` operation still requires `spotify:read`. `channelSchedule(from: DateTime, to: DateTime): [ScheduledStream!]!` (permission `connections:read`) returns the stream plan of the account's connected channels, starting inside `from..to` and sorted by `startsAt`. `from` defaults to now, `to` to `from` + 30 days; a window with `to <= from` or longer than 60 days fails with `BAD_REQUEST`. Each `ScheduledStream` carries `platform`, `title` (empty when none), `category`, `startsAt`, `endsAt` (nullable), `isRecurring`, `url` (YouTube watch page; Twitch/Kick channel page) and `source` (`youtube_broadcast`, `twitch_schedule`, `kick_schedule`). YouTube comes from the worker's scheduled-broadcast rows, Twitch from Helix `GET /schedule` (cancelled and vacation segments dropped), Kick from the undocumented kick.com calendar. A platform that is unavailable contributes nothing instead of failing the list, and the slot covering *now* is left out while that platform is live. Twitch/Kick are cached for 10 minutes. REST twin: `GET /v1/channels/schedule`. Details: [Channel Status → Stream plan](../features/channel-status.md#stream-plan). #### `connectionStatuses` - health-aware connection overview `connectionStatuses` (permission `connections:read`) returns one `ConnectionStatus` per supported channel platform - the overview feature surfaces read (Music control, Events, Multichat, incl. popouts). `isConnected` is **health-aware**: it is `true` only when a connection exists *and* is not flagged `reconnectRequired` (ZAF-754), so a dead-grant connection never appears usable. `ConnectionStatus` also carries `reconnectRequired` and `expiresAt`; a connection exists iff `isConnected || reconnectRequired`. The REST counterpart is `GET /v1/connections/status` (same fields, permission and `feature:connections` gate). Popout tokens carrying `connections:read` may read this query; the first-party-only `flaggedConnections` query (ZAF-469) is unaffected. `ConnectionStatus` additionally carries the acting user's **personal login-grant** health per platform (ZAF-1045), reported *separately* from the channel signal because the Multichat sends with the login grant, not the channel connection: `loginConnected` (login grant exists and is healthy) and `loginReconnectRequired` (login grant exists but is flagged - the send will fail until reconnected). Both are `false` with no login grant, no user context (fail-closed), or a platform not sent-to via a login grant. The channel `isConnected` / `reconnectRequired` are unchanged - the login fields are additive. `loginMissingScopes` (REST `login_missing_scopes`, same value) lists the scopes Lumio's login asks for that the acting user's stored login grant lacks (for example `moderator:manage:automod`); it is empty when nothing is missing, when the stored scopes are unknown (an empty array on older grants) or when there is no grant. OpenID identity scopes (`openid`, `email`, `profile`) are never reported. `sendChatToPlatform` returns `GqlSendResult { sent, messageId, error, errorCode, platform }`. `errorCode` is a machine-readable code the client translates instead of parsing `error`: `login_reconnect_required` (the sender's login grant is dead - flagged `reconnect_required`, no crypto/DB internals in `error`), `internal_error` (scrubbed internal failure), or `send_failed` (the platform rejected the send; `error` carries its message). Absent on success. The REST twin `POST /v1/chat/send` returns the identical `error_code` + `platform`. For `platform: "kick"`, `sendChatToPlatform` converts every word that exactly matches a known Kick emote into Kick's `[emote:ID:NAME]` markup before sending (same shared function as REST `POST /v1/chat/send`). `emotes(platform, channelId)` (`feature:multichat` + `chat:read`) returns `GqlEmoteSet { provider, emotes, section }` with `GqlEmote { id, name, url, provider, animated, ownerId, subscribersOnly }`. `section` is set only for Kick's native sets (`channel` | `global` | `emojis`, `null` otherwise); `subscribersOnly` marks Kick sub-only emotes. The REST twin `GET /v1/emotes/{platform}/{channel_id}` returns the same values as `section` / `subscribers_only`. For Kick, every `channel`-section emote has `ownerId = "kick:"` (namespaced so it never collides with a numeric Twitch owner id) and `owners` lists that channel owner as `GqlEmoteOwner { id, displayName, avatarUrl }` (Kick `username` / `profile_pic`); `owners` is `null` when no set has an owner, and REST returns the same entries as an `owners` map keyed by id. #### `platformCredentialModes` - where each connection's OAuth client comes from `platformCredentialModes` (permission `connections:read` + `feature:connections`) returns one `PlatformCredentialMode` per `(platform, kind)` that has an OAuth mode. It is the backend truth the frontend reads instead of hardcoding which platforms Lumio operates with a global OAuth app. Fields: - `platform` - platform slug. - `kind` - `CHANNEL` or `BOT`. Channel and bot can differ per platform (e.g. YouTube channel is `ACCOUNT`, YouTube bot is `SYSTEM`). - `mode` - `SYSTEM` (global Lumio app) or `ACCOUNT` (per-account `app_credentials`). This is the *configured* intent, before fail-safe degradation. - `systemConfigured` - whether global OAuth keys are actually configured. **Not** derivable from `mode`: `SYSTEM` + `systemConfigured=false` means "should be Lumio-managed but is not set up" (it degrades to `ACCOUNT` at runtime). Platforms without an OAuth mode for a kind are omitted (Spotify has no bot; Discord has no channel connection). The Discord bot has a `BOT` row: `mode` is always `SYSTEM` (the one global bot; an `"account"` override in `[platform_credentials.bot]` is ignored with a warning) and `systemConfigured` is `true` once `[auth].discord_bot_client_id` and `discord_bot_client_secret` are set. The REST twin is `GET /v1/connections/credential-modes` - identical payload, kind included, same guard. #### `me { activeAccountId }` - stale-membership filtering If the JWT carries an `accountId` the user is no longer a member of (e.g. the account owner removed them after the token was issued), the resolver returns `activeAccountId: null` rather than the stale claim. Permissions are computed against the corrected scope. The dashboard shell reads this signal and routes the user to onboarding instead of rendering an account context they can no longer access. The REST `DELETE /v1/accounts/{id}/members/{membership_id}` (admin kick) and `POST /v1/accounts/{id}/leave` (self-leave) endpoints invalidate the Redis permission cache for the removed user, matching the existing GraphQL `removeMember` mutation behaviour - two-protocol parity for cache invalidation. ### Mutations #### `adminUpdateUserAccountCreationOverride(userId: UUID!, override: AccountCreationOverride!): AdminUser!` Set or clear the per-user `system:account_creation` override. Requires `users:edit` admin permission. Invalidates the cached override for the user immediately. Returns the updated `AdminUser`. ### Types #### `FeatureStatusGql` | Field | Type | Description | |-------|------|-------------| | `key` | `String!` | Feature flag key (e.g., `feature:bots`) | | `enabled` | `Boolean!` | Whether the feature is enabled for this caller | | `reason` | `FeatureDisabledReasonGql` | Why the feature is disabled; `null` when enabled | #### `FeatureDisabledReasonGql` (enum) | Value | Meaning | |-------|---------| | `GLOBAL_OFF` | The flag is turned off globally by an admin kill-switch | | `PLAN_LOCKED` | The account's plan does not include this feature | | `ACCOUNT_OVERRIDE` | An explicit per-account override blocks this feature | | `USER_OVERRIDE` | An explicit per-user override blocks this feature (`system:account_creation` only) | #### `AccountCreationOverride` (enum) | Value | Description | |-------|-------------| | `DEFAULT` | Inherit the global `system:account_creation` flag setting | | `ALLOW` | User is always allowed to create accounts (overrides global OFF) | | `DENY` | User is always blocked from creating accounts (overrides global ON) | #### `AdminUser.accountCreationOverride: AccountCreationOverride!` Available on the `AdminUser` type returned by `adminUser(id)` and the user-list query. Reflects the stored per-user override value. > **Protocol parity:** All queries and mutations above have matching REST endpoints - see [REST API](./rest-api.md#feature-flags--status). ## User API Keys User API keys are personal `lm_usr_` bearer credentials for API and CLI access. They are bound to the current user and active account, and the requested permissions must be a subset of the creator's current permissions. The full key is returned only from `createUserApiKey`. > **First-party session required (ZAF-1017 / ZAF-469):** personal API keys are a first-party surface - `userApiKeys`, `createUserApiKey`, `updateUserApiKey` and `deleteUserApiKey` all key off the acting user's own identity and require a logged-in user session or a user's own API key. A popout/overlay/widget/extension token - whose `user_id` is bound at token-create time and could be the account owner - is rejected with a `FORBIDDEN` error, so a permission-capped member can never list/mint/rename/revoke a key **bound to the owner**. Same gate and status behaviour as the REST `/v1/api-keys` twins. `createUserApiKey` and `rotateUserApiKey` are stricter still - **session-only** (ZAF-1742): they mint a secret, so a user's own `lm_usr_` key is refused with `FORBIDDEN` `This action requires an interactive sign-in session` (see [Authentication → Session-only Surfaces](./authentication.md#session-only-surfaces)). ### Queries #### `apiKeyAssignablePermissions(kind: ApiKeyKind!): [ApiKeyPermission!]!` List the scopes assignable to an API key of the given `kind` (`PERSONAL` or `SERVICE`). Requires `feature:apikeys` and `apikeys:read`. Parity with REST `GET /v1/api-keys/permissions`. The registry is **derived** from the RBAC registries minus a small deny-list - an API key may carry any permission the principal behind it holds, not the narrow popout-token list (ZAF-1169): - `PERSONAL` (`lm_usr_`) → the 129 account permissions left after the four-key API-key deny-list **plus** the 10 user-scoped `ideas:*` permissions (**139** assignable scopes). A personal key acts with its member's user identity. - `SERVICE` (`lm_svc_`) → those 129 assignable account permissions only (an account-owned key has no user identity). - **Deny-list (both):** `apikeys:create`, `apikeys:edit`, `tokens:create` (credential minting) and `account:delete` (tenant destruction) are never assignable. Each field is `{ key, label, category }`, grouped by `category` for the picker. Scopes are validated against this registry on create (unknown/deny-listed → `Invalid permission: {perm}`) and are **immutable after creation**. ```graphql query { apiKeyAssignablePermissions(kind: PERSONAL) { key label category } } ``` #### `userApiKeys: [UserApiKey!]!` List the current user's own API keys for the active account. Requires `feature:apikeys` and `apikeys:read`. ```graphql query { userApiKeys { id userId accountId keyPrefix label permissions createdAt expiresAt lastUsedAt } } ``` ### Mutations #### `createUserApiKey(input: CreateUserApiKeyInput!): CreateUserApiKeyResult!` Create a user API key for the current user and active account. Requires `feature:apikeys` and `apikeys:create`. ```graphql mutation { createUserApiKey( input: { label: "CI/CD pipeline" permissions: ["events:read", "overlays:read"] expiresAt: "2026-09-01T00:00:00Z" } ) { key apiKey { id keyPrefix label permissions expiresAt } } } ``` #### `rotateUserApiKey(id: UUID!): CreateUserApiKeyResult!` Rotate one of the current user's own API keys - regenerate its secret in place. Requires `feature:apikeys` and `apikeys:edit`. The key id, label, scopes and expiry are unchanged, the old secret stops working immediately, and the new full `lm_usr_` key is returned in `key` **once** (same [`CreateUserApiKeyResult`](#createuserapikeyresult) shape as create - `{ key, apiKey }`). Emits the `user:api_key_rotated` audit event (metadata `api_key_id`, `old_key_prefix`, `new_key_prefix`). Parity with REST `POST /v1/api-keys/{id}/rotate`. #### `deleteUserApiKey(id: UUID!): DeleteTokenResult!` Revoke one of the current user's own API keys by hard-deleting it. Requires `feature:apikeys` and `apikeys:delete`. ### Types #### `CreateUserApiKeyInput` | Field | Type | Description | |-------|------|-------------| | `label` | `String!` | Required display name | | `permissions` | `[String!]!` | Permission subset granted to the key | | `expiresAt` | `String` | Optional RFC3339 expiry; `null` means no expiry | #### `CreateUserApiKeyResult` | Field | Type | Description | |-------|------|-------------| | `key` | `String!` | Full `lm_usr_` key, returned once | | `apiKey` | `UserApiKey!` | Stored key metadata | #### `UserApiKey` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Key ID | | `userId` | `UUID!` | Owning user | | `accountId` | `UUID!` | Owning account | | `keyPrefix` | `String!` | Display prefix for identification | | `label` | `String!` | Display name | | `permissions` | `[String!]!` | Granted permission subset, as stored | | `effectivePermissions` | `[String!]!` | The stored scopes the owner's current role still grants - what authorizes requests now. A scope missing here is dormant and returns when the owner regains it. Same as REST `effective_permissions` | | `createdAt` | `String!` | Creation timestamp | | `expiresAt` | `String` | Optional expiry timestamp | | `lastUsedAt` | `String` | Last successful use, when known (minute precision: rewritten at most once a minute) | #### `ApiKeyKind` `PERSONAL` (`lm_usr_`) or `SERVICE` (`lm_svc_`). Used as the argument to `apiKeyAssignablePermissions`, as the discriminator on the [Admin API Key](#admin-api-key-management) mutations, and as the [`AdminApiKey`](#adminapikey) `kind` field. #### `ApiKeyPermission` | Field | Type | Description | |-------|------|-------------| | `key` | `String!` | Permission string (`resource:action`) | | `label` | `String!` | Human-readable label | | `category` | `String!` | Grouping category for the picker | ## Account Service Keys Account service keys are `lm_svc_` bearer credentials owned by the **account** - for CI/CD and long-lived integrations that must **survive the departure of the member who created them**. Unlike personal `lm_usr_` keys they carry no member identity. They share the `feature:apikeys` flag and the `apikeys:*` permission family with personal keys, and create enforces the same permission-subset guard. The full key is returned only from `createAccountServiceKey`. Parity with REST `/v1/service-keys`. ### Queries #### `accountServiceKeys: [AccountServiceKey!]!` List the account's service keys. Requires `feature:apikeys` and `apikeys:read`. ### Mutations #### `createAccountServiceKey(input: CreateAccountServiceKeyInput!): CreateAccountServiceKeyResult!` Create a service key owned by the active account. Requires `feature:apikeys`, `apikeys:create` and an **interactive sign-in session** (a logged-in user only - a popout/overlay/widget/extension token, a personal `lm_usr_` key, a service `lm_svc_` key or a system key is rejected with `FORBIDDEN` "This action requires an interactive sign-in session"; ZAF-1742 rule, applied to service keys by ZAF-1740 C3). Parity with REST `POST /v1/service-keys`. ```graphql mutation { createAccountServiceKey( input: { label: "CI/CD deploy" permissions: ["events:read", "overlays:read"] } ) { key serviceKey { id keyPrefix label permissions expiresAt } } } ``` #### `updateAccountServiceKey(id: UUID!, label: String!): AccountServiceKey!` Rename a service key (label only; key material, permissions and expiry are immutable). Requires `feature:apikeys` and `apikeys:edit`. #### `rotateAccountServiceKey(id: UUID!): CreateAccountServiceKeyResult!` Rotate a service key - regenerate its secret in place. Requires `feature:apikeys`, `apikeys:edit` and an **interactive sign-in session** (same session-only gate as create, checked before the key is looked up), and the caller must hold every scope on the key - the same ceiling as create, rejected with `FORBIDDEN` "Cannot grant permission '…'" otherwise (ZAF-1740 M5). The key id, label, scopes and expiry are unchanged, the old secret stops working immediately, and the new full `lm_svc_` key is returned in `key` **once** (same [`CreateAccountServiceKeyResult`](#createaccountservicekeyresult) shape as create - `{ key, serviceKey }`). Emits the `account:service_key_rotated` audit event (metadata `service_key_id`, `old_key_prefix`, `new_key_prefix`). Parity with REST `POST /v1/service-keys/{id}/rotate`. #### `deleteAccountServiceKey(id: UUID!): DeleteTokenResult!` Revoke a service key by hard-deleting it. Requires `feature:apikeys` and `apikeys:delete`. ### Types #### `CreateAccountServiceKeyInput` | Field | Type | Description | |-------|------|-------------| | `label` | `String!` | Required display name | | `permissions` | `[String!]!` | Permission subset granted to the key | | `expiresAt` | `String` | Optional RFC3339 expiry; `null` means no expiry | #### `CreateAccountServiceKeyResult` | Field | Type | Description | |-------|------|-------------| | `key` | `String!` | Full `lm_svc_` key, returned once | | `serviceKey` | `AccountServiceKey!` | Stored key metadata | #### `AccountServiceKey` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Key ID | | `accountId` | `UUID!` | Owning account | | `createdBy` | `UUID` | Member who minted the key; `null` once they leave the account | | `keyPrefix` | `String!` | Display prefix for identification | | `label` | `String!` | Display name | | `permissions` | `[String!]!` | Granted permission subset | | `createdAt` | `String!` | Creation timestamp | | `updatedAt` | `String!` | Last modification timestamp | | `expiresAt` | `String` | Optional expiry timestamp | | `lastUsedAt` | `String` | Last successful use, when known (minute precision: rewritten at most once a minute) | ## Developer API Keys Developer API keys (`lm_dev_`, ZAF-1176) are the publishing credential for extension developers. Unlike account service keys they are **not** account-owned: a developer key is bound to a developer **profile** or **team**, carries no account and no member identity (so it survives the minting member's departure), and can carry **only** the `extension-dev:*` permission family. Eligibility is approved-developer status, not the Pro plan - the create path is **not** gated on `feature:apikeys`. Management is **first-party only** (`require_first_party_user`): a `lm_dev_` key can never mint or revoke another developer key. `createDeveloperApiKey` is **session-only** (`require_session_user`, ZAF-1742): a user's own `lm_usr_` key cannot mint one either. The full key is returned only from `createDeveloperApiKey`. Parity with REST `/v1/developer/api-keys`. ### Queries #### `developerApiKeys: [DeveloperApiKey!]!` Lists the caller's developer keys - their own profile keys plus keys on teams they belong to. ### Mutations - `createDeveloperApiKey(input: CreateDeveloperApiKeyInput!): CreateDeveloperApiKeyResult!` - mint a key; scopes are validated against the developer-key registry (the `extension-dev:*` family) and the creator-subset guard. A `teamId` binds the key to a developer team and requires the team's `team-secrets:edit` right; omit it to bind to your own developer profile. - `updateDeveloperApiKey(id: UUID!, label: String!): DeveloperApiKey!` - rename (label only). - `revokeDeveloperApiKey(id: UUID!): DeleteTokenResult!` - soft-revoke; the auth layer then fails closed on the revoked key. Rename and revoke share one authorization path with REST: a missing or foreign key fails with `Not found: Developer key not found` (code `NOT_FOUND`); a **team** key additionally requires the caller's current developer-team right `team-secrets:edit` on that team, else `Missing team permission: team-secrets:edit` (code `FORBIDDEN`) - membership alone (e.g. the default **Member** role) is not enough for either action. A **profile** key is managed by its owner. Lifecycle changes emit the **user-scoped** audit events `user:developer_api_key_created` / `user:developer_api_key_updated` / `user:developer_api_key_revoked`. ### Types #### `CreateDeveloperApiKeyInput` | Field | Type | Description | |-------|------|-------------| | `label` | `String!` | Required display name | | `permissions` | `[String!]!` | Subset of the `extension-dev:*` family to grant | | `teamId` | `UUID` | Bind to a developer team you belong to; `null` = your own developer profile | | `expiresAt` | `String` | Optional RFC3339 expiry; `null` means no expiry | #### `CreateDeveloperApiKeyResult` | Field | Type | Description | |-------|------|-------------| | `key` | `String!` | Full `lm_dev_` key, returned once | | `developerApiKey` | `DeveloperApiKey!` | Stored key metadata | #### `DeveloperApiKey` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Key ID | | `developerId` | `UUID` | Owning developer profile (`null` for a team key) | | `teamId` | `UUID` | Owning developer team (`null` for a profile key) | | `createdBy` | `UUID` | Member who minted the key; `null` once they leave | | `keyPrefix` | `String!` | Display prefix for identification | | `label` | `String!` | Display name | | `permissions` | `[String!]!` | Granted `extension-dev:*` subset | | `createdAt` | `String!` | Creation timestamp | | `updatedAt` | `String!` | Last modification timestamp | | `expiresAt` | `String` | Optional expiry timestamp | | `lastUsedAt` | `String` | Last successful use, when known (minute precision: rewritten at most once a minute) | ## Admin Role Management The following queries and mutations are admin-scope: they check the caller's **admin permissions** (not account permissions) and return errors for unauthenticated or unauthorized requests. ### Queries #### `adminRoles: [AdminRole!]!` List all admin roles with their permissions and member counts. Requires `admin-roles:read`. #### `adminRole(id: UUID!): AdminRole!` Get a single admin role by ID. Requires `admin-roles:read`. Returns `"Admin role not found"` if the ID does not exist. #### `adminRoleMembers(roleId: UUID!): [AdminRoleMember!]!` List all users assigned to an admin role. Requires `admin-roles:read`. #### `allAdminPermissions: [AdminPermissionInfo!]!` Return the full catalog of admin-scope permissions with their category labels. Requires `admin-roles:read`. Source: `lo_auth::rbac::all_admin_permissions()`. Used by the permission picker UI. #### `adminSystemKeyPermissions: [AdminPermissionInfo!]!` Return every scope assignable to a [System key](../admin/system-keys.md), each with its category. Requires `system-keys:read`. Source: `lo_auth::rbac::system_key::get_assignable_permission_infos()` - the derived registry (admin ∪ account ∪ user minus the credential-minting / tenant-destruction deny-list, ZAF-1213), the same set `adminCreateSystemKey` validates against. Guarded on `system-keys:read` (not `admin-roles:read`) so the operator who mints keys can read it. Parity: REST `GET /v1/admin/system-key-permissions`. ### Mutations #### `adminCreateRole(input: CreateAdminRoleInput!): AdminRole!` Create a new admin role. Requires `admin-roles:create`. - Auto-injects `admin:access` into the permissions list if not present - Validates all permissions against `all_admin_permissions()` - rejects unknown permissions with `"Invalid permission: "` - Returns `"Role name already in use"` on duplicate name #### `adminUpdateRole(id: UUID!, input: UpdateAdminRoleInput!): AdminRole!` Update an existing admin role. Requires `admin-roles:edit`. - Fields are optional: omit to leave unchanged - `description: null` clears the description; omitting it leaves it unchanged - Permissions are applied as a diff against known permissions (unknown/legacy permissions are preserved) - Same validation as create; `is_system = true` roles are fully editable (not protected from edits) #### `adminDeleteRole(id: UUID!): Boolean!` Delete an admin role. Requires `admin-roles:delete`. - Returns `true` if deleted - Rejects with `"Cannot delete system admin role"` if `is_system = true` - Cascades to `admin_role_permissions` and `user_admin_roles` #### `adminAssignUserRole(userId: UUID!, roleId: UUID!): Boolean!` Assign a user to an admin role. Requires `admin-roles:edit`. Idempotent - returns `true` if a new assignment was created, `false` if already assigned. #### `adminUnassignUserRole(userId: UUID!, roleId: UUID!): Boolean!` Remove a user's admin role assignment. Requires `admin-roles:edit`. Returns `true` if removed. ### Types #### `AdminRole` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Role ID | | `name` | `String!` | Display name | | `description` | `String` | Optional description | | `isSystem` | `Boolean!` | System roles cannot be deleted | | `permissions` | `[String!]!` | Sorted permission strings | | `memberCount` | `Int!` | Number of assigned users | | `createdAt` | `String!` | ISO-8601 timestamp | | `updatedAt` | `String!` | ISO-8601 timestamp | #### `AdminRoleMember` | Field | Type | Description | |-------|------|-------------| | `userId` | `UUID!` | User ID | | `displayName` | `String!` | User display name | | `email` | `String` | User email | | `avatarUrl` | `String` | User avatar URL | | `assignedAt` | `String!` | ISO-8601 timestamp | #### `AdminPermissionInfo` | Field | Type | Description | |-------|------|-------------| | `permission` | `String!` | Permission string (e.g., `admin-roles:read`) | | `category` | `String!` | Category label (e.g., `Admin Role Management`) | #### `CreateAdminRoleInput` | Field | Type | Required | |-------|------|----------| | `name` | `String!` | Yes | | `description` | `String` | No | | `permissions` | `[String!]!` | Yes (may be empty) | #### `UpdateAdminRoleInput` | Field | Type | Notes | |-------|------|-------| | `name` | `String` | Optional; trimmed | | `description` | `String` | `null` = clear, omit = leave unchanged | | `permissions` | `[String!]` | Optional; replaces via diff | > **Protocol parity:** All queries and mutations above have matching REST endpoints under `/v1/admin/admin-roles` and `/v1/admin/admin-permissions` - see [REST API](./rest-api.md). ## Admin API Key Management Platform-operator management of **any** account's or user's API keys, for support and incident response. These are **admin-scope** (checked against the caller's admin role via `AdminPermissionGuard`, the `account-apikeys:*` family) - distinct from the account-scope `apikeys:*` a member uses on their own keys ([`userApiKeys`](#user-api-keys) / [`accountServiceKeys`](#account-service-keys)). No field ever exposes a key's secret except the one-time `adminRotateApiKey` result. `kind` is the [`ApiKeyKind`](#apikeykind) enum - `PERSONAL` (`lm_usr_`, in `api_keys`) or `SERVICE` (`lm_svc_`, in `account_service_keys`). Parity with the REST endpoints under [`/v1/admin/.../api-keys`](./rest-api.md#admin-api-keys). ### Queries #### `adminAccountApiKeys(accountId: UUID!): [AdminApiKey!]!` List an account's API keys - **both** kinds (its members' personal `lm_usr_` keys and the account's `lm_svc_` service keys). Requires `account-apikeys:read`. Each item is an [`AdminApiKey`](#adminapikey) carrying non-secret metadata only - never the secret. #### `adminUserApiKeys(userId: UUID!): [AdminApiKey!]!` List a user's personal `lm_usr_` API keys (service keys have no owning user). Requires `account-apikeys:read`. ### Mutations #### `adminUpdateApiKey(kind: ApiKeyKind!, id: UUID!, label: String!, permissions: [String!]!): AdminApiKey!` Edit a key's `label` and `permissions` (scopes). Requires `account-apikeys:edit`. Scope edits validate against the assignable-scope registry for the key's `kind` (ZAF-1170) - an unknown, deny-listed, or user-scoped-on-a-`SERVICE`-key scope is rejected. For a `PERSONAL` key the scopes are additionally capped to the key **owner's** own grantable permissions; for a `SERVICE` key the account is the principal, so the registry minus the deny-list is the ceiling - an operator can never push a key above its own principal. A blank label is rejected. Emits `system:api_key_updated`. #### `adminRotateApiKey(kind: ApiKeyKind!, id: UUID!): AdminApiKeyRotated!` Rotate a key - regenerate its secret in place (id, label and scopes unchanged; old secret invalidated immediately). Requires `account-apikeys:rotate`. Returns an [`AdminApiKeyRotated`](#adminapikeyrotated) `{ key, apiKey }`: `key` is the new full secret (`lm_usr_` / `lm_svc_`), returned **once**; `apiKey` is the updated non-secret metadata. Emits `system:api_key_rotated` (metadata `old_key_prefix`, `new_key_prefix`). #### `adminDeleteApiKey(kind: ApiKeyKind!, id: UUID!): Boolean!` Revoke (hard-delete) a key. Requires `account-apikeys:delete`. **Idempotent** - deleting an already-gone key resolves to the same not-found result. Emits `system:api_key_revoked`. All three mutations emit a **system-scope** audit event with the operator as the actor (`user_id`) and the target `kind` / `api_key_id` / `account_id` / (personal only) `user_id` in metadata; these rows land only in the operator log, never in the target account's or user's own audit log. ### Types #### `AdminApiKey` A tenant/user API key as seen by a platform operator - never exposes the secret. Covers both kinds via the `kind` discriminator. | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Key ID | | `kind` | `ApiKeyKind!` | `PERSONAL` (`lm_usr_`) or `SERVICE` (`lm_svc_`) | | `accountId` | `UUID!` | Owning account | | `userId` | `UUID` | Key owner (personal keys); `null` for service keys | | `createdBy` | `UUID` | Member who created a service key; `null` once they leave / for personal keys | | `keyPrefix` | `String!` | Display prefix (e.g. `lm_usr_ab`); the full key is never returned by list | | `label` | `String!` | Display name | | `permissions` | `[String!]!` | Granted scopes | | `createdAt` | `String!` | Creation timestamp | | `expiresAt` | `String` | Expiry; `null` = never | | `lastUsedAt` | `String` | Last successful use, when known (minute precision: rewritten at most once a minute) | #### `AdminApiKeyRotated` Result of `adminRotateApiKey` - the new full key, shown exactly once. | Field | Type | Description | |-------|------|-------------| | `key` | `String!` | New full `lm_usr_` / `lm_svc_` key, returned once | | `apiKey` | `AdminApiKey!` | The rotated key's updated non-secret metadata | ## Admin Account Membership These fields use admin-scope permissions. The role read and member mutation have matching REST endpoints under `/v1/admin/accounts/`. ### `adminAccountRoles(accountId: UUID!): [AdminAccountRole!]!` List the account's roles in sort order for the add-member role picker. Requires `accounts:read`. Returns `"Not found: Account not found"` when the account does not exist. Each `AdminAccountRole` carries `id`, `slug`, `name`, nullable `color`, `isSystem`, `isDefault`, and `sortOrder`. The API includes the Owner role; the admin dialog excludes it from its choices. ### `adminAddAccountMember(accountId: UUID!, userId: UUID!, roleId: UUID!): AdminAccountDetail!` Add a user to an account with a required role belonging to that account. Requires `accounts:edit`. A missing `roleId` fails GraphQL input validation; a role from another account or an unknown role returns `"Not found: Role not found for this account"`. On success, returns the account detail and emits `system:account_member_added` with the selected `role_id`; adding a user who is already a member changes nothing and emits no event. ## Admin Account Limits `adminSetAccountLimits(accountId, input)` requires `accounts:edit`. The optional `input.chatRetentionDays` override must be 0–1095 days; values outside that range return `"chat_retention_days must be between 0 and 1095 days (the published maximum)"`. Omit the field to keep the existing override. The retention sweeps use the account override when set, otherwise the plan value. Its REST twin is `PUT /v1/admin/accounts/{id}/limits`. ## Plan Management Admin-scope queries and mutations for managing subscription plans. All check `plans:*` admin permissions. ### Queries #### `adminPlans: [AdminPlan!]!` List all plans with their feature assignments and account counts. Requires `plans:read`. ### Mutations #### `adminCreatePlan(input: AdminCreatePlanInput!): AdminPlan!` Create a new plan. Requires `plans:create`. - Validates `slug` against regex `^[a-z0-9]+(?:-[a-z0-9]+)*$`, length 2–40 chars. Returns `"Invalid slug format"` on failure. - Rejects duplicate slugs with `"Plan slug already in use"`. - Rejects negative prices / limits with `"Price cannot be negative"` / `"Limit cannot be negative"`. `chatRetentionDays` must be 0–1095; outside that range the error is `"chat_retention_days must be between 0 and 1095 days (the published maximum)"`. `0` is not unlimited: it means the 1095-day maximum for all channel data. - `currency` defaults to `"USD"` if omitted. - Stripe IDs are optional free-text strings. Lumio does not call Stripe - paste IDs from the Stripe dashboard. #### `adminUpdatePlan(id: UUID!, input: AdminUpdatePlanInput!): AdminPlan!` Update an existing plan. Requires `plans:edit`. - `slug` is **immutable** and is not part of `AdminUpdatePlanInput`. To change a slug, create a new plan, migrate accounts, then delete the old one. - All input fields rewrite the plan's editable state. `chatRetentionDays` must be 0–1095; outside that range the error is `"chat_retention_days must be between 0 and 1095 days (the published maximum)"`. - On success, invalidates the feature cache for every account currently on the plan. - Returns `"Plan not found"` if no plan has the given ID. #### `adminDeletePlan(id: UUID!): Boolean!` Delete a plan. Requires `plans:delete`. Returns `true` on success. - Returns `"Plan not found"` if no plan has the given ID. - Returns `"Cannot delete plan: N account(s) still reference it. Migrate them to a different plan first."` if any accounts are still on the plan. - Cascades to `plan_features` via the FK constraint. ### Types #### `AdminPlan` ```graphql type AdminPlan { id: UUID! slug: String! name: String! description: String priceMonthly: Int! priceYearly: Int! currency: String! isPublic: Boolean! sortOrder: Int! maxOverlays: Int! maxStorageBytes: Int! maxUploadSizeBytes: Int! maxIntegrations: Int! chatRetentionDays: Int! maxSettingPresets: Int! stripeProductId: String stripeMonthlyPriceId: String stripeYearlyPriceId: String features: [AdminPlanFeature!]! accountsUsing: Int! } ``` #### `AdminPlanFeature` ```graphql type AdminPlanFeature { featureId: UUID! featureKey: String! label: String! enabled: Boolean! } ``` #### `AdminCreatePlanInput` ```graphql input AdminCreatePlanInput { slug: String! name: String! description: String priceMonthly: Int! priceYearly: Int! currency: String isPublic: Boolean! sortOrder: Int! maxOverlays: Int! maxStorageBytes: Int! maxUploadSizeBytes: Int! maxIntegrations: Int! chatRetentionDays: Int! maxSettingPresets: Int! stripeProductId: String stripeMonthlyPriceId: String stripeYearlyPriceId: String } ``` #### `AdminUpdatePlanInput` ```graphql input AdminUpdatePlanInput { name: String! description: String priceMonthly: Int! priceYearly: Int! currency: String! isPublic: Boolean! sortOrder: Int! maxOverlays: Int! maxStorageBytes: Int! maxUploadSizeBytes: Int! maxIntegrations: Int! chatRetentionDays: Int! maxSettingPresets: Int! stripeProductId: String stripeMonthlyPriceId: String stripeYearlyPriceId: String } ``` `AdminUpdatePlanInput` intentionally has no `slug` field - slugs are immutable after creation. > **Protocol parity:** All queries and mutations above have matching REST endpoints under `/v1/admin/plans` - see [REST API](./rest-api.md#admin-plan-management). ## Public Pricing Public, auth-optional queries used by the marketing pricing surfaces (`/pricing`, landing page, dashboard onboarding, `/dashboard/settings/channel/subscription`). ### `plans` Returns every plan where `is_public = true`, ordered by `sort_order`. Admin-only `is_public = false` plans are filtered out. ```graphql type BillingPlan { id: UUID! slug: String! name: String! description: String priceMonthly: String! priceYearly: String! currency: String! isPublic: Boolean! sortOrder: Int! maxOverlays: Int! maxStorageBytes: String! maxUploadSizeBytes: String! maxIntegrations: Int! chatRetentionDays: Int! features: [PlanFeature!]! } type PlanFeature { featureId: UUID! featureKey: String! label: String! enabled: Boolean! "True when the underlying feature_flag kill-switch is on. Effective availability = enabled && globallyEnabled." globallyEnabled: Boolean! } ``` **`features[]` semantics:** Only flags whose `category` is in `('feature', 'widget', 'integration', 'bot_module')` are returned - `platform:*`, `system:*`, `automation:*`, `copyright_provider:*`, and `event:*` are admin/infrastructure concerns and stay off the pricing card. The query uses a `LEFT JOIN` on `plan_features` with `COALESCE(pf.enabled, false)` - a flag without an explicit `plan_features` row renders as struck-through on every plan card (fail-closed). To include a flag in a plan, add a matching `plan_features` row via migration (see `apps/api/migrations/20260415000008_backfill_plan_features_matrix.up.sql` for the canonical pattern). ### `enabledPlatforms` ```graphql extend type Query { enabledPlatforms: [String!]! } ``` Returns the list of streaming platform slugs whose kill-switch is globally enabled AND which have an enabled `:login` or `:bot` sub-flag. Used by the "Supported Platforms" badge row on every pricing card. Integration-only platforms (currently Spotify - channel-OAuth only, no login and no chat bot) are **intentionally excluded** so they don't appear as streaming destinations on pricing cards, even though their kill-switch is on. The companion resolver `enabledProviders(connectionType: "login" | "channel" | "bot")` returns the full per-subtype list and is what the ID app's login page and the `/dashboard/settings/security/login` use. ## Public Songs and Playlists These unauthenticated reads use the fail-closed `feature:copyright_detection` gate. The feature is resolved for the caller's account when one is signed in, otherwise globally. A disabled feature returns `FEATURE_DISABLED`; no `copyright:*` permission is required. REST twins are listed in [Public REST endpoints](/features/copyright#public-rest-endpoints). | Query | Arguments | Result | |---|---|---| | `publicSafeSongs`, `publicBlockedSongs` | Optional `search`, `limit`, `offset` | Paginated global safe or blocked songs. | | `publicSong` | `id: UUID!` | One global safe or blocked song, or `null` for an unknown or account-owned ID; twin of `GET /v1/songs/{id}`. | | `publicSongSitemap` | Optional `after: UUID`, `first: Int` | `items { id lastmod }` and `hasMore`; twin of `GET /v1/songs/sitemap`. | | `publicSystemPlaylists` | None | System-curated playlist summaries. | | `publicSystemPlaylist` | `id: UUID!` | One system-curated playlist, or `null` for an unknown ID; twin of `GET /v1/playlists/{id}`. | | `publicSongStatus` | Optional `spotifyTrackId`, `isrc`, `title`, `artist` | Global copyright status. | For `publicSongSitemap`, pass the last item's `id` as `after` while `hasMore` is true. `first` defaults to and is capped at 5,000. The server determines which global lists to include (safe and blocked); `lastmod` is the song's `created_at` or `null`. The public music detail pages read `publicSong` and `publicSystemPlaylist` by ID. ## Shopify The account connection operations need `integration:shopify` plus the listed permission; `hideShopifyOrderName` needs `events:edit` without the plan flag. REST twins in [REST → Shopify](./rest-api.md#shopify) use the same guards, fields and messages. Details: [Shopify](/features/shopify). | Operation | Permission | Returns | |-----------|-----------|---------| | `shopifyConnection` | `connections:read` | `ShopifyConnectionStatus { configured, appStoreUrl, connection: ShopifyConnection }` | | `shopifyLinkRequest(handle: String!)` | `connections:create`, interactive session | `ShopifyLinkRequest { shopHost, state }` — the store a link request comes from; a request another user confirmed is deleted (`CONFLICT`, as REST `409`) | | `shopifyLinkConfirm(handle: String!)` | `connections:create`, interactive session, not cross-site | `ShopifyLinkRequest` — binds the request to the active account; a request confirmed for another account is deleted (`CONFLICT`) | | `updateShopifySettings(customerDisplay: String, showItems: Boolean, showPrice: Boolean)` | `connections:edit` | `ShopifyConnection` — at least one argument | | `disconnectShopify` | `connections:delete` | `Boolean!` | | `hideShopifyOrderName(eventId: UUID!, createdAt: String!)` | `events:edit` (no plan gate) | `Boolean!` — removes the buyer label of one Shopify order (both of its events); `false` when there was none left | `ShopifyConnection` has `shopHost`, `shopName`, `status` (`connected` / `needs_reconnect`), `customerDisplay`, `showItems`, `showPrice`, `scopes`, `enabled`, `connectedAt`. `customerDisplay` defaults to `none`; `showItems` defaults to `true`, `showPrice` to `false`. Settings affect later orders: `raw.line_items` is absent when `showItems` was off, and `raw.total_price`, `raw.currency` and item `price` are absent when `showPrice` was off. `shopifyLinkConfirm` refuses a browser request whose `Sec-Fetch-Site` is not `same-origin`, like its REST twin. The embedded app's endpoints (`/v1/shopify/embedded/*`, Shopify ID token) are REST-only: their caller is not a Lumio principal ([REST → Embedded app](./rest-api.md#embedded-app-v1shopifyembedded)). ## Discord Bot Connections `authorizeBot(platform: "discord")` returns the verified invite URL for the one global Lumio bot. It requires `bot-connections:create`, `feature:bots`, and an interactive session. The invite uses `bot applications.commands identify`; after the unified callback verifies the Discord code, it links the server ID returned by Discord to the active account. The equivalent REST authorize endpoint is `GET /v1/bot-connections/discord/authorize`. `discordGuildConnections` requires `bot-connections:read` and returns each server with `botRemoved` and `missingPermissions`. `disconnectDiscordGuild(guildId)` requires `bot-connections:delete` and removes the server mapping and settings. Their REST twins are `GET /v1/discord-guilds` and `DELETE /v1/discord-guilds/{guild_id}`; they enforce the same account guards and emit the same disconnect audit event. See [Bot Connections](/features/bot-connections#discord-servers). ## Login Assignments Login assignments link an assignable login connection to a specific Lumio account, enabling multi-account ownership from a single user identity. Discord is sign-in-only and cannot be assigned. > **Own assignments are always allowed without permissions.** The `login-assignments:*` permissions only apply when managing assignments on behalf of another user. ### Queries #### `accountLoginAssignments(userId: UUID): [GqlLoginAssignment!]!` List all login assignments for the caller's active account. When `userId` is supplied, returns the assignments for that specific user (requires `login-assignments:read`). When omitted, returns the caller's own assignments. ```graphql query { accountLoginAssignments { provider loginConnectionId userId assignedAt } } ``` ### Mutations #### `assignLoginConnection(loginConnectionId: UUID!, provider: String!, userId: UUID, accountId: UUID): GqlLoginAssignment!` Assign a login connection to an account. `userId` defaults to the authenticated user when omitted. Requires `login-assignments:create` to assign on behalf of another user; own assignments are always allowed. If either the requested provider or the stored connection provider is Discord, it fails with `This sign-in method is login-only and cannot be assigned to a channel` (`BAD_REQUEST`), byte-identical to the REST twin. A successful assignment emits `account:login_connection_assigned`. #### `removeLoginAssignment(provider: String!, userId: UUID, accountId: UUID): Boolean!` Remove the login assignment for the given provider from an account. `userId` defaults to the authenticated user. Requires `login-assignments:delete` to remove another user's assignment; own assignments are always allowed. Returns `true` on success, and emits `account:login_connection_removed` when a row was actually removed. > **Which account is targeted.** `accountId` defaults to the caller's **active** account. A user-centric surface (the Profile page, and the ID App after an OAuth link) must always send the account the user picked, otherwise the assignment follows the account switcher instead of the UI (ZAF-1495). A supplied `accountId` must be an account the caller belongs to: on the caller's **own** login connection any account from their membership list is accepted — the same list the account picker offers; with `userId` set to another user the target is pinned to the active account, because `login-assignments:*` resolves against the active account's roles. Anything else fails with `You do not have access to the target account`. Identical decision on the REST twins. #### `disconnectLoginConnection(loginConnectionId: UUID!): LogoutResult!` Delete a login connection by UUID. The connection must belong to the authenticated user. No special permission required beyond ownership. Returns `true` on success. **Step-up:** after the "last sign-in method" check, the calling session needs a step-up in the last 10 minutes; otherwise the error carries `extensions.code` `STEP_UP_REQUIRED` ("Please confirm that it's you first") and nothing is deleted. Same code and message as `DELETE /v1/login-connections/{id}`. `startMfaSetup` and `enableMfa` need the same step-up. See [Authentication → Step-up before sign-in changes](../features/auth.md#step-up-before-sign-in-changes). > **First-party user surface (ZAF-469):** `me`, `updateMe`, `disconnectLoginConnection`, `assignLoginConnection`, `removeLoginAssignment`, `setNameSourceConnection`, `flaggedConnections`, the session and notification operations, `myDeveloperApplication` / `submitDeveloperApplication`, `developerVerificationStatus` / `submitDeveloperVerification`, `createAccount` / `dissolveAccount` / `leaveAccount`, and the JWT-minting `issueWsToken` are all keyed on the acting user's *own* identity. The first-party gate admits a logged-in user session or the user's own API key; the identity-changing operations and `issueWsToken` listed in the session-only note below require an interactive session. A popout/overlay/widget/extension token - whose `user_id` is bound at token-create time and could be the account owner - is rejected with a `FORBIDDEN` error (so it can never obtain an owner-bound JWT). Same gate and status behaviour as the REST twins. > **Session-only (ZAF-1742):** `disconnectLoginConnection`, `assignLoginConnection`, `removeLoginAssignment`, `setNameSourceConnection`, `updateDisplayName`, `removeAvatar` and `issueWsToken` refuse a user's own API key too. The channel name/picture mutations `setAccountIdentitySource`, `updateAccountName`, `removeAccountAvatar` and the deprecated `updateAccount` need an interactive session as well (see **Channel name and picture** below). `updateMe` does not set the profile email: `email` is deprecated and refused for every caller with `EMAIL_MANAGED_BY_LOGIN` (`Set your e-mail under Security › Sign-in › E-mail & password`). The account switch / clear (`activeAccountId`, `clearActiveAccount`) is session-only too, since it re-mints the session JWT (ZAF-1922). The other `updateMe` fields keep the first-party gate, and a non-session caller never receives a `token`. A JWT is only minted for a caller that already holds a session: `createAccount` returns `token: null` (`CreateAccountResult.token` is a nullable `String`) to a caller without one, and `dissolveAccount` returns no token at all. `dissolveAccount` needs a step-up as well, which only a session can hold (see below), so a user API key cannot dissolve an account. A user JWT without a `session_id` is not a session. The same session-only gate covers `createUserApiKey`, `rotateUserApiKey`, `createDeveloperApiKey`, `authorizeChannel` and `authorizeBot`. Provider linking has no GraphQL twin (REST `POST /v1/auth/link`, session-only). See [Authentication → Session-only Surfaces](./authentication.md#session-only-surfaces). **`dissolveAccount` step-up (ZAF-2052):** after the `account:delete`, owner and `confirmName` checks, the calling session needs a step-up in the last 10 minutes; otherwise the error carries `extensions.code` `STEP_UP_REQUIRED` ("Please confirm that it's you first") and nothing is deleted. A user API key, service key or popout token cannot hold a step-up and is refused even with `account:delete`. Same code and message as `DELETE /v1/accounts/{id}`. See [Authentication → Step-up before sign-in changes](../features/auth.md#step-up-before-sign-in-changes). `dissolveAccount` also refuses with `ACTIVE_SUBSCRIPTION` (a running Stripe subscription not set to cancel at period end) before the step-up. **Scheduled dissolution (ZAF-2116):** `scheduleAccountDissolution(accountId: UUID!, graceDays: Int): AccountDissolution!` is guarded by `PermissionGuard("account:delete")`. `graceDays` is 0, 7 or 30 (default 30). The checks run in the same order and with the same codes as `POST /v1/accounts/{id}/dissolution`: first-party user (popout tokens refused, `FORBIDDEN`), `INVALID_GRACE`, `accountId` must be the active account (`FORBIDDEN`), owner only ("Only the account owner can dissolve an account"), `DISSOLUTION_ALREADY_SCHEDULED`, `ACTIVE_SUBSCRIPTION` ("Cancel it in the billing settings first"), then a fresh step-up (`STEP_UP_REQUIRED`; a user API key cannot hold one). `AccountDissolution { dissolutionScheduledFor, dissolved }`: `graceDays: 0` dissolves at once and returns `dissolutionScheduledFor: null, dissolved: true`. `cancelAccountDissolution(accountId: UUID!): AccountDissolution!` (`AuthGuard`) is owner only and needs an interactive session (no API key, no popout token) but no step-up. It fails with `PROFILE_DELETION_SCHEDULED` while the owner's own profile is scheduled for deletion, with `DISSOLUTION_IN_PROGRESS` ("The dissolution is already running") once the date has passed, and with `NO_DISSOLUTION_SCHEDULED` when nothing is scheduled. It works while the channel is suspended, up to the end of the grace period. REST twin: `DELETE /v1/accounts/{id}/dissolution`. **Channel name and picture (ZAF-2118):** a channel's name and picture come from one source - `connection` (one of the channel's own Twitch, YouTube or Kick channel connections, never Spotify) or `own` (an editable name and an optional uploaded picture; without one `avatarUrl` is `null` and the initial is shown). New channels start with `own`. Background: [Accounts › Channel name and picture](../features/accounts.md#channel-name-and-picture). ```graphql type AccountIdentity { sourceKind: String! # "connection" | "own" sourceConnectionId: UUID # null with own sourcePlatform: String # "twitch" | "youtube" | "kick"; null with own name: String! avatarUrl: String # null = show the initial } ``` - `Account.identity: AccountIdentity!` and `AccountMembership.identity: AccountIdentity!` (in `me.accounts[]`) carry the resolved name and picture. `AccountMembership.accountName` is the same name. `AccountMembership.ownerAvatarUrl` is deprecated and equals `identity.avatarUrl` (no fallback to the owner's profile picture). REST twins: `AccountResponse.identity`, `accounts[].identity` on `GET /v1/users/me`. - `setAccountIdentitySource(accountId: UUID!, kind: String!, connectionId: UUID): AccountIdentity!` - `kind` `connection` needs a `connectionId`, `own` must not have one; an unknown kind, a missing or superfluous id or a Spotify connection fail with `INVALID_SOURCE`; another channel's or an unknown connection with `Connection not found`. Switching to `own` keeps the current name and starts with the initial; switching back to a connection deletes an uploaded picture. REST twin: `PUT /v1/accounts/{id}/identity-source`. - `updateAccountName(accountId: UUID!, name: String!): AccountIdentity!` - only with `own` (`SOURCE_NOT_OWN`). Trimmed, NFC-normalised, 2-50 characters, no control or invisible characters: `NAME_TOO_SHORT`, `NAME_TOO_LONG`, `NAME_INVALID_CHARACTERS`. REST twin: `PATCH /v1/accounts/{id}/name`. - `removeAccountAvatar(accountId: UUID!): AccountIdentity!` - only with `own` (`SOURCE_NOT_OWN`); shows the initial and deletes the files. REST twin: `DELETE /v1/accounts/{id}/avatar`. - **No GraphQL upload.** The picture upload is REST-only: [`POST /v1/accounts/{id}/avatar`](./rest-api.md#post-v1accountsidavatar). - `updateAccount(id: UUID!, name: String!): Account!` is deprecated and follows exactly the `updateAccountName` rules (own only, 2-50 characters, interactive session, audited). Guard for all of them: `PermissionGuard("account:edit")` (owner and administrator by default), an interactive sign-in session (a user API key or popout token is refused with `FORBIDDEN` `This action requires an interactive sign-in session`), and `accountId` must be the session's active channel (`FORBIDDEN` `Account id does not match the active account`). During a dissolution grace period they fail with `CHANNEL_DISSOLVING`. Same guard, validation and codes as the REST twins. An effective change writes `account:identity_changed` (scope `account`) with metadata `changed` (`source` / `name` / `avatar`), `source_kind` and `source_platform`; never a name or URL. While a dissolution is scheduled the channel is suspended: a member's session keeps its identity but holds no account permission, so permission-gated resolvers fail with `extensions.code = "CHANNEL_DISSOLVING"` and `extensions.dissolutionScheduledFor`. Channel-bound credentials (API keys, service keys, popout, overlay, widget and extension tokens) are refused outright; `createCheckout` fails with `CHANNEL_DISSOLVING`; every feature flag resolves off; the channel drops out of `botChannels` and `discordBotGuilds`. `me`, profile operations, switching or leaving accounts and `cancelAccountDissolution` keep working. Details: [REST API → Channels being dissolved](./rest-api.md#channels-being-dissolved). > **First-party session required - extension developer surface (ZAF-471):** the developer-identity surface includes `myDeveloperProfile`, `developerRevenue`, `developerPayoutSettings`, `developerPayouts`, `developerLimits`, `developerLimitRequests`, `developerLimitRequestEvents`, `developerExtensions` and the ownership-scoped `developerExtension*` reads, `updateDeveloperPayoutSettings`, `requestPayout`, `requestDeveloperLimitIncrease`, `createExtension` / `submitExtensionVersion` / `requestExtensionDeletion` / `inviteExtensionTester` and their ownership-scoped siblings, the `developerTeam*` reads and `DeveloperTeamMutation` operations, and `grantExtensionAccess` / `createExtensionAccessInvite`. A popout token's `user_id` is the user it is bound to - possibly the owner - not the person presenting it, so the first-party and ownership checks keep a permission-capped popout from acting as that developer. First-party operations admit a logged-in user session or the user's own API key; `updateDeveloperPayoutSettings` and `requestPayout` instead require an interactive session (ZAF-1750). Ownership-scoped extension operations can also admit developer keys under their separate guard, as described below. A popout/overlay/widget/extension token is rejected with `FORBIDDEN` on both protocols; this includes the extension **secrets** resolvers, which are ownership-scoped (secrets belong to the extension, not to an account). > **What happens to a requested payout (ZAF-1257):** the payout is `pending` and the balance is debited. The payout cycle sends Stripe and PayPal payouts within about 15 minutes; SEPA payouts and payouts with an unknown provider answer are settled by an operator (`completeDeveloperPayout` / `failDeveloperPayout`). Every request emits `developer:payout_requested` (user scope), as on REST. See [Developer Payouts](../features/developer-payouts.md). > **Payouts need a verified developer (ZAF-2804):** `requestPayout` fails with `DEVELOPER_VERIFICATION_REQUIRED` ("Payouts require a verified developer identity. Complete the verification in the developer settings first") until the developer verification is `verified`; the automatic payout cycle skips unverified developers. REST twin: `POST /v1/developer/payouts/request`, `403` with the same code and text. > **Closing payout (ZAF-2825):** `requestPayout(closing: true)` pays out the whole balance even below the method's minimum payout amount, but only while the user has a profile deletion scheduled (grace period running, or held by the deletion job); otherwise it fails with `CLOSING_PAYOUT_REQUIRES_DELETION` ("A closing payout below the minimum payout amount is only possible while a profile deletion is scheduled"). A balance of 0 still fails with the below-minimum error. Payout method, verified identity and the interactive session stay required. REST twin: `POST /v1/developer/payouts/request` `{ "closing": true }`, `409` with the same code and text. > **Every payout carries a self-billed credit note (ZAF-2807):** `requestPayout` issues the payout's credit note in the same transaction; the payout `amount` is its gross amount (19 % VAT on top for a German developer who charges VAT). It fails with `TAX_DETAILS_REQUIRED` while the verification's tax details are incomplete and with `CREDIT_NOTE_ISSUER_NOT_CONFIGURED` while `[billing.issuer]` is incomplete, with the same texts as REST; nothing is booked. See [Developer Credit Notes](#developer-credit-notes). > **Money-moving mutations are session-only (ZAF-1750):** `updateDeveloperPayoutSettings`, `requestPayout`, `createCheckout`, `createPortal` and `cancelSubscription` require an **interactive sign-in session**. A user's own API key is refused with `FORBIDDEN` `This action requires an interactive sign-in session`, whatever its scopes, before any row is written or Stripe is called (the billing mutations check the session before they check whether billing is enabled). The reads (`developerPayoutSettings`, `developerPayouts`, `developerRevenue`, `billingStatus`, `invoices`) keep the first-party gate. Stripe Connect onboarding has no GraphQL twin (REST `POST /v1/developer/stripe-connect/onboard`, session-only). REST twins identical. See [Authentication → Session-only Surfaces](./authentication.md#session-only-surfaces). > **Developer API keys (`lm_dev_`, ZAF-1176):** a developer key is **not** a first-party user session, so it is refused from the developer-application surface above (profile / revenue / payouts / limits, and `createExtension` / `submitExtensionVersion` / `requestExtensionDeletion` / `inviteExtensionTester`) with the same `FORBIDDEN`. It **is** admitted - where a popout is rejected - to the ownership-scoped `developerExtension*` reads and their mutations (update / delete extension, create / upload version, testers list & remove, analytics, limits, purchases, screenshots, and the extension **secrets** `developerExtensionSecrets` / `setExtensionSecret` / `deleteExtensionSecret` - see [REST → Extension secrets](./rest-api.md#extension-secrets) for the shared rules): those resolve the key's bound developer/team identity via the shared `resolve_ownership` helper and are gated on the key's `extension-dev:*` scope, decoupled from the `feature:extension_development` plan gate. > **Note:** This mutation supersedes the previous provider-based disconnect operation. The old form accepted a `provider` string; the new form accepts a `loginConnectionId` UUID to uniquely identify the connection even when a user has multiple connections to the same platform. ### Types #### `GqlLoginAssignment` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Assignment ID | | `accountId` | `UUID!` | Account the login is assigned to | | `userId` | `UUID!` | ID of the owning user | | `loginConnectionId` | `UUID!` | ID of the linked login connection | | `provider` | `String!` | Platform slug (e.g. `"twitch"`, `"google"`) | | `createdAt` | `String!` | ISO-8601 timestamp | > **Protocol parity:** All operations above have matching REST endpoints - see [REST API](./rest-api.md#login-assignments). ## Discord Servers The global Lumio Discord bot serves the Discord servers connected to an account (one server belongs to one account, an account can have several). - **Connect / reconnect:** `authorizeBot(platform: "discord")` (`feature:bots` + `bot-connections:create`, session-only) returns the Discord invite URL: scopes `bot applications.commands identify`, `integration_type=0`, targeted permissions (never Administrator), `redirect_uri` = the unified callback `/api/connections/oauth/discord/callback`. The exchange runs on REST `POST /v1/connections/oauth/discord/exchange` only (browser callback): the server is taken from Discord's token response, never from the callback query. Inviting a server the account already has reconnects it and keeps its settings. Without `[auth].discord_bot_client_id` / `discord_bot_client_secret` the call fails with `The Discord bot is not fully configured: …` on both protocols. `feature:custom_bot` does not apply to Discord. - **`discordGuildConnections`** (`bot-connections:read`) - `DiscordGuildConnection { id, guildId, guildName, guildIcon, permissions, createdAt, updatedAt, botRemoved, missingPermissions }`. `botRemoved`: the bot was removed from the server on Discord's side; the row and its settings stay until a reconnect (or are deleted 90 days later). `missingPermissions`: the server granted fewer permissions than the bot needs. REST twin: `GET /v1/discord-guilds`. - **`disconnectDiscordGuild(guildId)`** (`bot-connections:delete`) - deletes the connection and its settings, returns `{ success }`. REST twin: `DELETE /v1/discord-guilds/{guild_id}`. Errors of the connect step: `This Discord server is already connected to another account.` (the bot is still on that server; a server whose bot was removed can be taken over, with its settings reset), `Discord did not confirm the server invitation. Please add the bot again.`, `Discord did not report which server the bot was added to. Please add the bot again.` ## Notifications User-scoped notifications with read/unread tracking and actionable items. See [Notifications](../features/notifications.md) for the full feature documentation, queries, and mutations. The `invite` notification carries `data.inviteId` (the account invite row ID) and offers `accept_invite` and `decline_invite`. A `developer_team_invite` carries `data.invite_id` (the team invite row ID, never its secret code) and offers `accept_team_invite` and `decline_team_invite`. `executeNotificationAction(id: UUID!, action: String!): Notification!` dispatches these four actions for the owning first-party user and marks the notification acted. Team acceptance uses the same feature, expiry, use-limit and membership checks as `acceptDeveloperTeamInvite` by code; team decline deletes only an unused single-use invite and treats a missing invite as handled. Actions with a `url` are navigation actions, including `view_idea`, `view_developer_application`, `view_developer`, `view_extension`, and `install_extension`. The bell opens their page; passing one to `executeNotificationAction` yields `NOTIFICATION_ACTION_NAVIGATES`. Errors use the same message as the [REST action endpoint](./rest-api.md#notifications), with its stable code in `extensions.code`: `NOTIFICATION_NOT_FOUND`, `NOTIFICATION_NO_ACTIONS`, `NOTIFICATION_ACTION_NOT_AVAILABLE`, `NOTIFICATION_ACTION_NAVIGATES`, `NOTIFICATION_ALREADY_HANDLED`, `NOTIFICATION_UNKNOWN_ACTION`, `INVALID_INVITE_ID`, `INVITE_NOT_FOUND`, `INVITE_EXPIRED`, `INVITE_FULL`, `ALREADY_MEMBER`, `INVITE_NO_LONGER_VALID`, and `FEATURE_DISABLED`. An absent or foreign notification uses `NOTIFICATION_NOT_FOUND`. ### Notification preferences | Operation | Args | Returns | Permission | |-----------|------|---------|------------| | `notificationPreferences` | - | `[NotificationPreference!]!` | Auth only | | `updateNotificationPreference(notificationType: String!, channel: String!)` | - | `NotificationPreference!` | Auth only | `channel` accepts `"off"`, `"in_app"`, `"email"`, or `"in_app_email"`. Updating a locked type (e.g. `invite`) returns an error. ### Idea participant autocomplete | Query | Args | Returns | Permission | |-------|------|---------|------------| | `ideaParticipants(ideaId: UUID!, search: String)` | - | `[GqlIdeaAuthor!]!` | Signed-in user session or personal API key | Returns the union of the idea author, voters, and commenters, filtered by the optional `search` string. Used to populate the @mention autocomplete dropdown in idea comments. Anonymous callers receive `UNAUTHENTICATED`. Popout, overlay, widget, extension, shared-overlay, service, developer, and system credentials receive `FORBIDDEN`. The REST participant endpoint applies the same first-party user check. > **Protocol parity:** Both operations have matching REST endpoints - `GET /v1/notifications/preferences`, `PATCH /v1/notifications/preferences/{type}`, and `GET /v1/ideas/{id}/participants` - see [REST API](./rest-api.md). ## YouTube Member Badges Account-scoped read query and a system-admin-scoped erasure mutation. See [Member Badges](../features/memberships.md) for the underlying architecture. ### Queries #### `youtubeMembershipTiers: [GqlYoutubeMembershipTier!]!` List YouTube member-tier badges observed for the caller's account, sorted by first-observation order. Returns `[]` when the cache has not been populated yet (e.g. before the first member message of the very first stream). Requires `chat:read`. Fields on `GqlYoutubeMembershipTier`: `tooltip` (raw English InnerTube tooltip - uniquely identifies the badge artwork at this loyalty milestone), `tierName`, `badgeUrl`, `durationValue`, `durationUnit` (`GqlDurationUnit`: `MONTH`/`MONTHS`/`YEAR`/`YEARS`), `memberMonthsMin`, `sortOrder`, `firstSeenAt`, `lastSeenAt`. ### Mutations #### `eraseYoutubeMemberData(input: EraseYoutubeMemberDataInput!): GqlEraseYoutubeMemberResult!` GDPR Art. 17 - erase one YouTube member channel on one channel's instruction. Input `EraseYoutubeMemberDataInput`: `memberChannelId`, `accountId` (the instructing channel), `instructionReference` (ticket or e-mail id, at most 200 characters) and `initiatedBy` (`channel` or `subject`, as for `eraseChatSubjectData`), all required. Two parts: Lumio's own member cache - all `lumio:yt:member:*:{memberChannelId}` rows and the matching `lumio:yt:refresh_lock:*:{memberChannelId}` debounce locks - is cleared across every account (Lumio's YouTube API client cache, not channel data); the member's channel data (`platform = 'youtube', user_id = memberChannelId`) is erased in `accountId` only, exactly as `eraseChatSubjectData` below. It does **not** touch the emote catalog (`chat_emotes`): an emote shortcode plus its public CDN URL is cross-channel branding reference data, not personal data of the erased member, and falls outside Art. 17 (founder ruling - ZAF-580). Audit-logged as exactly one `system`-scoped `youtube_member_erasure` row naming the target account and the instruction reference, never the member channel id (see [audit log](../admin/audit-log.md#youtube_member_erasure)). Requires `admin:privacy-erase`. Result fields: `erasedKeyCount` and `erasure` (`ChatSubjectErasureResult`, below). Errors (same code and text as REST): `member_channel_id required`, `account_id is required`, `instruction_reference is required`, `instruction_reference must be at most 200 characters`, `initiated_by is required`, `initiated_by must be one of: channel, subject`, `Not found: Account not found`. > **Protocol parity:** mirror at `GET /v1/youtube/memberships/tiers` and `DELETE /v1/admin/privacy/youtube/member/{id}` - see [REST API](./rest-api.md#endpoints-overview). #### `eraseChatSubjectData(input: EraseChatSubjectInput!): ChatSubjectErasureResult!` GDPR Art. 17 - erase one data subject's chat data **in one channel**, on that channel's instruction. Channel data belongs to the channel (founder ruling ZAF-2626), so there is no cross-account erasure: other channels keep the same person's data. In the instructing account it hard-deletes the subject's `platform_chat_messages` rows, their `platform_users` row, the moderator notes about them, their AutoMod history log (`platform_automod_messages` and its `automod_allow` / `automod_deny` moderation-log rows), every other moderation-log row targeting them (rows where they are the acting moderator stay) and their `account_bots` entry, blanks their entries in the account's frozen stream-history lists, announcements and pins included (counts stay, `erased: true`), and closes their still-open messages in the channel's live AutoMod held queue. Events (`platform_event_logs`) stay. It also records the subject in the account's suppression list (`account_chatter_suppressions`), so the channel's later sessions freeze them blanked. Audit-logged as exactly one `system`-scoped `chat_pii_erasure` row (see [audit log](../admin/audit-log.md#chat_pii_erasure)). Requires `admin:privacy-erase`. Input `EraseChatSubjectInput`: `accountId` (the instructing channel), `instructionReference` (ticket or e-mail id, trimmed, at most 200 characters), `reason` (why the subject is erased, trimmed, at most 500 characters, written to the audit event) and `initiatedBy` (`channel` when the channel initiated the erasure, `subject` when the person asked for it; stored on the channel's suppression entry, only a `channel` entry can later be withdrawn, and a `subject` entry is never downgraded) are required; the subject is **exactly one** of `lumioUserId` (a registered Lumio user), or both `platform` + `userId` (a platform chatter such as a YouTube `UC…` channel id). Errors (same code and text as REST): `provide either lumio_user_id or (platform and user_id)`, `platform and user_id must be provided together`, `provide exactly one of lumio_user_id or (platform and user_id), not both`, `account_id is required`, `instruction_reference is required`, `instruction_reference must be at most 200 characters`, `reason is required`, `reason must be at most 500 characters`, `initiated_by is required`, `initiated_by must be one of: channel, subject`; an unknown account is `Not found: Account not found`. Only support operators (`admin:privacy-erase`) call it. Result fields (`ChatSubjectErasureResult`): `accountId`, `caseId` (Lumio's id of this erasure, also in the audit event; its first 8 characters are the case number in the owner's confirmation e-mail), `deletedMessageCount`, `deletedPlatformUserCount`, `deletedUserNoteCount`, `deletedAutomodMessageCount`, `deletedAutomodModerationLogCount`, `deletedModerationLogCount`, `scrubbedHistorySessionCount`, `deletedAccountBotCount`, `removedHeldQueueEntryCount` (the subject's still-open messages closed in the channel's live AutoMod held queue; best-effort, 0 when Redis is unreachable), `suppressed`. > **Protocol parity:** mirror at `POST /v1/admin/privacy/chat/erase` - see [REST API](./rest-api.md#endpoints-overview). Account deletion (dissolution and `adminDeleteAccount`) erases the account's chat, events, chatter profiles, notes and moderation log automatically. #### `withdrawChannelChatterSuppression(input: WithdrawChannelChatterSuppressionInput!): ChannelSuppressionWithdrawalResult!` Withdraw one channel's erasure instruction for one chatter: deletes the chatter's row in that account's suppression list (`account_chatter_suppressions`), so the channel's stream reports frozen from then on name the chatter again. What the erasure deleted or blanked stays gone - nothing is restored. Other channels' lists and the global Lumio list (`chatter_suppressions`, Lumio Stats) are not touched. Input `WithdrawChannelChatterSuppressionInput`: `accountId`, `platform` (`twitch`, `youtube` or `kick`), `userId` and `instructionReference` (ticket or e-mail id of the withdrawal) are all required. Only an erasure the channel initiated can be withdrawn: an entry the person asked for (`initiatedBy: subject`, and every entry recorded before the initiator was stored) is refused with `CONFLICT` and stays. Errors (same code and text as REST): the `addChatterObjection` validation texts, `account_id is required`, `instruction_reference is required`, `instruction_reference must be at most 200 characters`, `Not found: Account not found`, `Not found: no channel erasure is recorded for this chatter` and `Conflict: this erasure was requested by the person, not initiated by the channel; it cannot be withdrawn on the channel's instruction`. Result fields (`ChannelSuppressionWithdrawalResult`): `accountId`, `platform`, `suppressed` (always `false`). Requires `admin:privacy-erase-withdraw`. Audit-logged on success as one `system`-scoped `privacy:channel_suppression_withdrawn` row (metadata: account, instruction reference and platform, never the user id or login). > **Protocol parity:** mirror at `POST /v1/admin/privacy/chat/erase/withdraw` - same validation, error codes and texts, guard and audit event. #### `eraseChatSubjectAtLumio(input: EraseChatSubjectAtLumioInput!): LumioErasureResult!` Erase one person on Lumio's own surfaces only (ZAF-2640 R2): an `erasure` entry in the global Lumio list (`chatter_suppressions`), which masks them on the public Lumio Stats leaderboard, and on YouTube Lumio's member cache across every account. No channel's chat, profiles, logs or stream reports are touched - those are erased only on the channel's instruction (`eraseChatSubjectData`). Idempotent; an existing objection becomes an erasure entry. Input `EraseChatSubjectAtLumioInput`: `platform` (`twitch`, `youtube` or `kick`), `userId`, `instructionReference` and `reason` (trimmed, at most 500 characters, written to the audit event) required, `login` optional (Twitch and Kick). Errors as `addChatterObjection` plus `instruction_reference is required` / `instruction_reference must be at most 200 characters` / `reason is required` / `reason must be at most 500 characters`. Only support operators call it. Result fields: `platform`, `suppressed`, `erasedKeyCount`. Requires `admin:privacy-erase`. Audit-logged as one `system`-scoped `chat_pii_erasure` row with `target: "lumio"`, never the user id or login. > **Protocol parity:** mirror at `POST /v1/admin/privacy/lumio/erase` - same validation, error texts, guard and audit event. #### `eraseChatRecord(input: EraseChatRecordInput!): ChatRecordErasureResult!` Erase **one record** in one channel on that channel's instruction (ZAF-2640 R11 target i). The record is looked up by its id **and** `accountId`: a record of another account is `Not found: Record not found`, the same answer as for one that does not exist. No suppression entry is written, and Lumio Stats is not touched. Requires `admin:privacy-erase`. Audit-logged as one `system`-scoped `chat_pii_erasure` row with `target: "record"` per erased record (see [audit log](../admin/audit-log.md#chat_pii_erasure)); a record that is not found writes none. Input `EraseChatRecordInput`: `kind`, `accountId`, `instructionReference` (at most 200 characters) and `reason` (at most 500 characters) are always required. By `kind`: | `kind` | Fields | Removes | |---|---|---| | `chat_message` | `recordId`, `createdAt` (matched within ±1 s) | the message (hard delete) and its text copy in the moderation log (`details.message`; the moderation-log row stays) | | `user_note` | `recordId` | the moderator note | | `moderation_log` | `recordId` | the moderation-log entry, with any message text in it | | `automod_log` | `recordId` | the AutoMod-log entry, its `automod_allow` / `automod_deny` moderation-log rows, and a still-open entry of the live held queue | | `bot_list` | `recordId` | the bot-list entry | | `stream_report` | `sessionId`, `platform`, `userId`, optional `login` (Twitch/Kick) | the person's entries in that report's top chatters, links, commands and keywords, and their slots in its announcements (author, deleter) and pins (sender, pinner): place and counts stay, the identity goes ("Deleted user"); an announcement or pin they wrote also loses its message id. The report must be finalized (else `Conflict: the stream report is not finalized yet; erase the person once it is`) | A chat message's text can also sit in its stream report (a command's arguments, a posted link). `chat_message` does not reach the report; erase the person in that report with `stream_report` as well. Later recounts from the chat log (a keyword-list change, a bot-list change) leave a report with erased entries as it is, so the name does not come back. Errors (same text as REST): `kind must be one of: chat_message, user_note, moderation_log, automod_log, bot_list, stream_report`, `account_id is required`, `instruction_reference is required`, `instruction_reference must be at most 200 characters`, `reason is required`, `reason must be at most 500 characters`, `record_id is required`, `created_at is required for a chat_message`, `session_id is required for a stream_report`, the chatter errors of `addChatterObjection`, `Not found: Account not found`, `Not found: Record not found`. Result fields (`ChatRecordErasureResult`): `accountId`, `caseId` (also in the audit event), `kind`, `deletedRecordCount` (1, or 0 for `stream_report`), `moderationLogTextRemovedCount` (`chat_message`), `deletedModerationLogCount` and `heldQueueEntryRemoved` (`automod_log`), `scrubbedEntryCount` (`stream_report`; 0 when the person was not in the report). > **Protocol parity:** mirror at `POST /v1/admin/privacy/records/erase` - same validation, error texts, guard, result fields and audit event. #### `addChatterObjection(input: ChatterObjectionInput!): GqlChatterObjectionResult!` GDPR Art. 21 - record a chatter's objection in the global Lumio list (`chatter_suppressions`). It acts on Lumio Stats only: the public Stats leaderboard (`publicStreamStats.topChatters`) is masked at read time, so the objection also covers streams published earlier. Nothing is erased, and no channel's stream reports, exports, history share links or AutoMod log change. Idempotent. Requires `admin:privacy-objection-create`. Audit-logged as `privacy:chatter_objection_added` (`system` scope, metadata without the user id or login). Input `ChatterObjectionInput`: `platform` (`twitch`, `youtube` or `kick`) and `userId` are required; `login` (Twitch and Kick only) also covers the chatter's entries without a user id. Result fields: `platform`, `suppressed`, `reason` (`objection`, or `erasure` when the chatter's global entry is an erasure entry). #### `removeChatterObjection(input: ChatterObjectionInput!): GqlChatterObjectionResult!` Lift a chatter's objection: the public Stats leaderboard names the chatter again at once (it was only masked on read). `login` is ignored. Errors when no objection is recorded, or when the global entry is an erasure entry (it cannot be lifted). Requires `admin:privacy-objection-delete`. Audit-logged as `privacy:chatter_objection_removed`. > **Protocol parity:** mirrors `POST /v1/admin/privacy/chat/objections` and `DELETE /v1/admin/privacy/chat/objections/{platform}/{user_id}` - same validation, error texts, guard type and audit event - see [REST API](./rest-api.md#endpoints-overview). #### `adminSweepOrphanedExtensionUploads(apply: Boolean! = false): OrphanSweepReport!` One-off repair for extension upload files that no `extension_uploads` row references any more - left behind by uninstalls and extension deletes before those queued their files (see [Uploads](../features/uploads.md#extension-uploads-deletion-with-the-install-or-the-extension)). Lists `extensions/{extension_id}/uploads/` for every existing extension and counts the unreferenced files. `apply: false` (the default) is a dry run that changes nothing; `apply: true` queues the files in `storage_deletion_queue` (reason `orphaned`) for the hourly delete, which re-checks each file under its per-file lock, and records a `system:orphaned_storage_swept` audit event. Requires `admin:privacy-erase`. Fails with `Storage not configured` when no storage backend is set up. Result fields: `applied`, `extensionsScanned`, `extensionsFailed` (listing failed, skipped), `orphanedObjects`, `queuedObjects` (0 on a dry run). > **Protocol parity:** mirror at `POST /v1/admin/storage/orphaned-extension-uploads/sweep` - see [REST API](./rest-api.md#endpoints-overview). ## Emote catalog and hover-card details `emotes(platform, channelId)` and `userEmotes` return emote sets for the chat picker. An `Emote` has `id`, `name`, `url`, `provider`, `animated`, nullable `ownerId`, `subscribersOnly`, nullable `ownerName`, and nullable `scope`. `ownerName` is a creator display name when 7TV, FFZ, or BTTV supplies one; `scope` is `channel` or `global` for a third-party set, and `null` when unknown or for native sets. The `emotes` and `userEmotes` queries require `feature:multichat` and `chat:read`. Their REST counterparts and guard differences are described under [Emotes](./rest-api.md#emotes). ### `emoteInfo` ```graphql query EmoteInfo($platform: String!, $items: [GqlEmoteInfoItemInput!]!) { emoteInfo(platform: $platform, items: $items) { emoteId animated owner { ownerId login displayName avatarUrl } } } ``` `platform` is `twitch`, `youtube`, or `kick`. Each input item requires `emoteId`; `ownerId` and `url` are optional. At most 50 items are accepted, and results follow input order. An empty list returns `[]`. An empty or over-200-character emote ID, an owner ID over 200 characters, or a URL over 2048 characters is a validation error; unsupported platforms and more than 50 items are also validation errors. GraphQL and REST use the same resolver and validation messages. `owner` is `null` when it cannot be resolved. When present, it contains the platform owner ID, nullable login, display name, and nullable avatar URL. Twitch uses a fragment owner ID (`0` denotes a global emote); YouTube can derive the `UC…` channel ID from an emote ID; Kick accepts its namespaced `kick:` owner ID. Owner lookup uses the active account's platform profile service. Without an account context, owner details are `null`. `animated` is nullable. It is always `null` for Twitch; Kick and YouTube use a server-side image probe. Kick's URL is built from its numeric emote ID and ignores the supplied `url`. YouTube requires an HTTPS image URL on `yt3.ggpht.com`; without it, the result is `null`. The probe reads at most 512 KiB within 2 seconds, follows at most three same-origin redirects, and returns `null` when it cannot decide. A decided result is cached for 30 days; an undecidable probe is cached for one hour. One request resolves at most 10 distinct owners and probes at most 10 images; items beyond those caps return `owner: null` / `animated: null`. Probe results are cached by the fetched image URL (hashed), not by the client's emote id. **Guard:** authentication, no `chat:read` or feature gate. Dashboard sessions, API / service / developer keys, popout tokens and System keys are allowed; overlay, shared overlay, widget and extension tokens get `FORBIDDEN` `Emote details are not available to overlay, widget or extension tokens`. The REST twin is [`GET /v1/emotes/info`](./rest-api.md#get-v1emotesinfo). Twitch badge JSON on chat messages can carry `image_url_hd`, the Helix 4x image, alongside `image_url` when available; badges written without it still render with the ordinary image. ## Twitch Shared Chat session `sharedChatSession: SharedChatSessionView!` reads the account's current Twitch session with `chat:read` and `feature:multichat`; popout tokens with those grants can read it. `status` has three cases: `SESSION` (the own channel is in a session), `NONE` (no session), and `UNKNOWN` (Redis or Helix could not determine it). An `UNKNOWN` read must not clear the client's last known state. `revision` is strictly increasing and null only for `UNKNOWN`. The view also contains `sessionId`, `hostBroadcasterId`, `ownBroadcasterId`, `startedAt`, `updatedAt`, `participants` and `totalViewers`. A participant carries its broadcaster ID, name, avatar, join time, optional viewers/category/title/ followers, and `inferred`, `isHost`, `isOwnChannel`. The total adds the last known channel counts; it can count one multi-channel viewer several times. REST twin: [`GET /v1/chat/shared-chat`](./rest-api.md#get-v1chatshared-chat). ## Chat Moderation ### Announcements and current pins `ChatMessage.messageType` is `announcement` for an own or Shared Chat Twitch announcement. `ChatMessage.announcementColor` is `primary`, `blue`, `green`, `orange` or `purple` for those rows and `null` for ordinary messages. A missing or unrecognised Twitch colour becomes `primary`. The field persists through `chatHistory` and `mergedChatHistory`; the REST twin is `announcement_color`. `chatPins: ChatPins!` reads the active account's current `twitch` and `kick` pins with `chat:read` and `feature:multichat`, including from a popout token. `available: false` means the state could not be read, so two `null` pins do not establish that nothing is pinned. A `ChatPin` has `platform`, `channelId`, `messageId`, `sender`, `pinnedBy`, `text`, `emotes`, `sentAt`, `startsAt`, `endsAt` and `updatedAt`. `sentAt` is when the sender wrote the message, or `null` when unknown; it is not the pin time. `endsAt: null` means until stream end. Each person has `platform`, `userId`, `login`, `displayName`, `avatarUrl`, `color` and `badges`. | Mutation | Permission | Effect | |----------|------------|--------| | `pinChatMessage(platform, messageId, durationSeconds)` | `chat:pin` + `feature:multichat` | Pin an ordinary Twitch message, replacing the current pin. | | `updateChatPin(platform, messageId, durationSeconds)` | `chat:pin` + `feature:multichat` | Change the current Twitch pin's duration, counted from now. | | `unpinChatMessage(platform, messageId)` | `chat:pin` + `feature:multichat` | Remove the current Twitch pin. | `durationSeconds` is 30–1,800 or `null` for until stream end. The acting member's Twitch login needs moderation rights and `moderator:manage:chat_messages`; Kick actions return `success: false` with `errorCode: "pin_platform_unsupported"`. A `ChatPinActionResult` carries `success`, `platform`, `messageId`, `action`, `errorCode` and `details`. GraphQL and REST use the same validation, guard and result service. Account-scoped moderation across the supported chat platforms. Queries for chat history / user info live in the chat module - see [Chat](../features/chat.md) for the full surface, including `chatHistory`'s keyword `search` filter (`ChatFilterInput.search`, mirrored by the REST `search` query parameter), its bounded paging (`chatHistory` and `events` read the 30 days before `to`/now unless `from` is given; `total` is capped at 10 000 with `totalCapped: true` meaning more match, and is counted only when `total`/`totalCapped`/`pages` is selected — [Chat → Bounded list reads](../features/chat.md#bounded-list-reads)) and `mergedChatHistory` — the server-merged messages+events+AutoMod timeline paged by a single keyset cursor (mirrored by the REST `GET /v1/chat/history/merged`; one request reads at most 30 days below its cursor, so a page can be short or empty with `hasMore: true` — page until `hasMore` is `false`), which independently gates each stream (`chat:read` for messages, `events:read` for events, `feature:multichat` + `chat:read` + `chat:automod-read` for Twitch AutoMod holds — the account's full chat window with `history:read`, else the last 24 hours; popout tokens are scoped like a member, other token kinds and anonymous callers get none; skipped for a non-Twitch `platform` and for a `liveChatId`). `ChatHistoryItemKind` is `MESSAGE`, `EVENT` or `AUTOMOD`; an `AUTOMOD` item carries `ChatHistoryItem.automod: AutomodLogEntry` (the `automodLog` entry type), its `id` is the log row id and its `createdAt` is `heldAt`. A reply on a `ChatMessage` carries the parent author as both `replyParentUsername` (display name) and `replyParentLogin` (login/handle twin, ZAF-1452 F1; `null` where the platform has no distinct login) - the REST twin is `reply_parent_login` and the field is also on the `chat:message` WebSocket payload. `ChatMessage.messageType` is `channel_points_custom_reward` for a Twitch message sent through a channel-points reward with text input ([Chat → Channel-points messages](../features/chat.md#channel-points-messages)). `Event.raw` is returned as stored, including the Twitch Shared Chat partner-event fields `is_shared_chat`, `source_channel` and `shared_chat_notice_type` ([Events → Twitch Shared Chat partner events](../features/events.md#twitch-shared-chat-partner-events)). ### Twitch AutoMod held messages `heldChatMessages: HeldMessageList!` reads the active channel's holds, oldest first. It requires `chat:automod-read` and `feature:multichat`. `messages` contains `platform`, `messageId`, `sender` (optional `avatarUrl`, `color` and `badges`: the look at hold time from Lumio's chatter record, absent for a chatter Lumio has not seen), `text`, optional `fragments`, optional `emotes` (Twitch emotes and cheermotes in the `ChatMessage.emotes` shape; absent when none or the fragments were dropped for size), `reasonKind`, `reason`, `flaggedSpans`, `heldAt` and optional `expiresAt`. `reasonKind` is `automod`, `blocked_term` or `blocked_link`; each span uses Unicode code-point offsets with an inclusive `end`. `queueAvailable: false` means the Redis queue could not be read, so an empty `messages` array does not establish that no holds exist. Twitch currently supplies no expiry deadline; clients show elapsed waiting time. `allowHeldChatMessage(messageId: String!): HeldMessageDecisionResult!` needs `chat:automod-allow`; `denyHeldChatMessage(messageId: String!): HeldMessageDecisionResult!` needs `chat:automod-deny`. Both also need `feature:multichat` and act through the caller's own Twitch login as a channel moderator. The result has `success`, `messageId`, `decision`, `errorCode` and `details`. A refused decision returns `success: false`, for example `already_handled_or_expired`, `automod_queue_unavailable` or `login_scope_missing` (the caller's stored Twitch login scopes are known and lack `moderator:manage:automod`, checked without a Helix call; an empty stored set is unknown and Helix decides; a Helix `401` maps to the same code); the same codes and messages appear in REST. An allowed message is later published with a new ID. Popout tokens can carry the three live AutoMod permissions. The session history log has a separate `history:read` gate and refuses popout tokens. REST twins: `GET /v1/chat/automod/held` and `POST /v1/chat/automod/held/{message_id}/allow|deny`. ### `moderateChat(input: ModerationInput!): GqlModerationResult!` Perform a moderation action on `twitch`, `youtube`, or `kick`. Requires the `feature:multichat` flag (same gate as the REST twin `POST /v1/chat/moderate` and the live `chat:{account_id}` WebSocket stream). Permission depends on the action: `chat:ban` for `BAN`, `chat:timeout` for `TIMEOUT`, `chat:delete` for `DELETE`. The action runs on the **acting moderator's login token** for the platform (Twitch login, Google login, Kick login). The token is refreshed on demand before the call. Both protocols share one implementation (`services::chat_moderation`), so they return the same results and errors. Two kinds of failure: - **Request errors** (GraphQL error with a `code` extension, REST HTTP status): validation (`VALIDATION_ERROR`), an unsupported platform, a non-numeric Kick `userId`, a Kick timeout above 7 days, a missing login or channel connection (`BAD_REQUEST`). - **Refused actions** (`result.success = false`): `result.details` carries a readable message and `result.errorCode` a stable code that the frontend translates: | `errorCode` | Meaning | |-------------|---------| | `login_scope_missing` | The moderator's login grant lacks the provider scope for this action, checked before the call for Kick (and for Twitch AutoMod Allow/Deny, `moderator:manage:automod`), or the provider answered `401`. Fix: log in with that platform again. | | `login_reconnect_required` | The login grant is dead (revoked, undecryptable, or expired with no refresh token). It is flagged `reconnect_required`. | | `moderation_forbidden` | Kick answered `403`: the account has no moderation rights in this channel. | | `moderation_failed` | Any other provider error. `details` carries the provider message. | | `internal_error` | The login credential could not be read. The cause is logged, not returned. | `ModerationInput` fields: - `action: ModerationActionGql!` - `BAN`, `TIMEOUT`, or `DELETE` - `platform: String!` - `"twitch"`, `"youtube"`, or `"kick"` - `userId: String` - platform user ID (required for `BAN` / `TIMEOUT`) - `messageId: String` - required for `DELETE` - `durationSecs: Int` - timeout length in seconds, **required for `TIMEOUT`** (validation rejects a `TIMEOUT` without it, identically on REST; YouTube accepts `1`–`86400`) - `reason: String` - optional moderator reason (logged to `moderation_log`) - `liveChatId: String` - only used by YouTube. Optional: when omitted the server resolves the active broadcast's `liveChatId` from the polling worker's Redis cache (`lumio:youtube:active_streams:{account_id}`). Pass it explicitly when the broadcaster runs multiple concurrent broadcasts and you want to target a specific one. Per-platform behaviour: | Platform | Supported actions | Notes | |----------|-------------------|-------| | `twitch` | `BAN`, `TIMEOUT`, `DELETE` | Calls Helix; needs `moderator:manage:banned_users` / `moderator:manage:chat_messages` scope on the moderator's login token | | `youtube` | `BAN`, `TIMEOUT`, `DELETE` | `BAN`/`TIMEOUT` use `liveChatBans.insert` (`type=permanent` vs `type=temporary` + `banDurationSeconds`); `DELETE` uses `liveChatMessages.delete`. Returns 403 if the target is the broadcaster or another moderator - Lumios UI hides the buttons for those targets to surface the limitation as missing UI rather than a failed request. | | `kick` | `BAN`, `TIMEOUT`, `DELETE` | Official public API. `DELETE` → `DELETE /public/v1/chat/{message_id}` (login scope `moderation:chat_message:manage`); `BAN`/`TIMEOUT` → `POST /public/v1/moderation/bans` with `broadcaster_user_id` = the Kick channel connection's channel id (login scope `moderation:ban`). A timeout is sent in whole minutes, **rounded up** (90 s → 2 min, minimum 1). Anything above 10 080 min (7 days) is rejected, not clamped. `reason` is cut to 100 characters. `userId` must be the numeric Kick user id. The stored login scopes are checked before the call; a missing scope returns `errorCode = login_scope_missing` without calling Kick. | After a successful `BAN` or `TIMEOUT` the server soft-deletes every message from the affected user and broadcasts a `chat:clear_user` event to the chat WebSocket channel - see the [WebSocket reference](./websocket.md#chat-sub-types) for the payload. Every successful action (including `DELETE`) also writes a moderation-log row and broadcasts the matching `chat:moderation_log` event with `duration_secs` / `ends_at`, the same payload the platform webhooks produce. For Kick the logged duration is the applied one (whole minutes). The Kick `moderation.banned` webhook that echoes a Lumio-issued ban/timeout within 60 s is deduplicated. > **Protocol parity:** mirror at `POST /v1/chat/moderate` - see [REST API](./rest-api.md). ### `refreshPlatformUserProfile(platform: String!, platformUserId: String!): GqlUnifiedProfile!` Force a fresh enrichment of a platform user's profile, bypassing the normal 24h/14-day staleness interval. Returns the same `GqlUnifiedProfile` type as the `platformUserProfile` query. **Permission:** `chat:refresh_user` **Rate limit:** One refresh per (account, platform, user) triple every 10 minutes. When the cooldown is active the mutation returns an error with extension `{ "code": "REFRESH_COOLDOWN", "retry_after_seconds": N }`. Platforms: `"twitch"`, `"youtube"`, `"kick"`. `GqlUnifiedProfile` enrichment fields (all nullable - `null` means the platform does not expose the value or it has not been fetched yet): `description`, `broadcasterType`, `isFollower`, `followedAt`, `accountCreatedAt`, `followerCount: Int` (the user's **own** follower count; filled on Twitch, best-effort on Kick and may be `null` there, `null` on YouTube), `enrichedAt`. See [Profile Service](../features/profile-service.md#multi-platform-enrichment) for what each platform fills. > **Protocol parity:** mirror at `POST /v1/chat/users/{platform}/{platform_user_id}/refresh` - see [REST API](./rest-api.md#chat-moderation). ### `searchPlatformUsers(query: String!, platforms: [String!], limit: Int): [GqlPlatformUserSearchResult!]!` Prefix-search the account's known chatters (the `platform_users` table) by `username` **or** `displayName` for the multichat filter name-completion. Same guard as `platformUserProfile` - `feature:multichat` + `chat:userinfo` - so it never surfaces a user the caller could not already inspect (and without `chat:userinfo` it returns nothing, so the search leaks no data). - **`query`** - case-insensitive prefix. Shorter than 2 characters (after trimming) returns an **empty list, not an error** (the UI queries per keystroke). `%`, `_`, and `\` match literally. - **`platforms`** - optional platform filter; omit for all platforms. - **`limit`** - optional (default 10), **hard-capped at 25** regardless of the requested value. - Ordered `lastSeenAt DESC, messageCount DESC`. Each row is the slim `{ platform, platformUserId, username, displayName, avatarUrl, lastSeenAt, messageCount }`. > **Protocol parity:** mirror at `GET /v1/chat/users/search?q=&platform=&limit=` - same fields, validation, and empty-below-2-chars behaviour. See [REST API](./rest-api.md#chat-user-search) and [Chat](../features/chat.md#name-completion-searchplatformusers). ## Chatter List Who is in the chat right now, per platform — see [Chatter List](../features/chatter-list.md). ### `chatters(platforms: [String!]): ChattersSnapshot!` **Guard:** `chat:read` + feature `feature:chat-viewer-list` (identical on REST and the WebSocket channel). Popout tokens with `chat:read` are accepted. - **`platforms`** - optional filter; omit (or pass `[]`) for every supported platform. An unknown platform is a `BAD_REQUEST` error `Bad request: Unsupported chatter platform: `. A registered platform hidden for this account (Kick without a usable channel connection) returns no entry when explicitly requested. ```graphql { chatters { supportedPlatforms platforms { platform available reason total truncated updatedAt } chatters { platform userId login displayName avatarUrl color role lastMessageAt present firstSeenAt lastSeenAt } } } ``` | Type | Field | Notes | |---|---|---| | `ChattersSnapshot` | `supportedPlatforms: [String!]!` | Registered providers visible for this account: Twitch, plus Kick when a usable Kick channel is connected | | | `platforms: [ChatterPlatformStatus!]!` | One per requested platform | | | `chatters: [Chatter!]!` | Present chatters first, sorted broadcaster → moderators → bots → VIPs → viewers, then by display name; then the recently-left entries (`present: false`) in the same order | | `ChatterPlatformStatus` | `platform`, `available: Boolean!`, `reason: String` | `reason` = `missing_scope` \| `not_connected` \| `disabled` \| `source_unavailable` when not available. `source_unavailable` means the list source failed; recent writers still appear | | | `total: Int`, `truncated: Boolean!`, `updatedAt: DateTime` | Platform count of the last snapshot (people in the chat — never recently-left entries); `truncated` when the platform lists fewer entries than its full count or Lumio’s cap applies. Kick’s ordinary chatter group is capped at 100. `total` is `null` and `truncated` is `false` when the source is unavailable; count `present: true` entries | | `Chatter` | `platform`, `userId`, `login`, `displayName`, `avatarUrl`, `color`, `role`, `lastMessageAt` | Key = (`platform`, `userId`). `role` = `broadcaster` \| `moderator` \| `bot` \| `vip` \| `viewer` | | | `present: Boolean!` | `false` = recently left: kept for `recently_left_retention_secs` (30 min) after `lastSeenAt`, then dropped | | | `firstSeenAt: DateTime` | When the person (re)entered the chat in the current stream; stable while they stay, new after a rejoin. `null` only for entries written before this field existed | | | `lastSeenAt: DateTime` | Later of the last snapshot that listed them and their last message; frozen once `present` is `false`. For present rows it may be up to 5 min old — render "since" from `firstSeenAt` | No field is platform-specific. > **Protocol parity:** mirror at `GET /v1/chat/chatters` ([REST API](./rest-api.md#chatter-list)); live updates on the `chatters:{account_id}` channel ([WebSocket](./websocket.md#chatters-channel)). ## Ideas Hub Community idea board with voting, comments, moderation, categories, and tags. All GET queries use `OptionalAuth` - they are public and return data for unauthenticated callers too. Mutations require the `system:ideas_hub` feature flag to be enabled on the account. ### Queries | Query | Arguments | Returns | Permission | |-------|-----------|---------|------------| | `ideas` | `filter: IdeaFilterInput, sort: IdeaSortInput, limit: Int! = 20, offset: Int! = 0` | `GqlIdeaConnection!` | Public | | `idea` | `id: UUID!` | `GqlIdea` | Public | | `ideaComments` | `ideaId: UUID!` | `[GqlIdeaComment!]!` | Public | | `ideaCategories` | - | `[GqlIdeaCategory!]!` | Public | | `ideaTags` | `search: String` | `[GqlIdeaTag!]!` | Public | | `ideaVoters` | `ideaId: UUID!` | `[GqlIdeaVoter!]!` | Public | | `ideaTimeline` | `ideaId: UUID!` | `[GqlIdeaTimelineEntry!]!` | Public | | `ideaParticipants` | `ideaId: UUID!, search: String` | `[GqlIdeaAuthor!]!` | Signed-in user session or personal API key | `ideaParticipants` returns the union of the idea author, voters, and commenters filtered by the optional `search` string. Used to populate the @mention autocomplete dropdown in idea comments. ### Mutations | Mutation | Arguments | Returns | Permission | |----------|-----------|---------|------------| | `createIdea` | `input: CreateIdeaInput!` | `GqlIdea!` | `ideas:create` | | `updateIdea` | `id: UUID!, input: UpdateIdeaInput!` | `GqlIdea!` | `ideas:edit` or `ideas:moderate_edit` | | `deleteIdea` | `id: UUID!` | `Boolean!` | `ideas:delete` or `ideas:moderate_delete` | | `voteIdea` | `id: UUID!, voteType: String!` | `GqlIdea!` | `ideas:vote` | | `removeVote` | `id: UUID!` | `GqlIdea!` | `ideas:vote` | | `createIdeaComment` | `input: CreateIdeaCommentInput!` | `GqlIdeaComment!` | `ideas:comment_create` | | `updateIdeaComment` | `id: UUID!, body: String!` | `GqlIdeaComment!` | `ideas:comment_edit` | | `deleteIdeaComment` | `id: UUID!` | `Boolean!` | `ideas:comment_delete` or `ideas:moderate_comment` | | `voteIdeaComment` | `id: UUID!, voteType: String!` | `GqlIdeaComment!` | `ideas:comment_vote` | | `removeIdeaCommentVote` | `id: UUID!` | `GqlIdeaComment!` | `ideas:comment_vote` | | `updateIdeaStatus` | `id: UUID!, status: String!` | `GqlIdea!` | `ideas:moderate_status` | | `createIdeaCategory` | `input: CreateIdeaCategoryInput!` | `GqlIdeaCategory!` | Admin `ideas:edit` | | `updateIdeaCategory` | `id: UUID!, input: UpdateIdeaCategoryInput!` | `GqlIdeaCategory!` | Admin `ideas:edit` | | `deleteIdeaCategory` | `id: UUID!` | `Boolean!` | Admin `ideas:delete` | | `createIdeaTag` | `name: String!` | `GqlIdeaTag!` | `ideas:create` | | `deleteIdeaTag` | `id: UUID!` | `Boolean!` | Admin `ideas:delete` | ### Types #### `GqlIdea` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Idea ID | | `author` | `GqlIdeaAuthor` | Author info; `null` when `deleted` | | `deleted` | `Boolean!` | The idea was deleted while other people's comments hang off it (tombstone): no author, empty `title`/`description`, no votes, tags or timeline. See [Ideas Hub → Deletion and tombstones](../features/ideas-hub.md#deletion-and-tombstones) | | `category` | `GqlIdeaCategory!` | Category | | `tags` | `[GqlIdeaTag!]!` | Assigned tags | | `title` | `String!` | Idea title | | `description` | `String!` | Idea description (plain text or HTML) | | `status` | `String!` | Status slug (e.g. `open`, `in_progress`, `done`, `declined`) | | `voteCountUp` | `Int!` | Number of upvotes | | `voteCountDown` | `Int!` | Number of downvotes | | `commentCount` | `Int!` | Number of live comments (tombstones not counted) | | `myVote` | `String` | Authenticated caller's vote (`"up"`, `"down"`, or `null`) | | `createdAt` | `String!` | ISO-8601 timestamp | | `updatedAt` | `String!` | ISO-8601 timestamp | #### `GqlIdeaConnection` | Field | Type | Description | |-------|------|-------------| | `items` | `[GqlIdea!]!` | Page of ideas | | `total` | `Int!` | Total matching the filter | #### `GqlIdeaAuthor` Also the element type of `ideaParticipants`. | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | User ID | | `displayName` | `String!` | Display name | | `login` | `String` | Platform login. Only for Ideas staff (`ideas:moderate_read` on the user or admin role) and for the user themself; `null` for everyone else, including anonymous callers. Also `null` for a deleted user | | `avatarUrl` | `String` | Avatar URL | #### `GqlIdeaComment` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Comment ID | | `ideaId` | `UUID!` | Parent idea ID | | `author` | `GqlIdeaAuthor` | Comment author; `null` when `deleted` | | `deleted` | `Boolean!` | The comment was deleted while replies hang off it (tombstone): no author, empty `body`, no votes | | `parentId` | `UUID` | Parent comment ID for nested replies | | `body` | `String!` | Sanitized HTML from rich text editor | | `createdAt` | `String!` | ISO-8601 timestamp | | `updatedAt` | `String!` | ISO-8601 timestamp | | `voteCountUp` | `Int!` | Number of upvotes on the comment | | `voteCountDown` | `Int!` | Number of downvotes on the comment | | `myVote` | `String` | Authenticated caller's vote on the comment (`"up"`, `"down"`, or `null`) | | `replies` | `[GqlIdeaComment!]!` | Nested replies (one level deep) | Comment bodies contain sanitized HTML. @mentions appear as `@Name`. #### `GqlIdeaCategory` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Category ID | | `name` | `String!` | Machine-readable slug | | `label` | `String!` | Display label | | `color` | `String` | Hex color for UI display | | `sortOrder` | `Int!` | Sort position | | `createdAt` | `String!` | ISO-8601 timestamp | #### `GqlIdeaTag` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Tag ID | | `name` | `String!` | Tag name | | `createdBy` | `UUID` | Creating user | | `createdAt` | `String!` | ISO-8601 timestamp | #### `GqlIdeaTimelineEntry` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Entry ID | | `actor` | `GqlIdeaAuthor` | Who performed the action | | `action` | `String!` | `status_changed`, `title_changed`, `description_changed` or `category_changed` | | `oldValue` | `String` | Previous value (see below) | | `newValue` | `String` | New value (see below) | | `createdAt` | `String!` | ISO-8601 timestamp | What a caller sees in `oldValue` / `newValue` depends on the action. For `status_changed` (status key) and `category_changed` (category label), everyone sees both values. For `title_changed` and `description_changed` (the previous and new text), only the idea's author and Ideas staff (`ideas:moderate_read`) see the values. Anonymous callers and other signed-in users get `null` for both values; `action` is still returned. REST `GET /v1/ideas/:id` (`timeline`) applies the same rule. #### `GqlIdeaVoter` | Field | Type | Description | |-------|------|-------------| | `userId` | `UUID!` | Voting user | | `voteType` | `String!` | `"up"` or `"down"` | | `displayName` | `String!` | Display name | | `avatarUrl` | `String` | Avatar URL | #### `IdeaFilterInput` | Field | Type | Description | |-------|------|-------------| | `status` | `String` | Filter by status slug | | `categoryId` | `UUID` | Filter by category | | `tagIds` | `[UUID!]` | Filter by one or more tags | | `authorId` | `UUID` | Filter by author | | `search` | `String` | Full-text search on title and description | #### `IdeaSortInput` (enum) | Value | Description | |-------|-------------| | `NEWEST` | Most recently created first | | `MOST_VOTED` | Highest net vote count first | | `MOST_COMMENTED` | Most comments first | | `RECENTLY_UPDATED` | Most recently updated first | > **Protocol parity:** All queries and mutations above have matching REST endpoints under `/v1/ideas` - see [REST API](./rest-api.md#ideas-hub). ## Developer Applications User-scoped queries and mutations for the developer application flow. No account context is required. ### Queries #### `myDeveloperApplication: DeveloperApplication` Returns the authenticated user's latest developer application. Returns `null` if no application exists. ### Mutations #### `submitDeveloperApplication(input: SubmitDeveloperApplicationInput!): DeveloperApplication!` Submit a developer application. Validates the slug format and availability, checks that the user does not already have a developer profile or a pending application. **Errors:** - `"application_type must be 'solo' or 'team'"` - invalid type - `"Slug must be between 3 and 50 characters"` / `"Slug must start with a lowercase letter"` / `"Slug must contain only lowercase letters, digits, and hyphens"` - slug format - `"You already have a developer profile"` - user is already a developer - `"You already have a pending application"` - previous application still pending - `"Slug is already taken"` - slug in use ### Types #### `DeveloperApplication` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Application ID | | `userId` | `UUID!` | Applicant's user ID | | `applicationType` | `String!` | `"solo"` or `"team"` | | `displayName` | `String!` | Developer/team display name | | `slug` | `String!` | URL slug | | `description` | `String!` | Description | | `motivation` | `String!` | Why the user wants developer access | | `whatToBuild` | `String!` | Planned extensions | | `githubUrl` | `String` | GitHub profile URL | | `websiteUrl` | `String` | Website URL | | `experience` | `String` | Development experience | | `avatarKey` | `String` | Upload key for avatar | | `status` | `String!` | `"pending"`, `"approved"`, or `"rejected"` | | `reviewNotes` | `String` | Reviewer notes | | `reviewedBy` | `UUID` | Reviewer's user ID | | `reviewedAt` | `DateTime` | Review timestamp | | `createdAt` | `DateTime!` | Submission timestamp | | `updatedAt` | `DateTime!` | Last update timestamp | #### `SubmitDeveloperApplicationInput` | Field | Type | Required | |-------|------|----------| | `applicationType` | `String!` | Yes | | `displayName` | `String!` | Yes | | `slug` | `String!` | Yes | | `description` | `String!` | Yes | | `motivation` | `String!` | Yes | | `whatToBuild` | `String!` | Yes | | `githubUrl` | `String` | No | | `websiteUrl` | `String` | No | | `experience` | `String` | No | > **Protocol parity:** Both operations have matching REST endpoints - see [REST API](./rest-api.md#developer-applications). ## Developer Verification (self-service) KYC verification an **approved developer** submits about themselves (individual or company) to unlock paid-extension publishing - the developer-facing counterpart to the admin review operations below. First-party principal required (ZAF-469): a popout/overlay/widget/extension token is rejected with a `FORBIDDEN` error. The caller must be an approved developer (have a `developer_profiles` row). > Deliberately **not** gated on `feature:extension_development`: that feature is *granted by* verification approval, so gating submission on it would be circular. ### Queries #### `developerVerificationStatus: VerificationDetailGql` Returns the authenticated developer's own verification record, or `null` if none has been submitted (or the caller is not yet an approved developer). ### Mutations #### `submitDeveloperVerification(input: SubmitVerificationInput!): VerificationDetailGql!` Submit (or resubmit) the developer's own KYC verification. **Idempotent upsert** - one record per developer; a resubmit re-enters `pending` and clears any prior review verdict. An already-`verified` record is terminal via self-service and cannot be resubmitted. The `developer_type` is inferred from whether `companyName` is provided. Emits the `developer:verification_submitted` audit event (user scope; no KYC/PII in metadata). **Errors:** - `"You must be an approved developer to submit verification"` - caller has no developer profile - `"Your developer verification is already approved"` - the record is already `verified` - `"Legal name is required"` / `"Address line 1 is required"` / `"Postal code is required"` / `"City is required"` / `"Country is required"` / `"Country must be a 2-letter ISO code"` / `"Tax number must be at most 50 characters"` / `"VAT ID must be at most 50 characters"` - validation A submit without any of `taxNumber`, `vatId`, `smallBusinessDeclared` keeps the stored tax details. #### `updateDeveloperTaxDetails(input: TaxDetailsInput!): VerificationDetailGql!` Set the tax details (`taxNumber`, `vatId`, `smallBusinessDeclared`) a payout's credit note is derived from, in any verification status. They are self-declared: an update does not reset the review and never changes an issued note. Which details a payout needs is listed under [`PUT /v1/developer/verification/tax-details`](./rest-api.md#put-v1developerverificationtax-details). Emits `developer:tax_details_updated` (user scope). Errors: the validation texts above, `"You must be an approved developer to submit verification"`, `"Submit your developer verification before entering tax details"`. ### Types #### `SubmitVerificationInput` | Field | Type | Required | |-------|------|----------| | `legalName` | `String!` | Yes | | `companyName` | `String` | No | | `addressLine1` | `String!` | Yes | | `addressLine2` | `String` | No | | `postalCode` | `String!` | Yes | | `city` | `String!` | Yes | | `country` | `String!` | Yes (2-letter ISO) | | `taxId` | `String` | No | | `tradeRegisterId` | `String` | No | | `documentKey` | `String` | No | | `taxNumber` | `String` | No (Steuernummer) | | `vatId` | `String` | No (USt-IdNr. / foreign VAT ID) | | `smallBusinessDeclared` | `Boolean` | No (§ 19 UStG) | `TaxDetailsInput` has the three tax fields only. #### `VerificationDetailGql` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Verification ID | | `developerId` | `UUID!` | Developer profile ID | | `legalName` | `String!` | Legal name | | `companyName` | `String` | Company name (company type) | | `addressLine1` | `String!` | Address line 1 | | `addressLine2` | `String` | Address line 2 | | `postalCode` | `String!` | Postal code | | `city` | `String!` | City | | `country` | `String!` | ISO-3166-1 alpha-2 country code | | `taxId` | `String` | Tax ID (legacy free text) | | `taxNumber` | `String` | Steuernummer | | `vatId` | `String` | USt-IdNr. / foreign VAT ID | | `smallBusinessDeclared` | `Boolean` | § 19 UStG small business; `null` = not answered | | `taxDetailsUpdatedAt` | `String` | When the tax details were last entered (RFC 3339) | | `tradeRegisterId` | `String` | Trade register ID | | `documentKey` | `String` | Upload key for a supporting document | | `status` | `String!` | `"pending"`, `"verified"`, or `"rejected"` | | `reviewNotes` | `String` | Reviewer notes (e.g. rejection reason) | | `reviewedBy` | `UUID` | Reviewer's user ID | | `reviewedAt` | `String` | Review timestamp (RFC 3339) | | `createdAt` | `String!` | First submission timestamp (RFC 3339) | | `updatedAt` | `String!` | Last update timestamp (RFC 3339) | > **Protocol parity:** All three operations have matching REST endpoints under `/v1/developer/verification` - see [REST API](./rest-api.md#developer-verification-self-service). ## Developer Credit Notes Self-billed credit notes (Gutschrift) of developer payouts (ZAF-2807). Field set, guards and error texts match the [REST endpoints](./rest-api.md#developer-credit-notes). Binary downloads are not a GraphQL fit: each note carries `downloadUrl`, the authenticated REST URL of its PDF (`null` while the PDF is being generated). ### Queries #### `developerCreditNotes(limit: Int = 20, offset: Int = 0): [DeveloperCreditNote!]!` The caller's own credit notes and cancellations, newest first. Requires `extension-dev:payouts` and `feature:extension_development`; first-party user. #### `developerCreditNote(id: UUID!): DeveloperCreditNote!` One of the caller's own notes; another developer's note fails with `"Credit note not found"`. Same guard. #### `adminCreditNotes(developerId: UUID, limit: Int = 20, offset: Int = 0): [AdminCreditNote!]!` / `adminCreditNote(id: UUID!): AdminCreditNote!` Operators; include notes whose profile was deleted. Requires `billing:read` (admin-scope). #### `adminDeveloperPayouts(status: String, limit: Int = 20, offset: Int = 0): [AdminDeveloperPayout!]!` Developer payouts for operators, newest first, including payouts of deleted profiles; `status` filters on `pending`, `processing`, `completed` or `failed` (anything else fails with `"Status must be one of pending, processing, completed, failed"`). Requires `billing:read` (admin-scope). ### Mutations #### `cancelCreditNote(id: UUID!, input: CancelCreditNoteInput!): CancelCreditNoteResult!` Record a developer objection (`reason: "developer_objection"`, `objectionReceivedAt` required) or a correction (`reason: "correction"`), optionally with `reissue: true` for a corrected note for the same payout and period. Requires `billing:edit` (admin-scope). Emits `system:developer_credit_note_cancelled`. Errors as on REST, including `CREDIT_NOTE_AMOUNT_MISMATCH`. #### `completeDeveloperPayout(id: UUID!): SettleDeveloperPayoutResult!` / `failDeveloperPayout(id: UUID!): SettleDeveloperPayoutResult!` Mark a `pending` or `processing` developer payout paid, or failed (the balance is credited back and the credit note cancelled). Requires `billing:edit` (admin-scope). Emits `system:developer_payout_completed` / `system:developer_payout_failed`. Errors as on REST: `Payout not found`, `PAYOUT_NOT_IN_FLIGHT`, and for `failDeveloperPayout` `PAYOUT_BEING_SENT` while the payout cycle may be sending it. See [Developer Payouts](../features/developer-payouts.md). ### Types `DeveloperCreditNote`: `id`, `kind`, `number`, `issuedAt`, `payoutId`, `cancelsId`, `cancelledAt`, `cancellationReason`, `periodStart`, `periodEnd`, `currency`, `netAmount`, `vatRateBp`, `vatAmount`, `grossAmount`, `vatTreatment`, `pdfStatus`, `downloadUrl`, `deliveredAt` (meaning as on REST). `AdminCreditNote`: `note: DeveloperCreditNote!`, `developerId`, `supplierLegalName`, `supplierCountry`, `objectionReceivedAt`, `recordedBy`. `CancelCreditNoteResult`: `cancellation: AdminCreditNote!`, `reissued: AdminCreditNote`. `AdminDeveloperPayout`: `id`, `developerId`, `amount`, `currency`, `method`, `status`, `stripeTransferId`, `paypalBatchId`, `payoutReference`, `createdAt`, `completedAt`, `executionStartedAt`, `needsOperator` (meaning as on REST). `SettleDeveloperPayoutResult`: `payout: AdminDeveloperPayout!`, `refunded`, `cancellationNumber`. ## Admin Developer Applications Admin-scope queries and mutations for reviewing developer applications. All require `developer-verifications:*` admin permissions. ### Queries #### `adminDeveloperApplications(status: String): [AdminApplicationListItem!]!` List developer applications with optional status filter. Requires `developer-verifications:read`. #### `adminDeveloperApplication(id: UUID!): AdminApplicationDetail` Get full details of a specific application. Requires `developer-verifications:read`. ### Mutations #### `approveDeveloperApplication(input: ApproveApplicationInput!): ApplicationActionResult!` Approve an application. Creates the developer profile, seeds default team roles (for team applications), and sends notifications. Requires `developer-verifications:edit`. #### `rejectDeveloperApplication(input: RejectApplicationInput!): ApplicationActionResult!` Reject an application. Sends rejection notifications. Requires `developer-verifications:edit`. The `notes` field is required to provide a rejection reason. ### Types #### `AdminApplicationListItem` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Application ID | | `userId` | `UUID!` | Applicant's user ID | | `applicationType` | `String!` | `"solo"` or `"team"` | | `displayName` | `String!` | Display name | | `slug` | `String!` | URL slug | | `status` | `String!` | Application status | | `createdAt` | `String!` | Submission timestamp | | `userDisplayName` | `String` | Applicant's display name | | `userAvatarUrl` | `String` | Applicant's avatar URL | #### `AdminApplicationDetail` Same fields as `DeveloperApplication` with string-formatted timestamps. #### `ApplicationActionResult` Returned by `approveDeveloperApplication` and `rejectDeveloperApplication`. | Field | Type | Description | |-------|------|-------------| | `success` | `Boolean!` | Whether the action succeeded | | `newStatus` | `String!` | The new application status | #### `ApproveApplicationInput` / `RejectApplicationInput` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Application ID | | `notes` | `String` on approve, `String!` on reject | Reviewer notes | > **Protocol parity:** All queries and mutations have matching REST endpoints under `/v1/admin/developer-applications` - see [REST API](./rest-api.md#admin-developer-applications). ## Developer Teams Queries and mutations for developer team management. Team operations use team-scoped RBAC permissions (see [Team RBAC](../developer-guide/rbac-permissions.md#team-rbac-developer-teams)). Every operation except `developerTeamInviteByCode` requires a first-party user. `createDeveloperTeam` and `acceptDeveloperTeamInvite` additionally require the `feature:extension_development` plan feature, checked after the first-party gate with the same error as REST: `Feature 'feature:extension_development' is not available`. ### Queries | Query | Arguments | Returns | Permission | |-------|-----------|---------|------------| | `developerTeams` | - | `[DeveloperTeam!]!` | Auth (developer) | | `developerTeam` | `teamId: UUID!` | `DeveloperTeam` | Team member | | `developerTeamMembers` | `teamId: UUID!` | `[DeveloperTeamMember!]!` | `team-members:read` | | `developerTeamInvites` | `teamId: UUID!` | `[DeveloperTeamInvite!]!` | `team-members:read` | | `developerTeamInviteByCode` | `code: String!` | `DeveloperTeamInviteDetail` | Public | | `developerTeamRoles` | `teamId: UUID!` | `[DeveloperTeamRole!]!` | `team-members:read` | | `developerTeamRole` | `teamId: UUID!, roleId: UUID!` | `DeveloperTeamRoleDetail!` | `team-members:read` | | `availableTeamPermissions` | - | `[TeamPermission!]!` | Auth (first-party user) | ### Mutations | Mutation | Arguments | Returns | Permission | |----------|-----------|---------|------------| | `createDeveloperTeam` | `name: String!` | `DeveloperTeam!` | Auth (developer) + `feature:extension_development` | | `updateDeveloperTeam` | `teamId: UUID!, name: String!` | `Boolean!` | `team-settings:edit` | | `deleteDeveloperTeam` | `teamId: UUID!` | `Boolean!` | Team owner only | | `updateDeveloperTeamMemberRole` | `teamId: UUID!, memberId: UUID!, roleId: UUID!` | `Boolean!` | `team-members:edit` | | `removeDeveloperTeamMember` | `teamId: UUID!, memberId: UUID!` | `Boolean!` | `team-members:remove` | | `createDeveloperTeamInvite` | `teamId: UUID!, input: CreateTeamInviteInput!` | `DeveloperTeamInvite!` | `team-members:invite` | | `deleteDeveloperTeamInvite` | `inviteId: UUID!, teamId: UUID!` | `Boolean!` | `team-members:invite` | | `acceptDeveloperTeamInvite` | `code: String!` | `AcceptTeamInviteResult!` | Auth + `feature:extension_development` | | `createDeveloperTeamRole` | `teamId: UUID!, input: CreateTeamRoleInput!` | `DeveloperTeamRoleDetail!` | `team-settings:edit` | | `updateDeveloperTeamRole` | `teamId: UUID!, roleId: UUID!, input: UpdateTeamRoleInput!` | `DeveloperTeamRoleDetail!` | `team-settings:edit` | | `deleteDeveloperTeamRole` | `teamId: UUID!, roleId: UUID!` | `Boolean!` | `team-settings:edit` | The team-invite email opens `/invite/dev/{code}` on the configured web app origin. That page accepts by code; the bell accepts by the invite row ID without exposing the code. Both accept paths use the same checks and return `INVITE_NOT_FOUND`, `INVITE_EXPIRED`, `INVITE_FULL`, `ALREADY_MEMBER`, or `INVITE_NO_LONGER_VALID` in `extensions.code` when applicable. The bell's decline action deletes an unused single-use invite, leaves a shared or used invite in place, and succeeds if the invite is already gone. ### Types #### `DeveloperTeam` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Team ID | | `name` | `String!` | Team name | | `slug` | `String!` | URL slug | | `ownerId` | `UUID!` | Owning user | | `createdAt` | `String!` | Creation timestamp | | `updatedAt` | `String!` | Update timestamp | #### `DeveloperTeamMember` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Membership ID | | `teamId` | `UUID!` | Team ID | | `userId` | `UUID!` | Member's user ID | | `roleId` | `UUID!` | Assigned role ID | | `roleName` | `String!` | Role display name | | `roleSlug` | `String!` | Role slug | | `roleColor` | `String` | Role colour | | `displayName` | `String!` | Display name | | `email` | `String!` | Email address | | `avatarUrl` | `String` | Avatar URL | | `createdAt` | `String!` | When they joined | #### `DeveloperTeamInvite` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Invite ID | | `teamId` | `UUID!` | Team ID | | `invitedBy` | `UUID!` | Inviting user | | `invitedByName` | `String` | Inviting user's display name | | `email` | `String` | Target email, when the invite is addressed | | `inviteCode` | `String!` | Invite code | | `roleId` | `UUID!` | Role assigned on accept | | `roleName` | `String!` | Role name | | `roleSlug` | `String!` | Role slug | | `maxUses` | `Int!` | Maximum uses | | `useCount` | `Int!` | Current use count | | `expiresAt` | `String` | Expiration timestamp | | `acceptedAt` | `String` | When it was accepted | | `acceptedBy` | `UUID` | Accepting user | | `createdAt` | `String!` | Creation timestamp | #### `DeveloperTeamInviteDetail` Returned by `developerTeamInviteByCode` - the invite as seen by the recipient, without team-internal fields. | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Invite ID | | `teamId` | `UUID!` | Team ID | | `teamName` | `String!` | Team name | | `teamSlug` | `String!` | Team slug | | `invitedByName` | `String` | Inviting user's display name | | `inviteCode` | `String!` | Invite code | | `roleId` | `UUID!` | Role assigned on accept | | `roleName` | `String!` | Role name | | `roleSlug` | `String!` | Role slug | | `maxUses` | `Int!` | Maximum uses | | `useCount` | `Int!` | Current use count | | `expiresAt` | `String` | Expiration timestamp | | `isExpired` | `Boolean!` | Whether the invite has expired | | `isFull` | `Boolean!` | Whether `useCount` has reached `maxUses` | #### `DeveloperTeamRole` List shape - carries counts rather than the permission strings. | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Role ID | | `teamId` | `UUID!` | Owning team ID | | `name` | `String!` | Role name | | `slug` | `String!` | Role slug | | `description` | `String` | Role description | | `isDefault` | `Boolean!` | Default role | | `isSystem` | `Boolean!` | System role (cannot be deleted) | | `color` | `String` | Role colour | | `sortOrder` | `Int!` | Sort order | | `permissionCount` | `Int!` | Number of permissions granted | | `memberCount` | `Int!` | Number of members holding it | | `createdAt` | `String!` | Creation timestamp | | `updatedAt` | `String!` | Update timestamp | #### `DeveloperTeamRoleDetail` Detail shape - same as `DeveloperTeamRole` but with `permissions: [String!]!` in place of `permissionCount`. Retains `memberCount` (`Int!`, number of members holding the role). #### `TeamPermission` | Field | Type | Description | |-------|------|-------------| | `key` | `String!` | Permission string (e.g. `team-extensions:create`) | | `category` | `String!` | Grouping used by the role editor | > **Protocol parity:** All queries and mutations have matching REST endpoints under `/v1/developer/teams` - see [REST API](./rest-api.md#developer-teams). ## Developer Payout Settings Self-scoped read of the **authenticated developer's** saved payout configuration. This is a private, first-party resolver - do not confuse it with the public `developerProfile(slug)` store profile documented below (that returns the public `DeveloperPublicProfile`, which carries **no** payout fields). ### Queries #### `myDeveloperProfile: DeveloperProfile` Returns the authenticated developer's own profile. The caller needs a logged-in user session or their own API key. Errors with `Developer application required…` if the caller has not applied as a developer. REST twin: `GET /v1/developer/profile`. **Permission:** `extension-dev:read` · **Feature:** `feature:extension_development` #### `developerPayoutSettings: DeveloperProfile` Returns the authenticated developer's saved payout settings - `payoutMethod`, `stripeConnectId`, `paypalEmail`, and SEPA `bankIban` / `bankBic` / `bankName`, plus `revenueBalance` / `totalEarned` - for prefilling the payouts form. Companion read to the `updateDeveloperPayoutSettings` mutation, which returns the same `DeveloperProfile` type. Errors with `Developer profile not found` if the caller has not applied as a developer. `updateDeveloperPayoutSettings` bounds the plaintext with the same rule and error text as REST `POST /v1/developer/payouts`: IBAN at most 34 characters not counting spaces (50 with spaces), BIC at most 11, PayPal email and bank name at most 255 each. A longer value is refused with `IBAN must be at most 34 characters, not counting spaces`, `BIC must be at most 11 characters`, `PayPal email must be at most 255 characters` or `Bank name must be at most 255 characters` before anything is stored. The four fields are stored encrypted. **Permission:** `extension-dev:payouts` · **Feature:** `feature:extension_development` ### Types #### `DeveloperProfile` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Developer profile ID | | `userId` | `UUID!` | Owning user ID | | `stripeConnectId` | `String` | Stripe Connect account id (Phase 9) | | `paypalEmail` | `String` | PayPal payout email | | `bankIban` | `String` | SEPA IBAN | | `bankBic` | `String` | SEPA BIC | | `bankName` | `String` | Account holder name | | `payoutMethod` | `String` | `"stripe"`, `"paypal"`, or `"bank"` | | `revenueBalance` | `Int!` | Current balance, minor units | | `totalEarned` | `Int!` | Lifetime earnings, minor units | | `createdAt` | `DateTime!` | Profile creation date | | `updatedAt` | `DateTime!` | Last update date | > **Protocol parity:** matches REST `GET /v1/developer/payout-settings` - see [REST API](./rest-api.md#get-v1developerpayout-settings). ## Marketplace Public, unauthenticated queries for the extension store: `marketplaceExtensions`, `marketplaceExtension(slug)`, `marketplaceExtensionReviews(slug)`, `marketplaceFeatured` and `marketplaceCategories`. A review (`MarketplaceReviewEntry`) has `id`, `rating`, `comment`, `createdAt` and `updatedAt`. It contains no reviewer identity, neither a user ID nor a name. > **Protocol parity:** matches the REST endpoints under `/v1/marketplace` - see [REST API](./rest-api.md). ## Developer Store Profiles Public queries for developer and team profile pages displayed in the extension store. No authentication required. ### Queries #### `developerProfile(slug: String!): DeveloperPublicProfile` Get a public developer profile by slug. Returns `null` if not found. Includes aggregate stats and a list of published extensions. #### `developerTeamProfile(slug: String!): DeveloperTeamProfile` Get a public team profile by slug. Returns `null` if not found. Includes aggregate stats, public member list, and published extensions. #### `inlineDeveloperPanel(developerId: UUID, teamId: UUID): InlineDeveloperPanel` Get a compact developer info panel for display on extension detail pages. Returns the developer/team name, slug, avatar, extension count, and whether the developer is a team. ### Types #### `DeveloperPublicProfile` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Developer profile ID | | `displayName` | `String!` | Display name | | `slug` | `String!` | URL slug | | `description` | `String` | Bio/description | | `githubUrl` | `String` | GitHub URL | | `websiteUrl` | `String` | Website URL | | `avatarKey` | `String` | Avatar upload key | | `createdAt` | `DateTime!` | Profile creation date | | `stats` | `DeveloperStats!` | Aggregate statistics | | `extensions` | `[ProfileExtension!]!` | Published extensions | #### `DeveloperTeamProfile` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Team ID | | `name` | `String!` | Team name | | `slug` | `String!` | URL slug | | `description` | `String` | Description | | `githubUrl` | `String` | GitHub URL | | `websiteUrl` | `String` | Website URL | | `avatarKey` | `String` | Avatar upload key | | `createdAt` | `DateTime!` | Creation date | | `stats` | `DeveloperStats!` | Aggregate statistics | | `members` | `[TeamMemberSummary!]!` | Public member list | | `extensions` | `[ProfileExtension!]!` | Published extensions | #### `DeveloperStats` | Field | Type | Description | |-------|------|-------------| | `extensionCount` | `Int!` | Number of published extensions | | `totalInstalls` | `Int!` | Total installs across all extensions | | `avgRating` | `Float!` | Average rating across all extensions | #### `ProfileExtension` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Extension ID | | `shortId` | `String!` | Short identifier | | `slug` | `String!` | URL slug | | `name` | `String!` | Extension name | | `description` | `String` | Short description | | `category` | `String!` | Extension category | | `iconKey` | `String` | Icon upload key | | `pricingType` | `String!` | `"free"`, `"paid"`, etc. | | `pricingAmount` | `Int` | Price in minor units | | `pricingCurrency` | `String!` | ISO-4217 currency code | | `installCount` | `Int!` | Number of installs | | `ratingAvg` | `Float!` | Average rating | | `ratingCount` | `Int!` | Number of ratings | | `publishedAt` | `DateTime` | Publish date | #### `TeamMemberSummary` | Field | Type | Description | |-------|------|-------------| | `userId` | `UUID!` | Member's user ID | | `displayName` | `String!` | Display name | | `avatarUrl` | `String` | Avatar URL | | `role` | `String!` | Role name | #### `InlineDeveloperPanel` | Field | Type | Description | |-------|------|-------------| | `developerId` | `UUID!` | Developer/team ID | | `displayName` | `String!` | Display name | | `slug` | `String` | URL slug | | `description` | `String` | Short description | | `avatarKey` | `String` | Avatar upload key | | `extensionCount` | `Int!` | Number of extensions | | `isTeam` | `Boolean!` | Whether this is a team profile | > **Protocol parity:** `developerProfile` and `developerTeamProfile` have matching REST endpoints - see [REST API](./rest-api.md#developer-store-profiles). ### Extension Limits #### `developerLimits: DeveloperLimits!` Returns the resolved limits for the authenticated developer, including per-category breakdowns with resolution sources and all extension-specific overrides. **Permission:** `extension-dev:read` · **Feature:** `feature:extension_development` #### `developerLimitRequestEvents(requestId: UUID!): [LimitRequestEvent!]!` Returns the timeline events for a limit request owned by the authenticated developer. **Permission:** `extension-dev:read` · **Feature:** `feature:extension_development` #### `extensionLimits(extensionId: UUID!): ExtensionLimits!` Returns the resolved sound limits for an extension. Requires the caller to be the extension owner or a member of the owning developer team. **Permission:** `extension-dev:read` · **Feature:** `feature:extensions` #### `ExtensionLimits` | Field | Type | Description | |-------|------|-------------| | `maxSounds` | `Int!` | Resolved max bundled sounds | | `maxSoundsSource` | `String!` | Resolution source: `default`, `developer_override`, or `extension_override` | | `maxSoundFileSize` | `Int!` | Resolved max size of a single bundled sound, in bytes | | `maxSoundFileSizeSource` | `String!` | Resolution source for `maxSoundFileSize` | | `maxSoundStorageBytes` | `Int!` | Resolved total sound storage budget, in bytes | | `maxSoundStorageBytesSource` | `String!` | Resolution source for `maxSoundStorageBytes` | ### Admin - Developer Limits #### `adminDeveloperLimits(developerId: UUID!): DeveloperLimitsGql!` Fetch limit overrides for a developer. **Permission:** `developer-limits:read` #### `adminSetDeveloperLimit(input: AdminSetDeveloperLimitInput!): AdminActionResultGql!` Set or remove a developer limit override. Pass `null` for `limitValue` to remove the override. **Permission:** `developer-limits:edit` #### `adminSetExtensionLimits(input: AdminSetExtensionLimitsInput!): AdminActionResultGql!` Set or clear per-extension limit overrides (`maxSounds`, `maxSoundFileSize`, `maxSoundStorageBytes`). Pass `null` for any field to revert to developer/platform default. **Permission:** `developer-limits:edit` #### `adminAllLimitRequests(status: String, scope: String, search: String): [LimitRequestGql!]!` List all developer limit requests with optional status filter. **Permission:** `developer-limits:read` #### `adminDeveloperLimitRequests(developerId: UUID!): [LimitRequestGql!]!` List limit requests for a specific developer. **Permission:** `developer-limits:read` #### `adminLimitRequestEvents(requestId: UUID!): [LimitRequestEventGql!]!` Get timeline events for a limit request. **Permission:** `developer-limits:read` #### `adminReviewLimitRequest(input: AdminReviewLimitRequestInput!): AdminActionResultGql!` Approve or reject a limit request. Approving auto-applies the requested limits. Rejecting requires review notes. **Permission:** `developer-limits:edit` ### Admin - Undelivered Extension Purchases #### `adminUndeliveredExtensionPurchases(first: Int, after: String): UndeliveredPurchasePage!` Paid extension checkouts without delivery: still `completed`, never installed, older than one hour, newest first. Returns `{ items, nextCursor }`. Each item has `id`, `extensionId`, `extensionName`, `accountId`, `accountName` (the four are `null` once the extension or account is deleted), `stripeCheckoutSessionId`, `stripePaymentId`, `amount` (cents), `currency`, `undeliveredReason` (`detached`, `not_installable`, `install_failed`, `refund_failed`, or `null` when unknown) and `createdAt`. `first` is 1-100 (default 50); pass `nextCursor` as `after` for the next page. Invalid values fail with `first must be between 1 and 100` / `after is not a valid cursor`, as on the REST twin `GET /v1/admin/extension-purchases/undelivered`. **Permission:** `extension-purchases:read` (admin scope). See [Admin › Extension Purchases](../admin/extension-purchases.md). ## Bot Module Extension Kill Switch #### `setBotModuleKillSwitch(accountId: UUID!, killed: Boolean!): Boolean!` Set the extension kill switch for the caller's account: while `killed` is `true`, the bots match no extension bot-module trigger for the account, and every handler server function (`extensionExecuteHandler`) of the account's extensions of any category, widgets included, is refused with `EXTENSIONS_KILLED`. Declarative server functions (`extensionQuery`, `extensionMutate`) keep working. The state is stored (it survives a bot restart and is returned as `killed` by the internal trigger feed) and pushed live to the bots. Requires `bot-modules:edit`; `accountId` must be the caller's active account (`FORBIDDEN`, `Cannot control kill switch for another account` otherwise). Emits the audit event `account:extension_kill_switch_set`. > **Protocol parity:** REST twin `POST /v1/bot-modules/kill` with the same permission, check, error and audit event -- see [REST API](./rest-api.md#post-v1bot-moduleskill). ## Extension Install Logs #### `extensionInstallLogs(installId: UUID!, since: DateTime, level: String): ExtensionInstallLogsResponse!` The newest handler-execution logs (up to 100, newest first) of one bot-module or automation-node install of the caller's active account. `since` keeps entries at or after a timestamp; `level` filters to `info`, `warn` or `error`. **Permission:** follows the install's extension category — `bot-modules:read` for a bot module, `automations:read` for an automation node. The field itself only requires authentication. An install of another account, an unknown install, an install of any other category, and a caller without the category's permission all fail with the same error `Install not found`. **Redaction:** `message` and `stackTrace` are scrubbed of UUIDs, @-mentions, tokens and IPv4 addresses. For a bot module, `args` holds the positional command arguments, scrubbed the same way. For an automation node, `args.nodeConfig` keeps its values with secret-looking keys masked as `"***"` at any depth, and `args.inputData` (the triggering event) is shape only: keys and nesting are kept, values become `""`, `""` or `""`. `ExtensionInstallLogEntry` fields: `time`, `handler`, `triggerType`, `platform`, `level`, `message`, `stackTrace`, `args` (JSON), `userName`, `userRole`, `executionMs`, `success`. > **Protocol parity:** REST twin `GET /v1/extension-installs/{id}/logs` with the same guard, redaction and error -- see [REST API](./rest-api.md#get-v1extension-installsidlogs). ## Extension Server Functions #### `extensionQuery(input: ExtensionQueryInput!): ExtensionFunctionResult!` #### `extensionMutate(input: ExtensionMutateInput!): ExtensionFunctionResult!` Run a declarative server function of an extension install by name. `functionName` is an export of the extension's `server/functions.ts`; the API looks it up in the functions manifest of the version the install runs (parsed once per version and process, the same cache as REST) and binds `params` (a JSON object, the `arg()` values) as typed SQL parameters. `extensionQuery` runs a `queryRows` definition, `extensionMutate` an `insertRow` / `patchRow` / `deleteRow` definition. `ExtensionFunctionResult` has `rows` (JSON, each the full row with `_id`, `_account_id`, `_created_at`, `_updated_at`) and `affectedCount`. - **Editor-only:** a definition with `editorOnly: true` runs only for a dashboard session, personal API key or System key. Extension, overlay and widget tokens get `Editor-only function called outside editor`. The input has no field that could claim the editor surface. - **Raw operations:** sessions, personal API keys and System keys may send `table` (+ `filters` / `limit`, or `functionType` / `args` / `rowId`) instead of a name; `params` is then ignored. Tokens get `Raw data operations need a signed-in session or an API key; call the server function by name`. - **Errors:** `Not found: Unknown server function: {name}`; `Bad request: Server function '{name}' is a mutation, not a query` (and the reverse, and `... is a handler; call it through execute`); ``Bad request: missing argument `{name}` ``; the data errors and codes listed for REST. - **Callers:** `User` / `ApiKey` of the install's account with `extension-data:read` (query) / `extension-data:edit` (mutate); System keys with `bots:internal`; an extension token for its own install, an overlay token for an install that an `ext:{slug}` layer of its overlay renders, a widget token for its widget's install. `uploadExtensionVersion` takes the version's `functions.json` as `functionsManifest` (JSON) and checks it like the REST upload. > **Protocol parity:** REST twins `POST /v1/extension-installs/{install_id}/query/{function_name}` and `/mutate/{function_name}` with the same callers, resolution, messages and codes -- both protocols call `services::extension_functions::prepare_call`. See [REST API](./rest-api.md#post-v1extension-installsinstall_idqueryfunction_name-and-mutatefunction_name). #### `extensionExecuteHandler(input: ExtensionExecuteHandlerInput!): ExtensionHandlerResult!` Run a handler server function (`query()` / `mutation()` / `action()` of the extension's `server/functions.ts`) on the Bot Module Worker's V8 executor. The input is `installId`, `functionName` and `args` (a JSON object, passed as the handler's `args`). The handler's category, its `editorOnly` flag and its code come from the functions manifest and `server.js` of the version the install runs, never from the request. `ExtensionHandlerResult.data` is the handler's return value (JSON). - **Checks:** the callers of `extensionQuery` (an install whose code may not run - extension or version suspended, install disabled, account in its dissolution grace period - is `Not found: Extension install not found` and never reaches the worker); `extension-data:read` for a `query()` handler, `extension-data:edit` for `mutation()` / `action()` (sessions and API keys); `editorOnly` as above; the account's extension kill switch (`EXTENSIONS_KILLED`, `Extension handlers are switched off for this account`). - **Limits:** 5 s and 256 MB per call, `ctx.fetch` refuses every host, per install and minute 100 `query()` (shared with `extensionQuery`), 50 `mutation()` and 20 `action()` calls (`RATE_LIMIT_EXCEEDED`, also when the worker's rate-limit Redis fails), at most 16 handlers per worker replica and 4 per account at a time (`RATE_LIMIT_EXCEEDED` at once when no slot is free). - **Realtime:** the handler's `ctx.realtime.push` calls are validated and published exactly as on REST (32 per call, 16 KiB payload, topic charset, 300 per install and minute; a bad push throws inside the handler, and an invalid batch that still reaches the API is dropped while the call succeeds) and reach the WebSocket channel `extension_install:{install_id}`. There is no GraphQL subscription for them; the WebSocket channel is the delivery path. - **Errors:** `Bad request: Server function '{name}' is not a handler; call it through query or mutate`; `Not found: Unknown server function: {name}`; `Not found: The installed version has no server bundle`; `Not found: Extension install not found`; `HANDLER_ERROR` (the first line of the message the handler threw, without its stack trace; `Handler execution failed` for a failure outside the handler's code, whose text never reaches the caller), `HANDLER_MEMORY_LIMIT`, `HANDLER_TIMEOUT`; `EXECUTOR_UNAVAILABLE` (`Server-function executor is unavailable; try again later`) when the worker is not configured, unreachable or failing. > **Protocol parity:** REST twin `POST /v1/extension-installs/{install_id}/execute/{function_name}` with the same checks, messages and codes -- both protocols call `services::extension_functions::prepare_handler_call` and `services::server_function_executor`. See [REST API](./rest-api.md#post-v1extension-installsinstall_idexecutefunction_name). ## Automation Extension Nodes Queries for managing extension-provided automation nodes. ### Queries #### `installedAutomationNodes: [InstalledAutomationNode!]!` List all installed automation node extensions for the current account. Returns metadata for each node including schemas, trigger mode, icon, and color. Requires `automations:read`. #### `automationWebhookUrl(installId: UUID!, automationId: UUID!): AutomationWebhookUrl!` Get the webhook URL and secret for an installed trigger node within a specific automation. Requires `automations:read`. ### Types #### `InstalledAutomationNode` | Field | Type | Description | |-------|------|-------------| | `installId` | `UUID!` | Install ID | | `extensionId` | `UUID!` | Extension ID | | `extensionName` | `String!` | Extension name | | `extensionSlug` | `String!` | Extension slug | | `nodeType` | `String!` | `"trigger"`, `"action"`, or `"logic"` | | `inputSchema` | `JSON!` | JSON Schema for node input | | `outputSchema` | `JSON!` | JSON Schema for node output | | `triggerMode` | `String` | `"webhook"`, `"polling"`, or `"both"` (triggers only) | | `pollIntervalSeconds` | `Int` | Poll interval for polling triggers | | `icon` | `String` | Lucide icon name | | `color` | `String` | Hex color for canvas rendering | #### `AutomationWebhookUrl` | Field | Type | Description | |-------|------|-------------| | `webhookUrl` | `String!` | Full webhook URL for the external service | | `webhookSecret` | `String!` | 64-character hex secret for the `X-Webhook-Secret` header | > **Protocol parity:** Both queries have matching REST endpoints -- see [REST API](./rest-api.md#automation-extension-nodes). ## Extension Storage Per-install key/value storage behind `useExtensionStorage()`. Auth: the install's own extension token, or a member credential (user session, personal key, service key) of the owning account with the permission below. Overlay and widget tokens whose overlay or widget renders a runnable install may read it, never write it. Feature: `feature:extensions`. Errors (code and message) are identical to the REST twins. ### Queries #### `extensionStorage(installId: UUID!): [ExtensionStorageEntry!]!` All entries, ordered by key. Permission: `extension-store:read`, or an overlay/widget token that renders the install (`FORBIDDEN` `Extension install belongs to a different overlay` / `… widget` otherwise; a non-runnable install is `NOT_FOUND`). REST twin: `GET /v1/extension-installs/{install_id}/storage`. ### Mutations #### `setExtensionStorage(input: SetExtensionStorageInput!): ExtensionStorageEntry!` Set one key (`installId`, `key`, `value: JSON`) and broadcast `storage:update` on `ext-storage:{install_id}`. Permission: `extension-store:configure`. Overlay and widget tokens get `FORBIDDEN` `Overlay and widget tokens cannot write extension storage`. REST twin: `POST /v1/extension-installs/{install_id}/storage`. Limits per install, checked on every write: key at most 256 bytes (UTF-8, not empty, no `:`; surrounding whitespace is trimmed), value at most 64 KiB of JSON, at most 1 000 keys and 1 MiB in total (keys + JSON values). Overwriting an existing key with a smaller value is always allowed. A rejected write stores nothing (`code: BAD_REQUEST`). ### Types #### `ExtensionStorageEntry` | Field | Type | Description | |-------|------|-------------| | `key` | `String!` | Storage key | | `value` | `JSON!` | Stored JSON value | ## Extension Actions #### `executeExtensionAction(input: ExecuteExtensionActionInput!): ExtensionActionResult!` Run an action (`chat:send`, `events:emit`, `overlay:update_layer`, the OBS actions, …) on behalf of an extension install - the backend of `useLumioAction()`. Input: `installId`, `action`, `params` (JSON object; anything else → `params must be a JSON object`). Result: `success`, `data`, `error`; a failure inside the action returns `success: false` with `error` instead of a GraphQL error. Unknown actions → `Unknown action: …`, bad params → `Invalid params: …`. REST twin: [`POST /v1/extension-installs/{install_id}/actions/{action}`](./rest-api.md#extension-actions). **Auth** (identical on both protocols, one shared resolver): | Credential | May act on | |------------|-----------| | Extension token (`lm_ext_*`) | Only its own install; any other id → `Extension token install_id does not match the requested install` | | User session / personal API key | A live install of the caller's account, with `extension-store:configure` (`Missing permission: extension-store:configure`; another account's install → `Extension install belongs to a different account`; unknown or uninstalled → `Extension install not found`) | | Overlay token (`lm_overlay_*`) | Only a live install of the token's account that an extension layer (`ext:{slug}`) of **that** overlay renders; anything else → `Extension install belongs to a different overlay` | | Widget token (`lm_widget_*`) | Only the widget's own install; anything else → `Extension install belongs to a different widget` | | Shared overlay link (`lm_share_*`), popout token, anything else | Never → `Authentication required` | Overlay and widget tokens never run a `chat:*` action (`chat:send`, `chat:delete`, `chat:ban`, … and any future `chat:` action): their links are permanent and leak through stream captures and shared screenshots, and a chat action runs as the account's bot. Such a call is refused before the install is looked up with `Permission denied: chat actions require a signed-in session, not an overlay or widget token`. Spotify, OBS and the other actions stay available to them. Chat actions need a user session, an API key or the extension token. The extension identity is always read from the token or the install row, never from the request. The install's manifest must then declare the permission the action needs (`Permission denied: Extension does not declare the required permission: …`). Feature: `feature:extensions`. Actions run on behalf of the install and emit no audit event. ## Developer Extension Versions `createExtensionVersion(extensionId: UUID!, input: CreateVersionInput!)` creates a draft version. `uploadExtensionVersion(input: UploadExtensionVersionInput!)` records a draft bundle version; the REST multipart upload is the CLI path. Both validate `lumio.config.json`, require the manifest extension ID to match the target extension, and require the input `version` to equal the manifest version. Both take an optional `dataSchema` (the `defineSchema` result) with the same validation as REST: `422 INVALID_DATA_SCHEMA`, or `DATA_SCHEMA_NOT_ADDITIVE` with the violation list in the `details` error extension. An invalid manifest returns a validation error; a mismatched version returns `Version '' does not match the manifest version ''`. `UploadExtensionVersionInput` contains `extensionId`, `version`, `bundleHash`, `manifest`, and optional `isBreaking`, `releaseNotes`, and `changelogEntry`. It has no storage-key fields. The server derives the bundle and source keys from the extension ID and validated version. `submitExtensionVersion(versionId: UUID!)` revalidates the stored manifest before a draft enters review. Invalid data leaves the version in draft. On a team-owned extension, create and upload require `team-versions:create`; submit requires `team-extensions:publish`. A member lacking the team grant receives `FORBIDDEN` with `Missing team permission: `. Ownership checks run first, so a caller outside the owning team receives a not-found error. Create and update accept developer visibility values `private`, `unlisted`, `public`, and `paid`; `system` is reserved. Developer slugs beginning with `system-` are rejected. `installExtension` requires a published extension; a private extension additionally requires an account access grant or tester invitation. Ineligible install targets return `Extension not found`; installing an extension the account already has returns `Extension is already installed`. After `uninstallExtension` the install disappears from every query and per-install resolver at once. `deleteDataImmediately: true` deletes the account's extension data at once instead of after 30 days; like the retention path it removes only this account's rows from the shared `ext_{extension_id}` schema and drops the schema only with the extension's last install. Both protocols emit `account:extension_uninstalled`. installing it again within the 30-day data-retention window restores it with its saved configuration (REST parity: `POST`/`DELETE /v1/extension-installs`). `reviewExtension` accepts only store-visible targets (published or deprecated, excluding private). > **Protocol parity:** The REST developer version endpoints and install route apply the same manifest, team-role, visibility, and install checks. See [REST API](./rest-api.md#developer-extension-endpoints). ## Extension Version Files ### Queries #### `extensionVersionFiles(extensionId: UUID!, version: String!): [ExtensionVersionFile!]!` List all files in an extension version's bundle. Published versions are publicly accessible; draft and testing versions are restricted to the extension's own developer/team members (or a System key carrying `bots:internal`; a System key without it gets `Missing permission: bots:internal`) - an authenticated user from another account cannot enumerate them. The REST twin `GET /v1/extensions/{id}/versions/{version}/files` enforces the same ownership check. ```graphql query { extensionVersionFiles(extensionId: "uuid", version: "1.2.0") { id versionId filePath contentType sizeBytes contentHash createdAt } } ``` ### Types #### `ExtensionVersionFile` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | File record ID | | `versionId` | `UUID!` | Parent version ID | | `filePath` | `String!` | Path within the bundle (e.g. `layer.js`, `assets/logo.png`) | | `contentType` | `String!` | MIME type (e.g. `application/javascript`, `text/css`) | | `sizeBytes` | `Int!` | File size in bytes | | `contentHash` | `String` | SHA-256 content hash | | `createdAt` | `DateTime!` | Upload timestamp | > **Protocol parity:** `GET /v1/extensions/{id}/versions/{version}/files` in REST -- see [REST API](./rest-api.md#extension-bundle-serving). ## Platform Metadata ### Queries #### `platformMetadata: [PlatformMetadata!]!` Returns static platform info (name, slug, icon, features, OAuth scopes). Public query -- no authentication required. ```graphql query { platformMetadata { name slug icon features oauthScopes } } ``` ### Types #### `PlatformMetadata` | Field | Type | Description | |-------|------|-------------| | `name` | `String!` | Platform display name | | `slug` | `String!` | Platform slug (e.g. `twitch`, `youtube`) | | `icon` | `String!` | Platform icon identifier | | `hasLogin` | `Boolean!` | Platform can be used to sign in | | `hasChannel` | `Boolean!` | Platform supports a channel connection | | `hasBot` | `Boolean!` | Platform supports a custom bot identity | | `hasIntegration` | `Boolean!` | Platform exposes a non-channel integration | ## Sounds All sound operations require the `feature:sounds` feature flag to be enabled for the account. ### Queries #### `sounds(offset: Int! = 0, limit: Int! = 20, search: String): SoundsResult!` List sounds in the account's library with optional pagination and name search. **Permission:** `sounds:read` ```graphql query { sounds(limit: 20, offset: 0, search: "tada") { items { id name fileName contentType sizeBytes durationSecs createdAt } total } } ``` #### `sound(id: UUID!): SoundGql` Fetch a single sound by ID. **Permission:** `sounds:read` ### Mutations #### `updateSound(id: UUID!, input: UpdateSoundInput!): SoundGql` Update the metadata of an existing sound. **Permission:** `sounds:edit` ```graphql mutation { updateSound(id: "uuid", input: { name: "Fanfare" }) { id name updatedAt } } ``` #### `deleteSound(id: UUID!): Boolean!` Delete a sound from the library. Returns `true` on success. **Permission:** `sounds:delete` #### `playSound(id: UUID!, volume: Float! = 1.0, target: SoundTargetInput): Boolean!` Trigger playback of a sound in browser sources. `volume` is 0.0–1.0 (default 1.0). `target` is an optional overlay key; omit to broadcast to all browser sources. **Permission:** `sounds:play` #### `stopSound(id: UUID!, target: SoundTargetInput): Boolean!` Stop playback of a sound in browser sources. `target` is an optional overlay key; omit to broadcast to all browser sources. **Permission:** `sounds:play` ### Types #### `SoundGql` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Sound ID | | `accountId` | `UUID!` | Owning account | | `name` | `String!` | Display name | | `filename` | `String!` | Original file name | | `contentType` | `String!` | MIME type (e.g. `audio/mpeg`) | | `sizeBytes` | `Int!` | File size in bytes | | `durationMs` | `Int` | Duration in milliseconds (null until processed) | | `waveform` | `JSON` | Precomputed waveform peaks (null until processed) | | `createdAt` | `String!` | Upload timestamp | | `sourceExtensionName` | `String` | Extension that bundled this sound, when applicable | #### `SoundsResult` | Field | Type | Description | |-------|------|-------------| | `sounds` | `[SoundGql!]!` | Sound list for the current page | | `totalCount` | `Int!` | Total count matching the query | | `userSoundCount` | `Int!` | Sounds uploaded by the account (excludes extension-bundled) | | `maxSounds` | `Int!` | Plan limit on sound count | | `maxSoundFileSize` | `Int!` | Plan limit on a single upload, in bytes | | `maxSoundStorageBytes` | `Int!` | Plan limit on total storage, in bytes | | `usedStorageBytes` | `Int!` | Storage currently used, in bytes | #### `UpdateSoundInput` | Field | Type | Description | |-------|------|-------------| | `name` | `String` | New display name | | `durationMs` | `Int` | Duration in milliseconds | | `waveform` | `JSON` | Waveform peaks | #### `SoundTargetInput` Passed to `playSound` / `stopSound` to address a single browser source instead of every listener. | Field | Type | Description | |-------|------|-------------| | `type` | `String!` | Target kind (e.g. `"overlay"`, `"widget"`) | | `id` | `UUID!` | Target ID | > **Protocol parity:** All operations have matching REST endpoints - see [REST API](./rest-api.md#sounds). ## Widgets Account-scoped queries and mutations for widget instances, access management and duplication. ### Queries #### `widgetInstances: [WidgetInstanceGql!]!` List the widget instances of the active account. **Permission:** `widgets:read` A widget on which the caller has the role `none` is left out; no entry, `viewer` or `editor` is listed. `widgets:access-read` sees every widget. Same rule as REST `GET /v1/widgets` and the `overlays` query. #### `widgetAccess(widgetId: UUID!): [WidgetAccessEntry!]!` List access entries for a widget. Returns all users with explicit access grants. **Permission:** `widgets:access-read` #### `widgetAccessCandidates(widgetId: UUID!): [WidgetAccessCandidate!]!` List account members eligible for the widget access dialog. Excludes users who already have an access entry. **Permission:** `widgets:access-read` ### Mutations #### `setWidgetAccess(widgetId: UUID!, userId: UUID!, role: String!): WidgetAccessEntry!` Set a user's access role on a widget. `role` accepts `"viewer"`, `"editor"`, or `"none"`. **Permission:** `widgets:access-grant` #### `removeWidgetAccess(widgetId: UUID!, userId: UUID!): WidgetDeleteResult!` Remove a user's access entry from a widget. **Permission:** `widgets:access-revoke` #### `duplicateWidgetInstance(id: UUID!): CreateWidgetResult!` Duplicate a widget instance. Creates a copy of the widget with its configuration and returns the new widget with an access token. **Permission:** `widgets:create`, plus **Editor** access on the source widget; `widgets:access-grant` bypasses the per-widget check, `widgets:access-read` does not. A source the caller cannot edit returns `Widget not found`, the same as a missing one. The plan limit is checked inside the insert. A source bound to an install that is no longer live returns `Extension install not found`. Errors match REST `POST /v1/widgets/{id}/duplicate`. #### `createWidgetInstance(input: CreateWidgetInput!): CreateWidgetResult!` Create a widget instance with a one-time access token. **Permission:** `widgets:create` The stored `extensionId` comes from the install, never from the input. `installId` must be a live install of the active account; otherwise `Extension install not found`. An `extensionId` or `widgetType` naming a different extension returns `Widget extension does not match the install`; an `extensionId` or `ext:` `widgetType` without `installId` returns `An extension widget requires an install_id`. Errors match REST `POST /v1/widgets`. ### Types #### `WidgetExtensionRef` `WidgetInstanceGql.extension` - set for an extension widget, `null` otherwise. Describes the extension and its newest published version; the dashboard widget editor and the designer opened from it read it. REST returns the same object as `extension` on every widget response. | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Extension ID | | `slug` | `String!` | Extension slug | | `name` | `String!` | Display name | | `visibility` | `String!` | Store visibility | | `iconKey` | `String` | Icon storage key | | `developerName` | `String` | Developer display name (`Lumio` for system extensions) | | `version` | `String` | Newest published version | | `targets` | `JSON` | `targets` of that version's manifest | | `configSchema` | `JSON` | `config_schema` of that version's manifest | | `designerSchema` | `JSON` | `designer_schema` of that version's manifest | | `permissions` | `[String!]!` | `permissions` of that version's manifest (`events:read`, `chat:read`, ...). The host forwards live events and chat only for declared permissions; `[]` when it declares none. See [WebSocket → Extension hosts](./websocket.md#extension-hosts-read-events-and-chat) | #### `WidgetAccessEntry` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Access-entry ID | | `widgetId` | `UUID!` | Widget instance the grant applies to | | `userId` | `UUID!` | User ID | | `userName` | `String!` | User display name | | `userAvatar` | `String` | User avatar URL | | `role` | `String!` | Access role (`"viewer"`, `"editor"`) | | `grantedBy` | `UUID` | User who granted access; `null` once that profile is deleted | | `createdAt` | `String!` | When access was granted | #### `WidgetAccessCandidate` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | User ID | | `displayName` | `String!` | User display name | | `avatarUrl` | `String` | User avatar URL | > **Protocol parity:** All queries and mutations have matching REST endpoints - see [REST API](./rest-api.md#widgets). ## Setting Presets Named snapshots of chat/events/music display settings, keyed to the pair `(user, account)` (ZAF-622 §52c; see [Setting Presets](../features/setting-presets.md)). All fields require the `feature:setting_presets` feature flag. Create/edit/delete are scoped to *(the authenticated user, their active account)* with no `resource:action` permission and fail-closed on membership (a preset in an account you have left reads as missing); the **one** RBAC gate is `presets:share` on the share/un-share mutations. **Popout access:** the queries accept a **popout token** (the OBS-source popout can list/load presets). The mutations require a **first-party user session** - a popout/overlay/widget/extension token is rejected with `FORBIDDEN` (a popout token authenticates as the member it is bound to, not as the person holding it; ZAF-450/469). The quota is one number over all kinds, resolved **per account** from that account's own plan (plus any override; `0 = unlimited`) - a full account never blocks a different one. The effective limit is also readable without creating a preset - as `maxSettingPresets` on the `accountLimits` query and its REST twin `GET /v1/billing/account-limits` (both gated on `plan:read`) - so a UI can render "N of M used". **Account sharing (ZAF-622 §19):** a user can share one of their own presets with the members of their active account. For a **first-party** caller, `settingPresets` then also returns presets shared into the active account by a still-member owner - each marked `shared: true` with `ownerDisplayName`. Over a **popout token the list stays restricted to your own presets**. Sharing is a flag on the existing row (no copy, no quota cost); loading writes nothing server-side. ### Queries #### `settingPresets(kind: SettingPresetKind): [SettingPresetGql!]!` List the current user's presets, optionally filtered by `kind`, plus (for a first-party caller) presets shared into the active account by a still-member owner. Own rows sort first (default-first, then most-recently-changed) with `shared: false`; foreign shared rows follow with `shared: true` + `ownerDisplayName`. A popout token sees own presets only. #### `settingPreset(id: UUID!): SettingPresetGql` Get a single preset by ID (scoped to the current user + active account); `null` if missing, foreign, in another account, or in an account you have left. #### `settingPresetQuota: SettingPresetQuotaGql!` Your own preset quota in the active account: `used` (own presets across all kinds - foreign shared rows never count) and `limit` (the account's resolved cap, `0` = unlimited). Per-account and popout-token readable like the list, deliberately **not** gated on `plan:read`, so every member sees their limit. Both numbers come from the functions the create path gates on, so this read and the create rejection can never disagree. REST twin `GET /v1/setting-presets/quota`. ### Mutations #### `createSettingPreset(input: CreateSettingPresetInput!): SettingPresetGql!` Create a preset. First-party session only. Fails with a limit message when the user is at their preset quota. #### `updateSettingPreset(id: UUID!, input: UpdateSettingPresetInput!): SettingPresetGql!` Rename and/or overwrite a preset (moves `updatedAt`). First-party session only. #### `setDefaultSettingPreset(id: UUID!): SettingPresetGql!` Mark the preset as its kind's default (unsets the previous default of that kind). First-party session only. #### `deleteSettingPreset(id: UUID!): Boolean!` Hard-delete a preset. First-party session only. #### `shareSettingPreset(id: UUID!): SettingPresetGql!` Share one of your own presets with the members of your active account. First-party session + `presets:share`. The target is always the active account; a second share re-points the same row (no copy, no quota cost). Fails `FORBIDDEN` with `Missing permission: presets:share` without the permission, `Setting preset not found` if the preset is missing or not owned by the caller. Emits `account:setting_preset_shared`. #### `unshareSettingPreset(id: UUID!): SettingPresetGql!` Un-share one of your own presets (make it private again). First-party session + `presets:share`. Idempotent - un-sharing an already-private preset succeeds and emits no event; otherwise emits `account:setting_preset_unshared`. ### Operator (AdminApp) queries & mutations Platform operators view and delete a user's presets from the AdminApp (ZAF-1282). An operator manages presets by owning user and sees **all** of that user's presets across every account; deleting one removes that single `(user, account)` row (a preset lives in exactly one account). These use the same admin RBAC as the rest of the admin surface. #### `adminUserSettingPresets(userId: UUID!): [AdminSettingPreset!]!` List a user's presets for the operator view. Requires `users:read`. The `payload` is **not** exposed (it can hold viewer names the user typed). REST twin `GET /v1/admin/users/{id}/setting-presets`. #### `adminDeleteUserSettingPreset(id: UUID!): Boolean!` Delete one of a user's presets. Requires `users:edit`. Returns `false` if no such preset exists. Emits `system:user_setting_preset_deleted`. REST twin `DELETE /v1/admin/setting-presets/{id}`. #### `AdminSettingPreset` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Preset ID | | `userId` | `UUID!` | Owning user | | `kind` | `String!` | `chat` \| `events` \| `music` | | `name` | `String!` | Display name | | `isDefault` | `Boolean!` | Whether this is the kind's default | | `createdAt` | `DateTime!` | Creation timestamp | | `updatedAt` | `DateTime!` | Last-changed timestamp | ### Types #### `SettingPresetKind` Enum: `chat`, `events`, `music`. #### `SettingPresetGql` | Field | Type | Description | |-------|------|-------------| | `id` | `UUID!` | Preset ID | | `userId` | `UUID!` | Owning user | | `kind` | `String!` | `chat` \| `events` \| `music` | | `name` | `String!` | Display name | | `payload` | `JSON!` | The settings snapshot (ZAF-622 export format) | | `isDefault` | `Boolean!` | Whether this is the kind's default | | `sharedAccountId` | `UUID` | The account this preset is shared with, or `null` when private | | `shared` | `Boolean!` | `true` when this is a foreign preset shared into your active account (own rows are `false`) | | `ownerDisplayName` | `String` | The owner's display name - only for foreign shared rows | | `createdAt` | `DateTime!` | Creation timestamp | | `updatedAt` | `DateTime!` | Last-changed timestamp | #### `CreateSettingPresetInput` | Field | Type | Description | |-------|------|-------------| | `kind` | `SettingPresetKind!` | The preset kind | | `name` | `String!` | Display name (non-empty, ≤ 255) | | `payload` | `JSON!` | The settings snapshot | | `isDefault` | `Boolean` | Mark as this kind's default | #### `UpdateSettingPresetInput` | Field | Type | Description | |-------|------|-------------| | `name` | `String` | New name (rename) | | `payload` | `JSON` | New snapshot (overwrite) | > **Protocol parity:** All queries and mutations have matching REST endpoints - see [REST API](./rest-api.md#setting-presets). ## Popout Tokens Create, update, and revoke the long-lived popout/API tokens (`lm_pop_*`) used by overlays and embedded browser sources. See [Tokens](../features/tokens.md) for the full model, permissions, and REST twins. ### Mutations #### `createPopoutToken(input: CreatePopoutTokenInput!): CreatePopoutTokenResult!` Requires `tokens:create` (and the `feature:tokens` flag). Returns the full token string (shown only once) plus the token metadata. Binds to the **creator** (the acting first-party user) when `userId` is omitted. > **First-party session required (ZAF-1017 / ZAF-469):** minting a popout token is itself a first-party action - the omitted-`userId` (NULL) binding keys off the *creator*. A popout token is always bound to an explicit user; there is no fallback to the account owner, and an unbound token never resolves (ZAF-1922). A popout/overlay/widget/extension token calling `createPopoutToken` is rejected with `FORBIDDEN`. Passing an explicit `userId` for another account member is allowed only for a member of the account whose role does not exceed the caller's own permissions (ZAF-1740 A1) - the account owner counts as holding every account permission. The same binding rule applies when `updatePopoutToken` reassigns `userId`. Same gate, rule and messages as REST `POST /v1/tokens` / `PATCH /v1/tokens/{id}`. **`CreatePopoutTokenInput`** | Field | Type | Description | |-------|------|-------------| | `label` | `String` | Human-readable label. | | `permissions` | `[String!]!` | Permission strings the token grants (must be a subset of the creator's rights). | | `userId` | `UUID` | User to bind the token to (defaults to the authenticated user). | | `expiresAt` | `String` | Optional lifetime (RFC3339). `null`/absent = never expires (the default). Must **not** be in the past - a back-dated value is rejected with code `VALIDATION_ERROR` and message `Validation error: expires_at cannot be in the past`, **identical to REST `POST /v1/tokens`**. Optional and additive. | #### `updatePopoutToken(input: UpdatePopoutTokenInput!): PopoutToken!` Requires `tokens:edit`. `label` and `expiresAt` distinguish absent (don't change) from an explicit `null` (clear) and a value (set) - the same meaning as `"label": null` / `"expires_at": null` on REST `PATCH /v1/tokens/{id}` - plus a `revoke` boolean. `userId` distinguishes absent (no change) from an explicit `null`, which unassigns by binding the token to the **editor** - the editor must be a first-party user (a token-authenticated editor gets `FORBIDDEN` `This endpoint requires a logged-in user session`), and the resulting binding passes the same binding check as an explicit `userId` (ZAF-1922). REST twin: `PATCH /v1/tokens/{id}` with `"user_id": null`. Unlike create, the update path **allows** a back-dated `expiresAt` - that is a supported soft-revoke. > **Protocol parity:** matching REST endpoints under `/v1/tokens` - see [REST API](./rest-api.md#endpoints-overview). GraphQL and REST share the same fields, permissions, validation, and error messages. ## Popout Sessions Exchange a raw popout token (`lm_pop_*`) for a short-lived **popout session** so the raw token stays out of the page URL. See [Authentication → Popout Tokens & Popout Sessions](./authentication.md#popout-tokens--popout-sessions) for the full model. ### Queries #### `popoutSession: PopoutSessionIdentity!` Read the `(accountId, userId)` pair the current popout session represents, plus the represented user's display preferences (`locale`, `timezone`, `dateFormat`, `timeFormat`) and seasonal-look preferences (`season*`). Popout-token readable — gated with `AuthGuard`, **not** the first-party gate (a popout carries no logged-in user session). The pair is read straight from the auth context, never a client parameter, so the scope cannot be forged, and the response is only the caller's **own** session ids — never a list, never another account. Anonymous is rejected with `Authentication required`. The popout-session cookie is httpOnly, so the popout page cannot read this pair itself; this query is how it learns which account and user it acts as. `userId` is always the user the token is bound to — every popout token is bound to an explicit user, there is no owner fallback (ZAF-1922) — the same binding every popout read uses. No permission guard and no audit event (a read of the caller's own session). > **Protocol parity:** matching REST endpoint `GET /v1/auth/popout/session` — see [REST API](./rest-api.md#auth). ### Mutations #### `exchangePopoutToken(token: String!): PopoutSession!` Validates the popout token (prefix + `revoked_at` / `expires_at`), mints a 15-minute session JWT carrying the token's permission subset, and sets it as an httpOnly cookie (`lumio-popout-token`, `Path=/`, `SameSite=Lax`). No permission guard - possession of a valid popout token IS the authorization. A revoked, expired, unknown, or non-popout token is rejected with `Invalid or expired popout token`. The minted session resolves to the **same** popout auth context as the raw token (identical account, permission subset, OBS credential gate) - never a full user session. #### `issuePopoutWsToken: String!` Mint a **WebSocket-scoped** token for the current **popout session** - the popout counterpart of `issueWsToken`. `issueWsToken` is session-only (ZAF-469 / ZAF-1742) and returns `FORBIDDEN` for a popout, so a popout cannot mint there; this path mints a WS token that carries the popout's **own** permission subset (never the account owner's). No permission guard - possession of a valid popout session IS the authorization; a non-popout caller is rejected with `UNAUTHENTICATED` (anonymous) or `FORBIDDEN` (any non-popout credential). The token carries a WebSocket-only use tag and **no `session_id`**, so the API accepts it **only** on the `/v1/ws` upgrade path - a leaked `?token=` WS URL cannot drive a REST/GraphQL mutation. It is short-lived (`auth.ws_token_expiration`, ~15 min); the popout re-mints on reconnect. > **Protocol parity:** matching REST endpoint `POST /v1/auth/popout/ws-token` - see [REST API](./rest-api.md#auth). #### `popoutDismissSeasonBanner(instance: String!): PopoutSeasonLook!` Dismiss the season banner for one season instance (`:`, e.g. `halloween:2026`) on behalf of the current **popout session**. One of exactly two one-way profile writes a popout may make: it appends `instance` to the represented user's `seasonBannerDismissed` and changes nothing else. Idempotent (a present instance is a no-op); the same dedupe and 50-entry cap as `updateMe` apply (a full list drops its oldest entry). **Guard:** popout-only - anonymous → `UNAUTHORIZED` (`Authentication required`), every other credential (user session, API key, overlay, shared-overlay, widget, extension) → `FORBIDDEN` (`This endpoint requires a popout session`). A logged-in user uses `updateMe`. **Validation:** the season id must be in the server-side `SEASON_IDS` list (`halloween`, `nicholas`, `christmas`, `new-year`, `easter` - every season of the web season registry plus the planned ones; a season outside it extends both) and the year within ±1 of the current one; otherwise `VALIDATION_ERROR` with the same text as the REST twin. No audit event, no WebSocket push. > **Protocol parity:** matching REST endpoint `POST /v1/auth/popout/season-banner/dismiss` - see [REST API](./rest-api.md#auth). #### `popoutSwitchOffSeasonLook(instance: String!): PopoutSeasonLook!` Switch the seasonal look **off** on behalf of the current **popout session**: sets the represented user's global `seasonLookEnabled = false` (all seasons - the same switch as the Settings toggle) and, in the same write, dismisses the banner for `instance`, so switching the look on again later does not bring this season's banner back. **Off only** - no popout mutation can switch the look on or change `seasonStrength`, `seasonMotion` or `seasonCreatures`; that stays in dashboard Settings (`updateMe`). Same guard, validation and errors as `popoutDismissSeasonBanner`; idempotent. No audit event, no WebSocket push. > **Protocol parity:** matching REST endpoint `POST /v1/auth/popout/season-look/off` - see [REST API](./rest-api.md#auth). ### Types #### `PopoutSeasonLook` The represented user's seasonal-look state after a popout write (`popoutDismissSeasonBanner` / `popoutSwitchOffSeasonLook`). | Field | Type | Description | |-------|------|-------------| | `seasonLookEnabled` | `Boolean!` | Global seasonal-look switch (all seasons). | | `seasonBannerDismissed` | `[String!]!` | Dismissed season banners as `:`, oldest first (≤ 50). | > **Protocol parity:** REST twin `PopoutSeasonLookResponse` (snake_case fields) - see [REST API](./rest-api.md#auth). #### `PopoutSession` | Field | Type | Description | |-------|------|-------------| | `token` | `String!` | The popout-session JWT (`lm_...`). Store as an httpOnly cookie. | | `expiresAt` | `DateTime!` | Absolute expiry (`now + 15 min`). | | `accountId` | `UUID!` | Account the session is scoped to. | > **Protocol parity:** matching REST endpoint `POST /v1/auth/popout/exchange` - see [REST API](./rest-api.md#auth). #### `PopoutSessionIdentity` The `(accountId, userId)` pair the current popout session represents, plus the represented user's display preferences (`popoutSession` query). | Field | Type | Description | |-------|------|-------------| | `accountId` | `UUID!` | The Lumio account (`accounts.id`, `me.activeAccountId`) the session is scoped to — the "channel account", not a single platform connection. | | `userId` | `UUID!` | The user whose settings the session represents: the user the popout token is bound to. | | `locale` | `String!` | UI language preference of `userId`: `auto` · `en` · `de`. | | `timezone` | `String!` | Timezone preference of `userId`: `auto` or an IANA name. | | `dateFormat` | `String!` | Date-format preset of `userId`: `auto` · `dmy_dot` · `dmy_slash` · `mdy_slash` · `ymd_dash`. | | `timeFormat` | `String!` | Time-format preset of `userId`: `auto` · `h12` · `h24`. | | `seasonLookEnabled` | `Boolean!` | Seasonal look on/off of `userId`. | | `seasonStrength` | `String!` | `full` · `subtle`. | | `seasonMotion` | `String!` | `auto` · `off`. | | `seasonCreatures` | `JSON!` | `{ "": Boolean }` overrides; absent key = registry default. | | `seasonBannerDismissed` | `[String!]!` | Dismissed season banners as `:`, oldest first. | | `motionPreference` | `String!` | "Animations in Lumio" of `userId`: `system` · `reduced` · `off`. | The preference fields let a popout - which cannot reach the first-party-gated `me` - render language, timezone, date/time format, the seasonal look and the motion preference. They are written via `updateMe`; a popout session's only two writes are the one-way `popoutDismissSeasonBanner` / `popoutSwitchOffSeasonLook` mutations. None of them is a secret. > **Protocol parity:** matching REST endpoint `GET /v1/auth/popout/session` - see [REST API](./rest-api.md#auth). ## Account Bots The active account's bot list supports Twitch, Kick and YouTube. Reads use `chat-bots:read`; writes use `chat-bots:edit`. These account-scoped `PermissionGuard` checks match the [REST endpoints](./rest-api.md#account-bots). Automatic recognition from bot badges, Lumio's curated list and the account's own bot connections is read only. | Operation | Result | |-----------|--------| | `accountBots` | `[AccountBot!]!`, newest first. Each entry has `platform`, `platformUserId`, `login`, `displayName`, `source`, `addedBy`, `addedByName`, `addedAt`. | | `autoDetectedBots` | `[AutoDetectedBot!]!` with `platform`, `source` (`badge`, `known`, `own_connection`), `identity`, `name`. | | `addAccountBot(input: AddAccountBotInput!)` | `{ bot, created }`; supply `platform` and exactly one of `platformUserId` or `name`. A name or `@handle` must resolve on its platform before saving. An existing entry returns `created: false`. A real add starts background recalculation of retained sessions. | | `removeAccountBot(platform: String!, platformUserId: String!)` | `Boolean!`; `false` when the entry did not exist. Removal does not restore already recalculated history figures. | A real list change emits `account:bot_list_updated`. The REST twin uses snake_case fields. ## Stream History All account operations require `feature:stream_history`. The GraphQL endpoint is `POST /v1/gql`; structured operations have REST twins under `/v1/history`. The filtered-list file download is intentionally REST-only. ### Queries | Query | Permission | Result | |-------|------------|--------| | `channelHistory(page, perPage, from, to, sessionType, platform, platforms, search, category, sortBy, sortDir, currency)` | `history:read` | Paged sessions (25 by default, max 100). `viewerSparkline`, `milestones` and `platformStats` are filled when selected under `sessions`; `estimatedRevenue` is filled only when selected and allowed. | | `channelHistoryOverview(from, to, sessionType, platform, platforms, category, search, timeZone, currency, range)` | `history:read` | Totals, platforms, month totals, 371-day activity buckets, optional KPI range and newest session. `timeZone` is an IANA zone (UTC default); invalid names give `BAD_REQUEST`. `days` ignores `from`/`to` but applies other filters. Revenue fields require `history:revenue-read`. | | `channelHistoryMilestones(from, to, platforms)` | `history:read` | Up to 1,000 persisted milestones, oldest first; `from` inclusive and `to` exclusive. `platforms` keeps only those platforms' follower marks; the cross-platform records stay. Invalid reversed bounds or an unknown platform give `BAD_REQUEST`. | | `channelHistoryFilterOptions` | `history:read` | Distinct platforms and categories present in this account. | | `channelHistoryReport(id, currency)` | `history:read` | Session, streams, chart markers, `adBreaks`, `adStats`, and top lists (`topEmotes` up to 20, `topChatters`/`topGifs` up to 10). Each stream carries `tags: [String!]!`, `categoryChanges: [HistoryCategoryChange!]!` (`at`, `category`, `categoryId`) and `titleChanges: [HistoryTitleChange!]!` (`at`, `title`); the first entry is the state at stream start, YouTube returns empty lists, and the timeline is frozen at finalize. A Twitch stream with a Shared Chat also has `sharedViewersPeak` / `sharedViewersAvg` (per-bucket total of own + partners), `sharedChatSources` (JSON, snake_case keys) and `sharedChatTimeline` / `sharedChatInsights` (JSON). The timeline is event-exact and frozen at finalize; older streams fall back to minute samples. Partner insights hold term and count, without viewer identities. `session.estimatedRevenue` is `null` without revenue-read permission. `session.displayMoney` (`currency`, `donationsCents`, `superchatsCents`, `superstickersCents`, `rateDate`) converts donations, Super Chats and Super Stickers into `currency` under `history:read`; an amount without a rate is `null`. | | `channelHistoryRevenueSettings` | `history:revenue-read` | Saved assumptions or defaults, `youtubeMemberLevels`, read-only USD `fixedValues`; `updatedAt` and `updatedBy` are `null` until saved. | | `channelHistoryYoutubeRevenueStatus` | `history:revenue-read` | Account-level YouTube Analytics state without amounts: `connected`, `status`, `yppCheckedAt`, `revenueAuthVerifiedAt`. Requires `feature:stream_history`; REST twin: `GET /v1/history/youtube-revenue/status`. | | `channelHistoryYoutubeMemberLevelSuggestions` | `history:revenue-read` | Up to 50 level names seen in retained YouTube membership events, most frequent first: `name`, `events`, `lastSeenAt`, `entered`. Requires `feature:stream_history`. | | `channelHistoryStats(sessionId, platform)` | `history:read` | Time-series samples, optionally for one platform. Each sample may carry `liveCounters`; older samples return `null`. A Twitch sample during a Shared Chat carries `sharedViewers` (own + partners in that bucket, else `null`) and `sharedChatViewers: [HistorySharedChatViewers!]!` (`platformChannelId`, `viewerCount`; `[]` outside a Shared Chat, a missing partner was not in it, `viewerCount: null` = not live). | | `automodLog(sessionId, outcome, after, first)` | `history:read` **and** `chat:automod-read` | `AutomodLogPage` or `null`: the messages Twitch AutoMod held during the session, oldest first. `null` means unavailable (no Twitch broadcast in the session, or the session started before the chat retention window). `textRetentionDays` is the account's effective chat window: the entry, text included, is deleted that many days after the hold. `entries` carry `messageId`, `sender { userId login displayName avatarUrl color badges }` (look at hold time, `null` = unknown), `text` and `flaggedSpans` (code points, `end` inclusive), `emotes` (JSON, the `ChatMessage.emotes` shape: Twitch emotes and cheermotes; `null` when none; an unknown cheer stays plain text), `textState` (`stored`, or `not_stored` above the 4 KiB size cap), `reasonKind` + `reason { category level terms { termId text } }`, `outcome` (`open`, `approved`, `denied`, `expired`), `resolvedVia` (`lumio`/`twitch`), `moderatorLogin`, `moderatorName`, `heldAt`, `resolvedAt`. A hold nobody decided reads `expired` after the queue TTL, with no moderator and no `resolvedAt`. `counts { total open approved denied expired }` cover the whole session regardless of `outcome`. `first` is 1–100 (default 50); pass `nextCursor` as `after`. Popout tokens are refused. Never part of the report, share links or exports. Requires `feature:stream_history`; REST twin: `GET /v1/history/reports/{id}/automod`. | | `historySharedLinks(sessionId)` | `history:share` | Links, options, active state and expiry; never the full token. | | `historyKeywords` | `history:read` | `HistoryKeywordList!` with `keywords`, `lastSessionId`, `lastSessionStartedAt` and `maxKeywords` (50). Each entry includes `lastStreamCount`, which is `null` when unknown. | | `testHistoryKeywordPattern(pattern, match, caseInsensitive, sample)` | `history:read` | `{ valid, error, matches { start end text } }` without saving. A sample has at most 500 characters; offsets use UTF-16. At most 30 calls per minute per caller. | For an unfinalized session started within the last 48 hours, `HistorySessionSummary.live: HistoryLiveOverlay` describes running values on `channelHistory.sessions`, `channelHistoryReport.session`, public shares and `channelHistoryOverview.latestSession.session`. It has `computedAt`, nullable `sampledAt`, `samples`, `chat`, `topChatters` and nullable `youtubeSubscribers`. The state fields use `HistoryLiveValueState`: `LIVE` means the figure is running, `UNAVAILABLE` means its numeric zero or empty list must not be treated as a known value, and `AFTER_STREAM` marks YouTube subscriber gain. `youtubeSubscribers` is `null` without YouTube. The whole `live` field is `null` on finalized sessions and open sessions beyond the 48-hour overlay window. This session field is separate from `latestSession.live`, the dashboard's sampled support and viewer snapshot. The list filters are freely combinable. `from`/`to` bound `startedAt`; `sessionType` is `single` or `multi`; `search` matches session or stream titles case-insensitively; category matches either level. `sortBy` accepts `started_at`, `duration_secs`, `peak_viewers`, `avg_viewers`, `total_messages`, `unique_chatters`, `new_followers`, `new_paid_subs`, or `estimated_revenue`; `sortDir` is `asc` or `desc`. Nullable stats sort last; ties sort newest first. `estimated_revenue` orders by the revenue a row shows (the converted total in `currency`, else the raw estimate), computed over the whole filtered set before paging. Without `history:revenue-read` it is accepted but keeps the default order, so the order never reveals revenue. An invalid filter or reversed time range is rejected on both protocols. With `range: "today" | "7d" | "30d"`, `channelHistoryOverview` fills `range { key from to previousFrom previousTo }`, `current` and `previous` (each `sessions totalDurationSecs avgViewers newFollowers messages newPaidSubs giftedSubs`), and daily `kpiSeries` with the same metrics plus `date`. The time bounds use local midnights in `timeZone`; `to` is the start of the next day. The existing totals and estimated revenue cover that range, while `days` remains 371 days. `range` together with `from` or `to` is rejected. `latestSession` ignores all overview filters and contains `session`, `viewerSeries { platform points { time viewers } }` (up to 60 points per platform), `live`, and `milestones`; it is computed only when selected and is `null` if there is no session. **Platform filter (`platforms: [String!]`).** `channelHistory`, `channelHistoryOverview` and `channelHistoryMilestones` accept a list of `twitch`, `youtube` and `kick`. A missing or empty list means no filter. The list keeps every session with a broadcast on at least one selected platform. The overview also keeps only those sessions, but counts only the **share** of the selected platforms in its totals, `months`, `days`, `current`, `previous` and `kpiSeries`: - A session whose every platform is selected keeps its own values, so selecting all platforms gives the unfiltered result. - **Stream time** is the time at least one selected platform was live. - **Ø viewers** is each selected platform's stream average weighted by that platform's live time, divided by the stream time. Platforms that ran at the same time add up. The KPI average then weights each session by this stream time. - **Peak viewers** is the sum of the selected platforms' stream peaks. - **Followers, messages, Subs + Members and gifted Subs** come from the selected platforms' `platformBreakdown` entries. YouTube followers are the rounded subscriber delta. - A session or calendar day counts only when a selected platform streamed in it. - `platforms` in the result lists only the selected platforms. - `estimatedRevenue` and `months.revenue` cover the whole sessions that include a selected platform, not their share. With `platforms`, `latestSession` is the newest session with a selected platform. `latestSession.viewerTotal: [HistoryViewerPoint!]!` is the combined viewer curve (≤ 60 `{ time viewers }` points on the `viewerSeries` bucket times): per bucket the highest per-minute sum across platforms, not the sum of the per-platform maxima. The session's `viewerSeries`, `viewerTotal`, `live` (viewers, peak, messages, followers, Subs and the per-platform list) and follower marks cover only the selected platforms; tips stay session-wide. The session summary stays whole. `viewerSparkline` on the list sums only the selected platforms. Combining `platforms` with `platform` and passing an unknown platform both give `BAD_REQUEST` with the same message as REST (`` `platforms` cannot be combined with `platform` ``, `` `platforms` must only contain: kick, twitch, youtube ``). `HistorySessionSummary.sharedChat: HistorySessionSharedChat` is the Twitch Shared Chat marker of a list row; `null` means the session never ran Shared Chat. It has `hostBroadcasterId` (the host of the newest or running Shared Chat, `null` when unknown), `active` (a Shared Chat of the live session is running now), `channelCount` and `channels: [HistorySessionSharedChatChannel!]!` (`platformChannelId`, `login`, `displayName`, `avatarUrl`, `isHost`, `isOwnChannel`; host first, then the own channel, then join order). Sessions stored before the Shared Chat timeline list only the partner channels and have no host. `channelHistory` computes it only when selected, with at most two queries per page; it is `null` elsewhere. REST twin: `shared_chat` (`include=shared_chat`), same `history:read` guard. `channelHistory` and `channelHistoryOverview` accept `sharedChat: String` with `WITH` or `WITHOUT` (case-insensitive). It filters live and finalized sessions, overview tiles, trend, heatmap and export. An invalid value has the same bad-request message as REST `?shared_chat=`. Filter options expose `hasSharedChat`; no extra permission applies beyond the history read guard. `HistorySessionSummary.platformStats: [HistoryPlatformStats!]!` gives each platform's part of a session, in platform order: `platform`, `durationSecs` (the time it was live), `peakViewers`, `avgViewers`, `newFollowers`, `messages`, `paidSubs` and `giftedSubs`, with the same definitions as the filter for one platform. A single-platform session carries its own values. It is computed on `channelHistory` only when selected, always on `channelHistoryOverview.latestSession.session`, and is empty elsewhere. REST twin: `platform_stats` (`include=platform_stats`). `latestSession.live` contains `sampledAt`, `currency`, `viewerCount`, `peakViewers`, `messagesTotal`, `chatPerMin`, `newFollowers`, `paidSubs`, `giftedSubs`, `bits`, `kicks`, `superchats`, `superstickers`, `tips`, and per-platform figures. Each money kind has `count`, `byCurrency` (JSON currency-to-cents map), `unknownCents`, and `convertedCents` in the requested display currency; conversion is `null` if any part lacks a currency or rate. Tips are session-wide. `HistoryStatSample.liveCounters` keeps gifted Subs, Bits, KICKs, Super Chats, Super Stickers and tips separately, with money kinds as `count`, `byCurrency` and `unknownCents`. `chatPerMin` uses the two latest samples; live figures begin at the first sample. `latestSession.sharedChatSeries: HistorySharedChatSeries` has `total` (the per-bucket highest concurrent sum of own Twitch and partner viewers) and `channels` (one partner curve each, `platformChannelId`, name, avatar and `points`). Its buckets and times match `viewerSeries`; a partner without a known count has a gap. It is `null` without Shared Chat, when Twitch is excluded, or after the raw samples age out. REST: `latest_session.shared_chat_series`. `latestSession.live.sharedChat: HistoryLiveSharedChat` supplies `hostBroadcasterId`, ordered `participants`, `partnerViewers`, `totalViewers`, `partnerMessages`, `sessionChatPerMin` and `source` (`state` or `sample`). `sessionChatPerMin` is the own plus partner message rate over the two latest Twitch samples; it is `null` until a second sample exists and never falls back to the own `chatPerMin`. REST uses the corresponding snake_case fields under `latest_session.live.shared_chat`. `HistoryMilestone` has `id`, `sessionId`, `kind`, `platform`, `value`, `previous` and `achievedAt` (session start). `kind` is `viewer_record`, `sub_record`, `biggest_raid` or `follower_mark`; `platform` is set for follower marks. `channelHistoryReport.session.milestones` is always populated, while a list computes the field only when selected. Records require an earlier, lower value. The REST twins use snake_case fields and `include=milestones` for the list. Every session (list, report and public share) carries `botMessages: Int`: the chat messages left out of `totalMessages`, `uniqueChatters`, the per-platform counts, the top lists and the emote/GIF counts because a bot wrote them (bot badge, known bot, or the account's own bot account; see [Stream History → Bot messages](../features/stream-history.md#bot-messages)). `null` means unknown: the live chat calculation is unavailable, or the session was finalized before bot filtering and its chat log is gone. A live session with a completed chat calculation has a running count. REST twin: `bot_messages`. `viewerSparkline` contains at most 32 total-viewer points, or `null` when there are no samples. It uses raw samples when present and a 5-minute rollup for older sessions. The revenue object contains `currency`, `totalCents`, `lines`, `unpriced`, `actuals`, `grandTotal`, and `averages`. Each line has a converted `HistoryMoney` when a rate exists. `HistoryMoney` has `amountCents`, `currency`, `sourceAmountCents`, `sourceCurrency`, `rate`, `rateDate`, and `convertible`. `grandTotal` separates estimated and actual cents and lists `notConvertible` amounts. Values without an ECB rate within seven days stay in their source currency and are excluded from converted totals. The optional `currency` defaults to the user's effective display currency. `channelHistoryReport` also carries `links: [HistoryLink!]`, `commands: [HistoryCommand!]` and `keywords: [HistoryKeywordEntry!]` (see [Stream History → Links and commands](../features/stream-history.md#links-and-commands)), each at most 50 entries by count and `null` when unknown (no frozen list and no complete chat log) or not shared. `HistoryLink` has `url` (normalized), `display` (as first posted), `domain`, `count`, `firstSeenAt`, `lastSeenAt`, `posters: [HistoryInsightPoster!]!` (top 5: `platform`, `userId`, `login`, `displayName`, `count`, `isBot`, `erased`), `otherPosters`, `occurrences: [HistoryLinkOccurrence!]!` (first 100, oldest first: `at`, `platform`, `userId`, `login`, `displayName`, `isBot`, `erased`) and `occurrencesTruncated`. `erased: true` marks an erased chatter: `userId` is `null`, `login` and `displayName` are empty — render "Deleted user". The `topChatters` JSON carries the same `erased` key on erased entries. Bot posters are included and flagged. `HistoryCommand` has `name` (prefix + lower-cased name, e.g. `!discord`), `prefix`, `count`, `uniqueUsers`, `firstSeenAt`, `lastSeenAt`, `posters`, `otherPosters`, `topArgs: [HistoryCommandArgs!]!` (top 5 `args` + `count`, `""` = no arguments), `occurrences: [HistoryCommandOccurrence!]!` (as for links, plus `args`), `occurrencesTruncated` and `known` (an enabled Lumio bot command). Bot authors are left out of commands, so `isBot` is always `false` there. REST twin: `links` / `commands` in snake_case. `HistoryKeywordEntry` has `keywordId`, `keyword`, `match`, `caseInsensitive`, `count`, `botCount`, `uniqueUsers`, `firstSeenAt`, `lastSeenAt`, top `posters`, `otherPosters`, up to 100 `occurrences` and `occurrencesTruncated`. Every tracked keyword gets a row, even with count zero. Bot posts count and are flagged; a matching leading command token counts as a keyword rather than a command. REST returns `keywords` with snake_case fields. `channelHistoryReport` also has `announcements: [HistoryAnnouncement!]` and `pins: [HistoryPin!]`, oldest first (at most 500 and 200). `announcementsCaptured` and `pinsCaptured` distinguish sessions that began before capture from an empty list; `announcementsTruncated` and `pinsTruncated` signal the caps. `null` means unknown or a section not shared. An announcement includes its colour, author, optional Shared Chat source and deleted state; a pin includes sender, pinned-by person, planned duration, changes, actual duration, approximate-time flag and `endReason` (`expired`, `unpinned`, `replaced`, `message_deleted`, `stream_end`, `ran_past_stream_end`, `unknown`, `active`). People carry avatar, name and bot status. Neither list freezes message text: `text` and `emotes` resolve from retained chat data (pins can use the retained pin event), while `textStored: false` calls for **Text no longer stored**. The lists are masked for erased or objecting people. Announcements count as ordinary messages in chat totals and keyword insights. REST uses snake_case twins. `adBreaks` contains `startedAt`, `durationSecs`, `automatic`, `viewersStart`, and `viewersAfter`. `adStats` contains break counts, total/average/min/max seconds, minutes per hour, the three-minute threshold, estimated impressions, and average viewer change. These are report metrics under `history:read`. `HistoryRevenueSettings` exposes `currency`, `twitchShareBps`, `kickShareBps`, `twitchAdCpmCents`, `twitchAdShareBps`, `twitchAdFreeViewerBps`, `kickAdCpmCents`, `kickAdShareBps`, `kickAdFreeViewerBps`, `youtubeAdCpmCents`, `youtubeMidrollsPerHour`, `youtubeAdShareBps`, `youtubeAdFreeViewerBps`, `youtubeShareBps` (Super Chats), `youtubeSuperstickerShareBps`, `youtubeMembershipShareBps`, `youtubeMemberLevels { name priceCents }`, `fixedValues { item amountCents currency share source sourceUrl note }`, `updatedAt` and `updatedBy`. Fixed values are USD cents for Twitch tiers, Prime and Bits and Kick subs and KICKs; there are no editable Twitch or Kick unit-price fields. The input has the same editable fields without `fixedValues` or update stamps. The three YouTube creator shares default to 7,000 basis points when omitted; `youtubeMemberLevels` omitted keeps the stored list, while an empty list clears it. Kick and YouTube ad fields keep their stored value when omitted; explicit `null` clears `kickAdCpmCents`, `youtubeAdCpmCents` or `youtubeMidrollsPerHour`. `twitchAdCpmCents: null` also leaves Twitch ads without an amount. Level names are trimmed, unique ignoring case, non-empty and at most 100 characters; at most six levels are accepted. The REST twin uses snake_case names and identical validation. `channelHistoryYoutubeRevenueStatus.status` is `not_connected`, `not_granted`, `not_in_ypp`, `api_disabled`, `unchecked`, or `ok`; the query never returns amounts. On an owned report, `session.estimatedRevenue.youtubeAnalytics` contains `status` (`not_granted`, `not_in_ypp`, `api_disabled`, `pending`, `provisional`, or `final`) and nullable `fetchedAt`. It is `null` without a YouTube stream, on shared reports, and on the overview. `pending` keeps the Lumio estimate; `provisional` and `final` replace YouTube ads, memberships, Super Chats and Super Stickers with `youtube_analytics_ads`, `youtube_premium`, and `youtube_transactions`. The latter is derived from YouTube's reported total; their `note` is `youtube_analytics` or `derived_from_analytics`. These fields and values need `history:revenue-read` and `feature:stream_history`. Public share links and exports use only the Lumio estimate, including the total. ### Mutations | Mutation | Permission | Result | |----------|------------|--------| | `updateChannelHistoryRevenueSettings(input: HistoryRevenueSettingsInput!)` | `history:revenue-edit` | Writes the account's editable currency, shares, ad assumptions and YouTube membership levels. The fixed USD prices are read-only. Currency must be EUR or an ECB currency; shares are 0–10000 basis points; monetary inputs are 0–1,000,000 cents; mid-rolls are 0–60 per hour. Requires `feature:stream_history`. | | `exportHistoryReport(sessionId, format, currency, locale, timeZone)` | `history:export` | Returns `{ downloadUrl, filename, contentType, format }` for a finalized report. Formats: `csv`, `txt`, `json`, `pdf`; bytes come from REST. | | `createHistorySharedLink(sessionId, durationSecs, password, allowExport, options)` | `history:share` | Creates a link (up to 20 active per report); returns plaintext token and URL once. `options` accepts `sections` (`chat`, `top_chatters`, `ads`, `revenue`, `links`, `commands`, `keywords`, `announcements`, `pins`; these five are off unless listed), `includeRevenue`, and `currency`. Revenue inclusion also requires `history:revenue-read`. | | `addHistoryKeyword(input: HistoryKeywordInput!)` | `history:keywords-edit` | `{ keyword, created, changed }`; a duplicate term and mode is a no-op. | | `updateHistoryKeyword(id: UUID!, input: HistoryKeywordInput!)` | `history:keywords-edit` | `{ keyword, created, changed }`; `changed: false` for an unchanged entry. | | `removeHistoryKeyword(id: UUID!)` | `history:keywords-edit` | `Boolean!`; `false` when the entry did not exist. | | `extendHistorySharedLink(linkId, durationSecs)` | `history:share` | Extends expiry. A link that shares revenue also needs `history:revenue-read` (`FORBIDDEN`, same message as REST). | | `revokeHistorySharedLink(linkId)` | `history:share` | Revokes the link. | | `deleteChannelHistory(id)` | `history:delete` | Deletes a finalized session. A session that is not finalized yet (live, in its grace window or waiting for the finalizer) is refused with `CONFLICT` `This stream session is still live; it can be deleted once it is finalized` (same code and text as REST). | A keyword input has `keyword`, `match` (`word`, `contains`, `regex`) and optional `caseInsensitive` (true by default). The account may track 50 entries; text modes allow 100 characters, regex 200 characters with a 1 MiB compile limit and no empty-text match. Real changes emit `account:history_keywords_updated` and recount finalized sessions with complete chat logs in the background. A new share link defaults to chat and ads, without top chatters or revenue. A link created without `options` shows the legacy view (chat and top chatters; no ads, revenue, links, commands, keywords, announcements or pins — ad data needs the creator to turn on `ads`). `HistorySharedLink.options` describes the effective view and `legacyOptions` identifies that case. Durations are 1h, 6h, 24h, 7d, or 30d. A live report cannot be exported (`409` on REST); invalid formats and unknown sessions use the same validation as REST. ## Public Stats Per-channel publicness for the public Stats app, gated behind the `feature:public_stats_page` flag. Publicness is **opt-out / public-by-default**: a channel with no settings row is public. Mirrors the REST `/v1/public-stats/channels` endpoints exactly (same fields, guards, validation and errors). ### Queries | Query | Permission | Description | |-------|-----------|-------------| | `channelPublicSettings` | `public-stats:read` | The account's connected channels with their publicness settings (`platform`, `platformChannelId`, `channelName`, `isPublic`, `showTopChatters`, `optedOutVia`, `updatedAt`). Channels default to `isPublic: true` (opt-out) but `showTopChatters: false` (leaderboard opt-in, Art. 25(2)) until changed. | ### Mutations | Mutation | Permission | Description | |----------|-----------|-------------| | `setChannelPublicSettings(input: { platform, isPublic, showTopChatters })` | `public-stats:edit` | Toggle a channel's publicness (opt-out / opt-in) and chatter-leaderboard suppression. The channel is resolved to the caller's own connected channel for `platform` (the channel id is never taken from the client). No connection → error; an unresolved channel identity → error. Emits a `public_stats:channel_opted_{out,in}` audit event. | ### Public read model (unauthenticated) The read surface the public Stats app consumes. **No authentication, no permission** - reachable anonymously - and **no per-user data**, so responses are cache-friendly. Gated globally by the `system:public_stats` kill-switch flag (disabled → "not available"). Mirrors the REST `/v1/public-stats/{channels,streams}` endpoints exactly. Serves **only** the §5 public-safe field set; there is no field for donations, subscriber/gifter identities, revenue splits, or the raw `platformMetrics` / `platformBreakdown` JSONB. First-party and crawled rows use the same types, distinguished only by the `source` badge. | Query | Permission | Description | |-------|-----------|-------------| | `publicChannelStats(platform, channel)` | none - public | A channel's public aggregate by platform + URL handle (`channel` = `channelLogin`): `platformChannelId`, `channelDisplayName`, `source`, `streamCount`, `totalDurationSecs`, `peakViewers`, `avgViewers`, `totalMessages`, `lastStreamedAt`, `broadcasterType` (Twitch channel type: `"partner"` / `"affiliate"`, else `null` - no badge; ZAF-841), and `recentStreams` (public stream summaries). Returns `null` when the channel has no addressable streams or has **opted out** (`isPublic = false`). Unsupported platform → error. | | `publicStreamStats(platform, broadcastId)` | none - public | One stream by its public key `(platform, broadcastId)`: the stream summary, `emoteCount`, `topEmotes`, `gifCount` **and** `topGifs` (ZAF-968 - `topGifs` is `[{id, url, provider, alt, count}]`, the GIF twin of `topEmotes`; on the public-stats crawler path `provider`/`alt` are `null`, and both are erased after the 90-day GIF-content retention), de-identified `newPaidSubs` / `giftedSubs` **counts**, `newFollowers`, the `viewerCurve` timeseries, and `topChatters` **only** when the channel opted the leaderboard in (`showTopChatters = true`) - otherwise `null`. Erased and objecting chatters (`chatter_suppressions`) are blanked in `topChatters` on read (`erased: true`, count kept), also on streams frozen before the objection; if that lookup fails, `topChatters` is `null`. Returns `null` when the stream is unknown or its channel opted out. | Every public stream summary (the `publicStreamStats` summary and the `publicChannelStats.recentStreams` items) carries `sharedChatSources` (ZAF-819): a list of source-channel message counts. It also carries `sharedChatSessions: [PublicSharedChatSession!]!`: the public host and participation timeline for first-party streams. Crawled streams without EventSub session events return an empty list. The timeline contains no viewer counts or identities of individual viewers. REST twins are `shared_chat_sources` and `shared_chat_sessions`. `sharedChatSources` is a list of `{ platformChannelId, messageCount, displayName, avatarUrl }` naming which **source channels** contributed Twitch **Shared Chat** messages to that broadcast. `displayName` + `avatarUrl` (ZAF-868) are the source channel's public identity, resolved from the crawler channel-profile store so the participant list can render source avatars/names; both are `null` when the anonymous crawler never captured that channel's identity (fail-open - the client falls back to the id). Shared-chat messages are attributed to their source channel instead of being counted toward the host, so `messageCount` / `uniqueChatters` reflect only the host channel's own chat; the list is empty for a normal (non-shared) broadcast. Attribution is by **channel** (public broadcaster identity), never by viewer - aggregates only. | `publicChannelProfileHistory(platform, channel, limit, offset)` | none - public | A channel's **About-box change timeline** (ZAF-836) - bio / social-link / team changes over time, most-recent first, paginated (`limit` default 50, max 200; `offset` default 0). Each entry: `changeType` (`bio` / `link` / `team`), `oldValue` / `newValue` (opaque JSON - a string for `bio`, an array for `link` / `team`; `null` old = initial capture, `null` `bio` new = deletion), and `changedAt`. Returns `null` when the channel has no addressable streams or has **opted out**; an empty list when it has no recorded changes. Public data (founder ruling ZAF-791): no §5 minimisation, no retention cap. | | `publicStreamMetadataHistory(platform, broadcastId, limit, offset)` | none - public | A broadcast's **stream-metadata change timeline** (ZAF-890) - title / category / tags changes over time, most-recent first, paginated (`limit` default 50, max 200; `offset` default 0). Each entry: `changeType` (`title` / `category` / `tags`), `oldValue` / `newValue` (opaque JSON - a string for `title` / `category`, an array of tag strings for `tags`; `null` old = initial capture, `null` new = emptied field / all tags removed), and `changedAt`. Returns `null` when the stream is unknown or its channel has **opted out**; an empty list when the broadcast has no recorded changes. Public data (founder ruling ZAF-791/ZAF-794): no §5 minimisation, no retention cap. | | `publicChannelTrends(platform, channel, range)` | none - public | A channel's **"Stats"-tab trends** (ZAF-891) over the selected `range` (`DAYS_30` / `DAYS_90` / `DAYS_180` / `DAYS_365`): `followerSeries` (dated follower-total growth) and `avgViewerSeries` (dated average-viewers-over-time). Each is an oldest-first list of `{ date (`YYYY-MM-DD`, UTC), … }` day points - `followerSeries` items carry `followerCount`, `avgViewerSeries` items carry `avgViewers` (the exact sample-weighted daily mean). Follower points come from dated crawler snapshots (last value per day); avg-viewer points from the `channel_history_stats_1h` aggregate scoped to the channel's sessions. Returns `null` when the channel has no addressable streams or has **opted out**; either series is an empty list when the channel has no history in the window (frontend renders "not enough history"). Aggregate counts only - no viewer identities. | | `publicChannelPanelHistory(platform, channel)` | none - public | A channel's **ad-panel lifecycle + image-change history** (ZAF-892) for the "Placements" tab - the ad-marked panels grouped by panel, most-recently-active first. Each entry: `panelId`, latest content (`title` / `description` / `imageUrl` / `linkUrl`), full span (`firstSeenAt` / `lastSeenAt`), lifecycle (`status` = `"active"` / `"removed"`, `removedAt`), and `images: [{ imageUrl, firstSeenAt, lastSeenAt }]` - the distinct image versions over time, oldest-first (the slideshow). Returns `null` when the channel has no addressable streams or has **opted out**; an empty list when it has no ad-marked panels. Artwork hotlinked from the Twitch CDN (never re-hosted). The flat `publicChannelStats.panels` also carries the `status` / `removedAt` lifecycle fields. | ### Sitemap and share-card queries These three queries are anonymous. Their only gate is the account-less `system:public_stats` kill switch, checked on every call. When it is off, they return `0`, `[]`, and `null` respectively, without an authorization error. The REST twins use the same service. There is no WebSocket channel because a sitemap and a share card have no live-update consumer. | Query | Result | |-------|--------| | `publicSitemapChannelPageCount: Int!` | Number of zero-based channel sitemap pages, `ceil(candidate channels / 45000)`. The count does not subtract opt-outs, so the final page can be short or empty. | | `publicSitemapChannels(page: Int!): [PublicSitemapChannel!]!` | Up to 45,000 channel pages, ordered by platform and stable channel ID. Each entry has `platform`, `channelLogin`, and `lastmod` (UTC `YYYY-MM-DD`, from the most recent stream start). A page beyond the count returns `[]`; a negative page errors with `page must not be negative`. | | `publicChannelCard(platform: String!, platformChannelId: String!): PublicChannelCard` | Share-card data by stable platform channel ID, independent of the URL handle. An unknown or opted-out channel returns `null`, the same as a disabled surface. An unsupported platform errors with `Unsupported platform:

`; an ID outside 1–64 ASCII letters, digits, `_` or `-` errors with `Invalid platform channel id`. | The sitemap lists **channel pages only** for server-allowlisted platforms (`public_stats.sitemap_platforms`, default `twitch,kick`) with a non-null login and a stream started within the last 12 months. Opt-outs are removed at read time. Clients cannot widen the allowlist. The card also checks visibility on every hit; unknown and opted-out channels cannot be distinguished by its answer. `PublicChannelCard` has exactly these 13 fields: `platform`, `platformChannelId`, `channelLogin`, `channelDisplayName`, `isLive`, `currentViewers`, `followerCount`, `streamCount`, `avgViewers`, `peakViewers`, `watchSeconds` (viewer-seconds), `lastCategory`, and `lastStreamedAt` (UTC timestamp). The current `channelLogin` is the channel page's canonical handle. Unavailable figures remain `null`, never zero. The card contains no chatter identities, bio, links, team, or change history. ### Cross-channel browse (Form B) Public, **unauthenticated** browse/ranking **across all channels** - the metric nav, a Games directory, and channel search - gated only by the account-less `system:public_stats` kill-switch. Opted-out channels are excluded. GraphQL + REST only (no WebSocket: a browse/search directory has no live-push consumer). | Query | Permission | Description | |-------|-----------|-------------| | `publicChannelBrowse(platform, category, metric, live, search, page, limit): PublicChannelBrowsePage!` | none - public | Ranked channel list. `metric` (enum, default `WATCHTIME`): `WATCHTIME` (Σ avg-viewers·duration), `VIEWERS` (avg), `PEAK`, `FOLLOWER_GAIN` (Σ new-followers), `MESSAGES`, `RECENT`. Optional filters: `platform`, `category` (Games), `live` (`true`/`false`), `search` (case-insensitive substring on handle + display name). `page` 1-based; `limit` clamped `1..=100` (default 50). Each item: identity, `source` badge, `lastCategory`, `isLive`, `streamCount`, `watchSeconds`, `avgViewers`, `peakViewers`, `totalMessages`, `followerGain`, `lastStartedAt`, `broadcasterType` (Twitch channel type: `"partner"` / `"affiliate"`, else `null`; ZAF-841). Empty-string filters are treated as absent. Page fields: `page`, `limit`, `total`, `totalPages`, `totalCapped` - pages stop at row 10 000 (`page` is clamped), `total` counts at most 10 000, and `totalCapped` is `true` when more channels match (render "10 000+"). | | `publicGames(platform, search, page, limit): PublicGamesPage!` | none - public | Games directory: distinct categories with `channelCount`, `streamCount`, `liveStreamCount`, `peakViewers`, `watchSeconds`, `lastStartedAt`. Optional `platform` filter and `search` (case-insensitive substring on category). Page-based pagination with the same 10 000-row depth cap and `totalCapped` flag as `publicChannelBrowse`. | ### Cross-channel placements (`/partners`) Public, **unauthenticated** browse of the **active, broadcaster-marked advertising panels** of every public channel, grouped by **advertiser** - the panel link's host (lower-cased, leading `www.` stripped). Same gate as the other public Stats reads (`system:public_stats` kill-switch), opted-out channels excluded, artwork and avatar fields carry the Twitch CDN URLs (the Stats page shows them through its [image proxy](../developer-guide/image-proxy.md)), no viewer data. GraphQL + REST only - the only `public-stats` WebSocket channel is the per-broadcast live read; placements change per crawl, so there is no live-push consumer. | Query | Permission | Description | |-------|-----------|-------------| | `publicPlacementsBrowse(platform, domain, sort, after, limit, placementsLimit): PublicPlacementsPage!` | none - public | Advertisers with their active placements. `platform` defaults to `twitch` (the only supported value - anything else is a validation error `Unsupported platform: …`). `domain` restricts to one advertiser (normalized like the link host). `sort` (enum, default `CHANNEL_COUNT`): `CHANNEL_COUNT` (most channels first) or `LAST_SEEN` (most recently seen first); ties by `domain`. **Keyset pagination**: pass the previous page's `nextCursor` as `after`; a malformed cursor or one from the other `sort` is rejected (`Invalid cursor`). `limit` clamped `1..=100` (default 25), `placementsLimit` `1..=50` (default 10). Page: `advertisers`, `nextCursor` (`null` on the last page), `hasMore`. Advertiser: `domain`, `channelCount`, `placementCount`, `firstSeenAt`, `lastSeenAt`, `placements` (most recently seen first, at most `placementsLimit`). Placement: `platform`, `platformChannelId`, `channelLogin`, `channelDisplayName`, `avatarUrl`, `panelId`, `title`, `imageUrl`, `linkUrl`, `firstSeenAt` (earliest across the panel's versions), `lastSeenAt`. Only the latest version of a panel counts, removed panels and panels without an `http(s)` link are skipped, and only channels with an addressable Stats page are included. | ## Emote Directory Public, **unauthenticated** browse of the cross-platform emote catalog (`chat_emotes`) for the public Stats app. Read-only and edge-cacheable, gated **only** by the account-less `system:public_stats` operator kill-switch - no auth and no RBAC permission. Mirrors the REST `GET /v1/public-stats/emotes` endpoint exactly (same fields, filters, pagination and errors). There is **no** WebSocket channel: a static browse / search surface has no live-push consumer (a deliberate single-protocol omission). ### Queries | Query | Auth | Description | |-------|------|-------------| | `publicEmotes(platform, provider, channelId, animated, search, page, limit): PublicEmotePage!` | none (public) | Paginated browse of the emote catalog. All filters optional: `platform` (`twitch`/`youtube`/`kick`), `provider` (the platforms plus `7tv`/`bttv`/`ffz`), `channelId`, `animated`, and `search` (case-insensitive substring on `name`). `page` is 1-based; `limit` is clamped to `1..=100` (default 50). Empty-string filters are treated as absent. When the kill-switch is off the query errors with `Feature 'system:public_stats' is not available`. | ### Types - `PublicEmote { id, platform, provider, channelId, name, url, animated, ownerId, source, firstSeenAt, lastSeenAt }` - a public-safe projection of a `chat_emotes` row. `channelId` is `null` for a global (platform-wide) set; `source` is `api_fetch` | `observed`; `firstSeenAt` / `lastSeenAt` are RFC-3339. The internal `providerEmoteId` is **not** exposed. - `PublicEmotePage { emotes, page, limit, total, totalPages }` - a page of `PublicEmote` with page-based metadata (identical to the REST `pagination` object). Caching: the browse is public reference data. The REST mirror sets edge-cache headers; the GraphQL surface (a `POST`, not edge-cacheable) is cached at the SSR layer of the public Stats app. ## Audit Log Three scoped readers over the `audit_events` table, one per `scope` column value (`user` | `account` | `system`). The scope is set by the writer and never inferred at read time, so each reader returns only its own audience's rows. ### Queries | Query | Permission | Description | |-------|-----------|-------------| | `myAuditLog(filter)` | none - self-only | The caller's own `scope=user` rows. No RBAC permission, but requires a **first-party principal** - a logged-in session or the user's own API key; a popout/overlay/widget/extension token cannot read it. Current writers record successful logins, failed login attempts tied to a known user, and OAuth grant consent. | | `accountAuditLog(filter)` | `audit-log:read` | The active account's `scope=account` events. Current emitters write role/permission, member-role, channel-connection, and account-scoped GDPR erasure rows. Requires the **account-scope** `audit-log:read` permission and returns only the caller's account's rows. | | `adminAuditLog(filter)` | `audit-log:read` (admin) | The operator audit view. Guarded by the **admin-scope** `audit-log:read`, a distinct permission from the account one of the same name. Accepts an optional `scope` filter to narrow the superuser view to `user`, `account`, or `system`; without it, admins can read rows from every scope. | ### Types - `AuditEvent { id, accountId, userId, eventType, scope, ipAddress, userAgent, country, city, metadata, createdAt, resolvedNames }` - `metadata` is a JSON string; `scope` is `user` | `account` | `system`. - `AuditLogPage { items, total, page, limit }` - a page of `AuditEvent`. - `AuditLogFilter { page, limit, eventType, dateFrom, dateTo }` - `dateFrom` / `dateTo` are RFC-3339; an unparseable value is ignored. - `ResolvedName { id, kind, displayName }` - a read-time resolution of one entity UUID the event references to its display name (`kind` is `user` | `account` | `role`). `resolvedNames` is computed once per page for the actor plus an allowlist of metadata ids (`target_user_id`, `member_id`, `from_account_id`, `to_account_id`, `target_account_id`, `role_id`); **misses are omitted** (a deleted/unknown entity keeps only its raw UUID). The raw UUIDs are never removed - `resolvedNames` is additive. Identical field on the admin `AdminAuditEvent` and the REST `AuditEventResponse` (ZAF-1233). Scope isolation is enforced by the reader: `myAuditLog` never returns `account` or `system` rows, and `accountAuditLog` never returns another account's rows. **Overlap rule:** an account-context action that has an actor lives **only** in the account log (the actor is shown), never additionally in the user log; personal-security events live **only** in the user log. ## Account Sessions First-party session management for the logged-in user - the GraphQL twin of the REST endpoints under `GET`/`DELETE /v1/users/me/sessions`. All three require a **first-party principal** (a logged-in session or the user's own API key; a popout/overlay/widget/extension token is rejected), guarded by `AuthGuard` + `require_first_party_user` rather than an account-scope permission - session rows are keyed on the acting user, not the active account (ZAF-516). ### Queries | Query | Description | |-------|-------------| | `sessions` | The caller's active (non-expired) sessions. | ### Mutations | Mutation | Description | |----------|-------------| | `deleteSession(id)` | Revoke one of the caller's own sessions. Emits `user:session_revoked`. | | `deleteAllOtherSessions` | Revoke every session except the current one. Emits `user:session_revoked` with `revoked_all: true`. | ### Types - `Session { id, userId, activeAccountId, activeAccountName, ipAddress, userAgent, expiresAt, createdAt, isCurrent }` - `activeAccountName` is the display name of the account the session currently has selected (`activeAccountId`), resolved by a `LEFT JOIN accounts` (ZAF-1418); it is `null` whenever no account is selected (API-key / fresh sessions, or after the account is dissolved), and the LEFT JOIN keeps such rows in the list. Parity with the REST `SessionResponse.active_account_name` (both protocols carry the same `active_account_id` + `active_account_name`). `isCurrent` is `true` for the one row the request is authenticated with (resolved from the session id on the principal); it lets the client render the "Current session" badge and hide the revoke button on the caller's own session. Parity with the REST `SessionResponse.is_current` (ZAF-1412) - a non-session principal leaves every row `false`. `ipAddress` / `userAgent` are first-party disclosure, readable only by the session's owner. ## Real-time Updates Lumio does not expose a GraphQL `Subscription` type. For real-time updates (events, chat, overlay changes), use the channel-based WebSocket at `/v1/ws` - see [WebSocket](./websocket.md). ### Passkey and 2FA inputs Passkey sign-in and assertion challenge queries return `challengeId` and browser `options`. Send the browser's `PublicKeyCredentialJSON` answer as `response` with that `challengeId`. `startPasskeyRegistration` returns browser `options` and, for the first method, `recoveryCodes`; `finishPasskeyRegistration` takes the browser `response` without a separate challenge ID. `verifyMfaChallenge`, `stepUpMfa`, `removeMfaFactor`, `disableMfa` and `regenerateRecoveryCodes` accept `passkeyChallengeId` plus `passkeyResponse` from `stepUpPasskeyOptions` (or `passkeyMfaOptions` for the pending sign-in). A second factor can also be an app `code` or `recoveryCode`. `mfaStatus.factors` reports `id`, `kind` (`totp` or `passkey`), `label`, `createdAt`, `lastUsedAt`; passkeys additionally report `provider`, `backupEligible` and `backupState`. Errors include `INVALID_MFA_FACTOR_LABEL`, `RECOVERY_CODES_NOT_STORED`, `PASSKEY_NOT_SET_UP`, `PASSKEY_INVALID`, `PASSKEY_CHALLENGE_EXPIRED`, `PASSKEY_ALREADY_REGISTERED` and `MFA_FACTOR_LIMIT`. See [REST security routes](/api-reference/rest-api#profile-security-and-data-routes) for the equivalent paths and field casing. --- ## API Reference / WebSocket Source: https://docs.lumio.vision/api-reference/websocket # WebSocket Real-time communication via WebSocket using a channel-based pub/sub protocol. ## Connection | Environment | URL | |-------------|-----| | Production | `wss://api.lumio.vision/v1/ws` | | Production Preview | `wss://lumio.api.prod.zaflun.dev/v1/ws` | | Staging | `wss://lumio.api.staging.zaflun.dev/v1/ws` | The endpoint is an HTTP `GET /v1/ws` request that is upgraded to a WebSocket connection. All fields in both directions use `snake_case` JSON. ## Protocol Lumio uses a custom JSON-message channel-subscription protocol. Clients subscribe to named channels (e.g. `events:{account_id}`, `overlay:{key}`) and receive messages pushed by the server whenever workers publish to the matching Redis pub/sub channel. Lumio does **not** use `graphql-ws` and does **not** expose GraphQL subscriptions (`EmptySubscription`). All real-time updates flow through this WebSocket. ## Authentication Auth is resolved by the same middleware that guards REST and GraphQL. Two transport mechanisms are supported, and **what they accept differs**: | Token type | `Authorization: Bearer …` header | `?token=…` query param | Rate limit | |------------|----------------------------------|------------------------|------------| | JWT (`lm_eyJ…`) | ✓ | ✓ | 600/min | | User API Key (`lm_usr_…`) | ✓ | ✗ | 1200/min | | System Key (`lm_sys_…`) | ✓ | ✗ | unlimited | | Popout Token (`lm_pop_…`) | ✗ | ✓ | 600/min | | Overlay Token (`lm_overlay_…`) | ✓ | ✓ | 600/min | | Shared Overlay Token (`lm_share_…`) | ✓ | ✓ | 600/min | | Extension Token (`lm_ext_…`) | ✓ | ✓ | 600/min | | Widget Token (`lm_widget_…`) | ✓ | ✓ | 600/min | | Anonymous | (no token sent) | (no token sent) | 120/min | API keys and System keys go **only** through the header. Query strings end up in server access logs, browser history, and `Referer` headers - long-lived credentials must not be passed there. JWTs are short-lived (~15 min), Popout tokens are revocable, and Overlay tokens are revocable/rotatable, so they're acceptable as query-param transport for browser `WebSocket` clients (which can't set custom headers natively). Rate-limit tiers are fixed per auth context, not derived from the account's plan. ### Presented-but-invalid credentials The distinction the middleware draws is **"credential presented and rejected"** vs. **"no credential"** - not "anonymous allowed, yes or no": - **No token** (neither header nor `?token=`) → the connection is `Anonymous`. Public, overlay and widget channel paths are unaffected. - **A token is presented but fails validation** - an expired or bad-signature JWT, a `refresh`/legacy JWT on the request path, or a JWT whose backing session was revoked (logout) - the **WebSocket upgrade is rejected with HTTP `401`** *before* the `101` switch, and no session is started. The 401 body is the standard error envelope `` `{ "error": "TOKEN_EXPIRED" | "UNAUTHORIZED", "message": "…" }` `` - `TOKEN_EXPIRED` when the JWT is expired, `UNAUTHORIZED` for an invalid signature, a wrong token type, or a revoked session. Previously a presented-but-invalid token was silently downgraded to `Anonymous`: the upgrade returned `101`, then the first `chat:*` / account-scoped subscribe failed with `UNAUTHORIZED` while the socket stayed open - indistinguishable from a healthy connection. Clients should treat the `401` as "re-mint the token and reconnect" (Lumio's own clients do; see [Client reconnection](#client-reconnection)). > **Scope:** > On **REST and GraphQL** routes a presented-but-invalid credential still resolves to `Anonymous` (unchanged), so public routes with a stale cookie or header keep working; only the `/v1/ws` upgrade fails closed today. Either way the rejection is logged once, distinguishably from a genuinely anonymous request (`auth_type: rejected`, no token material): at `debug` for an expired JWT, which the client renews on `TOKEN_EXPIRED`, and at `warn` for an invalid signature, a wrong token type or a revoked session. Browser apps that want to use an API key for the WebSocket connection must proxy through their own SSR layer (Next.js Route Handler → API with the header attached) - the browser's `WebSocket` API can't send headers directly. Dashboard/popout pages must **not** place the raw session cookie JWT in the `?token=` URL (that value is also the API/session credential and would be exposed to JS, browser history and proxy logs, defeating the cookie's `httpOnly` protection). Instead the SSR page mints a **WebSocket-scoped** JWT and passes that as the query-param token. A first-party dashboard page calls the `issueWsToken` GraphQL mutation (REST twin `POST /v1/auth/ws-token`); a **popout** page calls `issuePopoutWsToken` (REST twin `POST /v1/auth/popout/ws-token`) - a separate mint because `issueWsToken` is first-party gated (ZAF-469) and rejects a popout, and because the popout token must carry the popout's **own** permission subset, never the account owner's. Either token carries **no `session_id`** and a WebSocket-only use tag, so the API accepts it **only** on the `/v1/ws` upgrade path - a leak of the WS URL cannot be replayed against a REST/GraphQL mutation or a session-management endpoint. It is short-lived (`auth.ws_token_expiration`, ~15 min); the client re-mints on reconnect. Raw JWTs passed via `?token=` on any **non-WebSocket** route are ignored. A JWT-authenticated session's access is bounded by the token's expiry for the **whole** connection, not just at the handshake: the server checks the token `exp` on every heartbeat tick (5s) and disconnects an already-open session with `TOKEN_EXPIRED` once it passes (the same in-memory expiry enforcement applied to shared overlay tokens). This covers both a logged-in user's JWT and a popout session JWT (the `issuePopoutWsToken` token or the exchanged popout session). The client re-mints a fresh WS token and reconnects (see [Client reconnection](#client-reconnection)), so an open socket cannot outlive its token. Overlay tokens (`lm_overlay_*`) are per-overlay access tokens designed for browser sources. Each token is bound 1:1 to a specific overlay and can only subscribe to the `overlay:{key}` channel matching that overlay. They carry no RBAC permissions and cannot access REST or GraphQL endpoints. There are a few exceptions for extension content: an overlay or widget token also reaches its own account's `events:` / `chat:` stream when it renders an extension that declares `events:read` / `chat:read` (see [Extension hosts](#extension-hosts-read-events-and-chat)). It also reaches the storage and realtime channels of the installs it renders. Shared overlay tokens (`lm_share_*`) are temporary, time-limited tokens for sharing overlay access with collaborators. Like overlay tokens, they are bound to a specific overlay and can only subscribe to the matching `overlay:{key}` channel. The server checks expiry every heartbeat tick (5s) and disconnects with `TOKEN_EXPIRED` when the token's duration elapses. Revocation is caught by the 30-second [credential re-validation](#credential-re-validation). ### Channels being dissolved While a channel is scheduled for dissolution, the `/v1/ws` upgrade is refused with HTTP `403` and `{ "error": "CHANNEL_DISSOLVING", … }` for **any** credential of that channel, a member's session JWT included. Sockets that are already open are closed with the error code `CHANNEL_DISSOLVING` on the next 30-second [credential re-validation](#credential-re-validation). Every feature flag also resolves off for the account, so the feature-flag gate refuses its channels. After the owner cancels the dissolution, clients connect again normally. See [REST API → Channels being dissolved](./rest-api.md#channels-being-dissolved). ### RBAC gate (subscribe-time) Channel access is enforced at subscribe time. `crates/lo-websocket/src/gate.rs::channel_gate_for` maps each channel type to one of these gate kinds: | Gate | Meaning | |------|---------| | `Public` | Anyone, including anonymous callers | | `PublicWhenFeature(flag)` | Anyone while the named system flag is on; rejected with `FEATURE_DISABLED` when off | | `Authenticated` | Any non-anonymous identity | | `Permission(perm)` | Caller can access the account ID in the channel name **and** holds `perm` on it | | `PermissionOrExtensionHost(perm)` | Same as `Permission(perm)`, **or** an `lm_overlay_*` / `lm_widget_*` token of that account whose overlay or widget renders a runnable install whose manifest declares `perm` (see [Extension hosts](#extension-hosts-read-events-and-chat)) | | `AccountScoped` | Caller can access the account ID in the channel name; no specific permission | | `UserPermission(perm)` | Caller holds `perm` in their **user-scoped** permissions; the channel ID is not an account UUID | | `OverlayToken` / `WidgetToken` | Requires that bound token type, with a matching `overlay_id` / `widget_id` | | `ExtensionOwner` | Caller is a developer who owns the extension in the channel ID | | `Unknown` | Unrecognised channel type - always rejected | The complete channel map: | Channel | Gate | |---------|------| | `overlay:{key}` | `OverlayToken` - an `lm_overlay_*` or `lm_share_*` token bound to this specific overlay | | `widget:{widget_instance_id}` | `WidgetToken` - an `lm_widget_*` token bound to this widget instance | | `events:{account_id}` | `PermissionOrExtensionHost("events:read")` | | `spotify:{account_id}` | `Permission("spotify:read")` | | `chat:{account_id}` | `PermissionOrExtensionHost("chat:read")` | | `automod:{account_id}` | `Permission("chat:automod-read")` | | `automations:{account_id}` | `Permission("automations:read")` | | `history:{account_id}` | `Permission("history:read")` | | `chatters:{account_id}` | `Permission("chat:read")` | | `ext-storage:{install_id}` | `ExtensionStorage` - the readers of the install's storage: its own extension token, a member session, user API key or service key of the owning account with `extension-store:read`, or an overlay / widget token whose overlay or widget renders the (runnable) install; shared overlay, popout and System keys are refused | | `ext-install-logs:{install_id}` | `InstallLogs` - the install's owning account plus its category's read permission: `bot-modules:read` (bot module) or `automations:read` (automation node) | | `extension_install:{install_id}` | `ExtensionInstall` - the callers of a `query` server function on the install: its own extension token, an overlay token whose overlay renders it, a widget token whose widget renders it, or a member session or user API key of the owning account with `extension-data:read`; a System key is refused (the `feature:extensions` check needs an account of its own) | | `sounds:{account_id}` | `AccountScoped` - Widget/Overlay tokens carry no account RBAC but must still stay inside their own account; a System key needs `bots:internal` | | `presence:{resource_channel}` | `PresenceScoped` - signed-in members of the account that owns the wrapped resource; a widget token only for its own widget | | `ext-logs:{extension_id}` | `ExtensionOwner` | | `ideas:list`, `ideas:{idea_id}` | `PublicWhenFeature("system:ideas_hub_public")` | | `public-stats:{platform}:{broadcast_id}` | `PublicWhenFeature("system:public_stats")` | | `login-assignments:{id}` | `Public` | `presence:*` channels track who is connected to an editor session. Subscribe to `presence:widget:{id}` to receive `presence:join`, `presence:leave`, and bootstrap events with the current connections list - which include each editor's display name, avatar, `user_id` and `account_id`. Because that bootstrap leaks member identities, presence is bound to **both** the owning account and the caller's identity class. The server parses the wrapped resource (e.g. `widget:{id}`), resolves it to its owning account, and accepts only: - a signed-in member (JWT session) of that account; - a widget token (`lm_widget_*`) whose widget is the wrapped resource - `presence:widget:{id}` for its own widget id, and no other widget of the same account; - a System key carrying `bots:internal` (a System key without it is rejected like any other credential). Every other credential is rejected with `UNAUTHORIZED`, even inside the owning account: popout, overlay, shared overlay, extension, user API key, service key and developer key tokens, and anonymous callers. The subscribe also fails closed if the resource type is unknown, the id is malformed, or the resource cannot be resolved to an account. "Can access the account" means the account in the caller's own auth context, or a System key. On `Permission` channels a System key also needs the channel's permission; on `sounds:*` and `presence:*`, which carry no channel permission, it needs `bots:internal` - never the key type alone. No other identity has a cross-tenant reach: a platform operator holding `admin:access` is gated exactly like any other member of their active account. `admin:access` is only the admin-dashboard entry gate, so it never opens another tenant's `events`, `chat`, `presence`, or other account-scoped channel. Subscribing without the matching permission yields an `error` message with `code: "UNAUTHORIZED"` and the channel is **not** registered. #### Extension hosts read events and chat An OBS browser source renders extensions with an overlay token (`lm_overlay_*`, overlay page) or a widget token (`lm_widget_*`, widget page). Those tokens carry no account RBAC, so the extension's own manifest is the grant. An extension declares `events:read` and/or `chat:read` in `permissions` of its `lumio.config.json`. Such a token may subscribe `events:{account_id}` / `chat:{account_id}` when all of these hold: - the channel's account is the token's own account; - its overlay has an `ext:{slug}` layer for the extension, or its widget renders the install (`widget_instances.install_id`); - the install is runnable: live, enabled, extension and run version not suspended, account not in its dissolution grace period; - the manifest of the version the host renders (`extension_versions.manifest` of the install's `version_id`, the version the bootstrap sends) declares that same permission. `events:read` never opens `chat:` and vice versa. The check runs once per subscribe. Anything else is rejected with `UNAUTHORIZED`: another account's channel, an install without the declaration, a disabled or uninstalled install, an overlay without the extension's layer. Shared overlay links (`lm_share_*`), popout tokens without the grant and System keys follow the plain `Permission` rule. The feature gates still apply (`feature:events`, `feature:multichat` on the channel's account). Frames are unchanged: `events:` carries `{"type":"event","data":StreamEvent}` and `chat:` carries `{"type":"chat:message","data":…}`. The host forwards each event only to extensions that declared the matching permission: chat types (`chat:message`, `twitch:chat`, `youtube:chat`, `kick:chat`, `twitch:ban`, `twitch:unban`) need `chat:read`, every other event type needs `events:read`. The hosts learn the declared permissions from `extension_permissions` in the overlay and widget bootstraps, and the dashboard widget editor learns them from `WidgetInstance.extension.permissions`. ### Feature-flag gate (subscribe-time) After the RBAC gate passes, the WebSocket layer also checks the feature-flag layer (same source as REST/GraphQL - `account_features` overrides on top of plan defaults). Channels mapped in `crates/lo-websocket/src/gate.rs::channel_feature_for` reject the subscribe with `code: "FEATURE_DISABLED"` when the flag is off for the account: | Channel prefix | Required feature flag | |----------------|----------------------| | `overlay:{key}` | `feature:overlays` | | `chat:{account_id}` | `feature:multichat` | | `automod:{account_id}` | `feature:multichat` | | `automations:{account_id}` | `feature:automation` | | `events:{account_id}` | `feature:events` | | `spotify:{account_id}` | `feature:music` | | `sounds:{account_id}` | `feature:sounds` | | `history:{account_id}` | `feature:stream_history` | | `chatters:{account_id}` | `feature:chat-viewer-list` | | `widget:{widget_instance_id}` | `feature:widgets` | | `ext-storage:{install_id}` | `feature:extensions` (checked on the subscriber's account) | | `ext-logs:{extension_id}` | `feature:extension_development` | | `ext-install-logs:{install_id}` | `feature:bot_module_extensions` (checked on the subscriber's active account) | | `extension_install:{install_id}` | `feature:extensions` (checked on the subscriber's account) | | `ideas:*` | `system:ideas_hub` | | `public-stats:*` | `system:public_stats` | | _all others_ (`presence`, `login-assignments`) | _no feature gate_ | For `overlay:{key}` the channel suffix is the opaque overlay key, **not** an account UUID, so the entitlement is checked against the account carried by the `lm_overlay_*` / `lm_share_*` token - not by parsing the channel id. This mirrors the `feature:overlays` `FeatureGuard` on every GraphQL overlay path, so an account that loses the feature (e.g. a downgrade) stops streaming over an already-issued overlay token. `widget:{widget_instance_id}` works the same way. Its suffix is the widget id, so `feature:widgets` is checked against the account that owns the widget, which the `lm_widget_*` token carries, never against the widget id. Plan-bypass via the WebSocket protocol is therefore impossible for gated channels - disabling `feature:automation` for a Free account stops both the REST endpoints **and** the live `automations:*` stream. Add new mappings to `channel_feature_for` when introducing paid-tier streams. ## Client Messages All client messages are JSON with a `type` discriminator. | `type` | Fields | Purpose | |--------|--------|---------| | `subscribe` | `channel: string` | Subscribe to a channel. Server responds with `subscribed` or `error`. **Idempotent:** subscribing again to a channel this session already holds re-acks with `subscribed` but does **not** open a second stream - a channel is delivered exactly once per session regardless of repeat subscribes. | | `unsubscribe` | `channel: string` | Unsubscribe. Server responds with `unsubscribed` and delivery for that channel stops immediately. | | `ping` | -- | Heartbeat. Server replies with `pong`. | The socket is **read-only for clients**. No frame lets a client publish into a channel. Every channel's publisher is server-side code, such as the event pipeline, chat ingest, workers, and REST/GraphQL mutations. Any other `type`, including a `broadcast` frame, is answered with `code: "INVALID_FORMAT"` and has no effect. Example: ```json {"type":"subscribe","channel":"events:550e8400-e29b-41d4-a716-446655440000"} ``` Messages larger than 64 KiB are rejected with `MESSAGE_TOO_LARGE`. ### Subscribe rate limit Each session has a token bucket for `subscribe` and `unsubscribe` frames. Both frame kinds draw from the same bucket, on every channel type. A fresh session may send **120** of them back-to-back (`subscribe_burst`), then **5 per second** (`subscribe_refill_per_second`). The burst is never below the session channel cap (`max_channels_per_session`, default 100): a lower configured value is raised to the cap at startup, so a client can always subscribe a full channel set at connect. A frame sent with the bucket empty gets an `error` with `code: "RATE_LIMITED"` and is dropped: the subscription is not changed, and no authorization or bootstrap work runs for it. The message names the channel and how many milliseconds until the next frame is accepted. The session stays open, and channels already subscribed keep delivering. Repeat the dropped frame after that delay. `ping` frames do not count against the bucket. The limit exists because every subscribe runs the channel's authorization lookups and its bootstrap read. For `public-stats:*` that bootstrap is a database read, available to anonymous callers. A normal client subscribes to a few channels after `welcome` and stays far below the burst. A reconnect opens a new session with a full bucket. The `/ws` upgrade itself is covered by the HTTP rate limiter, so one caller cannot reset its bucket faster than it is allowed to open connections. ## Server Messages All server messages are JSON with a `type` discriminator. Messages generated by the session loop itself use the `ServerMessage` shapes: | `type` | Fields | Meaning | |--------|--------|---------| | `welcome` | `session_id: string`, `server_version: string` | Sent once immediately after the socket opens. | | `subscribed` | `channel: string` | Subscription accepted. | | `unsubscribed` | `channel: string` | Unsubscription accepted. | | `pong` | -- | Reply to `ping`. | | `error` | `message: string`, `code: string` | Error. See codes below. | | `event` | `channel: string`, `event_type: string`, `data: object` | Event payload for a subscribed channel. | Relayed traffic reuses `event` for most families but keeps three legacy per-family discriminators: | `type` | Fields | Meaning | |--------|--------|---------| | `spotify:now-playing` | `data: object` | Spotify playback-state update relayed from `spotify:{account_id}`. | | `chat:message` | `data: object` | Chat event relayed from `chat:{account_id}` (new messages, deletions, moderation logs, etc.). | | `automation` | `data: object` | Automation-engine update relayed from `automations:{account_id}`. | ### Relay envelope When a worker publishes to a Redis pub/sub channel, the API's event-relay worker forwards the payload to every subscribed WebSocket session, wrapped in an envelope. The envelope depends on the Redis channel prefix. Only the prefixes listed below are relayed - a publish on any other Redis channel never reaches a WebSocket client. | Redis channel | WebSocket envelope | Published to sessions subscribed on | |---------------|--------------------|-------------------------------------| | `lumio:events:{account_id}` | `{"type":"event","data":…}` (`data` is the full event row; `raw` is forwarded unchanged, incl. the Twitch Shared Chat `is_shared_chat` / `source_channel` / `shared_chat_notice_type` partner-event fields - see [Events](../features/events.md#twitch-shared-chat-partner-events)) | `events:{account_id}` | | `lumio:spotify:{account_id}` | `{"type":"spotify:now-playing","data":…}` | `spotify:{account_id}` | | `lumio:chat:{account_id}` | `{"type":"chat:message","data":…}` (including `data.type: "chat:pins"` and `data.type: "shared_chat_session"`) | `chat:{account_id}` | | `lumio:automod:*` | `event` with `event_type` and `data` | `automod:{account_id}` | | `lumio:automations:{account_id}` | `{"type":"automation","data":…}` | `automations:{account_id}` | | `lumio:overlay:{key}` | `{"type":"event","channel":…,"event_type":…,"data":…}` - `event_type` from the payload, default `overlay:update` | `overlay:{key}` | | `lumio:widget:{widget_instance_id}` | `{"type":"event","channel":…,"event_type":…,"data":…}` - `event_type` from the payload's `type`, default `widget:update` | `widget:{widget_instance_id}` | | `lumio:ext-storage:{install_id}` | `{"type":"event","channel":…,"event_type":"storage:update","data":…}` | `ext-storage:{install_id}` | | `lumio:extension_install:{install_id}` | `{"type":"event","channel":…,"event_type":"extension:realtime","data":{"topic":…,"payload":…}}` | `extension_install:{install_id}` | | `lumio:sounds:{account_id}` | `{"type":"event","channel":…,"event_type":"sound:play"\|"sound:stop","data":…}` | `sounds:{account_id}` | | `lumio:history:{account_id}` | `{"type":"event","channel":…,"event_type":"history:session_started"\|"history:session_sampled"\|"history:session_updated"\|…,"data":…}` | `history:{account_id}` | | `lumio:chatters:{account_id}` | `{"type":"event","channel":…,"event_type":"chatters:delta","data":…}` | `chatters:{account_id}` | The relay is one-way (Redis to WebSocket). For the four legacy families (`events`, `spotify`, `chat`, `automations`) `data` is the exact JSON the publisher sent, including publisher-specific fields such as `type`, `event_type`, and `payload`. For the other families the relay lifts `event_type` out of the payload and forwards the payload's `data` member. A `chat:message` envelope whose `data.type` is `shared_chat_session` contains the **full** REST-shaped session state, not a delta: `status`, `revision`, `session_id`, host and own broadcaster IDs, times, `participants` and `total_viewers`. It uses the existing `chat:{account_id}` subscription, including for delegated popout tokens with `chat:read`; there is no new channel or Redis pub/sub key. Clients drop an older revision and keep the last state on `unknown`. Every persisted transition also sends `history:session_updated` on the existing history channel, with `session_id`, `reason: "shared_chat"` and `revision`, so open reports can reload at once. Channel-status transitions do not have their own WebSocket channel: the channel-status relay republishes them onto `lumio:events:{account_id}` as `channel:online` / `channel:offline`, so they arrive on `events:{account_id}`. After each successful history sampler tick, `history:{account_id}` receives `history:session_sampled` with `data: { "session_id": "", "platforms": ["twitch", "kick"], "sampled_at": "" }`. The channel requires `history:read` and `feature:stream_history`. The event tells the dashboard to refetch `channelHistoryOverview`; it does not contain the sampled counters. The dashboard reuses its existing socket for this subscription. #### Relay liveness One relay task holds one Redis pub/sub subscription and feeds **every** channel in the table above, for every account. Its health is therefore all-or-nothing: if that subscription dies, every WebSocket client goes silent at once while their sockets stay open and their pings keep getting answered. A pub/sub subscriber never writes after subscribing, so a peer that disappears without a clean TCP close leaves a socket that looks perfectly healthy and simply never delivers again. `lo_cache::RedisSubscriber` therefore probes its own connection: after 30 s of silence it sends a Redis `PING`, and if that goes unanswered for 5 s the subscription is declared dead so the relay's reconnect loop takes over. TCP keepalive and `TCP_USER_TIMEOUT` are enabled on the same socket as a kernel-level backstop (`redis` leaves both off by default). Every `RedisSubscriber` user inherits this - the API relays and workers as well as all five chat bots. Watch `ws_relay_connected`, `ws_relay_reconnects_total` and `ws_relay_messages_total` (see [Metrics](../developer-guide/metrics.md)). Example relayed follower event on `events:{account_id}`: ```json {"type":"event","data":{"type":"twitch:follower","payload":{"username":"viewer123"}}} ``` #### Chat sub-types Inside the `chat:message` envelope, the `data.type` field disambiguates the underlying chat event: | `data.type` | Direction | Payload shape | |-------------|-----------|---------------| | _absent / regular `ChatMessage` fields_ | Worker ➔ clients | Full chat-message row (id, username, message, emotes, badges, reply fields, …). An announcement has `message_type: "announcement"` and `announcement_color` (`primary`, `blue`, `green`, `orange`, `purple`); an ordinary message has no announcement colour. A reply carries the parent author as both `reply_parent_username` (display name) and `reply_parent_login` (login/handle twin; `null` where unavailable). For a Twitch **Shared Chat** message originating in a different channel's room, also carries `is_shared_chat: true` and `source_channel: {platform_channel_id, login, display_name, avatar_url}`; both are absent/`null` for normal and host-origin messages. | | `chat:pins` | Pin worker ➔ clients | `{type: "chat:pins", twitch, kick}` replaces the full current pin state for both platforms. Each pin has `sent_at` (the original message time or `null`) beside `starts_at`, `ends_at` and `updated_at`. Subscribe to the existing `chat:{account_id}` channel; there is no separate pins subscription. | | `chat:delete` | Worker ➔ clients | `{type, platform_message_id, deleted_by}` - a single message was moderation-deleted, either issued through Lumio **or** detected natively (Twitch EventSub `channel.chat.message_delete`, YouTube moderator-delete / author-retract, InnerTube, Kick `MessageDeletedEvent` from the unofficial chat socket - `deleted_by` is `"moderator"` or `"Kick AutoMod"`). The row is only ever **soft-deleted** - never hard-deleted - and the removal is recorded in the affected user's moderation log; clients grey out that row. | | `chat:clear_user` | Worker ➔ clients | `{type, platform, platform_user_id, deleted_by, action, duration_secs}` - fired after a successful ban/timeout (issued through Lumio or detected via Twitch EventSub `channel.ban` / Kick webhook `moderation.banned`); clients grey out **every** message from `(platform, platform_user_id)` and append a localised "Banned/Hidden/Timed-out by X" suffix. `action` is `"ban"` or `"timeout"`; `duration_secs` is `null` for permanent bans. | | `chat:moderation_log` | Worker ➔ clients | `{type, data: {action, platform, target_user_id, target_username, target_display_name, moderator_name, moderator_login, reason, duration_secs, ends_at, target_message_id, created_at}}` - moderation-log entry for the user card and moderation feed widgets. It is published for every stored entry: native actions (Twitch EventSub ban/unban, Kick webhook `moderation.banned`, Kick delete/unban from the unofficial chat socket) **and** actions issued through Lumio (REST/GraphQL `moderate`, all platforms), all built from the same fields as the stored row. `duration_secs` / `ends_at` are set for a timeout and `null` for a ban, delete or unban. `target_username` / `moderator_login` carry the login half, `target_display_name` / `moderator_name` the display half; either half may be `null` when a platform exposes only one name (e.g. YouTube) or a system placeholder acted. | | `chat:user_treatment_update` | Worker ➔ clients | `{type, platform, platform_user_id, user_treatment}` - per-user treatment changed (`user_treatment` ∈ `active_monitoring`, `restricted`, `none`); REST, GraphQL, and Twitch EventSub all publish this identical flat shape | Subscribe to `chat:{account_id}` once and dispatch on `data.type` - the chat-shell, multichat hook, and OBS browser-source clients all use the same demultiplexer. The `events:{account_id}` relay also carries the five pin events (`twitch:pin_added`, `twitch:pin_updated`, `twitch:pin_removed`, `kick:pin_added`, `kick:pin_removed`) and the four Twitch role events (`twitch:mod_added`, `twitch:mod_removed`, `twitch:vip_added`, `twitch:vip_removed`) as ordinary `event` frames. Pin `raw` includes the sender and moderator person shapes described in [Events](/features/events#pin-and-twitch-role-events); `raw.sent_at` on a pin-added event is the original message time or `null`. ### Error codes | `code` | Meaning | |--------|---------| | `UNAUTHORIZED` | Caller lacks access to the requested channel (missing account access or `resource:action` permission). | | `FEATURE_DISABLED` | Caller is authorized but the channel's feature flag is disabled for the account (e.g. `feature:automation` off). | | `TOKEN_REVOKED` | The credential the session was opened with is no longer valid: it was revoked, rotated, deleted or has expired, or (popout tokens, personal API keys) the member it is bound to left the account. See [Credential re-validation](#credential-re-validation). Reconnect with a fresh credential - for a browser source, a fresh URL from the dashboard. | | `AUTH_UNAVAILABLE` | The server could not re-validate the session's credential twice in a row (database timeout or error), so it closed the session instead of keeping it open unchecked. Transient - reconnect with backoff. | | `TOKEN_EXPIRED` | The session's token has expired mid-connection - a shared overlay token whose duration elapsed, or a JWT session whose token `exp` passed. Re-mint the token and reconnect (Lumio's clients do this automatically); for a shared link, create a new one to regain access. | | `CHANNEL_DISSOLVING` | The channel the session belongs to is scheduled for dissolution and suspended. Sent on the 30-second [credential re-validation](#credential-re-validation) tick, then the socket is closed. Reconnecting is refused until the owner cancels the dissolution. | | `SUBSCRIBE_FAILED` | Server-side failure while registering the subscription. | | `CHANNEL_DROPPED` | The client fell too far behind on one channel and the server dropped it from that channel (its outbound buffer stayed full). The session stays connected and other channels are unaffected; resubscribe to resume. Surfaced instead of the channel silently disappearing. | | `INVALID_FORMAT` | Message was not valid JSON or did not match any known client-message shape. | | `MESSAGE_TOO_LARGE` | Text frame exceeded the 64 KiB limit. | | `RATE_LIMITED` | A `subscribe` or `unsubscribe` frame arrived with the session's [subscribe rate limit](#subscribe-rate-limit) used up. The frame was dropped; the session stays open. Retry the frame after the delay named in the message. | ## Heartbeats and timeouts The server disconnects any session that has been silent for 30 seconds (`client_timeout`, 6× the 5-second heartbeat interval - a margin wide enough that throttled background tabs are not dropped). **The server does not send WebSocket `Ping` frames**, so the client is responsible for keeping the connection alive: send a `{"type":"ping"}` every few seconds, or maintain any other inbound traffic. A hidden browser tab has its `setInterval` clamped to at most once per minute, so the wide `client_timeout` margin is what keeps backgrounded overlay and Multichat tabs connected - do not shrink it toward the heartbeat interval. Standard WebSocket `Ping` control frames sent *by the client* are auto-answered with `Pong`. (The 5-second heartbeat tick is an internal timer used for credential re-validation and JWT / shared-token expiry enforcement, not a ping to the client.) The same `client_timeout` also bounds every outbound send: if a client stops draining its socket (a sleeping phone, a half-open TCP connection), the server closes and reaps the session within that window instead of buffering its broadcasts without limit. A per-session outbound queue caps at 256 messages; a session that stays behind past that point is disconnected rather than allowed to grow unbounded. ### Credential re-validation Every session opened with a revocable credential has that credential re-validated against the database every 30 seconds (throttled inside the 5-second heartbeat tick), not only at the handshake. This covers every token kind: | Credential | Invalid on the next re-check when | |------------|-----------------------------------| | Popout token `lm_pop_*`, and popout session JWTs minted from one | the origin token is revoked, expired or deleted, or the member it is assigned to left the account | | Personal API key `lm_usr_*` | the key is rotated, expired or deleted, or its owner left the account | | Service key `lm_svc_*` | the key is rotated, expired or deleted | | Developer key `lm_dev_*` | the key is revoked, rotated, expired or deleted | | Overlay token `lm_overlay_*` | the token is rotated, revoked, expired or deleted | | Shared overlay token `lm_share_*` | the link is revoked or has expired | | Extension token `lm_ext_*` | the token has expired or was deleted | | Widget token `lm_widget_*` | the token is rotated, revoked, expired or deleted | | JWT carrying a `session_id` | the session was logged out or has expired | The check reads the stored token hash, so a **rotated** secret stops working on an open socket even though the token row (and its id) survives the rotation. Nothing about validity is cached: a revocation is visible on the very next re-check. When the credential is no longer valid, the server sends an `error` with `code: "TOKEN_REVOKED"` and closes the WebSocket. Browser sources automatically attempt to reconnect, at which point they need a fresh URL from the dashboard. The re-check **fails closed**. Each check has a 500 ms budget; a timeout or database error keeps the session's last result (it was valid, or the session would already be closed) for **one** check only. A second consecutive failure closes the session with `code: "AUTH_UNAVAILABLE"`; clients reconnect with backoff, and the reconnect handshake validates the credential afresh. A single successful check resets the count, so an isolated database blip never disconnects anyone. A JWT without a `session_id` (the WS token minted by `issueWsToken` / `issuePopoutWsToken`) has nothing to look up; its lifetime is bounded by its `exp` instead (see [JWT session expiry](#jwt-session-expiry)). Anonymous sessions and system keys are not re-validated. ### JWT session expiry For sessions authenticated with a JWT (a logged-in `User`, including the WebSocket-scoped token minted by `issueWsToken`, and a popout session JWT, including the token minted by `issuePopoutWsToken`), the server enforces the token's `exp` for the entire connection. On each heartbeat tick it compares the token expiry (carried in from the validated claims - no per-tick database round-trip) against the current time and, once it has passed, sends an `error` with `code: "TOKEN_EXPIRED"` and closes the WebSocket. This bounds an already-open socket to its token's lifetime: the handshake rejection alone (see [Authentication](#authentication)) only stops an *expired* token from *starting* a session, whereas this closes a session whose token expires *while connected*. Lumio's clients re-mint a fresh WS token and reconnect automatically. ### Client reconnection Lumio's own browser clients (dashboard chat/events/history feeds, popout windows, overlay browser sources) share one resilient reconnection strategy: - **Fresh credential per connect.** The dashboard mints its WS token (`issueWsToken`) from a short-lived route on **every** connect attempt (never a value frozen at page load), and popout windows mint their own WS-scoped token (`issuePopoutWsToken`) the same way. A tab left open past the token TTL therefore re-mints and recovers on its next reconnect without a page reload. - **`welcome`-gated subscribe.** The client subscribes only after the `welcome` frame and reports itself "connected" only once the server acknowledges a subscription with `subscribed`. A rejected subscribe surfaces the `error` `code` in the UI rather than a false "connected" state. - **Bounded auth recovery.** An `error` with `UNAUTHORIZED` / `FORBIDDEN` / `TOKEN_EXPIRED` / `TOKEN_REVOKED` triggers exactly one fresh-token reconnect; a second consecutive auth error stops the loop (no reconnect storm). `FEATURE_DISABLED` stops immediately; `CHANNEL_DROPPED` reconnects to re-subscribe; `AUTH_UNAVAILABLE` is transient and reconnects with the normal backoff. - **Jittered backoff.** Reconnect delay is exponential (1s → 30s cap) with equal jitter so a fleet of tabs does not stampede the API in lockstep after a restart, and the backoff counter resets only after a connection has held stably - a socket that drops right after upgrading keeps escalating instead of hammering at the 1s floor. - **Subscribe only to channels the credential can read.** Because auth recovery is triggered by an `UNAUTHORIZED` `error` frame, a client that runs several channels over one socket must not subscribe to a channel its own credential cannot read - the per-channel rejection would enter the reconnect path and churn the *whole* socket, dropping the channels that were working. So a surface asks only for what its token's permission subset allows, resolved before it opens the socket. Concretely, a **delegated/shared chat pop-out** (a popout session from a shared `lm_pop_*` link) always subscribes to `chat:{account_id}`, and additionally subscribes to `events:{account_id}` **only when its token was granted `events:read` and the account holds `feature:events`** - the same conditions the [subscribe-time RBAC gate](#rbac-gate-subscribe-time) enforces server-side. The common delegated subset (`chat:read`, no `events:read`) therefore never attempts the `events:` subscribe, so it shows live chat but no inline stream-event lines; a pop-out whose creator explicitly delegated `events:read` gets both. See [Multichat → Inline stream events](../features/chat.md#where-inline-events-appear-and-where-they-dont). ### Chatters channel `chatters:{account_id}` carries the live [chatter list](../features/chatter-list.md). Same guard as GraphQL `chatters` and REST `GET /v1/chat/chatters`: `chat:read` + `feature:chat-viewer-list`; popout tokens (ws-token) with `chat:read` are accepted. Clients never publish. Its `supported_platforms` contains Twitch and, when the account has a usable Kick channel connection, Kick. - **Subscribe** → one `bootstrap` event whose `data` is the full `ChattersSnapshot` (`supported_platforms`, `platforms`, `chatters` — the REST shape without the `data`/`links` wrapper). Holding the channel open keeps the server polling the platform. Every subscription also renews the account's 7-day *wanted* marker, so the server keeps polling while that platform's stream is live even with no subscriber (see [Chatter List → Polling](../features/chatter-list.md#polling-open-panel-or-live-stream)). An account that never subscribes causes no platform calls. - **Then deltas only**, `event_type: "chatters:delta"`: | `data.op` | Payload | Client action | |---|---|---| | `join` | `{op, platform, entries: [Chatter]}` | Add each entry. A `join` for a key the client already holds is applied as an `update` (dedupe, e.g. after a reconnect) | | `update` | `{op, platform, entries: [Chatter]}` | Replace the entry with the same (`platform`, `user_id`). This includes **leaving the chat** (`present: false`, frozen `last_seen_at`) and **coming back** (`present: true`, new `first_seen_at`) | | `leave` | `{op, platform, entries: [Chatter]}` | **Drop the row** with the same (`platform`, `user_id`); unknown keys are ignored. Sent when the recently-left retention is over, and for every entry when the stream goes offline | | `status` | `{op, platform, entries: [], status: ChatterPlatformStatus}` | Replace that platform's status (`available`, `reason`, `total`, `truncated`, `updated_at`) | `status.reason` can be `missing_scope`, `not_connected`, `disabled`, or `source_unavailable`. The last value means that the platform’s list source failed (for example, Kick’s undocumented endpoint): `available` is `false`, `total` is `null`, `truncated` is `false`, and recent writers remain in the list. After two failed polls, snapshot-only entries receive `update` with `present: false`. Other platforms continue independently. Entries are keyed by (`platform`, `user_id`), never by name. One frame carries at most 500 entries. A new `bootstrap` (re-subscribe) replaces the whole list. **Presence lifecycle of one key** (ZAF-1767): 1. `join` (`present: true`, `first_seen_at` set) — the person appears. 2. `update`s while they stay: role, avatar, color, name. `first_seen_at` does not move; a present row's moving `last_seen_at` is **not** re-sent per poll. 3. `update` with `present: false` — they left. `last_seen_at` is now final. Show them as recently left; never count them. 4. Either `update` with `present: true` and a **new** `first_seen_at` (they came back within 30 min), or `leave` once `last_seen_at` is older than the retention (30 min) — remove the row. When the stream goes offline the server sends `leave` for every entry, so the next stream starts empty. A client that missed frames re-subscribes and gets a fresh `bootstrap`. ### Observability The WebSocket server exposes two live gauges on the public health surface (`GET /v1/health`, `data.websocket`): `sessions` (active WebSocket sessions) and `channels` (active broadcast channels). Both are aggregate counters with no per-user or per-channel detail. They are maintained on every connect/disconnect and subscribe/unsubscribe and read lock-free, so scraping the health endpoint never contends with the broadcast hot path. Watching `sessions` trend flat rather than climbing is the production signal that the connection-reaping path is healthy. ### AutoMod events `automod:{account_id}` carries `automod:held`, `automod:resolved` and `automod:moderation_log` as `event` envelopes. It is separate from `chat:{account_id}`: a held message never reaches ordinary chat, overlays or widgets. The channel requires `chat:automod-read` and `feature:multichat`; a popout token with that permission can subscribe. The client also fetches `heldChatMessages` / `GET /v1/chat/automod/held` to restore open holds after reconnect. The `automod:held` payload has the same fields as a `heldChatMessages` entry: `emotes` (Twitch emotes and cheermotes in the chat-message `emotes` shape, derived from the fragments when the message leaves the server; absent or `null` when there are none or the fragments were dropped for size) and the sender's `avatar_url`, `color` and `badges` (a snapshot of Lumio's chatter record at hold time; absent for a chatter Lumio has not seen). Resolved holds stay readable after a reload as `automod` items of the merged chat history ([`mergedChatHistory`](../features/chat.md#server-merged-history-read-mergedchathistory)). ## Example Channels - `events:{account_id}` -- Stream events (follows, subs, cheers, raids, redemptions, tips, channel online/offline, bot-command updates, `spotify:track`). Requires `feature:events`. Readers are members with `events:read` and [extension hosts](#extension-hosts-read-events-and-chat) whose extension declares `events:read`. - `spotify:{account_id}` -- Spotify "now playing" state changes for the Spotify overlay widget. - `chat:{account_id}` -- Live chat messages, deletions, moderation logs, user-treatment updates. Requires `feature:multichat`. Readers are members with `chat:read` and [extension hosts](#extension-hosts-read-events-and-chat) whose extension declares `chat:read`. - `automod:{account_id}` -- Twitch AutoMod holds and decisions; requires `feature:multichat` and `chat:automod-read`. - `automations:{account_id}` -- Automation-engine lifecycle updates (triggers, execution progress). - `history:{account_id}` -- Stream-history session lifecycle (started / updated / ended) and sampler ticks (`history:session_sampled`). Requires `feature:stream_history` and `history:read`. - `chatters:{account_id}` -- Live chatter list: snapshot as `bootstrap`, then `chatters:delta` (`join` / `leave` / `update` / `status`). Requires `feature:chat-viewer-list` and `chat:read`. See [Chatters channel](#chatters-channel). - `sounds:{account_id}` -- Sound playback commands (`sound:play`, `sound:stop`) consumed by browser sources to trigger in-overlay audio. Requires `feature:sounds` and access to the account in the channel name. - `overlay:{key}` -- Per-overlay updates for browser-source popouts. Requires an `lm_overlay_*` or `lm_share_*` token bound to the specific overlay identified by `key`. On subscribe the server pushes a `bootstrap` event with `account_id` and the overlay's `layers` (`id`, `overlay_id`, `type`, `name`, `config`, `visible`, `sort_order`, `created_at`, `updated_at`), plus `obs`, `spotify` and `chat` state when the overlay has such layers. An extension layer (`type: "ext:{slug}"`) also carries `extension_id`, `extension_install_id`, `extension_slug` `extension_version` and `extension_permissions` (the `permissions` array of that version's manifest, `[]` when it declares none) when the overlay's account has a live, enabled install of that extension and the extension is not suspended; the bundle is served at `/extension-bundles/{extension_id}/{extension_version}/layer.js`. Without such an install these keys are absent and the overlay mounts nothing for the layer. A `layers:updated` event (sent when the overlay is saved in the editor) carries `layers` in the same shape, so a live overlay keeps its extension layers mounted. The overlay bootstrap exists only on WebSocket; GraphQL `overlayLayers` is the dashboard editor read and returns the raw rows. - `widget:{widget_instance_id}` -- Per-widget config/state updates for browser sources. Requires an `lm_widget_*` token bound to that instance and `feature:widgets`. On subscribe the server pushes a `bootstrap` event with `account_id`, `widget_type`, `name`, `config`, `width`, `height`, `extension_id` and `install_id`. An extension widget also gets `version` and `extension_permissions`: `version` is the bundle version, served at `/extension-bundles/{extension_id}/{version}/layer.js`. `extension_permissions` is the `permissions` array of that version's manifest. Both are sent only while the install is live and enabled and the extension is not suspended. Otherwise `version` is `null` and `extension_permissions` is `[]`. - `ext-storage:{install_id}` -- Live key/value updates for an extension installation's storage: every write through GraphQL `setExtensionStorage` / REST `POST /v1/extension-installs/{install_id}/storage` arrives as a `storage:update` event with `data: { "key", "value", "install_id" }`. The id is an install id; the server resolves it to the install and admits exactly the callers that may read that storage on GraphQL `extensionStorage` / REST `GET /v1/extension-installs/{install_id}/storage` - the install's own extension token, a member session, user API key or service key of the owning account holding `extension-store:read`, or an overlay token whose overlay renders the install in an extension layer (`ext:{slug}`) or a widget token whose widget renders it, as long as the install is runnable. Overlay or widget tokens that do not render the install, shared overlay, popout, developer and System keys, anonymous callers, a member of another account, an unknown install, an install waiting for its data deletion and a malformed id are rejected with `UNAUTHORIZED`. Also requires `feature:extensions` on the caller's account. Updates are not stored; read the current values through `extensionStorage`. The web extension host subscribes to this channel for every install it renders and hands each update to the extension as `storage.update`, so `useExtensionStorage()` re-renders on every surface. - `ext-logs:{extension_id}` -- Build and runtime log stream for an extension, restricted to its owning developer. Requires `feature:extension_development`. - `ext-install-logs:{install_id}` -- Runtime logs of one bot-module or automation-node install. The server resolves the install to its owning account and extension category; the caller needs access to that account and the category's read permission: `bot-modules:read` for a bot module, `automations:read` for an automation node. This is the same rule as GraphQL `extensionInstallLogs` and REST `GET /v1/extension-installs/{id}/logs`. An unknown or uninstalled install, an install of any other category, and a malformed id are rejected with `UNAUTHORIZED`. Also requires `feature:bot_module_extensions` on the caller's account. - `extension_install:{install_id}` -- Realtime messages of one extension install: every `ctx.realtime.push(topic, payload)` of its server-function and bot-module handlers arrives as an `extension:realtime` event with `data: { "topic", "payload" }`. The extension host in the dashboard, overlay and widget pages subscribes with its own credential and hands each message to the extension (`server.realtime`, SDK `useRealtime`). Admitted are exactly the callers of a `query` server function on the install - the install's extension token, an overlay token whose overlay has a layer of the extension, a widget token whose widget renders the install, and a member session or user API key of the owning account holding `extension-data:read`. System keys (even with `bots:internal`, since the `feature:extensions` check runs on the subscriber's own account), popout, shared overlay, service key, developer key and anonymous credentials, an unknown or uninstalled install, an install whose code may not run (extension or version suspended, install disabled, account in its dissolution grace period) and a malformed id are rejected with `UNAUTHORIZED`. Also requires `feature:extensions` on the caller's account. Messages are not stored; a client that is not subscribed when a push happens never receives it. There is no GraphQL subscription twin: the API has no GraphQL subscription transport, and browsers never call GraphQL directly. - `presence:widget:{id}` -- Editor presence (`presence:join`, `presence:leave`, bootstrap list). Open to signed-in members of the account that owns the widget, to that widget's own widget token, and to a System key carrying `bots:internal`; every other credential is rejected. Resource types without an account resolver fail closed. - `ideas:list` / `ideas:{idea_id}` -- Ideas Hub list and per-idea updates. Public while `system:ideas_hub_public` and `system:ideas_hub` are on. - `public-stats:{platform}:{broadcast_id}` -- Public Stats live read for one stream. On subscribe the server pushes a `bootstrap` event with the field-gated, opt-out-respecting stream snapshot (the same DTO as the GraphQL `publicStreamStats` / REST `GET /v1/public-stats/streams/...`). **Unauthenticated** and carries no per-user data; gated globally on `system:public_stats`. An **opted-out** channel (or an unknown stream) yields **no bootstrap**. Clients never publish - server-side only. The cross-channel **browse/search** surface (`GET /v1/public-stats/{browse,games}` + GraphQL `publicChannelBrowse` / `publicGames`) has **no** WebSocket channel - a browse/search directory has no live-push consumer (a justified single-protocol omission, like the emote directory). - `login-assignments:{id}` -- Login-assignment updates. Public. --- ## API Reference / Authentication Source: https://docs.lumio.vision/api-reference/authentication # Authentication Email/password and platform sign-ins are completed in the ID app. A verified email address is an independent sign-in credential; Lumio does not auto-link profiles from an email claim supplied by a platform. Where 2FA is enabled, OAuth, password and PKCE authorization return a pending challenge with `mfaMethods` (`passkey` and/or `totp`) until an app code, passkey or recovery code is verified. A passkey sign-in completes 2FA in one browser ceremony. The confirmation link only verifies the address and does not sign anyone in. Sensitive profile actions use a ten-minute session-bound step-up. With 2FA enabled, it accepts a passkey, authenticator app code or recovery code; otherwise an existing provider connection or email password can confirm identity. `STEP_UP_REQUIRED` means the client should ask the user to confirm again. See [REST profile security routes](/api-reference/rest-api#profile-security-and-data-routes). Lumio resolves every incoming request into one of **eleven** authentication contexts. The auth middleware never rejects a request by itself - it resolves the strongest context it can and falls back to `Anonymous`; the individual route, resolver, or WebSocket gate then decides whether that context is sufficient. ## Auth Types | Context | Prefix | Length | Carries RBAC | Purpose | |---------|--------|--------|--------------|---------| | System Key | `lm_sys_` | 71 | System-scope grants (may include `resource:*` / `*:*`) | Internal service-to-service and bot auth | | User API Key | `lm_usr_` | 71 | Scoped subset of the creator's account permissions | Programmatic external API access | | Service Key | `lm_svc_` | 71 | Scoped subset of the creator's account permissions (no user identity) | Account-owned CI/CD credential that survives member departure | | Developer API Key | `lm_dev_` | 71 | The `extension-dev:*` family only, bound to a developer profile or team (no account) | `lumio deploy` / CI publishing for extension developers | | JWT | `lm_` + `eyJ…` | variable | Admin, account and user permission sets | Logged-in browser sessions | | Popout Token | `lm_pop_` | 71 | Narrow subset of the creator's permissions | OBS browser sources, dashboard popout windows | | Overlay Token | `lm_overlay_` | 75 | none | Per-overlay browser-source access | | Shared Overlay Token | `lm_share_` | 73 | none | Time-limited shared overlay links | | Extension Token | `lm_ext_` | 71 | none | Extension iframe access to its own install data | | Widget Token | `lm_widget_` | 74 | none | Per-widget-instance browser-source access | | Anonymous | - | - | none | No credential presented | Every non-JWT token is a prefix plus 32 random bytes hex-encoded (64 characters). Only the SHA-256 hash is stored; the raw value is shown once at creation. ### Channels being dissolved While a channel is scheduled for dissolution (`dissolution_scheduled_for` set), the auth middleware checks the resolved credential right after resolution. Credentials bound to that channel - User API Keys, Service Keys, Popout, Overlay, Shared Overlay, Widget and Extension Tokens - are refused with `403 CHANNEL_DISSOLVING` (`details.dissolution_scheduled_for` names the date). They are not revoked and work again once the owner cancels the dissolution. A member's JWT session keeps its identity but holds no account permission for the channel: permission-gated routes answer `403 CHANNEL_DISSOLVING` (GraphQL `extensions.code`), while user-level routes - profile, `me` with the account list, switching and leaving accounts, the owner's cancel - keep working. The WebSocket upgrade is refused for every credential of the channel, sessions included. See [REST API → Channels being dissolved](./rest-api.md#channels-being-dissolved). ## Transports Two transports are accepted, and **what each accepts differs**: | Context | `Authorization: Bearer …` | `?token=…` query param | |---------|---------------------------|------------------------| | System Key (`lm_sys_`) | ✓ | ✗ | | User API Key (`lm_usr_`) | ✓ | ✗ | | Service Key (`lm_svc_`) | ✓ | ✗ | | Developer API Key (`lm_dev_`) | ✓ | ✗ | | JWT (`lm_eyJ…`) | ✓ | ✓ | | Popout Token (`lm_pop_`) | ✗ | ✓ | | Overlay Token (`lm_overlay_`) | ✓ | ✓ | | Shared Overlay Token (`lm_share_`) | ✓ | ✓ | | Extension Token (`lm_ext_`) | ✓ | ✓ | | Widget Token (`lm_widget_`) | ✓ | ✓ | Long-lived service credentials (System keys, User API keys) are header-only on purpose: query strings end up in access logs, browser history, and `Referer` headers. The query-param path exists because the browser `WebSocket` API cannot set custom headers. Resolution is ordered: the `Authorization` header is inspected first and, when it carries a recognised prefix, that result is final - a `?token=` value is not consulted as a fallback. An unrecognised or invalid credential resolves to `Anonymous` rather than an error. ## Rate Limits Limits are per auth context, fixed per tier - they are not derived from the account's plan. | Context | Requests / minute | |---------|-------------------| | System Key | unlimited (rate limiting skipped) | | User API Key | 1200 | | JWT | 600 | | Popout / Overlay / Shared Overlay / Extension / Widget Token | 600 | | Anonymous | 120 | Popout tokens are keyed on the SHA-256 hash of the token, so several popout tokens on the same account get independent buckets - one overactive OBS browser source cannot starve the account's other popouts. Anonymous requests are keyed on a client IP the caller cannot forge: Cloudflare's `CF-Connecting-IP`, else the peer address. `X-Forwarded-For`, `X-Real-IP` and `X-Lumio-Client-IP` never choose the bucket of a direct request. An IPv6 client is counted per `/64` network, an IPv4 client per address. A valid `X-Internal-Token` (our own Next.js servers calling the API) grants **no** exemption. An anonymous call our web, admin or id app makes on a visitor's behalf is counted in the `proxied:{ip}` bucket of the visitor IP it forwards in `X-Lumio-Client-IP`, at 600 requests / minute. That is more than a direct anonymous caller gets, because one server-rendered page view makes several API calls, and it is a separate bucket, so it never adds to that IP's direct budget. An internal call that forwards no visitor (background or build-time traffic) shares one `internal:anon` bucket of 1200 requests / minute. The forwarded IP is trusted only together with the internal token and only when it parses as an IP address. Authenticated internal calls are counted per credential as usual. Inbound webhook receivers under `/v1/webhooks/*` are **not** rate limited: they live in a dedicated scope outside the limiter and are gated by per-platform signature verification instead. Platform deliveries arrive from a shared pool of the platform's own IPs, so the per-IP anonymous bucket would throttle every tenant's callbacks into a single 120/min cap (ZAF-1519). See [Webhooks → Rate limiting](/features/webhooks#rate-limiting--webhook-receivers-are-exempt). ## JWT (JSON Web Tokens) Issued by the ID app (NextAuth) and validated by the API. A JWT resolves to the `User` context and carries three separate permission sets: `admin_permissions` (from admin roles), `account_permissions` (from the active account's role), and `user_permissions` (cross-account, non-admin - e.g. Ideas Hub). ``` Authorization: Bearer lm_eyJ... ``` The token may carry a `session_id` referencing the `sessions` row for the request. A JWT is tagged with its **use**: `access` (authorizes any REST/GraphQL/WebSocket request), `ws` (authorizes **only** the `/v1/ws` upgrade), or `refresh` (accepted only at `/auth/refresh`). The `issueWsToken` mutation (REST twin `POST /v1/auth/ws-token`) mints a dedicated **`ws`** JWT that deliberately carries **no** `session_id` and a short TTL (`auth.ws_token_expiration`, ~15 min), so a leaked WebSocket URL cannot be replayed against a REST/GraphQL mutation or a session-management endpoint. Raw JWTs passed in the `?token=` query string are honored only on the WebSocket upgrade path. ## User API Keys Long-lived keys (`lm_usr_`) for programmatic access, created from **Settings → Channel → Access → API Keys** (or through the API key endpoints). The full key is shown once, only its hash is stored, and each key carries an explicit permission scope that can never exceed what its creator held at creation time. A personal key is **member-bound**: it stops authenticating once its owner is no longer a member of the key's account (removing the member purges their personal keys, and the auth layer refuses any `lm_usr_` key whose owner has left the account). For a credential that must outlive team changes, use an account-owned Service Key (`lm_svc_`) instead. ``` Authorization: Bearer lm_usr_... ``` `GET /v1/tokens/me` (and GraphQL `myPermissions`) returns the caller's **effective** permissions - a scoped key reports exactly its own scope, never a wildcard. ## Session-only Surfaces A small class of operations defines **who you are**, **mints a credential**, or **moves money**. They accept **only an interactive sign-in session** (the JWT the ID App issues at login, which is bound to a revocable session row through its `session_id`). A user JWT that carries no `session_id` is not a session and is refused like an API key. A user's own API key (`lm_usr_`) is refused here **regardless of its scopes**, as is every token type and every system / service / developer key: | Operation | REST | GraphQL | |-----------|------|---------| | Link or reconnect a login provider | `POST /v1/auth/link` | – (REST only; called by the ID App) | | Switch or clear the active account | `PATCH /v1/users/me` with `active_account_id` / `clear_active_account` | `updateMe(input: { activeAccountId })` / `updateMe(input: { clearActiveAccount })` | | Rearrange or remove login identities | `POST /v1/accounts/login-assignments`, `DELETE /v1/accounts/login-assignments/{provider}`, `DELETE /v1/login-connections/{id}`, `PUT /v1/users/me/name-source-connection`, `PATCH /v1/users/me/display-name`, `POST`/`DELETE /v1/users/me/avatar` | `assignLoginConnection`, `removeLoginAssignment`, `disconnectLoginConnection`, `setNameSourceConnection`, `updateDisplayName`, `removeAvatar` (the upload has no GraphQL twin) | | Change the channel's name or picture (also needs `account:edit` and the active channel) | `PUT /v1/accounts/{id}/identity-source`, `PATCH /v1/accounts/{id}/name`, `POST`/`DELETE /v1/accounts/{id}/avatar` | `setAccountIdentitySource`, `updateAccountName`, `removeAccountAvatar`, deprecated `updateAccount` (the upload has no GraphQL twin) | | Mint a WebSocket JWT | `POST /v1/auth/ws-token` | `issueWsToken` | | Mint / rotate a personal API key | `POST /v1/api-keys`, `POST /v1/api-keys/{id}/rotate` | `createUserApiKey`, `rotateUserApiKey` | | Mint / rotate an account service key | `POST /v1/service-keys`, `POST /v1/service-keys/{id}/rotate` | `createAccountServiceKey`, `rotateAccountServiceKey` | | Mint a developer key | `POST /v1/developer/api-keys` | `createDeveloperApiKey` | | Start a channel / bot OAuth connect | `GET /v1/connections/channel/{platform}/authorize`, `GET /v1/bot-connections/{platform}/authorize` | `authorizeChannel`, `authorizeBot` | | Complete a channel / bot OAuth connect | `POST /v1/connections/oauth/{platform}/exchange`, `POST /v1/connections/channel/{platform}/exchange`, `POST /v1/bot-connections/{platform}/exchange` | – (REST only; called by the web callback) | | Set the payout destination (Stripe Connect onboarding) | `POST /v1/developer/stripe-connect/onboard` | – (REST only) | | Change developer payout settings | `POST /v1/developer/payouts` | `updateDeveloperPayoutSettings` | | Request a developer payout | `POST /v1/developer/payouts/request` | `requestPayout` | | Start a plan checkout, open the billing portal, cancel the subscription | `POST /v1/billing/checkout`, `POST /v1/billing/portal`, `POST /v1/billing/cancel` | `createCheckout`, `createPortal`, `cancelSubscription` | A WebSocket JWT carries the user's full permission set, so an API key cannot mint one - present the `lm_usr_` key itself on `/v1/ws` (`Authorization` header). The popout ws-token path (`POST /v1/auth/popout/ws-token` / `issuePopoutWsToken`) is separate and unaffected. Popout-token creation (`POST /v1/tokens` / `createPopoutToken`) is **not** session-only: an API key may mint a popout token, capped to the key's own scopes. A leaked or over-scoped API key therefore cannot redirect payouts or change billing. The money reads stay open to a user's own API key with the right scope: `GET /v1/developer/stripe-connect/status`, `GET /v1/developer/payout-settings` / `developerPayoutSettings`, payout history, revenue and the billing status / invoice reads. The billing mutations check the session before they check whether billing is enabled, so an API key always gets the session refusal. Only the `active_account_id` and `clear_active_account` fields of `PATCH /v1/users/me` / `updateMe` are session-only (`email` is refused for every caller with `400 EMAIL_MANAGED_BY_LOGIN`; the profile email is set by the email sign-in) - an API key can still change the other profile fields (streamer mode, display preferences), and the response never carries a `token` for a non-session caller. **A session JWT is only minted for a caller that already holds a session (ZAF-1922).** The account switch / clear above, account creation and account dissolution each hand back a replacement JWT bound to the caller's existing session (`lo_auth::reissue_session_token`). Creating an account (`POST /v1/accounts` / `createAccount`) and dissolving one (`DELETE /v1/accounts/{id}`) are not session-only - they run under their usual guards - but a caller without an interactive session gets `token: null`; GraphQL `dissolveAccount` returns no token at all. Refusals are identical on both protocols: anonymous → `401` / `UNAUTHENTICATED` (`Authentication required`); any other credential → `403` / `FORBIDDEN` with `This action requires an interactive sign-in session`. The channel / account-bot OAuth exchange is additionally **bound to the session that started it**: the authorize step records the signed-in user on the server-side `state` handle, and the exchange completes only for that same user's session, re-checking account access and the connect permission (`connections:create` / `bot-connections:create`). Any other caller is refused before a connection row is written, and the single-use `state` is spent. The admin global-bot connect is exempt: it returns through the web host, where the browser session is never an admin session, and only the admin-only authorize step can start it. ## Developer API Keys Long-lived publishing credentials (`lm_dev_`) for extension developers - the key `lumio deploy` and CI use instead of a personal `lm_usr_` key. A developer key is deliberately **narrow** and differs from every other credential in three ways: - **Bound to a developer identity, not an account or a member.** It is owned by a developer **profile** or a developer **team** (exactly one), and carries **no account** and **no member identity** - so it can never satisfy an account-scoped check and, like a Service Key, keeps authenticating after the member who minted it leaves the team. - **Plan-decoupled.** Eligibility is *approved-developer status* (an existing developer profile / team membership), **not** the Pro plan. The `feature:extension_development` account plan gate does not apply to a developer key - free-tier developers keep publishing when API keys are Pro-only. - **Scoped to `extension-dev:*` only.** A developer key can carry **only** the `extension-dev:*` permission family (`read`, `create`, `edit`, `delete`, `publish`, `analytics`, `payouts`) - never an ordinary account scope such as `chat:ban` and never a user-scoped scope such as `ideas:*`. Scopes can never exceed what the creator held at mint time. It reaches the **extension-developer publishing routes and nothing else**: listing / reading / updating / deleting its own extensions, versions (create / upload / list), analytics, limits, purchases, testers, screenshots and the extension's secrets (`GET/PUT/DELETE …/secrets`; secrets belong to the extension, not to an account). It is refused (fail-closed) from: - the **first-party developer surface** - developer profile, payout settings, revenue, payout requests and limit requests (these key off the acting logged-in user), plus `create` / `submit` / `invite tester` / `request deletion` (they require a first-party user); - **developer-key management itself** - a `lm_dev_` key can never mint or revoke another developer key, and minting one requires an interactive sign-in session (see [Session-only Surfaces](#session-only-surfaces)). Manage developer keys from **Settings → You → Developer → Developer keys** (`/dashboard/settings/developer/keys`; REST `/v1/developer/api-keys`, GraphQL `developerApiKeys` / `createDeveloperApiKey` / `updateDeveloperApiKey` / `revokeDeveloperApiKey`). The full key is returned once at creation and only its hash is stored. ``` Authorization: Bearer lm_dev_... ``` ## System Keys Service identities that are not bound to an account: a system key can act on any account, but only within the scopes it carries. Two provisioning surfaces resolve to the same `System` context: static keys from TOML configuration (an empty list in the deployed API - every live system key is admin-provisioned), and admin-provisioned keys stored in `api_keys` with `is_system = true`. System keys are the only contexts that legitimately hold `resource:*` or `*:*` grants. Internal System surfaces check a **named grant**, not the key type alone: the bot fleet's feeds, the bot chat ingest, extension functions on any install, draft extension files and bundles, and the WebSocket `presence:*` / `sounds:*` channels require `bots:internal` (a [system-key-only permission](./permissions.md#system-key-only-permissions)), the audit ingest requires `audit:ingest`. The GraphQL `SystemGuard` takes the grant as an argument (`SystemGuard::new(grant)`); there is no type-only form. A system key without the grant is refused with `403` / `Missing permission: …`. ``` Authorization: Bearer lm_sys_... ``` ## Popout Tokens & Popout Sessions Popout tokens (`lm_pop_*`) back OBS browser sources and dashboard popout windows. Unlike overlay tokens they carry a **narrow RBAC permission subset** (never more than their creator holds) rather than being bound to a single overlay. The raw token appears **only** in the very first page request. On that first load a popout page **exchanges** it once, server-side, for a short-lived **popout session**; the page then redirects to the token-less URL and every subsequent proxied API call, WebSocket subscription, and SSR fetch authenticates through the session cookie. The raw `lm_pop_*` token is never forwarded to a proxied API call - proxy auth reads the session cookie only, so a logged-in operator can never be downgraded to token-scoped rights by a `?token=` left in a URL. ``` POST /v1/auth/popout/exchange { "token": "lm_pop_..." } # or GraphQL: mutation { exchangePopoutToken(token: "lm_pop_...") { token expiresAt accountId } } ``` - The response returns an `lm_`-prefixed **session JWT** and also sets it as an httpOnly cookie `lumio-popout-token` (`Path=/`, `SameSite=Lax`, `Secure` outside development, `Max-Age` = 15 min). Store it as an httpOnly cookie - never expose it to client-side JavaScript. - The session resolves to the **same** popout auth context as the raw token (identical account, permission subset, and OBS credential gate) - it is **not** a full user session, so it cannot escalate to the delegated user's roles. - **TTL is 15 minutes.** When it expires, the popout page transparently re-exchanges the still-valid raw `lm_pop_*` token for a fresh session. Every request additionally revalidates the origin token (revoked, expired, deleted), so deleting or revoking the popout token kills active sessions immediately; an open WebSocket closes on its next 30-second [credential re-validation](./websocket.md#credential-re-validation), or with `TOKEN_EXPIRED` when its session JWT expires. - Possession of a valid `lm_pop_*` token IS the authorization - the exchange endpoint has no permission guard, since presenting the raw popout token has always been proof of access. A revoked, expired, unknown, or non-popout token - or one assigned to a member who has left the account - is rejected with `Invalid or expired popout token`. - **WebSocket credential.** A popout mints its `/v1/ws` token via `issuePopoutWsToken` (REST twin `POST /v1/auth/popout/ws-token`) - **not** `issueWsToken`, which is session-only (ZAF-469 / ZAF-1742) and rejects a popout. The popout mint carries the popout's **own** permission subset (never the account owner's), a WebSocket-only use tag, and no `session_id`, so the API honors it **only** on the `/v1/ws` upgrade path. ``` Authorization: Bearer # forwarded from the httpOnly cookie ``` - **Profile writes: exactly two, both one-way.** A popout session is read-only for the represented user's profile (`updateMe` / `PATCH /v1/users/me` are first-party gated and reject it) with two deliberate exceptions for the seasonal look: **dismiss the season banner** (`popoutDismissSeasonBanner` / `POST /v1/auth/popout/season-banner/dismiss`) and **switch the seasonal look off** (`popoutSwitchOffSeasonLook` / `POST /v1/auth/popout/season-look/off`). Both only append a `:` instance to `season_banner_dismissed`; the second also sets `season_look_enabled = false`. No popout path can switch the look back on or change strength, motion, creatures, language or any other field - that stays in dashboard Settings. These two routes are **popout-only**: anonymous gets `401`, every other credential (user session, API key, overlay, shared-overlay, widget, extension) `403 This endpoint requires a popout session`. **Member-bound.** A popout token bound to a member stops working once that member is no longer part of the account: removing the member (or the member leaving) deletes the popout tokens assigned to them for that account, and the raw-token resolver, the exchange, the popout-session resolver and the WebSocket re-validation additionally refuse any token whose assigned member is neither a current member nor the owner of the token's account. **Always bound to an explicit user (ZAF-1922).** Every popout token resolves to the user it is bound to - creating without a user binds the creator, unassigning binds the editor - and there is no fallback to the account owner. A token row with no bound user is rejected by the raw-token resolver, the exchange and the WebSocket re-validation like an invalid token. A popout token's `user_id` is the user it is bound to, not the person presenting it, so first-party routes that treat the user id as the acting principal (profile edits, session revocation, notifications, developer application, account dissolution) use `first_party_user_id()` instead - that accessor returns a value only for `User` and `ApiKey` contexts and rejects the whole token class. ## Overlay Tokens Per-overlay access tokens for OBS browser sources, bound 1:1 to a specific overlay row. They carry no RBAC permissions: `has_permission()` returns `false` for every input, and they cannot reach REST or GraphQL resources. Their sole capability is subscribing to the `overlay:{key}` WebSocket channel whose resolved overlay matches the token's `overlay_id`. ``` ?token=lm_overlay_... Authorization: Bearer lm_overlay_... ``` Sessions authenticated with an overlay token are re-validated against the database every 30 seconds, like every token-authenticated WebSocket session (see [Credential re-validation](./websocket.md#credential-re-validation)); a rotated, revoked or deleted token is disconnected with `TOKEN_REVOKED`. ## Shared Overlay Tokens Time-limited links for sharing an overlay with collaborators or a backup streaming setup, without granting account access. Same capability envelope as an overlay token, plus an `expires_at`. Expiry is checked on every 5-second heartbeat tick (`TOKEN_EXPIRED`) and revocation on the 30-second [credential re-validation](./websocket.md#credential-re-validation) (`TOKEN_REVOKED`). ## Extension Tokens Short-lived tokens (15-minute TTL, auto-refreshed via `postMessage`) issued to an extension iframe. Scoped to a single extension installation: `extension_id`, `install_id`, and `account_id`. They carry no RBAC permissions - capability comes from the permissions the extension declared in `lumio.config.json` and was granted at install time, plus access to the installation's isolated storage. ## Widget Tokens Per-widget-instance access tokens for browser sources, bound 1:1 to a `widget_id`. No RBAC permissions; the sole capability is subscribing to the `widget:{widget_instance_id}` WebSocket channel matching the bound widget. ## Anonymous The fallback context when no credential is presented or the presented credential does not resolve. Anonymous callers reach only explicitly public surfaces (health probes, public plan/provider reads, the fonts proxy, platform webhooks, and WebSocket channels whose gate is `Public` or `PublicWhenFeature`). ## RBAC Authenticated requests are subject to role-based access control using `resource:action` permission strings, checked by `require_permission()` (REST), `PermissionGuard` (GraphQL), and the channel gate (WebSocket). See [Permissions](./permissions.md) for the full registry and [Feature Flags](/admin/feature-flags) for feature-level access control. --- ## API Reference / Permissions Source: https://docs.lumio.vision/api-reference/permissions # Permissions Lumio uses role-based access control (RBAC) to manage what each team member can do within an account. ## How It Works Each account has **roles** (e.g., Owner, Administrator, Moderator). Each role has a set of **permissions**. When a user performs an action, the system checks if their role grants the required permission. Permission checks run on **all four protocol surfaces** with the same `resource:action` strings: - **REST** - `require_permission()` in route handlers - **GraphQL** - `PermissionGuard` on resolvers - **WebSocket** - channel-subscribe gate in `crates/lo-websocket/src/gate.rs` - **Frontend** - `` and `hasPerm()` in client code The WebSocket layer maps each channel type to one of several gate kinds - a `resource:action` permission on the account in the channel name, a user-scoped permission, plain account scoping, a bound token type, or public. Most account-scoped streams use a `:read` permission (`chat:{account_id}` → `chat:read`, `events:{account_id}` → `events:read`). See [WebSocket → RBAC gate](./websocket.md#rbac-gate-subscribe-time) for the full mapping. A caller that fails the gate receives `code: "UNAUTHORIZED"` on subscribe. ## Default Roles Three roles are seeded for every new account, in this sort order: Owner, Administrator, Moderator. Accounts created earlier may have a fourth role named `viewer`: unused roles were deleted, while roles with a member or a redeemable invite are ordinary custom roles with unchanged permissions. ### Owner (`owner`) Full account access. System role (`is_system: true`, cannot be deleted). Holds every account permission - the list is loaded explicitly, not via a wildcard. ### Administrator (`administrator`) Everything Owner holds **except** `account:delete`, `plan:read`, `plan:edit`, and `extension-dev:payouts`. Account dissolution and plan changes are additionally enforced through an `owner_id` check rather than a permission. ### Moderator (`moderator`) Chat moderation, event monitoring, Spotify playback control, and read access across the rest of the account. | Category | Permissions | |----------|------------| | Events | `events:read`, `events:create`, `events:edit`, `events:userinfo` | | Overlays | `overlays:read` | | Spotify | `spotify:read`, `spotify:playback`, `spotify:queue`, `spotify:playlist`, `spotify:device` | | Chat | `chat:read`, `chat:write`, `chat:userinfo`, `chat:delete`, `chat:ban`, `chat:timeout`, `chat:notes`, `chat:raid`, `chat:pin`, `chat:refresh_user`, `chat:poll`, `chat:prediction`, `chat:automod-read`, `chat:automod-allow`, `chat:automod-deny` | | Connections | `connections:read` | | Uploads | `uploads:read` | | Rewards | `rewards:read` | | Automations | `automations:read`, `automations:execute` | | Members & Roles | `members:read`, `roles:read` | | OBS | `obs:read` | | Copyright | `copyright:vote`, `copyright:report`, `copyright:recommend` | | Bot | `bot-modules:read`, `bot-connections:read`, `bot-commands:read`, `chat-bots:read` | | Extensions | `extension-store:read`, `extension-store:review`, `extension-data:read` | | Widgets | `widgets:read` | | Sounds | `sounds:read`, `sounds:play` | | Stream History | `history:read`, `history:export` | | Setting Presets | `presets:share` | | Public Stats | `public-stats:read` | Custom roles can be created under **Dashboard > Settings > Roles**. ## Permission Reference The account registry holds **133** permissions, pinned by `account_permission_infos_has_expected_count` in `crates/lo-auth/src/rbac.rs`. One is assignable and seeded on the default roles but the API does not re-check it - the dashboard `` hides the UI, but the endpoints do not gate on them, so assigning or withholding them changes nothing on the API surface: | Permission | Where it takes effect | |-----------|----------------------| | `events:userinfo` | Dashboard only - `` hides the UI; the API does not re-check it | > `sessions:read`/`sessions:delete`, `automations:history`, and `events:delete` were **removed** from the account registry in ZAF-1094 - they gated nothing (session management is self-scoped, there is no automation-history surface, and only the system retention sweep deletes events). `copyright:report`/`copyright:recommend` are **not** unenforced: they carry a `PermissionGuard` on the GraphQL mutations `createCopyrightReport` / `createSafeRecommendation`. There is simply no dedicated REST route - reporting and recommending both go through `POST /v1/copyright/vote` under `copyright:vote`. The account role editor (`GET /v1/roles/permissions` and the GraphQL `accountPermissions` query) exposes **exactly** the account permissions listed below - the picker and the create/update validation registry share one source of truth (`get_all_account_permission_infos()`), so every permission you can assign is visible and vice-versa (ZAF-231). The admin panel's per-user and per-account permission-override pickers draw from the same source via the `adminAssignablePermissions` query (proxied as `GET /api/assignable-permissions`), so they list this exact set too and cannot drift (ZAF-430). ### Events | Permission | Description | |-----------|-------------| | `events:read` | View events, notifications, and event history | | `events:create` | Send test events and manage notifications | | `events:edit` | Edit stored events: hide the buyer name of a Shopify order (`hideShopifyOrderName`, `POST /v1/connections/shopify/events/{event_id}/hide-name`). Owner and Administrator | | `events:userinfo` | View user cards and profiles from event entries | ### Overlays | Permission | Description | |-----------|-------------| | `overlays:read` | View overlay configurations | | `overlays:create` | Create new overlays | | `overlays:edit` | Edit existing overlays | | `overlays:delete` | Delete overlays | | `overlays:access-read` | Read per-overlay access entries/candidates and see all overlays (access-manager visibility) | | `overlays:access-grant` | Grant (set) per-overlay access for a user | | `overlays:access-revoke` | Revoke per-overlay access for a user | ### Widgets | Permission | Description | |-----------|-------------| | `widgets:read` | View widget instances | | `widgets:create` | Create and duplicate widget instances | | `widgets:edit` | Edit widget configuration | | `widgets:delete` | Delete widget instances | | `widgets:access-read` | Read per-widget access entries/candidates and bypass per-widget restrictions (access-manager visibility) | | `widgets:access-grant` | Grant (set) per-widget access for a user | | `widgets:access-revoke` | Revoke per-widget access for a user | ### Extension Data Gates **direct** account-actor calls to an installed extension's server functions (dashboard editor, `lumio run` CLI). Widget runtime calls authenticated by an Extension Token are governed by the install's own scope and are not gated by these permissions. | Permission | Description | |-----------|-------------| | `extension-data:read` | Read installed-extension data via query functions (`queryRows`, query-category handlers) | | `extension-data:edit` | Mutate installed-extension data via `insertRow` / `patchRow` / `deleteRow` and mutation/action handlers | ### Spotify | Permission | Description | |-----------|-------------| | `spotify:read` | View now playing state, queue, devices, playlists, and search tracks | | `spotify:playback` | Control playback: play, pause, skip, previous, seek, shuffle, repeat | | `spotify:volume` | Change playback volume (gates the volume slider in the dashboard and popout player) | | `spotify:queue` | Add tracks to the playback queue | | `spotify:playlist` | Manage playlists: create, rename, delete, add/remove tracks, start playlist playback | | `spotify:device` | Transfer playback between Spotify devices | | `spotify:worker` | Start and stop the manual Spotify worker connect | ### Chat | Permission | Description | |-----------|-------------| | `chat:read` | View chat messages, history, and emotes | | `chat:write` | Send chat messages | | `chat:userinfo` | View user profiles, follow status, and moderation log | | `chat:delete` | Delete chat messages | | `chat:ban` | Ban and unban users | | `chat:timeout` | Timeout users | | `chat:notes` | View and manage user notes | | `chat:raid` | Cancel active raids | | `chat:pin` | Pin, re-time and unpin Twitch chat messages | | `chat:poll` | End active polls | | `chat:prediction` | End active predictions | | `chat:refresh_user` | Manually refresh a user's platform profile data | | `chat:automod-read` | View Twitch messages held by AutoMod in the live chat and, with `history:read`, in a session's AutoMod log | | `chat:automod-allow` | Allow a held Twitch message with the acting member's own Twitch sign-in | | `chat:automod-deny` | Deny a held Twitch message with the acting member's own Twitch sign-in | ### Connections | Permission | Description | |-----------|-------------| | `connections:read` | View connected platforms, credentials and live channel status (`channelStatus`, `GET /v1/channel-status`) | | `connections:create` | Connect new platforms | | `connections:edit` | Change connection settings: the Shopify customer label and order details (`updateShopifySettings`, `PUT /v1/connections/shopify/settings`) | | `connections:delete` | Disconnect platforms | ### Settings | Permission | Description | |-----------|-------------| | `settings:read` | View account settings | | `settings:edit` | Modify account settings | ### Automations | Permission | Description | |-----------|-------------| | `automations:read` | View automations and their configuration, and the handler logs of automation-node extension installs | | `automations:create` | Create new automations | | `automations:edit` | Edit automation metadata, nodes, edges | | `automations:delete` | Delete automations | | `automations:execute` | Manually trigger and start/stop automations | ### Members | Permission | Description | |-----------|-------------| | `members:read` | View the members list and all invitations | | `members:create` | Create invites, search users for direct invites | | `members:edit` | Change member roles, create/edit/delete custom roles | | `members:delete` | Remove members from the account, revoke pending invites | ### Uploads | Permission | Description | |-----------|-------------| | `uploads:read` | View uploaded files | | `uploads:create` | Upload new files | | `uploads:delete` | Delete uploaded files | ### Rewards | Permission | Description | |-----------|-------------| | `rewards:read` | View channel point rewards | | `rewards:create` | Create new rewards | | `rewards:edit` | Edit existing rewards | | `rewards:delete` | Delete rewards | ### Tokens | Permission | Description | |-----------|-------------| | `tokens:read` | View popout tokens | | `tokens:create` | Create popout tokens | | `tokens:edit` | Edit popout tokens | | `tokens:delete` | Revoke popout tokens | ### API Keys Personal **user API keys** (`lm_usr_…`) and account **service keys** (`lm_svc_…`) share one dedicated permission family, separate from popout tokens. | Permission | Description | |-----------|-------------| | `apikeys:read` | View API keys (personal `lm_usr_` and account service `lm_svc_`) | | `apikeys:create` | Create an API key (personal or account service key) | | `apikeys:edit` | Rename an API key (label only) **and rotate its secret** | | `apikeys:delete` | Revoke an API key | A key **owner** can **rotate** their own key's secret under `apikeys:edit` - GraphQL `rotateUserApiKey` / `rotateAccountServiceKey`, REST `POST /v1/api-keys/{id}/rotate` / `POST /v1/service-keys/{id}/rotate`. Rotation regenerates the secret in place (id, label and scopes unchanged) and returns the new full key exactly once. Creating or rotating an account service key requires an interactive sign-in session and the caller must hold every permission carried by the key, including on rotation. API keys cannot mint or rotate service keys. (Scopes themselves stay immutable on the owner surface - see below.) Platform operators can additionally manage **any** account's/user's keys, including editing scopes, under the distinct admin-scope [`account-apikeys:*`](#admin-account--user-api-keys) family. `tokens:create`, `tokens:edit` and `apikeys:create` only let a caller mint or edit a token/key **within their own permissions**: it can never be granted a permission the caller does not currently hold. Requesting one is rejected with `FORBIDDEN`, identically on GraphQL and REST. A key or popout token that mints or edits a credential grants from its own **capped** scope (see [API Keys](#api-keys)), so a scope that went dormant after a demotion cannot be copied onto a new credential. A `resource:*` grant (system keys only) lets the caller grant any action under that resource; a bare `*:*` / `*` is not a global wildcard (it grants nothing beyond a literal `*:*` / `*`). **API-key scope registry (ZAF-1169).** Unlike popout tokens - which keep a deliberately narrow, fixed list - an API key may carry **any permission the account/user behind it holds**, minus a small deny-list. The assignable-scope registry is derived from the RBAC registries and exposed on both protocols (GraphQL `apiKeyAssignablePermissions(kind)` / REST `GET /v1/api-keys/permissions?kind=personal|service|developer`), guarded on `apikeys:read` + `feature:apikeys`: - **Personal keys** (`lm_usr_`) can be assigned the account permissions **and** the user-scoped `ideas:*` family below - a personal key carries its member's user identity, so it can exercise user-scoped permissions (this is enforced end-to-end: `require_user_permission` accepts a personal API key that holds the scope). - **Service keys** (`lm_svc_`) can be assigned the account permissions only - an account-owned key has no user identity, so user-scoped permissions are meaningless on it and are never offered or honoured. - **Developer keys** (`lm_dev_`, ZAF-1176) can be assigned the **`extension-dev:*` family only** (derived from the account registry by prefix) - never an ordinary account scope such as `chat:ban`, never a user-scoped scope such as `ideas:*`. A developer key is a narrow publishing credential bound to a developer profile or team; it is minted from `/v1/developer/api-keys` (not the `/v1/api-keys` surface) and is **not** gated on `feature:apikeys`. - **Deny-list (all kinds), never assignable:** `apikeys:create`, `apikeys:edit`, `tokens:create` (credential minting - a key that mints credentials would outlive the human it belongs to) and `account:delete` (tenant destruction). Requested scopes are validated against this registry on create - an unknown or deny-listed string is rejected with `Invalid permission: {perm}` (the same message role writes use), **before** the subset check. Scopes are **immutable after creation**; a too-narrow key must be re-minted. ### Account | Permission | Description | |-----------|-------------| | `account:read` | View account settings and information | | `account:edit` | Edit account settings: the channel name/picture source, own name and own picture (`/v1/accounts/{id}/identity-source`, `/name`, `/avatar`), etc. | | `account:delete` | Dissolve (permanently delete) the account | ### Plan | Permission | Description | |-----------|-------------| | `plan:read` | View account plan and billing information | | `plan:edit` | Change account plan, manage subscription | ### Login Assignments Assignable to account roles, but enforced at the user level: own assignments are always allowed for any authenticated user, and these permissions only gate actions taken on behalf of *other* users on the account. | Permission | Description | |-----------|-------------| | `login-assignments:read` | View login assignments for the active account. Own assignments are always visible without this permission. | | `login-assignments:create` | Assign a login connection to an account on behalf of another user. Own assignments are always allowed without this permission. | | `login-assignments:delete` | Remove a login assignment for the active account on behalf of another user. Own assignments are always removable without this permission. | ### Sessions Session management is **not** an account-role permission. Every authenticated user can view and revoke their **own** active browser sessions regardless of account role (and with no active account selected). The surface is own-scoped on both GraphQL (`sessions` / `deleteSession` / `deleteAllOtherSessions`) and REST (`GET` / `DELETE /v1/users/me/sessions`): the acting user's own id selects the rows, and the only gate is the first-party principal check (ZAF-469 / ZAF-516). The former `sessions:read` / `sessions:delete` role permissions were **removed** in ZAF-1094 - they gated nothing. ### Extension Developer Extension developer permissions gate access to the developer dashboard and extension management. They are assignable to account roles and are also exercised through developer team RBAC. See [Team-Scoped Permissions](#team-scoped-permissions-developer-teams) for the team permission model. This is also the **entire** scope surface of a developer API key (`lm_dev_`): such a key can carry any subset of this family and nothing else (ZAF-1176). | Permission | Description | |-----------|-------------| | `extension-dev:read` | View extension developer dashboard and owned extensions | | `extension-dev:create` | Create new extensions | | `extension-dev:edit` | Edit owned extensions (metadata, code, assets) | | `extension-dev:delete` | Delete owned extensions | | `extension-dev:publish` | Submit extensions for review and publish approved versions | | `extension-dev:analytics` | View extension analytics (installs, usage, ratings) | | `extension-dev:payouts` | Manage extension revenue payouts | ### Sounds | Permission | Description | |-----------|-------------| | `sounds:read` | View sounds | | `sounds:create` | Upload sounds | | `sounds:edit` | Edit sound metadata | | `sounds:delete` | Delete sounds | | `sounds:play` | Play sounds in browser sources | ### Bot Modules | Permission | Description | |-----------|-------------| | `bot-modules:read` | View bot module configurations, and the handler logs of bot-module extension installs | | `bot-modules:edit` | Edit bot module configurations | ### Copyright | Permission | Description | |-----------|-------------| | `copyright:read` | View safe/blocked songs, copyright check results | | `copyright:edit` | Add to this account's safe/blocked lists, import playlists | | `copyright:delete` | Remove entries from safe/blocked lists | | `copyright:vote` | Vote on community copyright-status candidates | | `copyright:report` | Report a song's copyright status - GraphQL `createCopyrightReport` (`PermissionGuard`); no dedicated REST route (reporting goes through `POST /v1/copyright/vote` under `copyright:vote`) | | `copyright:recommend` | Recommend songs for the global list - GraphQL `createSafeRecommendation` (`PermissionGuard`); no dedicated REST route (recommending goes through `POST /v1/copyright/vote` under `copyright:vote`) | `copyright:moderate` is **not** an account permission - it is admin-scope (platform operator). See [Admin: Copyright Moderation](#admin-copyright-moderation). It was moved out of account scope in ZAF-742 because approving/dismissing vote candidates writes the account-less **global** catalogue. ### OBS | Permission | Description | |-----------|-------------| | `obs:read` | View OBS integration configuration and status | | `obs:edit` | Edit OBS integration settings and remote control | | `obs:delete` | Delete the account's OBS integration configuration | ### Roles | Permission | Description | |-----------|-------------| | `roles:read` | View account roles and the assignable-permission catalog | | `roles:edit` | Create and edit custom account roles | | `roles:delete` | Delete custom account roles (system roles are protected server-side) | Role create/update validates every permission string against the account registry. A wildcard (`resource:*` / `*:*`) is rejected on both REST and GraphQL and can never be persisted into an account role. ### Bot Commands | Permission | Description | |-----------|-------------| | `bot-commands:read` | View chat commands and their per-platform overrides | | `bot-commands:create` | Create chat commands | | `bot-commands:edit` | Edit chat commands and set overrides | | `bot-commands:delete` | Delete chat commands and clear overrides | ### Account Chat Bots | Permission | Description | |-----------|-------------| | `chat-bots:read` | Read the account bot list and automatically recognised bots (Owner, Administrator, Moderator) | | `chat-bots:edit` | Mark or unmark a platform account as a bot (Owner and Administrator) | These account-scoped permissions govern [Channel settings → Bots](../user-guide/channel-settings.md#bots) and the GraphQL/REST account-bot endpoints. They are separate from `bot-connections:*`, which manages the channel's connected bot identity. ### Bot Connections | Permission | Description | |-----------|-------------| | `bot-connections:read` | View the account's custom bot identities | | `bot-connections:create` | Start and complete the custom bot OAuth flow | | `bot-connections:delete` | Remove a custom bot identity | Declared in **both** account and admin scope with the same string; the admin-scope copy gates system-level bot connections through `require_admin_permission`. ### StreamElements | Permission | Description | |-----------|-------------| | `se-tokens:read` | View stored StreamElements JWT tokens | | `se-tokens:create` | Store a StreamElements JWT token | | `se-tokens:delete` | Delete a stored StreamElements JWT token | ### Extension Store | Permission | Description | |-----------|-------------| | `extension-store:read` | Browse the extension store and view installed extensions | | `extension-store:install` | Install an extension into the account | | `extension-store:uninstall` | Uninstall an extension from the account | | `extension-store:configure` | Change an installed extension's configuration | | `extension-store:review` | Write store reviews and ratings | ### Stream History | Permission | Description | |-----------|-------------| | `history:read` | View stream/session history, reports and stats; also unlocks the current paid subscriber count on `channelConnections.paidSubscriberCount` / `GET /v1/connections/channel?include=paid_subscriber_count` (`null` without it) | | `history:share` | Create, extend and revoke shareable report links | | `history:export` | Export filtered history lists and finalized reports (CSV/PDF/TXT/JSON) | | `history:delete` | Delete stored history sessions | | `history:revenue-read` | View estimated revenue, its converted amounts and assumptions; allow revenue in share links | | `history:revenue-edit` | Edit revenue assumptions | | `history:keywords-edit` | Add, change or remove tracked chat keywords; `history:read` permits listing and pattern tests | Default roles: **Owner** and **Administrator** hold all seven; **Moderator** holds `history:read` + `history:export`. Gated behind the `feature:stream_history` feature flag. ### Setting Presets | Permission | Description | |-----------|-------------| | `presets:share` | Share one of your own setting presets with the members of your active account | Default roles: **Owner**, **Administrator** and **Moderator** hold it. Only the *act of sharing* is gated - creating/editing/loading presets is user-scoped and needs no permission, and reading the shared section needs only account membership. Gated behind the `feature:setting_presets` feature flag. ### Public Stats | Permission | Description | |-----------|-------------| | `public-stats:read` | View this account's channel publicness settings for the public Stats app | | `public-stats:edit` | Toggle a channel's publicness / chatter-leaderboard suppression (opt-out / opt-in) | Default roles: **Owner** and **Administrator** hold both; **Moderator** holds `public-stats:read`. Gated behind the `feature:public_stats_page` feature flag. > **Crawler control moved to admin scope (ZAF-725).** The five `crawler:*` > permissions that steer the platform-wide stats crawler are **admin-scope** > (platform-operator) permissions, not account permissions - see > [Admin: Crawler](#admin-crawler) below. ### Audit Log | Permission | Description | |-----------|-------------| | `audit-log:read` | Read this account's audit log (`GET /v1/account/audit-log` / `accountAuditLog`) - role/permission changes, member-role assignment, channel connections | Default roles: **Owner** and **Administrator** hold it; **Moderator** does not. This account-scope `audit-log:read` is a **distinct** permission from the identically-named admin-scope `audit-log:read` (see [Admin: Support, Audit & StreamElements](#admin-support-audit--streamelements)): the account grant reads a single account's log through `require_permission`, the admin grant reads the operator audit surface through `require_admin_permission`, and holding one never unlocks the other. ## Admin-Scope Permissions Admin-scope permissions are checked against a user's **admin role** (not their account role). They gate the Lumio admin panel - `/admin`. The dashboard entry gate is `admin:access`, which is automatically included in every admin role. > **Note:** Some permissions (marked below) share an identical string with an account-scope permission but are enforced in a different context (`require_admin_permission` vs `require_permission`). This mirrors the `bot-connections:*` precedent. ### Admin: Core Access | Permission | Description | |-----------|-------------| | `admin:access` | Dashboard entry gate. Auto-injected into every admin role. Not a tenant bypass: it grants no access to another account's account-scoped surfaces (WebSocket channels, login assignments, OAuth callbacks). | ### Admin: Role Management | Permission | Description | |-----------|-------------| | `admin-roles:read` | View admin roles, their permissions, and member lists | | `admin-roles:create` | Create new admin roles | | `admin-roles:edit` | Edit role name, description, permissions, and user assignments | | `admin-roles:delete` | Delete admin roles (`is_system` roles are protected server-side) | ### Admin: Privacy | Permission | Description | |-----------|-------------| | `admin:privacy-erase` | Erase persisted personal data on one channel's instruction (GDPR Art. 17). Powers `DELETE /v1/admin/privacy/youtube/member/{id}` (clears Lumio's cached YouTube member records across all accounts, and erases the member's chat data in the instructing account only) and `POST /v1/admin/privacy/chat/erase` (erases a data subject's chat messages, profile, moderator notes, AutoMod and moderation-log rows, bot-list entry and stream-history entries in the instructing account only, by Lumio user id or `platform`+`user_id`; both require `account_id` and `instruction_reference`, the chat erasure also `reason`), `POST /v1/admin/privacy/lumio/erase` (erases a person on Lumio's own surfaces only: the Lumio Stats suppression entry and, on YouTube, Lumio's member cache; no channel data; requires `instruction_reference` and `reason`), `POST /v1/admin/privacy/records/erase` (erases one record in the instructing account: a chat message, moderator note, moderation-log entry, AutoMod-log entry, bot-list entry or one person in one stream report; requires `account_id`, `instruction_reference` and `reason`), and `POST /v1/admin/storage/orphaned-extension-uploads/sweep` (finds - and with `apply` queues for deletion - extension upload files no row references any more). The chat, Lumio-only and record erasures are also available in the admin app under **Moderation → Privacy Erasure** (`/privacy-erasure`). Reserved for support tooling; assigned only to `system_admin`. | | `admin:privacy-objection-create` | Record a chatter's GDPR Art. 21 objection in the global `chatter_suppressions`: the public Stats leaderboard masks the chatter on read, also on streams published earlier; channel reports, share links and AutoMod logs are not affected, and nothing is erased. Powers `POST /v1/admin/privacy/chat/objections` / GraphQL `addChatterObjection`. Assigned only to `system_admin`. | | `admin:privacy-objection-delete` | Lift a recorded chatter objection (never an erasure entry). Powers `DELETE /v1/admin/privacy/chat/objections/{platform}/{user_id}` / GraphQL `removeChatterObjection`. Assigned only to `system_admin`. | | `admin:privacy-erase-withdraw` | Withdraw one channel's erasure instruction for one chatter: deletes the chatter's row in that account's `account_chatter_suppressions`, so the channel's later reports name them again; nothing the erasure deleted comes back. Powers `POST /v1/admin/privacy/chat/erase/withdraw` / GraphQL `withdrawChannelChatterSuppression`. Assigned only to `system_admin`. | ### Admin: Developer Verifications | Permission | Description | |-----------|-------------| | `developer-verification:read` | View developer applications and their details | | `developer-verification:edit` | Approve or reject developer applications | ### Admin: Feature Flags | Permission | Description | |-----------|-------------| | `features:read` | View feature flags | | `features:edit` | Toggle feature flags | | `feature-flags:read` | View the feature-flag registry detail view | | `feature-flags:edit` | Edit feature-flag metadata (category, description, plan defaults) | ### Admin: Users & Accounts | Permission | Description | |-----------|-------------| | `users:read` | View users, login connections, and membership details | | `users:edit` | Edit user display name, email, overrides, and manage login connections | | `users:delete` | Delete users | | `accounts:read` | View account details, limits, features | | `accounts:edit` | Update account name, plan, limits, feature overrides, members, and reconnect flags | | `accounts:delete` | Delete accounts | | `accounts:overrides-read` | Read per-account permission overrides | | `accounts:overrides-edit` | Set or remove per-account permission overrides | ### Admin: Providers & Platforms | Permission | Description | |-----------|-------------| | `providers:read` | View platform provider configurations | | `providers:edit` | Toggle providers and sub-flags | | `platforms:read` | View platform connection configuration | | `platforms:edit` | Edit platform connection configuration | ### Admin: Infrastructure | Permission | Description | |-----------|-------------| | `system-keys:read` | View system API keys; also reads the assignable-scope registry (`adminSystemKeyPermissions` / `GET /v1/admin/system-key-permissions`, ZAF-1213) | | `system-keys:create` | Create system API keys (scopes validated against the derived assignable-scope registry, ZAF-1213) | | `system-keys:delete` | Delete system API keys | | `oauth-clients:read` | View OAuth client registrations | | `oauth-clients:create` | Create OAuth clients | | `oauth-clients:edit` | Edit OAuth clients | | `oauth-clients:delete` | Delete OAuth clients | | `system-connections:read` | View system-level platform connections | | `system-connections:edit` | Create and update system-level platform connections | | `system-connections:delete` | Delete system-level platform connections | ### Admin: Account & User API Keys Platform-operator management of **any** account's or user's API keys - personal `lm_usr_` keys (in `api_keys`) and account service `lm_svc_` keys (in `account_service_keys`) - for support and incident response. Admin-scope (checked via `require_admin_permission` / `AdminPermissionGuard`), seeded to `system_admin`. This is a **distinct** family from the account-scope [`apikeys:*`](#api-keys), which manages the caller's **own** keys; holding one never grants the other. | Permission | Description | |-----------|-------------| | `account-apikeys:read` | View any account's / user's API keys (never the secret) | | `account-apikeys:edit` | Edit an API key's label and scopes | | `account-apikeys:rotate` | Regenerate an API key's secret (new plaintext returned once) | | `account-apikeys:delete` | Revoke (delete) any API key | Scope edits validate against the ZAF-1170 assignable-scope registry for the key's kind (rejecting unknown, deny-listed, or user-scoped-on-service scopes). For a **personal** key the scopes are additionally capped to the key **owner's** own grantable permissions; for a **service** key the account is the principal, so the registry minus the deny-list is the ceiling - an operator can never push a key above its own principal. The rotated plaintext secret is returned exactly once, never in a list, never logged. Every operator action emits a **system-scope** audit event (`system:api_key_updated` / `system:api_key_rotated` / `system:api_key_revoked`) with the operator as the actor; these rows never surface in the target account's or user's own log. See [Admin: API Keys](../admin/api-keys.md). ### Admin: Developer Funds | Permission | Description | |---|---| | `developer-funds:release-deletion` | Release a developer balance Lumio cannot pay out (no payout method, identity not verified) as a liability and delete the profile or the user, on the person's request. Always combined with the delete permission (`users:delete` or `developer-verification:edit`) and an interactive session. | | `developer-funds:settle` | Record that a person presented the claim code of a released developer balance, and credit that balance to their new, verified developer profile. Interactive session required. | Admin scope, seeded to `system_admin` by `20261025200000_developer_released_balances` (`release-deletion`) and `20261025210000_developer_funds_settle_permission` (`settle`); never an account-role permission. GraphQL `adminReleaseFundsAndDeleteUser` / `adminReleaseFundsAndDeleteDeveloper` use `AdminPermissionGuard` plus the shared `services::developer_funds_release::require_operator`; the REST twins call the same helper. GraphQL `adminStartReleasedBalanceClaim` / `adminSettleReleasedBalance` use `AdminPermissionGuard(DEVELOPER_FUNDS_SETTLE)` plus `services::developer_funds_settlement::require_operator`; the REST twins `POST /v1/admin/released-balances/{claim,settle}` call the same helper. ### Admin: Contract Declarations The withdrawal and cancellation queue spans all accounts. These are admin-scoped grants, seeded to `system_admin`; they are not account-role permissions. | Permission | Description | |---|---| | `contract-declarations:read` | List and inspect declarations across accounts. | | `contract-declarations:edit` | Match an open public case, close a case, or extend retention; each mutation also requires an interactive session. | The admin page is [Contract Declarations](/admin/contract-declarations). REST uses `require_admin_permission`; GraphQL uses `AdminPermissionGuard`, with matching permission strings. ### Admin: Billing, Plans & Coupons | Permission | Description | |-----------|-------------| | `billing:read` | View billing data | | `billing:edit` | Modify billing data | | `plans:read` | View plans | | `plans:create` | Create plans | | `plans:edit` | Edit plans and their feature mappings | | `plans:delete` | Delete plans | | `subscriptions:read` | View subscriptions | | `subscriptions:edit` | Modify subscriptions | | `coupons:read` | View coupons | | `coupons:create` | Create coupons | | `coupons:edit` | Edit coupons | | `coupons:delete` | Delete coupons | ### Admin: Bot Control & Commands | Permission | Description | |-----------|-------------| | `bot-control:read` | View bot enabled/disabled status across all accounts | | `bot-control:edit` | Toggle the bot on or off for an account | | `bot-commands:read` | View chat commands across all accounts | | `bot-commands:create` | Create chat commands on any account | | `bot-commands:edit` | Edit chat commands on any account | | `bot-commands:delete` | Delete chat commands on any account | | `bot-connections:read` | View system bot connections | | `bot-connections:create` | Create system bot connections | | `bot-connections:delete` | Delete system bot connections | | `discord-guilds:read` | View Discord guild connections across all accounts | | `discord-guilds:delete` | Delete a Discord guild connection | ### Admin: Support, Audit & StreamElements | Permission | Description | |-----------|-------------| | `support:read` | View support tickets | | `support:edit` | Handle support tickets | | `support:verify` | Reveal or check a user's hourly support code and read recent checks; admin scope only. GraphQL and REST use the same admin guard. | | `audit:read` | Read the audit log | | `audit:ingest` | Server-to-server user-scope audit ingest - the narrow grant `apps/id` needs to write `user:login` / `user:oauth_granted` to `POST /v1/internal/audit-ingest`. Declared in the admin registry so the [system-key mint](../admin/system-keys.md) accepts it, but **system-key-only in effect**: `authorize_ingest` requires an `AuthContext::System`, so a human admin holding it in a role can never write. Never seeded into any admin role (ZAF-1212). | | `audit-log:read` | Read the admin (system-scope) audit log (`GET /v1/admin/audit-log`). Distinct from the identically-named **account-scope** [`audit-log:read`](#audit-log), which reads a single account's log via `require_permission`; this admin copy is checked via `require_admin_permission` and holding one never unlocks the other. | | `csp-reports:read` | Read the CSP-violation calibration aggregate - `cspViolationReports` / REST `GET /v1/admin/csp-reports`. Distinct `(violated_directive, blocked_host)` pairs with counts, from the capped `csp_violation_reports` TimescaleDB table (≤5000 pairs, ≤30 d rolling). Admin-scope (platform operator) - seeded to `system_admin`, not assignable to any tenant role (ZAF-1051). | | `devtools:read` | Open the Dev & Admin menu in the web app (`lumio.vision`, production included). A pure UI-visibility grant: the web app reads it from `me { adminPermissions }` / REST `GET /v1/users/me` (`admin_permissions`) - both resolve the same admin-role rows - and no resolver or route is guarded on it. The menu's actions (e.g. previewing a seasonal look) change only the viewer's own browser and are never sent to the server, so it emits no audit event. Admin-scope (platform operator) - seeded to `system_admin`, assignable to further admin roles via [Admin Roles](../admin/admin-roles.md), not assignable to any tenant role (ZAF-1668). | | `se-tokens:read` | View StreamElements tokens across all accounts | | `se-tokens:delete` | Delete a StreamElements token on any account | ### Admin: Extensions & Developers | Permission | Description | |-----------|-------------| | `extension-review:read` | View the extension review queue and submission details | | `extension-review:edit` | Approve, reject, or request changes on extension submissions | | `developer-limits:read` | View developer rate limits and quotas | | `developer-limits:edit` | Edit developer rate limits and quotas | | `extension-purchases:read` | Read paid extension checkouts across all accounts that were never delivered - GraphQL `adminUndeliveredExtensionPurchases` / REST `GET /v1/admin/extension-purchases/undelivered` ([Admin › Extension Purchases](../admin/extension-purchases.md)). Admin scope - seeded to `system_admin`, not assignable to any tenant role (ZAF-2745). | ### Admin: Ideas Moderation | Permission | Description | |-----------|-------------| | `ideas:moderate_read` | View all ideas with moderation details (reports, flags, internal notes) | | `ideas:moderate_status` | Change idea status (approve, reject, mark as planned, close) | | `ideas:moderate_edit` | Edit any idea regardless of ownership | | `ideas:moderate_delete` | Delete any idea regardless of ownership | | `ideas:moderate_comment` | Delete any comment on ideas regardless of ownership (comment editing is author-only) | ### Admin: Copyright Moderation | Permission | Description | |-----------|-------------| | `copyright:moderate` | Approve/dismiss community vote candidates into the **global** cross-account safe/blocked catalogue (`approveReport`/`dismissReport` + REST `/v1/copyright/vote-candidates/{track_id}/approve`·`/dismiss`). Admin-scope (platform operator) - seeded to `system_admin`, not assignable to any tenant role (ZAF-742). | ### Admin: User Roles | Permission | Description | |-----------|-------------| | `user-roles:read` | View user roles and their permissions | | `user-roles:create` | Create new user roles | | `user-roles:edit` | Edit user role name, description, permissions, and assignments | | `user-roles:delete` | Delete user roles (`is_system` roles are protected server-side) | ### Admin: Crawler Platform-operator control of the public stats crawler - a single **global** resource (one crawler, one global scope flip). Moved from account scope to admin scope in ZAF-725; enforced via `require_admin_permission` / `AdminPermissionGuard` and seeded to the `system_admin` role only. | Permission | Description | |-----------|-------------| | `crawler:scope-read` | Read the public stats crawler's scope mode (`GET /v1/crawler/scope` / `crawlerScope`) | | `crawler:scope-edit` | Set the crawl scope mode, incl. the opt-in `global` flip (`PUT /v1/crawler/scope` / `setCrawlerScope`) | | `crawler:watchlist-read` | List the crawl watchlist (`GET /v1/crawler/watchlist` / `crawlerWatchlist`) | | `crawler:watchlist-create` | Add a channel to the crawl watchlist (`POST /v1/crawler/watchlist` / `addCrawlerWatchlistChannel`) | | `crawler:watchlist-delete` | Remove a channel from the crawl watchlist (`DELETE /v1/crawler/watchlist/{platform}/{platform_channel_id}` / `removeCrawlerWatchlistChannel`) | Actions are granular (the coarse `:manage` is disallowed); each scope change or watchlist mutation emits a **system-scope** audit event. The Admin app additionally gates entry on `admin:access`. ### Admin: Shared with Account Scope Seventeen permission strings are declared in **both** admin scope and account scope. They share the same string value but are enforced by different guards (`require_admin_permission` against the user's admin role vs `require_permission` against their account role), so holding one does not grant the other: | Permissions | Category | |-------------|----------| | `copyright:read`, `copyright:edit`, `copyright:delete` | Copyright | | `obs:read`, `obs:edit` | OBS | | `bot-modules:read`, `bot-modules:edit` | Bot Modules | | `bot-commands:read`, `bot-commands:create`, `bot-commands:edit`, `bot-commands:delete` | Bot Commands | | `bot-connections:read`, `bot-connections:create`, `bot-connections:delete` | Bot Connections | | `se-tokens:read`, `se-tokens:delete` | StreamElements | | `audit-log:read` | Audit Log | ## System-Key-Only Permissions Grants that exist **only** for [system keys](../admin/system-keys.md). They are listed in `lo_auth::rbac::system_key::SYSTEM_ONLY_PERMISSIONS`, offered by the system-key mint registry (`adminSystemKeyPermissions` / `GET /v1/admin/system-key-permissions`), and absent from the account, user and admin (operator) registries - so no role can ever hold them: `create_role` / `update_role` reject them as unknown permissions on both protocols. Each guarded site checks them with `AuthContext::require_system_permission`, which requires a System key **and** the grant. | Permission | Description | |-----------|-------------| | `bots:internal` | The bot fleet's internal surface (ZAF-1922): GraphQL `botChannels`, `botCommandsForAccount`, `emitBotEvent`, `discordBotGuilds`, `discordBotGuildFeatures`, `discordBotAllGuildFeatures`, `updateDiscordGuildInfo`, `removeDiscordGuildByBot`, the bot chat ingest (`sendChatMessage` / `POST /v1/chat/message`) and the REST feeds `GET /v1/internal/bot-modules/{account_id}/triggers`, `GET /v1/internal/bot-modules/handlers`, `GET /v1/internal/automation-nodes/handlers`; and the cross-account extension and live surfaces (ZAF-1976): extension server functions on any install (`extensionQuery`, `extensionMutate`, `extensionExecuteHandler`; REST `POST /v1/extension-installs/{install_id}/{query,mutate,execute}/{function_name}`), draft/testing extension file trees (`extensionVersionFiles`; REST `GET /v1/extensions/{id}/versions/{version}/files`) and bundle bytes (`GET /v1/extension-bundles/...`), and the WebSocket `presence:*` and `sounds:*` channels. A non-System caller gets `FORBIDDEN` (`System access required`; REST feeds: `System key required for internal endpoints`); a System key without the grant gets `Missing permission: bots:internal`. Carried by the keys of `twitch-bot`, `kick-bot`, `youtube-bot`, `discord-bot`, `bot-module-worker` and `automation-worker`. | `audit:ingest` (see [Admin: Support, Audit & StreamElements](#admin-support-audit--streamelements)) is system-key-only **in effect** by a different route: it is declared in the admin registry, but its only consumer requires a System key. ## User-Scoped Permissions User-scoped permissions are **global** - they are not tied to a specific account. They control access to platform-wide features that exist outside the account context, such as the Ideas Hub. User-scoped permissions are assigned via **user roles** (`user_roles` / `user_role_permissions`), not account roles. Enforcement uses `auth.require_user_permission()` on the backend, which is satisfied by a logged-in user session **or a personal API key** (`lm_usr_`) that carries the scope - a personal key acts with its member's user identity (ZAF-1169). An account **service key** (`lm_svc_`) has no user identity and is always rejected here, fail-closed. ### Ideas Hub | Permission | Description | |-----------|-------------| | `ideas:read` | Read ideas | | `ideas:create` | Create new ideas | | `ideas:edit` | Edit own ideas | | `ideas:delete` | Delete own ideas | | `ideas:vote` | Vote on ideas | | `ideas:comment_read` | Read comments on ideas | | `ideas:comment_create` | Create comments on ideas | | `ideas:comment_edit` | Edit own comments | | `ideas:comment_delete` | Delete own comments | | `ideas:comment_vote` | Vote on comments and replies | ### Default User Roles Three system user roles are seeded by default: #### Member Assigned to all users automatically. Grants full Ideas Hub participation. | Category | Permissions | |----------|------------| | Ideas | `ideas:read`, `ideas:create`, `ideas:edit`, `ideas:delete`, `ideas:vote` | | Comments | `ideas:comment_read`, `ideas:comment_create`, `ideas:comment_edit`, `ideas:comment_delete`, `ideas:comment_vote` | #### Restricted Limited to read-only access. | Category | Permissions | |----------|------------| | Ideas | `ideas:read` | | Comments | `ideas:comment_read` | #### Moderator Full user permissions plus admin-level moderation capabilities. The five `ideas:moderate_*` permissions are admin-scoped - see [Admin: Ideas Moderation](#admin-ideas-moderation) above. | Category | Permissions | |----------|------------| | Ideas | `ideas:read`, `ideas:create`, `ideas:edit`, `ideas:delete`, `ideas:vote` | | Comments | `ideas:comment_read`, `ideas:comment_create`, `ideas:comment_edit`, `ideas:comment_delete`, `ideas:comment_vote` | | Moderation (admin-scoped) | `ideas:moderate_read`, `ideas:moderate_status`, `ideas:moderate_edit`, `ideas:moderate_delete`, `ideas:moderate_comment` | ## Team-Scoped Permissions (Developer Teams) Team-scoped permissions control what each member can do within a developer team. They are separate from both account-scope and admin-scope permissions and are defined in `crates/lo-auth/src/rbac.rs::team`. Team management uses `require_team_permission()` in REST and an equivalent GraphQL helper. Extension actions use the same shared team-role check on both protocols after ownership is established. That check reads the role from the database, so a role change applies on the next extension request. Missing grants return `403 Missing team permission: `; non-members fail the ownership check first. ### Extension Development | Permission | Description | |-----------|-------------| | `team-extensions:create` | Create extensions within the team | | `team-extensions:edit` | Update metadata; upload or delete icons and screenshots | | `team-extensions:delete` | Delete a draft extension or request deletion | | `team-extensions:publish` | Submit a version for review | | `team-extensions:analytics` | View extension analytics | | `team-versions:create` | Create or upload an extension version | | `team-versions:edit` | Edit extension versions | ### Secrets | Permission | Description | |-----------|-------------| | `team-secrets:read` | List the key names of a team extension's secrets (`…/secrets`, `developerExtensionSecrets`) | | `team-secrets:edit` | Set or delete a team extension's secrets; create, rename and revoke the team's developer API keys | ### Testers | Permission | Description | |-----------|-------------| | `team-testers:read` | List testers, private access grants, and access invites | | `team-testers:invite` | Invite testers, grant private access, and create access invites | | `team-testers:remove` | Remove testers, revoke private access, and delete access invites | ### Team Members | Permission | Description | |-----------|-------------| | `team-members:read` | Read team member list | | `team-members:invite` | Invite new team members | | `team-members:edit` | Edit team member roles | | `team-members:remove` | Remove team members | ### Team Settings | Permission | Description | |-----------|-------------| | `team-settings:read` | Read team settings | | `team-settings:edit` | Edit team settings | ### Payouts | Permission | Description | |-----------|-------------| | `team-payouts:read` | Read team payout information | | `team-payouts:edit` | Edit team payout configuration | `team-versions:edit`, `team-settings:read`, and `team-payouts:read`/`team-payouts:edit` are present in the team-role catalogue, but no current extension action checks those grants. Assigning one does not grant a version-edit or payout action by itself. ### Default Team Roles Three roles are seeded for every new developer team: #### Owner (`team-owner`) System role (cannot be deleted). All 20 team permissions. #### Admin (`team-admin`) Manage extensions and team members. All permissions except `team-settings:edit`, `team-payouts:edit`, and `team-members:remove`. #### Member (`team-member`) View and develop extensions. Permissions: `team-extensions:create`, `team-extensions:edit`, `team-extensions:analytics`, `team-versions:create`, `team-members:read`, `team-secrets:read`, `team-settings:read`. Members can edit and create versions, but cannot submit, delete, manage private access or testers, or list testers without a custom role granting those actions. Solo extensions have no team-role gate. A developer API key bound to a team can act only on that team's extensions. ## Popout Tokens Popout tokens inherit specific permissions when created. A popout token can only have permissions that the creating user's role grants. For example, a Moderator cannot create a popout token with `settings:edit`. The `/popout/test-events` link needs `events:create` in its own permission list to show the catalog and send a test. The Events feed popout uses `events:read`. A token without `events:create` sees a permission notice on the test page and cannot send. `spotifyManualStatus` remains gated on `spotify:read`; live `channelStatus` uses `connections:read` on GraphQL and REST. ## Overlay Tokens Overlay tokens (`lm_overlay_*`) have **no RBAC permissions**. They cannot access any REST or GraphQL endpoint. Their sole capability is subscribing to the `overlay:{key}` WebSocket channel where the token's bound `overlay_id` matches the resolved overlay key. - `has_permission(_)` always returns `false` - Cannot broadcast to any channel - Cannot subscribe to any channel other than the one matching their bound overlay - Rate-limited at 600 requests/min (same tier as Popout tokens) ## Shared Overlay Tokens Shared overlay tokens (`lm_share_*`) provide temporary, time-limited access to a specific overlay. They are designed for sharing overlays with collaborators or backup streaming setups without granting permanent account access. - Prefix: `lm_share_` (9 characters) - Body: 32 random bytes, hex-encoded (64 characters) - Total length: 73 characters - Transport: `?token=lm_share_…` query parameter or `Authorization: Bearer lm_share_…` header - Rate-limited at 600 requests/min (same tier as Popout and Overlay tokens) - `has_permission(_)` always returns `false` - Cannot access REST or GraphQL endpoints - Can only subscribe to the `overlay:{key}` WebSocket channel matching the token's bound overlay - Server checks expiry every heartbeat tick (5s) - disconnects with `TOKEN_EXPIRED` when elapsed - Server checks DB revocation every 30 seconds - disconnects with `TOKEN_REVOKED` if revoked (the same [credential re-validation](./websocket.md#credential-re-validation) every token-authenticated WebSocket session gets) ### Token Types | Type | Prefix | Length | Purpose | |------|--------|--------|---------| | System Key | `lm_sys_` | 71 | Internal service-to-service auth | | User API Key | `lm_usr_` | 71 | External API access | | JWT | `lm_` + `eyJ...` | variable | Session-based auth | | Popout Token | `lm_pop_` | 71 | Overlay/popout access with a scoped permission subset | | Overlay Token | `lm_overlay_` | 75 | Per-overlay browser source access | | Shared Overlay Token | `lm_share_` | 73 | Temporary shared overlay link | | Extension Token | `lm_ext_` | 71 | Extension iframe, scoped to one install | | Widget Token | `lm_widget_` | 74 | Per-widget-instance browser source access | Popout tokens accept an optional `expires_at`; absent or `null` means the token never expires. See [Authentication](./authentication.md) for transports and rate limits. ## Extension Permissions Extension tokens (`lm_ext_*`) have no RBAC permissions (`has_permission()` always returns `false`). Instead, extensions declare required permissions in `lumio.config.json` and are granted only those capabilities at install time. The Lumio API validates each action call against the extension's declared permission set. ### Available Extension Permissions The manifest validator accepts exactly these strings in `lumio.config.json` `permissions` - anything else fails validation at upload: | Permission | Description | Review level | |-----------|-------------|--------------| | `events:read` | Read events from the Lumio event bus | Standard | | `events:emit` | Emit custom events | Standard | | `chat:read` | Read chat messages | Standard | | `chat:send` | Send chat messages | Standard | | `chat:delete` | Delete chat messages | Enhanced | | `chat:ban` | Ban and unban users, timeout users | Enhanced | | `obs:set_scene` | Switch the active OBS scene | Standard | | `obs:set_source_visible` | Toggle OBS source visibility | Standard | | `obs:start_stream` | Start the OBS stream | Standard | | `obs:stop_stream` | Stop the OBS stream | Standard | | `overlay:update_layer` | Update overlay layer properties | Standard | **Enhanced review:** Extensions requesting `chat:ban` or `chat:delete` receive additional scrutiny during the approval process. Reviewers verify that the extension implements appropriate safeguards (e.g., confirmation prompts, configurable thresholds, exempt role support) and that the moderation logic does not create excessive false positives. The `chat:ban` permission grants `ban`, `unban`, and `timeout` capabilities through a single declaration. ### Action-to-declaration mapping At call time, each action resolves to the manifest permission the install must have declared; an undeclared permission is rejected before the action runs: | Action | Required declaration | |--------|---------------------| | `chat:send`, `chat:delete` | `chat:read` | | `events:emit` | `events:read` | | `overlay:update_layer` | `overlays:edit` | | `obs:set_scene`, `obs:set_source_visible`, `obs:start_stream`, `obs:stop_stream` | `obs:control` | `obs:control` and `overlays:edit` are the strings this mapping checks for; they are not among the values the manifest validator accepts, so the OBS and overlay-layer actions cannot currently be authorised through a validated manifest. ## API Keys A User API key carries an explicit permission list stored on the key row. At creation that list can never exceed what the creator holds. On every request the key is also capped to its owner's **current** role: the auth middleware keeps only the stored scopes that the owner's account-role and user-scoped permissions still grant. A member demoted from Administrator to Viewer therefore loses every Administrator scope on their keys at the next request, and a re-promotion restores them, because the stored list is never rewritten. Popout tokens and popout sessions are capped to their bound member the same way. The ceiling comes from the same permission cache a signed-in session uses, so a role change or role edit reaches keys exactly as fast as it reaches the dashboard. If the ceiling cannot be read, the request fails with `503 authorization_unavailable`; it never falls back to the stored list. Account owners without a membership row are capped to the account's owner role. `GET /v1/tokens/me` (GraphQL `myPermissions`) reports the capped scope, never a wildcard. Key and popout-token responses carry both `permissions` (stored) and `effective_permissions` (what authorizes requests now); a stored scope missing from `effective_permissions` is dormant. Service keys (`lm_svc_`) and developer keys (`lm_dev_`) have no bound member and are not capped this way. Keys may carry an `expires_at`; an expired key resolves to `Anonymous` rather than erroring. Rate limits differ by auth type - see [Authentication](./authentication.md). --- ## Developer Guide / RBAC & Permissions Source: https://docs.lumio.vision/developer-guide/rbac-permissions # RBAC & Permissions Lumio uses a role-based access control (RBAC) system with granular permissions. Every authenticated action is checked against the user's assigned role for the active account. ## Default Roles Every new account is created with three default roles: | Role | Slug | Color | `is_system` | `is_default` | Notes | |------|------|-------|:-----------:|:------------:|-------| | Owner | `owner` | `#f59e0b` | true | true | Every account registry permission, listed explicitly in `ROLE_OWNER`. Cannot be edited or deleted. | | Administrator | `administrator` | `#ef4444` | false | true | Everything except `account:delete`, `plan:read`, `plan:edit`, and `extension-dev:payouts`. | | Moderator | `moderator` | `#22c55e` | false | true | Chat moderation, event monitoring, read access (49 permissions). | The role rows are seeded by `create_default_roles()` in `apps/api/src/db/roles.rs` from the `DEFAULT_ROLES` slice in `crates/lo-auth/src/rbac.rs`; `is_default` is set to `true` for all three at insert time. ### Role Properties | Property | Type | Description | |----------|------|-------------| | `slug` | `string` | Machine-readable identifier. Stable, lowercase, used in code checks. Never changes. | | `name` | `string` | Human-readable display name. Editable on custom roles, translated for default roles. | | `is_system` | `bool` | Cannot be edited or deleted. Only the Owner role. | | `is_default` | `bool` | Cannot be deleted. All three default roles have this set. | | `color` | `string` | Hex color for UI display (e.g., `"#f59e0b"`). | ### Role Name Translation Default role names are translated via the `useTranslateRole()` hook in `apps/web/src/lib/translate-role.ts`. The hook returns a `(slug, name) => string` function that checks if the slug matches a known default role and returns the localized name; custom roles fall back to their `name` field. Translation keys live in `apps/web/messages/{en,de}.json` under `"roles.labels"`: ```json { "roles": { "labels": { "owner": "Owner", "administrator": "Administrator", "moderator": "Moderator" } } } ``` ## Permission Format All permissions follow the `resource:action` format. Examples: - `events:read`, `events:create` - `chat:ban`, `chat:timeout`, `chat:notes` - `automations:execute` ### Rules 1. **Always granular.** Every resource needs at minimum `read` plus granular `create`/`edit`/`delete`. 2. **Domain-specific actions** are added when CRUD is insufficient (e.g., `automations:execute`, `chat:ban`). 3. **Never use wildcards.** No `resource:*` / `*:*` account-level permissions. Account actions are always granular verbs - there is no coarse `:manage` action; management surfaces are split into discrete permissions (for example `overlays:access-read` / `overlays:access-grant` / `overlays:access-revoke`). ## Account Permissions The tables below summarize the major account permission categories. The authoritative list of constants lives in `crates/lo-auth/src/rbac.rs` (under `pub mod account {}`) - treat that file as the source of truth; categories and counts may shift as features evolve. The registry holds **133** account permissions across 31 categories (pinned by `account_permission_infos_has_expected_count`): API Keys, Account, Audit Log, Automations, Bot Commands, Bot Connections, Bot Modules, Chat, Connections, Copyright, Events, Extension Data, Extension Developer, Extension Store, Login Assignments, Members, OBS, Overlays, Plan, Public Stats, Rewards, Roles, Setting Presets, Settings, Sounds, Spotify, Stream History, StreamElements, Tokens, Uploads, Widgets. Crawler control belongs to admin scope (see [Admin-Scope Permissions → Crawler](#crawler-admin-scope)). For the complete permission-by-role matrix see [Complete Permissions Reference](./roles-permissions-guide.md#complete-permissions-reference). `lo_auth::rbac::account::get_all_account_permission_infos()` is the **single source of truth** for the account permission set - it returns every account permission with its picker `label` and `category`. Both role-editor pickers (REST `GET /v1/roles/permissions` and GraphQL `availablePermissions`, both guarded on `roles:read`) map this list, and the create/update validation registry `get_all_account_permissions()` derives its key set from it. As a result the role editor exposes **exactly** the set of assignable permissions - there are no "shadow" permissions that validation accepts but the editor hides (ZAF-231). The **admin-app permission-override pickers** derive from the same registry (ZAF-430). Both the per-user override picker (`/admin/users/[id]`) and the per-account override picker (`/admin/accounts/[id]`) fetch the registry from the API - the admin proxy `GET /api/assignable-permissions` calls the `adminAssignablePermissions` GraphQL query (guarded `admin:access`), which maps `get_all_account_permission_infos()`. They hold **no** hardcoded permission arrays, so a new registry key appears in every picker automatically and the admin pickers can never drift from validation. (Before ZAF-430 these two pickers carried hand-maintained copies of the registry that had silently drifted.) ### Events | Permission | Label | |-----------|-------| | `events:read` | Read Events | | `events:create` | Create Events | | `events:edit` | Edit Events | | `events:userinfo` | Event User Info | ### Overlays (7) | Permission | Label | |-----------|-------| | `overlays:read` | Read Overlays | | `overlays:create` | Create Overlays | | `overlays:edit` | Edit Overlays | | `overlays:delete` | Delete Overlays | | `overlays:access-read` | Read Overlay Access | | `overlays:access-grant` | Grant Overlay Access | | `overlays:access-revoke` | Revoke Overlay Access | ### Widgets (7) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `widgets:read` | View Widgets | x | x | x | | `widgets:create` | Create Widgets | x | x | | | `widgets:edit` | Edit Widgets | x | x | | | `widgets:delete` | Delete Widgets | x | x | | | `widgets:access-read` | Read Widget Access | x | x | | | `widgets:access-grant` | Grant Widget Access | x | x | | | `widgets:access-revoke` | Revoke Widget Access | x | x | | ### Extension Data (2) Gates direct account-actor calls to an installed extension's server functions. Widget-runtime calls made with an Extension Token use the install's own scope and are not checked against these permissions. | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `extension-data:read` | Read Extension Data | x | x | x | | `extension-data:edit` | Edit Extension Data | x | x | | ### Spotify (7) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `spotify:read` | Read Spotify | x | x | x | | `spotify:playback` | Spotify Playback | x | x | x | | `spotify:volume` | Spotify Volume | x | x | | | `spotify:queue` | Spotify Queue | x | x | x | | `spotify:playlist` | Spotify Playlists | x | x | x | | `spotify:device` | Spotify Devices | x | x | x | | `spotify:worker` | Spotify Worker | x | x | | ### Chat (16) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `chat:read` | Read Chat | x | x | x | | `chat:write` | Write Chat | x | x | x | | `chat:userinfo` | Chat User Info | x | x | x | | `chat:delete` | Delete Chat Messages | x | x | x | | `chat:ban` | Ban Chat Users | x | x | x | | `chat:timeout` | Timeout Chat Users | x | x | x | | `chat:notes` | Chat User Notes | x | x | x | | `chat:raid` | Cancel Raids | x | x | x | | `chat:pin` | Manage Twitch chat pins | x | x | x | | `chat:poll` | End Polls | x | x | x | | `chat:prediction` | End Predictions | x | x | x | | `chat:refresh_user` | Refresh User Profile | x | x | x | | `chat:automod-read` | Read AutoMod Queue | x | x | x | | `chat:automod-allow` | Allow Held Messages | x | x | x | | `chat:automod-deny` | Deny Held Messages | x | x | x | The live queue uses `chat:automod-read`; the session AutoMod log also needs `history:read`. Allow and deny use separate permissions and the acting member's Twitch login. Popout tokens can carry all three AutoMod permissions for the live chat, but cannot read the session log. AutoMod rows in `mergedChatHistory` and `GET /v1/chat/history/merged` require `feature:multichat`, `chat:read` and `chat:automod-read`; `history:read` permits the account's full chat plan window, otherwise the server returns only holds from the last 24 hours. Members, user and service keys, and popout tokens use that same window rule. ### Connections | Permission | Label | |-----------|-------| | `connections:read` | Read Connections and channel live status (`channelStatus`, `GET /v1/channel-status`) | | `connections:create` | Create Connections | | `connections:edit` | Edit Connections | | `connections:delete` | Delete Connections | ### Settings (2) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `settings:read` | Read Settings | x | x | | | `settings:edit` | Edit Settings | x | x | | ### Members (4) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `members:read` | Read Members | x | x | x | | `members:create` | Create Invites | x | x | | | `members:edit` | Edit Members | x | x | | | `members:delete` | Delete Members | x | x | | ### Roles (3) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `roles:read` | Read Roles | x | x | x | | `roles:edit` | Edit Roles | x | x | | | `roles:delete` | Delete Roles | x | x | | ### Uploads | Permission | Label | |-----------|-------| | `uploads:read` | Read Uploads | | `uploads:create` | Create Uploads | | `uploads:delete` | Delete Uploads | ### Sounds (5) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `sounds:read` | Read Sounds | x | x | x | | `sounds:create` | Upload Sounds | x | x | | | `sounds:edit` | Edit Sounds | x | x | | | `sounds:delete` | Delete Sounds | x | x | | | `sounds:play` | Play Sounds | x | x | x | ### Stream History (7) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `history:read` | Read Stream History | x | x | x | | `history:share` | Share History Reports | x | x | | | `history:export` | Export History Reports | x | x | x | | `history:delete` | Delete Stream History | x | x | | | `history:revenue-read` | Read Estimated Revenue | x | x | | | `history:revenue-edit` | Edit Revenue Assumptions | x | x | | | `history:keywords-edit` | Edit Tracked Keywords | x | x | | Gated behind the `feature:stream_history` feature flag. ### Setting Presets (1) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `presets:share` | Share Setting Presets | x | x | x | Only the *act of sharing* a preset with your account is gated; creating/editing/loading presets is user-scoped (no permission), and reading the shared section needs only account membership. Gated behind the `feature:setting_presets` feature flag. ### Public Stats (2) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `public-stats:read` | Read Public Stats Settings | x | x | x | | `public-stats:edit` | Edit Public Stats Settings | x | x | | Gated behind the `feature:public_stats_page` feature flag. > **Crawler control moved to admin scope (ZAF-725).** The five `crawler:*` > permissions are no longer account permissions - steering the single global > stats crawler is a platform-operator action, so they now live in `pub mod > global` and are gated by `AdminPermissionGuard` / `require_admin_permission`. > See [Admin-Scope Permissions → Crawler](#crawler-admin-scope). ### Audit Log (1) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `audit-log:read` | Read Audit Log | x | x | | Reads this account's `scope=account` audit rows (`accountAuditLog` / `GET /v1/account/audit-log`). The identical string in `pub mod global {}` is a **separate** admin-scope permission for the system audit log - see [Admin-Scope Permissions](#admin-scope-permissions). ### Rewards | Permission | Label | |-----------|-------| | `rewards:read` | Read Rewards | | `rewards:create` | Create Rewards | | `rewards:edit` | Edit Rewards | | `rewards:delete` | Delete Rewards | ### Tokens Popout tokens (OBS browser-source docks). | Permission | Label | |-----------|-------| | `tokens:read` | Read Tokens | | `tokens:create` | Create Tokens | | `tokens:edit` | Edit Tokens | | `tokens:delete` | Delete Tokens | ### API Keys Personal user API keys (`lm_usr_…`) - a dedicated family, separate from popout tokens. A personal key, a popout token and a popout session are **member-bound**: the auth middleware caps their stored scopes to the bound member's current account-role and user-scoped permissions on every request (`lo_api::middleware::auth::load_member_permission_ceiling`), through the same Redis permission cache `resolve_jwt` reads. Role changes and role edits already invalidate that cache, so they need no extra hook for credentials. A ceiling that cannot be loaded returns `AuthUnavailable` (`503`). Response objects expose the capped list as `effective_permissions` / `effectivePermissions` via `services::token_permissions::EffectivePermissions`. Service and developer keys have no member and are not capped. | Permission | Label | |-----------|-------| | `apikeys:read` | Read API Keys | | `apikeys:create` | Create API Keys | | `apikeys:edit` | Edit API Keys | | `apikeys:delete` | Delete API Keys | ### Automations (6) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `automations:read` | Read Automations | x | x | x | | `automations:create` | Create Automations | x | x | | | `automations:edit` | Edit Automations | x | x | | | `automations:delete` | Delete Automations | x | x | | | `automations:execute` | Execute Automations | x | x | x | ### Account (3) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `account:read` | Read Account | x | x | | | `account:edit` | Edit Account | x | x | | | `account:delete` | Delete Account | x | | | ### Plan (2) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `plan:read` | View Plan & Billing | x | | | | `plan:edit` | Change Plan | x | | | Both plan permissions are owner-only in the default roles - Administrator carries neither. ### User-Level Permissions The following permission categories are tied to the authenticated **user identity** rather than to an account resource. **Own** actions (managing your own login assignments) are allowed regardless of account role. Because they live in the account permission registry, they are still assignable in the account role editor - the picker exposes the complete account registry (ZAF-231). #### Sessions Session management is **not** a role permission. Every authenticated user can view and revoke their **own** sessions regardless of account role: `sessions` / `deleteSession` / `deleteAllOtherSessions` (GraphQL) and `GET`/`DELETE /v1/users/me/sessions` (REST) select and delete strictly by the acting user's id, carrying `AuthGuard` plus the first-party check `lo_graphql::require_first_party_user()` (ZAF-469). The former `sessions:read` / `sessions:delete` account constants were **removed** in ZAF-1094 (ZAF-516) - they lived in the registry and seeded roles but no handler ever enforced them, since a user on their personal profile carries an empty `account_permissions` list. #### Login Assignments (3) Controls access to login assignment management. Own assignments are always allowed for any authenticated user regardless of role - the permissions below only gate actions performed on behalf of other users. | Permission | Label | |-----------|-------| | `login-assignments:read` | Read Login Assignments | | `login-assignments:create` | Create Login Assignments | | `login-assignments:delete` | Delete Login Assignments | ### Extension Developer (7) Extension developer permissions gate access to the developer dashboard, extension CRUD, publishing, analytics, and payouts. They are account-scope permissions and **are** seeded on the default roles: Owner carries all seven, Administrator carries all but `extension-dev:payouts`; Moderator carries none. Work performed *inside a developer team* is additionally checked against team-scope permissions (see [Team RBAC](#team-rbac-developer-teams) below). | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `extension-dev:read` | View Developer Dashboard | x | x | | | `extension-dev:create` | Create Extensions | x | x | | | `extension-dev:edit` | Edit Extensions | x | x | | | `extension-dev:delete` | Delete Extensions | x | x | | | `extension-dev:publish` | Publish Extensions | x | x | | | `extension-dev:analytics` | View Extension Analytics | x | x | | | `extension-dev:payouts` | Manage Extension Payouts | x | | | ### Extension Store (5) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `extension-store:read` | Browse Extension Store | x | x | x | | `extension-store:install` | Install Extensions | x | x | | | `extension-store:uninstall` | Uninstall Extensions | x | x | | | `extension-store:configure` | Configure Extensions | x | x | | | `extension-store:review` | Review Extensions | x | x | x | ### Copyright (6) | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `copyright:read` | View Copyright | x | x | | | `copyright:edit` | Edit Copyright Lists | x | x | | | `copyright:delete` | Delete Copyright Entries | x | x | | | `copyright:vote` | Vote on Songs | x | x | x | | `copyright:report` | Create Copyright Reports | x | x | x | | `copyright:recommend` | Recommend Songs | x | x | x | `copyright:moderate` is **not** in this account-role matrix - it is an **admin-scope** (platform-operator) permission, seeded to the `system_admin` admin role and not assignable to any tenant role. It governs the **global** community catalogue (approving/dismissing vote candidates into the cross-account blocked/safe lists); `copyright:edit` only touches the account's own lists. It was moved out of account scope in ZAF-742 (mirroring the ZAF-725 crawler move) because writing the account-less global catalogue is a platform-operator action. ### OBS, Bot Modules, Bot Commands, Bot Connections, StreamElements | Permission | Label | Owner | Admin | Mod | |-----------|-------|:-----:|:-----:|:---:| | `obs:read` | View OBS Config | x | x | x | | `obs:edit` | Edit OBS Config | x | x | | | `obs:delete` | Delete OBS Config | x | x | | | `bot-modules:read` | View Bot Modules | x | x | x | | `bot-modules:edit` | Edit Bot Modules | x | x | | | `bot-commands:read` | View Bot Commands | x | x | x | | `bot-commands:create` | Create Bot Commands | x | x | | | `bot-commands:edit` | Edit Bot Commands | x | x | | | `bot-commands:delete` | Delete Bot Commands | x | x | | | `chat-bots:read` | View Account Chat Bots | x | x | x | | `chat-bots:edit` | Edit Account Chat Bots | x | x | | | `bot-connections:read` | View Bot Connections | x | x | x | | `bot-connections:create` | Create Bot Connections | x | x | | | `bot-connections:delete` | Delete Bot Connections | x | x | | | `se-tokens:read` | View SE Tokens | x | x | | | `se-tokens:create` | Create SE Tokens | x | x | | | `se-tokens:delete` | Delete SE Tokens | x | x | | ### Ideas Hub (User-Scoped) The Ideas Hub introduces a **user-scoped permissions layer** that is distinct from both account-scoped and admin-scoped permissions. User-scoped permissions are global (not tied to a specific account) and are assigned via the `user_roles` and `user_role_permissions` tables. Enforcement uses `auth.require_user_permission()`. The moderation checks use `has_moderate_permission()`, which accepts BOTH user-scoped and admin-scoped grants. **User-scoped permissions:** | Permission | Label | |-----------|-------| | `ideas:read` | Read Ideas | | `ideas:create` | Create Ideas | | `ideas:edit` | Edit Own Ideas | | `ideas:delete` | Delete Own Ideas | | `ideas:vote` | Vote on Ideas | | `ideas:comment_read` | Read Comments | | `ideas:comment_create` | Create Comments | | `ideas:comment_edit` | Edit Own Comments | | `ideas:comment_delete` | Delete Own Comments | | `ideas:comment_vote` | Vote on Comments | **Admin-scoped moderation permissions:** | Permission | Label | |-----------|-------| | `ideas:moderate_read` | Read All Ideas (Admin) | | `ideas:moderate_status` | Change Idea Status | | `ideas:moderate_edit` | Edit Any Idea | | `ideas:moderate_delete` | Delete Any Idea | | `ideas:moderate_comment` | Delete Any Comment | Admin-scoped moderation permissions are enforced via `auth.require_admin_permission()` and live in `crates/lo-auth/src/rbac.rs::global`. ## Admin-Scope Permissions The admin-scope `support:verify` grant controls the Support Identity card on an admin user detail page. The reveal and check operations, and the recent-check list, use `AdminPermissionGuard` on GraphQL and `require_admin_permission` on REST. Revealing or checking a code emits a system-scope audit event; a failed code check is still recorded. The separate `users:edit` grant controls an operator's 2FA reset, which requires a reason. Neither grant can be supplied by an account role. Admin-scope permissions gate the Lumio admin panel (`/admin`) and are entirely separate from account-scope permissions. They are stored in the `admin_permissions` column of `CachedPermissions` (not `account_permissions`) and enforced by `require_admin_permission()`. ### Permission Modules The `crates/lo-auth/src/rbac.rs` file defines three permission modules: - **`pub mod account {}`** - Account-scope permission constants (listed above) - **`pub mod global {}`** - Admin-scope permission constants - **`pub mod team {}`** - Team-scope permission constants (see [Team RBAC](#team-rbac-developer-teams)) Constants in `global` use the same `resource:action` format. The canonical list is exposed via: ```rust lo_auth::rbac::all_admin_permissions() -> Vec<(&'static str, &'static str)> // Returns (permission_string, category_label) pairs ``` This function is used by the permission picker UI and the REST/GraphQL permission validation. ### Admin Roles Admin roles are managed in the `admin_roles` / `admin_role_permissions` / `user_admin_roles` tables; the seeded roles (`system_admin`, `support`, `billing`, …) and their grants are maintained by migrations such as `20260330000005_feature_flag_phase1` and `20260412000001_seed_admin_refactor_permissions`. Unlike account roles, admin roles: - Are **global** (not account-scoped) - Have `is_system: bool` - system roles cannot be deleted - Auto-receive `admin:access` (the dashboard entry gate) on create **and** on update - both the REST handler and the GraphQL mutation push it back into the permission list if it is missing - Are CRUD-managed via `adminRoles*` GraphQL mutations and `GET/POST/PATCH/DELETE /v1/admin/admin-roles` ### Shared Permissions Some permission strings (e.g., `copyright:read`, `obs:read`, `bot-modules:read`) are declared in **both** `pub mod account {}` and `pub mod global {}` with identical string values. This is intentional - the same string gates different resources in different contexts: - Account scope: enforced by `auth.require_permission(account::COPYRIGHT_READ)` in account route handlers - Admin scope: enforced by `auth.require_admin_permission(global::COPYRIGHT_READ)` in admin route handlers This follows the precedent set by `bot-connections:*`. ### `admin:access` `admin:access` is the single gate that controls dashboard access. Any user without this permission sees a "No Access" page. It is: - Always present in the `global` module as `admin::ACCESS` - Auto-injected into every admin role at creation time (both REST and GraphQL mutation) - Never removed by the permission diff algorithm - **Not** a tenant bypass. `AuthContext::can_access_account` lets only System keys reach a foreign account. An operator session holding `admin:access` reaches only its own active account on every account-scoped check: WebSocket subscribes, login-assignment writes, and OAuth callbacks/exchanges. Admin features that legitimately span accounts (`/v1/admin/*`, `admin*` GraphQL) are gated on their own granular admin permission, such as `accounts:read` or `account-apikeys:rotate`. A new operator cross-tenant need gets its own dedicated admin permission key, seeded and checked at that call site, never `admin:access`. ### `admin:privacy-erase` GDPR-Art-17 erasure tooling. It gates three surfaces, each with matching REST and GraphQL entry points: | Surface | REST | GraphQL | |---------|------|---------| | YouTube member-badge erasure - clears Lumio's cached YouTube member-channel records across all accounts and erases the member's chat data in the instructing account only | `DELETE /v1/admin/privacy/youtube/member/{member_channel_id}` | `eraseYoutubeMemberData` | | Chat-subject erasure - erases a chat subject's stored messages and profile data in the instructing account only (`account_id`, `instruction_reference` and `reason` required) | `POST /v1/admin/privacy/chat/erase` | `eraseChatSubjectData` | | Lumio-only erasure - records a person's `erasure` entry for Lumio Stats and clears Lumio's YouTube member cache; touches no channel data (`instruction_reference` and `reason` required) | `POST /v1/admin/privacy/lumio/erase` | `eraseChatSubjectAtLumio` | | Record erasure - erases one record (chat message, note, moderation-log entry, AutoMod-log entry, bot-list entry, or one person in one stream report) in the instructing account only (`account_id` + `instruction_reference` + `reason` required) | `POST /v1/admin/privacy/records/erase` | `eraseChatRecord` | | Orphaned extension upload sweep - finds (dry run) or queues for deletion (`apply`) extension upload files no row references | `POST /v1/admin/storage/orphaned-extension-uploads/sweep` | `adminSweepOrphanedExtensionUploads` | The erasure handlers check the permission by hand against the caller's `admin_permissions` (`lo_auth::rbac::global::ADMIN_PRIVACY_ERASE`) rather than via a route-level macro; the sweep uses the admin router's `require_permission` (REST) and `AdminPermissionGuard` (GraphQL) with the same string. It is seeded on the `system_admin` role only (migration `20260428000002_seed_admin_privacy_erase`) and is not auto-injected into any other admin role. The audit log records each erasure as one system-scoped event (`chat_pii_erasure`, or `youtube_member_erasure` for the member path). ### `admin:privacy-objection-create` / `admin:privacy-objection-delete` GDPR-Art-21 objection tooling for Lumio's global suppression list (`chatter_suppressions`). Recording an objection keeps the chatter off every public Stats leaderboard (masked on read, so also on streams published earlier), without erasing anything; channel stream reports, exports, share links and AutoMod logs are not affected. Lifting it names the chatter again on the public leaderboard. | Permission | REST | GraphQL | |------------|------|---------| | `admin:privacy-objection-create` | `POST /v1/admin/privacy/chat/objections` | `addChatterObjection` | | `admin:privacy-objection-delete` | `DELETE /v1/admin/privacy/chat/objections/{platform}/{user_id}` | `removeChatterObjection` | Both protocols check the permission by hand against the caller's `admin_permissions` (`require_admin_permission`, the same guard type as `admin:privacy-erase`) and share the validation in `ChatterObjection::from_parts`. Both are seeded on the `system_admin` role only (migration `20261015120000_create_chatter_suppressions`). The events are `privacy:chatter_objection_added` / `privacy:chatter_objection_removed` (system scope). ### `admin:privacy-erase-withdraw` Withdraws one channel's erasure instruction for one chatter: deletes the chatter's row in that account's suppression list (`account_chatter_suppressions`), so the channel's stream reports frozen afterwards name them again. Nothing the erasure deleted or blanked is restored, and the global `chatter_suppressions` list is not touched. It is a separate permission from `admin:privacy-erase`, so an operator role can carry out erasures without being able to undo their suppression. | Permission | REST | GraphQL | |------------|------|---------| | `admin:privacy-erase-withdraw` | `POST /v1/admin/privacy/chat/erase/withdraw` | `withdrawChannelChatterSuppression` | Both protocols check it by hand against the caller's `admin_permissions` (`require_admin_permission`) with the same error code and text (`Forbidden: Missing required permission: admin:privacy-erase-withdraw`), and share the validation in `SuppressionWithdrawal::from_parts`. It is seeded on the `system_admin` role only (migration `20261022120000_admin_privacy_erase_withdraw`). The event is `privacy:channel_suppression_withdrawn` (system scope). ### Crawler (Admin Scope) Platform-operator control of the public stats crawler (scope mode + watchlist). Declared in `pub mod global`, surfaced by `all_admin_permissions()`, and seeded to the `system_admin` admin role (migration `20260825000001_crawler_perms_to_admin_scope`). | Permission | Label | |-----------|-------| | `crawler:scope-read` | Read Crawler Scope | | `crawler:scope-edit` | Edit Crawler Scope | | `crawler:watchlist-read` | Read Crawler Watchlist | | `crawler:watchlist-create` | Add Crawler Watchlist Channels | | `crawler:watchlist-delete` | Remove Crawler Watchlist Channels | Actions are granular (the coarse `:manage` is disallowed); each mutation emits a system-scope audit event. Originally (ZAF-706) these were account permissions seeded to `owner`, but the Admin app that consumes them gates on admin scope only - so the control was unreachable and every account owner could flip the global crawl scope via the API. ZAF-725 moved them to admin scope and dropped the account-side seed. ### CSP Reports (Admin Scope) Platform-operator read of the CSP-violation calibration aggregate (ZAF-1051 — observability for the ZAF-995 CSP enforce-flip). Declared in `pub mod global`, surfaced by `all_admin_permissions()`, and seeded to the `system_admin` admin role (migration `20260904000004_seed_csp_reports_read_permission`). | Permission | Label | |-----------|-------| | `csp-reports:read` | Read CSP Violation Reports | It gates GraphQL `cspViolationReports` (`AdminPermissionGuard`) and REST `GET /v1/admin/csp-reports` (`require_admin_permission`) - same guard type, string, and errors on both. It reads the capped `csp_violation_reports` TimescaleDB aggregate (distinct `(violated_directive, blocked_host)` pairs with counts; ≤5000 pairs, ≤30 d rolling). The rows carry no viewer identity (host-only), so this is a read of non-personal calibration data, not an account resource - admin scope only, never assignable to a tenant role. ### Developer Funds (Admin Scope) `developer-funds:release-deletion` is declared in `lo_auth::rbac::global` (`DEVELOPER_FUNDS_RELEASE_DELETION`), included in `all_admin_permissions()` (category `developer-funds`) and seeded to `system_admin` by `20261025200000_developer_released_balances`. It is absent from the account registry. It gates the release of an unpayable developer balance as a liability before an admin delete. GraphQL `adminReleaseFundsAndDeleteUser` and `adminReleaseFundsAndDeleteDeveloper` carry `AdminPermissionGuard(DEVELOPER_FUNDS_RELEASE_DELETION)`; both protocols then call `services::developer_funds_release::require_operator(auth, delete_permission)`, which requires the release permission, the plain delete permission (`users:delete` for a user, `developer-verification:edit` for a profile) and an interactive session. The plain deletes keep their own guards and still refuse a positive balance. `developer-funds:settle` (`DEVELOPER_FUNDS_SETTLE`, same category, seeded to `system_admin` by `20261025210000_developer_funds_settle_permission`) gates the settlement of a released balance when the person comes back. GraphQL `adminStartReleasedBalanceClaim` and `adminSettleReleasedBalance` carry `AdminPermissionGuard(DEVELOPER_FUNDS_SETTLE)`; both protocols then call `services::developer_funds_settlement::require_operator(auth)`, which requires the permission and an interactive session. It is separate from `billing:edit` on purpose: it moves money into a developer balance that the normal payout then pays out. ### Contract Declarations (Admin Scope) `contract-declarations:read` and `contract-declarations:edit` are declared in `lo_auth::rbac::global`, included in `all_admin_permissions()`, and seeded to `system_admin` by `20261024130102_seed_contract_declarations_permissions`. They are absent from the account permission registry: public declarations and the queue contain personal details across tenants. The queue's GraphQL readers (`adminContractDeclarations`, `adminContractDeclaration`) use `AdminPermissionGuard("contract-declarations:read")`; REST `GET /v1/admin/contract-declarations` and `GET /v1/admin/contract-declarations/{id}` use `require_admin_permission` with the same key. Close, match and retention mutations use `contract-declarations:edit` on both protocols and additionally require an interactive session. The admin sidebar and page require read; each action uses ``. Signed-in withdrawal uses `plan:edit` for a plan, `extension-store:uninstall` for an extension purchase, or only an interactive session for a free-text `other` contract. Plan cancellation needs `plan:edit`; extension subscription cancellation needs `extension-store:uninstall`. Public withdrawal and cancellation need no role, but are rate limited and Turnstile protected. Both protocols call `services::contract_declarations` for the same validation and result. ### Extension Purchases (Admin Scope) Platform-operator read of paid extension checkouts across accounts (ZAF-2745). Declared in `pub mod global`, surfaced by `all_admin_permissions()`, and seeded to the `system_admin` admin role (migration `20261023120001_seed_extension_purchases_read_permission`). | Permission | Label | |-----------|-------| | `extension-purchases:read` | Read Extension Purchases | It gates GraphQL `adminUndeliveredExtensionPurchases` (`AdminPermissionGuard`) and REST `GET /v1/admin/extension-purchases/undelivered` (`require_admin_permission`) - same guard type, string, fields, validation and errors on both (`services::undelivered_purchases`). It reads other accounts' payments, so it is admin scope only and never assignable to a tenant role; the account-scope string of the same name does not exist. ### Dev & Admin Menu (Admin Scope) Gate for the Dev & Admin menu inside the web app, production included (ZAF-1668). Declared in `pub mod global`, surfaced by `all_admin_permissions()`, and seeded to the `system_admin` admin role (migration `20260930000001_seed_devtools_read_permission`). Operators grant it to further admin roles (e.g. a "Developer" role) in the admin-roles UI. | Permission | Label | |-----------|-------| | `devtools:read` | Open Dev & Admin Menu (web app) | Only admins — users who can sign in to the admin app — ever hold it: it lives only in admin roles (which always carry `admin:access`), and it is absent from the account and user registries, so no channel role, web-app user, or developer grant can carry it. The web app shows the menu only when `adminPermissions` contains **both** `admin:access` and `devtools:read`. It is a UI-visibility grant only: the web app reads it from `me { adminPermissions }` / REST `GET /v1/users/me` (`admin_permissions`), and both protocols resolve the same `user_admin_roles` → `admin_role_permissions` rows via `db::auth::get_user_admin_permissions`. No resolver or route is guarded on it. Everything the menu does is client-side and viewer-local (e.g. previewing a seasonal look), so it emits no audit event. A future server-side action behind the menu (e.g. forcing a season for all users) needs its own granular admin permission, a real `AdminPermissionGuard` / `require_admin_permission` guard on both protocols, and an audit event. ### System-Key Assignable-Scope Registry A System key (`lm_sys_*`) is a machine credential: `is_system = true`, not bound to an account, so any grant it holds is effectively platform-wide - but it reaches only what its grants allow. It needs two classes of grant - **service grants** (what the machine does: `events:*`, `chat:*`, `overlays:*`, `connections:*`, the user-scope family, `audit:ingest`) and **platform-operator grants** (the `all_admin_permissions()` family, for admin-automation keys). Both create paths (`adminCreateSystemKey` and `POST /v1/admin/system-keys`) validate every requested permission against **one derived registry** — `lo_auth::rbac::system_key::get_assignable_permissions()` (ZAF-1213), never a hand-maintained list. It unions the admin, account, and user registries, removes a two-category **deny-list** (`SYSTEM_KEY_PERMISSION_DENY_LIST`), and adds the system-only grants (`SYSTEM_ONLY_PERMISSIONS`, see [System-Only Permissions](#system-only-permissions-botsinternal) below): - **credential minting** - `system-keys:create`, `apikeys:create`/`apikeys:edit`, `tokens:create`, `admin-roles:create`/`edit`/`delete`, `oauth-clients:create` (a key that mints or manages long-lived credentials outlives its purpose and self-escalates); - **tenant / platform destruction** - `accounts:delete`, `account:delete`, `admin:privacy-erase`. Widening the deny-list beyond these two categories needs founder sign-off on ZAF-1169. Validating against the operator registry alone was the ZAF-1213 bug (every account-scope service chip was rejected); validating against nothing was the earlier ZAF-735 hole. **Wildcards** (`admin:*`, `*:*`) are never registry members, so the mint rejects them - they stay legitimate only for config-provisioned system keys. There is no `admin:access` auto-injection on the system-key mint (a machine credential is not a dashboard seat, unlike `adminCreateRole`). The registry is served for the picker by `adminSystemKeyPermissions` / `GET /v1/admin/system-key-permissions`, guarded on `system-keys:read` (the same grant that lists keys) - **not** `admin-roles:read`, which the mint operator need not hold, so guarding on it would render the picker empty. ### Audit Ingest (System-Key-Only) The narrow grant `apps/id` needs to write user-scope security events (`user:login`, `user:oauth_granted`) to `POST /v1/internal/audit-ingest` (ZAF-1212). Declared in `pub mod global` and surfaced by `all_admin_permissions()` so it is derived into the [system-key assignable-scope registry](#system-key-assignable-scope-registry) above, which the [system-key mint](../admin/system-keys.md) validates against - that is the sole supported provisioning path. | Permission | Label | |-----------|-------| | `audit:ingest` | Server-to-Server Audit Ingest (system keys only) | Unlike every other admin-registry permission it is **not** a functional admin-role grant and is **never seeded into any admin role** (no migration seeds it). The ingest endpoint's `authorize_ingest` calls `require_system()` before `require_permission("audit:ingest")`, so only an `AuthContext::System` (an `lm_sys_*` key) can ever write - a human admin who is handed `audit:ingest` in a role holds an inert string. This is deliberate: the assignable-scope registry is the single "what may an operator put on a system key" allowlist, and audit ingest is a legitimate system-key grant even though it is meaningless on a user/admin principal. The endpoint is deliberately REST-only (a server-to-server infrastructure write for one trusted internal caller), so it has no GraphQL/WebSocket counterpart to keep in parity. ### System-Only Permissions (`bots:internal`) `lo_auth::rbac::system_key::SYSTEM_ONLY_PERMISSIONS` holds the grants that exist **only** for System keys. They are chained into the assignable-scope registry (so the mint and its picker offer them - `bots:internal` shows under category `bots`), but they are in **none** of the account, user or admin registries, so no role can ever hold one: `create_role` / `update_role` reject them as unknown permission strings, identically on both protocols. A unit test (`bots_internal_is_system_key_only`) pins that split. | Permission | Constant | Guards | |-----------|----------|--------| | `bots:internal` | `system_key::BOTS_INTERNAL` | The bot fleet's internal surface (ZAF-1922): GraphQL `botChannels`, `botCommandsForAccount`, `emitBotEvent`, `discordBotGuilds`, `discordBotGuildFeatures`, `discordBotAllGuildFeatures`, `updateDiscordGuildInfo`, `removeDiscordGuildByBot`, `sendChatMessage`; REST `POST /v1/chat/message`, `GET /v1/internal/bot-modules/{account_id}/triggers`, `GET /v1/internal/bot-modules/handlers`, `GET /v1/internal/automation-nodes/handlers`; and the cross-account extension and live surfaces (ZAF-1976): extension server functions on any install (`extensionQuery`, `extensionMutate`, `extensionExecuteHandler`; REST `POST /v1/extension-installs/{install_id}/{query,mutate,execute}/{function_name}`), draft/testing extension file trees (`extensionVersionFiles`; REST `GET /v1/extensions/{id}/versions/{version}/files`) and bundle bytes (`GET /v1/extension-bundles/...`), and the WebSocket `presence:*` and `sounds:*` channels | The keys of `twitch-bot`, `kick-bot`, `youtube-bot`, `discord-bot`, `bot-module-worker` and `automation-worker` carry it (see [Provisioning bot and worker keys](../admin/system-keys.md#provisioning-bot-and-worker-keys)); the `apps/id` audit-ingest key carries only `audit:ingest` and reaches none of it. **`require_system_permission`.** `AuthContext::require_system_permission(perm)` is `require_system()` followed by `require_permission(perm)`: a non-System caller gets `Forbidden("System access required")`, a System key without the grant gets `MissingPermission`. The REST internal feeds share `routes::internal_extension_handlers::require_bots_internal`, which returns `403 System key required for internal endpoints` for a non-System caller and `403 Missing permission: bots:internal` for a System key without the grant. **Rule for new internal System surfaces:** check a **named grant** with `require_system_permission` (adding a new entry to `SYSTEM_ONLY_PERMISSIONS` when no existing grant fits), never `require_system()` alone. `require_system()` is a type-only gate that every System key passes - including narrow single-grant keys such as the audit-ingest key - so a type-only gate makes the surface reachable by every system key ever minted. No `AuthContext::System` branch is type-only any more (ZAF-1976): the former cross-account branches (extension function calls on behalf of any install, draft extension bundles / file trees, WS `presence:*` and `sounds:*`) require `bots:internal` like the fleet surface, and the GraphQL `SystemGuard` takes its grant as an argument (`SystemGuard::new(grant)`). A match arm that only *labels* a System caller (the extension handler's auth role, the rate-limit skip, the auth-type name) or that refuses it (first-party / session gates) is not an authorization grant. The tripwire `apps/api/tests/zaf1976_system_grant_gate.rs` lists every System arm in `apps/api/src` as gated or exempt; a new arm fails it until classified. ### Account & User API Keys (Admin Scope) Platform-operator management of **any** account's or user's API keys - personal `lm_usr_` keys (in `api_keys`) and account service `lm_svc_` keys (in `account_service_keys`) - for support and incident response. Declared in `pub mod global`, surfaced by `all_admin_permissions()`, and seeded to the `system_admin` admin role (migration `20260909000001_seed_account_apikeys_operator_permissions`). This is a **distinct** family from the account-scope [`apikeys:*`](#api-keys) that a member uses on their **own** keys; the strings differ (`account-apikeys:*` vs `apikeys:*`), so holding one never grants the other. | Permission | Label | |-----------|-------| | `account-apikeys:read` | Read Account/User API Keys | | `account-apikeys:edit` | Edit Account/User API Keys | | `account-apikeys:rotate` | Rotate Account/User API Keys | | `account-apikeys:delete` | Delete Account/User API Keys | Gated by `AdminPermissionGuard` (GraphQL `adminAccountApiKeys` / `adminUserApiKeys` / `adminUpdateApiKey` / `adminRotateApiKey` / `adminDeleteApiKey`) and `require_admin_permission` (REST `GET /v1/admin/accounts/{account_id}/api-keys`, `GET /v1/admin/users/{user_id}/api-keys`, `PATCH` / `POST .../rotate` / `DELETE /v1/admin/api-keys/{kind}/{id}`) - same guard type, string and errors on both. Scope edits validate against the ZAF-1170 assignable-scope registry for the key's kind, and a **personal** key is additionally capped to its owner's own grantable permissions (a **service** key's ceiling is the account registry minus the deny-list), so an operator can never push a key above its own principal. The rotated plaintext secret is returned exactly once, never listed or logged. Each write emits a **system-scope** audit event (`system:api_key_updated` / `system:api_key_rotated` / `system:api_key_revoked`) with the operator as actor. ## Team RBAC (Developer Teams) Developer teams use a **third permission scope** - team-scoped - that is separate from both account-scope and admin-scope. Team permissions are defined in `crates/lo-auth/src/rbac.rs::team` and enforced by the local `require_team_permission()` in `apps/api/src/routes/developer_teams.rs` and by `lo_graphql::check_team_permission(ctx, team_id, permission)` in resolvers. Neither is a route-level guard attribute - call it explicitly at the top of each handler. Permissions are cached in Redis per `(team_id, user_id)` pair; an empty resolved set means "not a team member" and returns 403 before the permission itself is examined. ### Three Modules, Three Contexts The `crates/lo-auth/src/rbac.rs` file now defines three permission modules: - **`pub mod account {}`** - Account-scope permission constants - **`pub mod global {}`** - Admin-scope permission constants - **`pub mod team {}`** - Team-scope permission constants ### Team Permissions (18) All team permissions follow the `team-resource:action` naming convention. | Permission | Label | |-----------|-------| | `team-extensions:create` | Create Extensions | | `team-extensions:edit` | Edit Extensions | | `team-extensions:delete` | Delete Extensions | | `team-extensions:publish` | Publish Extensions | | `team-extensions:analytics` | View Extension Analytics | | `team-versions:create` | Create Versions | | `team-versions:edit` | Edit Versions | | `team-secrets:read` | Read Secrets (list a team extension's secret key names) | | `team-secrets:edit` | Edit Secrets (set/delete a team extension's secrets; manage team developer API keys) | | `team-testers:read` | Read Testers | | `team-testers:invite` | Invite Testers | | `team-testers:remove` | Remove Testers | | `team-members:read` | Read Members | | `team-members:invite` | Invite Members | | `team-members:edit` | Edit Member Roles | | `team-members:remove` | Remove Members | | `team-settings:read` | Read Settings | | `team-settings:edit` | Edit Settings | | `team-payouts:read` | Read Payouts | | `team-payouts:edit` | Edit Payouts | For extension operations, the REST and GraphQL twins call `services::developer_extensions::ensure_extension_team_permission` (or the version-ID wrapper) after ownership resolution. The check reads current role grants from the database rather than the Redis team-role cache. Missing grants return `403 Missing team permission: ` in REST and `FORBIDDEN` with the same message in GraphQL. An outsider receives a not-found ownership response first. Solo extensions have no team-role requirement; a team-bound developer key is limited to its own team. | Permission | Extension operations | |---|---| | `team-extensions:edit` | Metadata update; icon and screenshot upload; screenshot delete | | `team-extensions:delete` | Draft deletion and deletion request | | `team-extensions:publish` | Submit a version for review | | `team-extensions:analytics` | Analytics | | `team-versions:create` | Version creation and upload | | `team-testers:invite` | Tester invite, private access grant, access invite creation | | `team-testers:remove` | Tester removal, access revoke, access invite deletion | | `team-testers:read` | Tester, access grant, and access invite lists | `team-versions:edit`, `team-settings:read`, and `team-payouts:read|edit` remain catalogue keys without a corresponding extension-action guard. The role editor can assign them, but no version-edit or payout endpoint relies on them today. ### Default Team Roles Every new developer team is seeded with three roles: | Role | Slug | `is_system` | `is_default` | Notes | |------|------|:-----------:|:------------:|-------| | Owner | `team-owner` | true | false | All 20 team permissions. Cannot be deleted. | | Admin | `team-admin` | false | true | All permissions except `team-settings:edit`, `team-payouts:edit`, `team-members:remove`. | | Member | `team-member` | false | true | Edit extensions, create/upload versions, view analytics and read members/secrets/settings; cannot submit, delete, or manage/list testers and private access. | Custom team roles can be created via `POST /v1/developer/teams/{id}/roles` or the `createDeveloperTeamRole` GraphQL mutation. ### Team Permission Cache Team permissions are cached in Redis similarly to account permissions: - **Cache key:** `lumio:team_perms:{team_id}:{user_id}` - **Invalidation:** On role assignment changes, role permission updates, and member removal — on the team-owner routes and on the admin `/v1/admin/developer-teams/{id}/members/{member_id}` routes alike (see [Invalidation rule](#invalidation-rule)) ### `is_developer` Field The `me` query (GraphQL) and `GET /users/me` (REST) expose an `isDeveloper` / `is_developer` boolean field. This field is `true` when the authenticated user has a row in the `developer_profiles` table OR has the `extension_dev_mode` admin override enabled. It drives access to the developer dashboard and extension management features. ## Guard selection When writing a GraphQL resolver, pick the right guard: - **`AdminPermissionGuard::new("resource:action")`** - admin-scope operations (the permission lives in `crates/lo-auth/src/rbac.rs::global`). Only users with admin permissions can call these; account-scope role permissions do NOT grant access. - **`PermissionGuard::new("resource:action")`** - account-scope operations (the permission lives in `rbac.rs::account`). System admins can also act (their admin role has the permission via `is_system = true`). - **`AuthGuard`** - any-authenticated operations, no specific permission required. - **`auth.require_system_permission("resource:action")`** - internal System-key-only surfaces (the bot fleet, workers). Called in the resolver/handler body; requires a System key **and** the named grant (see [System-Only Permissions](#system-only-permissions-botsinternal)). Never gate a System surface on `require_system()` alone. - **`lo_graphql::check_team_permission(ctx, team_id, permission)`** - team-scope operations (the permission lives in `rbac.rs::team`). Called inside the resolver body, not as a `#[graphql(guard = ...)]`. The REST twin is `require_team_permission(state, user_id, team_id, permission)` in `apps/api/src/routes/developer_teams.rs`. ## Backend Enforcement ### GraphQL: PermissionGuard and AdminPermissionGuard GraphQL resolvers use one of two guards from `crates/lo-graphql/src/guard.rs`. Account-scope resolvers use `PermissionGuard`: ```rust #[graphql(guard = "lo_graphql::PermissionGuard::new(\"events:read\")")] async fn events(&self, ctx: &Context<'_>) -> async_graphql::Result> { // ... } ``` Admin-scope resolvers use `AdminPermissionGuard`: ```rust #[graphql(guard = "lo_graphql::AdminPermissionGuard::new(\"users:edit\")")] async fn admin_edit_user(&self, ctx: &Context<'_>) -> async_graphql::Result { // ... } ``` `PermissionGuard` checks `AuthContext::has_permission()`, which accepts both admin and account permissions (system admins pass both). `AdminPermissionGuard` delegates exclusively to `auth.require_admin_permission()`, which only inspects `admin_permissions` - account-scope role grants do NOT satisfy it. ### REST: require_permission Every Actix route handler uses `require_permission()` from `crates/lo-auth/src/context.rs`: ```rust use lo_auth::rbac::account; pub async fn list_events(auth: Auth, state: web::Data) -> Result { auth.require_permission(account::EVENTS_READ) .map_err(from_auth_error)?; // ... } ``` This returns an `AuthError::MissingPermission` which maps to HTTP 403. ### Guard parity (standing in-PR review question) The live channel-status pair uses the same account permission: GraphQL `channelStatus` has `PermissionGuard::new("connections:read")`; REST `GET /v1/channel-status` calls `require_permission("connections:read")`. The separate `spotifyManualStatus` query remains behind `spotify:read`. A popout token may read channel status only when its own permission list includes `connections:read`. Because every feature exposes both GraphQL and REST, a permission that guards one protocol must guard its twin **identically**. Treat guard parity as a standing review question in every PR - the same class of check as "does this need an audit event?" and "is RBAC seeded for the `owner` slug?". For each field, route, or resolver a PR adds or touches, confirm the GraphQL and REST twins agree on all three of: 1. **The permission string** - the exact same `resource:action` constant on both (use the `rbac.rs` constant, never a string literal, so a rename can't drift one side). 2. **The guard *type* / scope** - account-scope (`PermissionGuard` ↔ `require_permission`), admin-scope (`AdminPermissionGuard` ↔ `require_admin_permission`), user/first-party-scope (`AuthGuard` + `require_first_party_user()` ↔ the REST first-party check), team-scope (`check_team_permission` ↔ `require_team_permission`), or an explicit ownership check where the resource is owned by the caller. A `UserPermission`/user-scope check where an account `PermissionGuard` was required - or a bare authenticated gate where an ownership check was needed - is a guard-type mismatch even when the permission string matches. 3. **Fail-closed behaviour** - both deny by default and surface the same 403/FORBIDDEN error. The recurring defect class is **"REST enforces, GraphQL doesn't"** (or the reverse) and **"wrong guard type"**. A one-sided or mismatched guard is a security bug, not a style nit. If a single-protocol path is deliberate, document *why* at the call site. (ZAF-722 findings #4/#8/#9/A1; standing rule ZAF-738.) ### Permission Constants All permission constants are defined in `crates/lo-auth/src/rbac.rs` inside `pub mod account {}`. Use the constants (not string literals) in REST handlers to prevent typos. ### WebSocket: channel gate The WebSocket layer (`crates/lo-websocket/src/gate.rs`) maps each channel type to a gate. The `WsSession` calls `channel_gate_for(channel_type)` on every subscribe request, then runs the check against the same `AuthContext` the REST/GraphQL layers see. "Reach the account" below means `AuthContext::can_access_account`: the caller's own account, or a System key. `admin:access` grants no cross-tenant reach. `ChannelGate` is not permission-only - the variants are: | Variant | Meaning | |---------|---------| | `Permission("resource:action")` | Caller must reach the account ID in the channel name **and** hold the permission | | `AccountScoped` | Caller must reach the account ID; no RBAC permission (used where Widget/Overlay tokens must subscribe) | | `UserPermission("resource:action")` | Checked against the caller's **user-scoped** permissions; the channel ID is not an account UUID | | `Authenticated` | Any non-anonymous identity | | `Public` | Anyone | | `PublicWhenFeature("flag")` | Anyone, as long as the named system flag is on; otherwise `FEATURE_DISABLED` | | `OverlayToken` / `WidgetToken` | Requires that token type, with a matching `overlay_id` / `widget_id` | | `ExtensionOwner` | Requires that the subscriber owns the extension in the channel ID | | `Unknown` | Default arm - always rejects | Current channel mapping: | Channel type | Gate | Feature flag (`channel_feature_for`) | |--------------|------|--------------------------------------| | `overlay` | `OverlayToken` | `feature:overlays` | | `events` | `Permission("events:read")` | `feature:events` | | `spotify` | `Permission("spotify:read")` | `feature:music` | | `chat` | `Permission("chat:read")` | `feature:multichat` | | `automations` | `Permission("automations:read")` | `feature:automation` | | `history` | `Permission("history:read")` | `feature:stream_history` | | `ext-storage` | `ExtensionStorage` (the install's extension token, `extension-store:read` in the install's account, or an overlay/widget token that renders the install) | `feature:extensions` | | `ext-install-logs` | `InstallLogs` (`bot-modules:read` or `automations:read` by install category) | `feature:bot_module_extensions` | | `sounds` | `AccountScoped` | `feature:sounds` | | `widget` | `WidgetToken` | `feature:widgets` | | `ext-logs` | `ExtensionOwner` | `feature:extension_development` | | `presence` | `PresenceScoped` (signed-in members of the owning account; a widget token only for its own widget) | - | | `login-assignments` | `Public` | - | | `ideas` | `PublicWhenFeature("system:ideas_hub_public")` | `system:ideas_hub` | `sounds` is `AccountScoped` rather than `Permission("sounds:read")` on purpose: Widget and Overlay tokens carry an account but no account-level RBAC, so a permission gate would lock them out - while the account check still stops cross-account subscribes. After RBAC, the same module runs the feature-flag check via the `FeatureGate` trait (implemented by `FeatureService` in `apps/api/src/websocket/mod.rs`). Channels mapped in `channel_feature_for` reject subscribes for accounts whose plan/account-feature row has the flag off. Adding a new paid-tier channel: extend both functions in one edit. For `overlay` and `widget` the channel suffix is not an account UUID (an overlay key, a widget id), so the feature check resolves the **owning account** from the `lm_overlay_*` / `lm_share_*` / `lm_widget_*` token instead of parsing the channel id. There is no client publish path. The WebSocket protocol has no `broadcast` frame, so a client can only read. Server-side `WsServer::broadcast_raw` is the only publisher on every channel, and its callers (REST/GraphQL mutations, workers, ingest) carry their own write guards. A subscribe failure raises a typed error message back to the client: `code: "UNAUTHORIZED"` for missing permissions and `code: "FEATURE_DISABLED"` for disabled feature flags. Clients should surface both as user-facing "no access" / "upgrade plan" hints. **Important:** when adding a new account-scoped WebSocket channel type, you MUST extend `channel_gate_for` in the same PR - the default match arm returns `ChannelGate::Unknown` which rejects the subscribe with `UNAUTHORIZED` for *every* caller, including Owners. Forgetting this step ships a broken channel that nobody can use. ## Frontend Enforcement ### PermissionProvider The `PermissionProvider` React context (`apps/web/src/contexts/permission-context.tsx`) receives the user's permissions from the server layout and makes them available to all client components via hooks. ### Gate Component Declaratively renders children only when the user has the required permission: ```tsx ``` `` and `useHasPerm()` resolve against the permission list handed to `PermissionProvider`; the only wildcard they honour is a literal `"*"` entry, which no account role can carry. ### PermissionErrorBoundary Wraps entire page content to show a "No Access" fallback for users without the permission: ```tsx ``` ### Imperative Checks For use in hooks or event handlers: ```tsx const canEdit = useHasPerm("overlays:edit"); ``` See [Frontend Permission System](./frontend-permissions.md) for the full API reference. ## Redis Cache Permissions are cached in Redis to avoid database lookups on every request. ### CachedPermissions The `CachedPermissions` struct in `crates/lo-cache/src/client.rs` stores permissions split by scope, stamped with the cache generation they were loaded under: ```rust pub struct CachedPermissions { #[serde(flatten)] pub stamp: PermissionStamp, // { epoch, ver } at load time pub global: Vec, // Admin-scope permissions pub account: Vec, // Account-scope permissions pub user: Vec, // User-scope permissions (Ideas Hub etc.) } ``` | Cache | Key | TTL | Invalidator | |-------|-----|-----|-------------| | Account + admin + user, per active account | `lumio:perms:v2:{user_id}:{account_id}` | 300 s | exact `DEL` via `invalidate_permission_cache()`, or a generation bump | | User-scope only, account-independent | `lumio:user_perms:v2:{user_id}` | 300 s | generation bump | | Team scope | `lumio:team_perms:{team_id}:{user_id}` | 300 s | `RedisClient::invalidate_team_permissions()` | Two **generation keys** make entries invalid without enumerating them: | Key | Value | TTL | Bumped by | |-----|-------|-----|-----------| | `lumio:perms:ver:{user_id}` | unique token (UUID v4) | 900 s | `lo_api::middleware::auth::invalidate_user_permission_cache()` | | `lumio:perms:epoch` | unique token (UUID v4) | 900 s | `lo_api::middleware::auth::invalidate_all_permission_caches()` | `resolve_jwt` reads `lumio:perms:epoch`, `lumio:perms:ver:{user}`, the combined entry and the user-scope entry in **one `MGET`** (`RedisClient::read_permission_cache`). An entry counts as a hit only while its stamp equals both current values (an absent key equals an absent stamp). On a miss the DB load is cached under the stamp read **before** the load, so an edit committed during the load leaves a stale stamp and the next request misses again. The combined entry is written on a miss only, so its 300 s TTL is a real upper bound. The generation values are unique tokens, never counters: a counter that expires and is incremented again can repeat an old value and re-validate a stale entry. The generation TTL (900 s) is longer than the entry TTL (300 s), so an entry stamped "key absent" has expired before the key can expire and be set again. ### Invalidation rule TTL is only the backstop. Every write that changes what a cached set would resolve to **invalidates in the same code path, after the database write, on both GraphQL and REST**. A one-sided invalidation is a security bug: the other protocol leaves a revoked permission usable until the TTL expires. | Write | Invalidation | Shared helper | |-------|--------------|---------------| | Role permission set edited (`PATCH /v1/roles/{id}` · `updateRole`) | `DEL lumio:perms:v2:{member}:{account}` for every member of the role | `services::roles::invalidate_cache_for_role` | | Role deleted (`DELETE /v1/roles/{id}` · `deleteRole`) | same — members are collected before the delete and dropped after it | `services::roles::role_member_user_ids` + `invalidate_permission_cache_for_users` | | Member role changed / member removed / member left | `DEL lumio:perms:v2:{user}:{account}` | `lo_api::middleware::auth::invalidate_permission_cache` | | Admin adds or removes an account member (`/v1/admin/accounts/{id}/members` · `adminAddAccountMember` / `adminRemoveAccountMember`) | `DEL lumio:perms:v2:{user}:{account}` | `invalidate_permission_cache` | | Account permission override set/removed (admin) | `DEL lumio:perms:v2:{member}:{account}` for every member | `RedisClient::invalidate_permissions` | | User role assigned/removed, user override set/removed (`/v1/users/{id}/roles*`, `/v1/users/{id}/permission-overrides*` · `assignUserRole` / `removeUserRole` / `setUserPermissionOverride` / `removeUserPermissionOverride`) | bump `lumio:perms:ver:{user}` — every entry of that user, in every account | `invalidate_user_permission_cache` | | Admin role assigned/unassigned (`/v1/admin/admin-roles/{id}/members*` · `adminAssignUserRole` / `adminUnassignUserRole`) | bump `lumio:perms:ver:{user}` | `invalidate_user_permission_cache` | | User role permissions edited or role deleted (`PATCH`/`DELETE /v1/user-roles/{id}` · `updateUserRole` / `deleteUserRole`) | bump `lumio:perms:epoch` — the holders include every user without an assignment when the role is the default role, so they cannot be enumerated | `invalidate_all_permission_caches` | | Admin role permissions edited or role deleted (`PATCH`/`DELETE /v1/admin/admin-roles/{id}` · `adminUpdateRole` / `adminDeleteRole`) | bump `lumio:perms:epoch` | `invalidate_all_permission_caches` | | Team member role changed / removed (team owner **and** admin surfaces) | `DEL lumio:team_perms:{team}:{user}` | `RedisClient::invalidate_team_permissions` | An epoch bump makes every active user reload from the database once on their next request. Role-definition edits are rare platform-operator actions, so this costs a handful of indexed queries per active user a few times a month. Invalidation is best-effort: a failed Redis write is logged, and the 300 s TTL bounds the damage. A role that still has members cannot be deleted (the membership foreign key refuses it), so a delete never leaves a member holding a removed role's grants. API-key, service-key and developer-key permissions are **not** cached — they are read from their key row on every request, so editing or revoking a key takes effect immediately. There is no pub/sub permission-invalidation channel: the cache is shared Redis, so a `DEL` or a generation bump is visible to every replica at once and no in-process permission cache exists to notify. ## Key Files | File | Purpose | |------|---------| | `crates/lo-auth/src/rbac.rs` | Permission constants (`account` / `global` / `team` / `user`), `get_all_account_permission_infos()` registry, default roles, `all_admin_permissions()` | | `crates/lo-auth/src/context.rs` | `AuthContext::has_permission()` + `require_permission()` + `require_admin_permission()` + `require_user_permission()` | | `crates/lo-graphql/src/guard.rs` | `PermissionGuard` (account-scope) + `AdminPermissionGuard` (admin-scope) for GraphQL resolvers | | `crates/lo-websocket/src/gate.rs` | `channel_gate_for` / `channel_feature_for` | | `crates/lo-cache/src/client.rs` | `CachedPermissions`, `PermissionStamp`, `read_permission_cache` + Redis cache/invalidation (account, user, team) | | `apps/api/src/graphql/roles.rs` | GraphQL `availablePermissions` picker + role create/update validation | | `apps/api/src/graphql/admin.rs` | GraphQL `adminRoles*` queries/mutations + `adminAssignablePermissions` (admin scope) | | `apps/api/src/routes/roles.rs` | REST `GET /v1/roles/permissions` picker + validation | | `apps/api/src/routes/admin.rs` | REST `admin-roles` + `admin-permissions` + account permission overrides | | `apps/api/src/db/admin_roles.rs` | DB helpers for admin role CRUD | | `apps/web/src/contexts/permission-context.tsx` | `PermissionProvider` + `usePermissions()` + `useHasPerm()` | | `apps/web/src/components/gate.tsx` | `` declarative permission component | | `apps/web/src/components/permission-error-boundary.tsx` | Page-level access denial | | `apps/web/src/lib/translate-role.ts` | `useTranslateRole()` hook for default role names | --- ## Developer Guide / Roles and Permissions Guide Source: https://docs.lumio.vision/developer-guide/roles-permissions-guide # Roles and Permissions Guide This guide covers everything you need to know when adding new permissions or modifying the roles system in Lumio. --- ## Concepts ### Permission Format Permissions follow the `resource:action` format: - `events:read`, `events:create` - `chat:ban`, `chat:timeout` - `automations:execute` **Rules:** - Use granular scopes. Every resource needs at minimum `read` plus granular `create`/`edit`/`delete`. - Add domain-specific actions where the CRUD model is insufficient (e.g., `automations:execute`, `chat:ban`). - Never use wildcard permissions like `resource:*` or `*:*` for account-level permissions. `:manage` action names are not used for account permissions; choose granular actions. ### Role Properties | Property | Type | Description | |----------|------|-------------| | `slug` | `string` | Machine-readable identifier (lowercase, stable - used in code checks) | | `name` | `string` | Human-readable display name (user-editable on custom roles) | | `is_system` | `bool` | Cannot be edited or deleted. Only the Owner role is a system role. | | `is_default` | `bool` | Cannot be deleted. All three default roles have this set. | | `color` | `string` | Hex color string, e.g. `"#f59e0b"` | ### Default Roles Lumio creates three default roles for every new account: | Role | Slug | Color | Notes | |------|------|-------|-------| | Owner | `owner` | `#f59e0b` | System role - every account registry permission, cannot be deleted or edited | | Administrator | `administrator` | `#ef4444` | all but four account permissions - everything except `account:delete`, `plan:read`, `plan:edit`, `extension-dev:payouts` | | Moderator | `moderator` | `#22c55e` | 49 permissions - chat moderation, event monitoring, read access | The definitions live in `ROLE_OWNER` / `ROLE_ADMIN` / `ROLE_MODERATOR` in `crates/lo-auth/src/rbac.rs` and are inserted by `create_default_roles()` (`apps/api/src/db/roles.rs`), which sets `is_default = true` on all three. --- ## Adding New Permissions ### Step 1 - Define constants in `rbac.rs` All account-level permission constants live in `crates/lo-auth/src/rbac.rs` inside the `pub mod account {}` block. ```rust // crates/lo-auth/src/rbac.rs pub mod account { // ... existing permissions ... /// Read resource data. pub const RESOURCE_READ: &str = "resource:read"; /// Create new resource entries. pub const RESOURCE_CREATE: &str = "resource:create"; /// Edit existing resource entries. pub const RESOURCE_EDIT: &str = "resource:edit"; /// Delete resource entries. pub const RESOURCE_DELETE: &str = "resource:delete"; } ``` Use descriptive doc comments - they serve as inline documentation for the permission's purpose. ### Step 2 - Add to default roles in `rbac.rs` Update the `ROLE_OWNER`, `ROLE_ADMIN`, and `ROLE_MODERATOR` constants in the same file. **Decision guide:** | Role | Gets the permission? | |------|----------------------| | Owner | Always - Owner gets ALL permissions | | Administrator | Yes, unless it is owner-only (`account:delete`, `plan:read`, `plan:edit`, `extension-dev:payouts`) | | Moderator | Read permissions + feature-specific actions relevant to moderation | ```rust pub const ROLE_OWNER: DefaultRole = DefaultRole { // ... permissions: &[ // ... existing ... account::RESOURCE_READ, account::RESOURCE_CREATE, account::RESOURCE_EDIT, account::RESOURCE_DELETE, ], // ... }; pub const ROLE_ADMIN: DefaultRole = DefaultRole { // ... permissions: &[ // ... existing ... account::RESOURCE_READ, account::RESOURCE_CREATE, account::RESOURCE_EDIT, account::RESOURCE_DELETE, ], // ... }; pub const ROLE_MODERATOR: DefaultRole = DefaultRole { // ... permissions: &[ // ... existing ... account::RESOURCE_READ, ], // ... }; ``` ### Step 3 - Add GraphQL guards Every GraphQL resolver that touches the new resource needs a `PermissionGuard`: ```rust // apps/api/src/graphql/resource.rs impl ResourceQuery { #[graphql(guard = "lo_graphql::PermissionGuard::new(\"resource:read\")")] async fn resources(&self, ctx: &Context<'_>) -> async_graphql::Result> { // ... } } impl ResourceMutation { #[graphql(guard = "lo_graphql::PermissionGuard::new(\"resource:create\")")] async fn create_resource( &self, ctx: &Context<'_>, input: CreateResourceInput, ) -> async_graphql::Result { // ... } #[graphql(guard = "lo_graphql::PermissionGuard::new(\"resource:edit\")")] async fn update_resource( &self, ctx: &Context<'_>, id: Uuid, input: UpdateResourceInput, ) -> async_graphql::Result { // ... } #[graphql(guard = "lo_graphql::PermissionGuard::new(\"resource:delete\")")] async fn delete_resource( &self, ctx: &Context<'_>, id: Uuid, ) -> async_graphql::Result { // ... } } ``` ### Step 4 - Add REST guards Every Actix route handler that touches the new resource needs a permission check. Use the constant from `rbac.rs` rather than a string literal: ```rust // apps/api/src/routes/resource.rs use lo_auth::rbac::account; pub async fn list_resources( auth: Auth, state: web::Data, ) -> Result { auth.require_permission(account::RESOURCE_READ) .map_err(from_auth_error)?; // ... } pub async fn create_resource( auth: Auth, state: web::Data, body: web::Json, ) -> Result { auth.require_permission(account::RESOURCE_CREATE) .map_err(from_auth_error)?; // ... } ``` ### Step 5 - Register the permission in the registry There is exactly **one** list to edit: `account::get_all_account_permission_infos()` in `crates/lo-auth/src/rbac.rs`. It returns every account permission with the `label` and `category` the role editor renders. ```rust // crates/lo-auth/src/rbac.rs — inside get_all_account_permission_infos() // Resource p!(RESOURCE_READ, "Read Resource", "Resource"), p!(RESOURCE_CREATE, "Create Resource", "Resource"), p!(RESOURCE_EDIT, "Edit Resource", "Resource"), p!(RESOURCE_DELETE, "Delete Resource", "Resource"), ``` Everything downstream derives from it and needs no edit: - `account::get_all_account_permissions()` - the validation registry consulted by role create/update on both protocols (`apps/api/src/{graphql,routes}/roles.rs`). It maps the info list's keys, so a key the editor hides can never be accepted by validation and vice versa (ZAF-231). - The web role editor pickers: REST `GET /v1/roles/permissions` and GraphQL `availablePermissions`, both guarded on `roles:read`. - The admin override pickers via `adminAssignablePermissions` (ZAF-430). Unit tests in both `roles.rs` files assert the picker set equals the registry set, so a divergence fails CI rather than shipping. ### Step 6 - Write a DB migration Existing accounts already have their roles in the database. The migration must backfill the new permissions for default roles. New migrations use the reversible format with `.up.sql` and `.down.sql` suffixes (`YYYYMMDD######_description.up.sql` / `.down.sql`); see `apps/api/migrations/` for examples. ```sql -- apps/api/migrations/20260401000001_add_resource_permissions.up.sql -- -- Add resource permissions to existing default roles. -- Owner: all permissions INSERT INTO account_role_permissions (role_id, permission) SELECT ar.id, perm.permission FROM account_roles ar CROSS JOIN ( VALUES ('resource:read'), ('resource:create'), ('resource:edit'), ('resource:delete') ) AS perm(permission) WHERE ar.slug = 'owner' ON CONFLICT DO NOTHING; -- Administrator: all permissions INSERT INTO account_role_permissions (role_id, permission) SELECT ar.id, perm.permission FROM account_roles ar CROSS JOIN ( VALUES ('resource:read'), ('resource:create'), ('resource:edit'), ('resource:delete') ) AS perm(permission) WHERE ar.slug = 'administrator' ON CONFLICT DO NOTHING; -- Moderator: read only INSERT INTO account_role_permissions (role_id, permission) SELECT ar.id, perm.permission FROM account_roles ar CROSS JOIN ( VALUES ('resource:read') ) AS perm(permission) WHERE ar.slug = 'moderator' ON CONFLICT DO NOTHING; ``` Note: `ON CONFLICT DO NOTHING` makes migrations safe to re-run. --- ## Frontend Translations (Critical) Translations must be updated in **both** `apps/web/messages/en.json` and `apps/web/messages/de.json`. ### Permission labels Each permission key must have a translation in the `"permissions"` namespace: ```json { "permissions": { "resource:read": "Read Resource", "resource:create": "Create Resource", "resource:edit": "Edit Resource", "resource:delete": "Delete Resource" } } ``` ### Permission category Each distinct `category` value used in `get_all_account_permissions()` must have a translation under `"permissions.categories"`: ```json { "permissions": { "categories": { "Resource": "Resource" } } } ``` ### Default role names Default role display names are translated via `"roles.labels"` keyed by slug: ```json { "roles": { "labels": { "owner": "Owner", "administrator": "Administrator", "moderator": "Moderator" } } } ``` Use the `useTranslateRole()` hook (from `apps/web/src/lib/translate-role.ts`) when displaying role names - it returns a `(slug, name) => string` function. Default role slugs are translated; custom roles fall back to their `name` field unchanged. --- ## Frontend Permission Checks ### Sidebar navigation Add a permission-gated nav entry in `apps/web/src/app/(main)/(app)/shell.tsx`: ```tsx // apps/web/src/app/(main)/(app)/shell.tsx { label: "manage", items: [ // ... { href: "/dashboard/resource", icon: Box, translationKey: "resource", permission: "resource:read", }, ], }, ``` The shell filters out items where the user lacks the required permission. ### Page-level boundary Wrap the main page content in `` to show a "No Access" fallback for users without the permission: ```tsx // apps/web/src/app/(main)/(app)/dashboard/resource/resource-list.tsx import { PermissionErrorBoundary } from "@/components/permission-error-boundary"; import { Gate } from "@/components/gate"; export function ResourceList() { return (

{/* list content */}
); } ``` ### Component-level checks For ad-hoc imperative checks (e.g., inside hooks or event handlers): ```tsx const canEdit = useHasPerm("resource:edit"); if (canEdit) { // show edit button } ``` `useHasPerm` resolves as `perms.includes("*") || perms.includes(permission)`. An **empty** permission array grants nothing - it means the identity has no account-scope grants (for example a user whose active context is their personal profile), so every gate closes. Never hand-roll a `permissions.length === 0` escape hatch. Use `` for declarative rendering and `useHasPerm("...")` for boolean checks. See [Frontend Permission System](./frontend-permissions.md) for the full API reference. ### Next.js proxy routes The frontend never calls the Rust GraphQL endpoint directly. Follow the established proxy pattern: ``` Browser → Next.js API route → serverGql() → Rust GraphQL ``` ```ts // apps/web/src/app/api/resource/route.ts import { NextRequest, NextResponse } from "next/server"; import { serverGql } from "@/lib/server-gql"; import { transformKeys } from "@/lib/transform"; const RESOURCES_QUERY = ` query Resources { resources { id accountId name createdAt updatedAt } } `; export async function GET(request: NextRequest) { try { const { data } = await serverGql<{ resources: unknown[] }>( request, RESOURCES_QUERY, ); return NextResponse.json({ data: (data.resources || []).map(transformKeys), }); } catch (e) { const status = (e as Error & { status?: number }).status ?? 500; return NextResponse.json( { error: (e as Error).message }, { status }, ); } } ``` --- ## Complete Permissions Reference Every account-scope permission in `account::get_all_account_permission_infos()`, with its role-editor label, picker category, and the default roles that carry it. 129 entries - Owner holds every one of them. | Permission | Label | Category | Owner | Admin | Moderator | |-----------|-------|----------|:-----:|:-----:|:---------:| | `events:read` | Read Events | Events | ✓ | ✓ | ✓ | | `events:create` | Create Events | Events | ✓ | ✓ | ✓ | | `events:userinfo` | Event User Info | Events | ✓ | ✓ | ✓ | | `overlays:read` | Read Overlays | Overlays | ✓ | ✓ | ✓ | | `overlays:create` | Create Overlays | Overlays | ✓ | ✓ | | | `overlays:edit` | Edit Overlays | Overlays | ✓ | ✓ | | | `overlays:delete` | Delete Overlays | Overlays | ✓ | ✓ | | | `overlays:access-read` | Read Overlay Access | Overlays | ✓ | ✓ | | | `overlays:access-grant` | Grant Overlay Access | Overlays | ✓ | ✓ | | | `overlays:access-revoke` | Revoke Overlay Access | Overlays | ✓ | ✓ | | | `spotify:read` | Read Spotify | Spotify | ✓ | ✓ | ✓ | | `spotify:playback` | Spotify Playback | Spotify | ✓ | ✓ | ✓ | | `spotify:volume` | Spotify Volume | Spotify | ✓ | ✓ | | | `spotify:queue` | Spotify Queue | Spotify | ✓ | ✓ | ✓ | | `spotify:playlist` | Spotify Playlists | Spotify | ✓ | ✓ | ✓ | | `spotify:device` | Spotify Devices | Spotify | ✓ | ✓ | ✓ | | `spotify:worker` | Spotify Worker | Spotify | ✓ | ✓ | | | `chat:read` | Read Chat | Chat | ✓ | ✓ | ✓ | | `chat:write` | Write Chat | Chat | ✓ | ✓ | ✓ | | `chat:userinfo` | Chat User Info | Chat | ✓ | ✓ | ✓ | | `chat:delete` | Delete Chat Messages | Chat | ✓ | ✓ | ✓ | | `chat:ban` | Ban Chat Users | Chat | ✓ | ✓ | ✓ | | `chat:timeout` | Timeout Chat Users | Chat | ✓ | ✓ | ✓ | | `chat:notes` | Chat User Notes | Chat | ✓ | ✓ | ✓ | | `chat:raid` | Cancel Raids | Chat | ✓ | ✓ | ✓ | | `chat:refresh_user` | Refresh User Profile | Chat | ✓ | ✓ | ✓ | | `chat:poll` | End Polls | Chat | ✓ | ✓ | ✓ | | `chat:prediction` | End Predictions | Chat | ✓ | ✓ | ✓ | | `chat:automod-read` | Read AutoMod Queue | Chat | ✓ | ✓ | ✓ | | `chat:automod-allow` | Allow Held Messages | Chat | ✓ | ✓ | ✓ | | `chat:automod-deny` | Deny Held Messages | Chat | ✓ | ✓ | ✓ | | `chat-bots:read` | Read Bot List | Chat | ✓ | ✓ | ✓ | | `chat-bots:edit` | Edit Bot List | Chat | ✓ | ✓ | | | `connections:read` | Read Connections | Connections | ✓ | ✓ | ✓ | | `connections:create` | Create Connections | Connections | ✓ | ✓ | | | `connections:edit` | Edit Connections | Connections | ✓ | ✓ | | | `connections:delete` | Delete Connections | Connections | ✓ | ✓ | | | `settings:read` | Read Settings | Settings | ✓ | ✓ | | | `settings:edit` | Edit Settings | Settings | ✓ | ✓ | | | `account:read` | Read Account Settings | Account | ✓ | ✓ | | | `account:edit` | Edit Account Settings | Account | ✓ | ✓ | | | `account:delete` | Delete Account | Account | ✓ | | | | `plan:read` | View Plan & Billing | Plan | ✓ | | | | `plan:edit` | Change Plan | Plan | ✓ | | | | `members:read` | Read Members | Members | ✓ | ✓ | ✓ | | `members:create` | Create Invites | Members | ✓ | ✓ | | | `members:edit` | Edit Members | Members | ✓ | ✓ | | | `members:delete` | Delete Members | Members | ✓ | ✓ | | | `roles:read` | Read Roles | Roles | ✓ | ✓ | ✓ | | `roles:edit` | Edit Roles | Roles | ✓ | ✓ | | | `roles:delete` | Delete Roles | Roles | ✓ | ✓ | | | `login-assignments:read` | View Login Assignments | Login Assignments | ✓ | ✓ | | | `login-assignments:create` | Assign Login Connections | Login Assignments | ✓ | ✓ | | | `login-assignments:delete` | Remove Login Assignments | Login Assignments | ✓ | ✓ | | | `uploads:read` | Read Uploads | Uploads | ✓ | ✓ | ✓ | | `uploads:create` | Create Uploads | Uploads | ✓ | ✓ | | | `uploads:delete` | Delete Uploads | Uploads | ✓ | ✓ | | | `rewards:read` | Read Rewards | Rewards | ✓ | ✓ | ✓ | | `rewards:create` | Create Rewards | Rewards | ✓ | ✓ | | | `rewards:edit` | Edit Rewards | Rewards | ✓ | ✓ | | | `rewards:delete` | Delete Rewards | Rewards | ✓ | ✓ | | | `tokens:read` | Read Tokens | Tokens | ✓ | ✓ | | | `tokens:create` | Create Tokens | Tokens | ✓ | ✓ | | | `tokens:edit` | Edit Tokens | Tokens | ✓ | ✓ | | | `tokens:delete` | Delete Tokens | Tokens | ✓ | ✓ | | | `automations:read` | Read Automations | Automations | ✓ | ✓ | ✓ | | `automations:create` | Create Automations | Automations | ✓ | ✓ | | | `automations:edit` | Edit Automations | Automations | ✓ | ✓ | | | `automations:delete` | Delete Automations | Automations | ✓ | ✓ | | | `automations:execute` | Execute Automations | Automations | ✓ | ✓ | ✓ | | `obs:read` | View OBS Config | OBS | ✓ | ✓ | ✓ | | `obs:edit` | Edit OBS Config | OBS | ✓ | ✓ | | | `obs:delete` | Delete OBS Config | OBS | ✓ | ✓ | | | `copyright:read` | View Copyright | Copyright | ✓ | ✓ | | | `copyright:edit` | Edit Copyright Lists | Copyright | ✓ | ✓ | | | `copyright:delete` | Delete Copyright Entries | Copyright | ✓ | ✓ | | | `copyright:vote` | Vote on Songs | Copyright | ✓ | ✓ | ✓ | | `copyright:report` | Create Copyright Reports | Copyright | ✓ | ✓ | ✓ | | `copyright:recommend` | Recommend Songs | Copyright | ✓ | ✓ | ✓ | | `copyright:moderate` | Moderate Global Copyright List | Copyright | ✓ | ✓ | | | `bot-modules:read` | View Bot Modules | Bot Modules | ✓ | ✓ | ✓ | | `bot-modules:edit` | Edit Bot Modules | Bot Modules | ✓ | ✓ | | | `bot-commands:read` | View Bot Commands | Bot Commands | ✓ | ✓ | ✓ | | `bot-commands:create` | Create Bot Commands | Bot Commands | ✓ | ✓ | | | `bot-commands:edit` | Edit Bot Commands | Bot Commands | ✓ | ✓ | | | `bot-commands:delete` | Delete Bot Commands | Bot Commands | ✓ | ✓ | | | `bot-connections:read` | View Bot Connections | Bot Connections | ✓ | ✓ | ✓ | | `bot-connections:create` | Create Bot Connections | Bot Connections | ✓ | ✓ | | | `bot-connections:delete` | Delete Bot Connections | Bot Connections | ✓ | ✓ | | | `se-tokens:read` | View SE Tokens | StreamElements | ✓ | ✓ | | | `se-tokens:create` | Create SE Tokens | StreamElements | ✓ | ✓ | | | `se-tokens:delete` | Delete SE Tokens | StreamElements | ✓ | ✓ | | | `extension-dev:read` | View Developer Dashboard | Extension Developer | ✓ | ✓ | | | `extension-dev:create` | Create Extensions | Extension Developer | ✓ | ✓ | | | `extension-dev:edit` | Edit Extensions | Extension Developer | ✓ | ✓ | | | `extension-dev:delete` | Delete Extensions | Extension Developer | ✓ | ✓ | | | `extension-dev:publish` | Publish Extensions | Extension Developer | ✓ | ✓ | | | `extension-dev:analytics` | View Extension Analytics | Extension Developer | ✓ | ✓ | | | `extension-dev:payouts` | Manage Extension Payouts | Extension Developer | ✓ | | | | `extension-store:read` | Browse Extension Store | Extension Store | ✓ | ✓ | ✓ | | `extension-store:install` | Install Extensions | Extension Store | ✓ | ✓ | | | `extension-store:uninstall` | Uninstall Extensions | Extension Store | ✓ | ✓ | | | `extension-store:configure` | Configure Extensions | Extension Store | ✓ | ✓ | | | `extension-store:review` | Review Extensions | Extension Store | ✓ | ✓ | ✓ | | `extension-data:read` | Read Extension Data | Extension Data | ✓ | ✓ | ✓ | | `extension-data:edit` | Edit Extension Data | Extension Data | ✓ | ✓ | | | `widgets:read` | View Widgets | Widgets | ✓ | ✓ | ✓ | | `widgets:create` | Create Widgets | Widgets | ✓ | ✓ | | | `widgets:edit` | Edit Widgets | Widgets | ✓ | ✓ | | | `widgets:delete` | Delete Widgets | Widgets | ✓ | ✓ | | | `widgets:access-read` | Read Widget Access | Widgets | ✓ | ✓ | | | `widgets:access-grant` | Grant Widget Access | Widgets | ✓ | ✓ | | | `widgets:access-revoke` | Revoke Widget Access | Widgets | ✓ | ✓ | | | `sounds:read` | View Sounds | Sounds | ✓ | ✓ | ✓ | | `sounds:create` | Upload Sounds | Sounds | ✓ | ✓ | | | `sounds:edit` | Edit Sounds | Sounds | ✓ | ✓ | | | `sounds:delete` | Delete Sounds | Sounds | ✓ | ✓ | | | `sounds:play` | Play Sounds | Sounds | ✓ | ✓ | ✓ | | `history:read` | Read Stream History | Stream History | ✓ | ✓ | ✓ | | `history:share` | Share History Reports | Stream History | ✓ | ✓ | | | `history:export` | Export History Reports | Stream History | ✓ | ✓ | ✓ | | `history:delete` | Delete Stream History | Stream History | ✓ | ✓ | | | `history:revenue-read` | Read Estimated Revenue | Stream History | ✓ | ✓ | | | `history:revenue-edit` | Edit Revenue Assumptions | Stream History | ✓ | ✓ | | | `history:keywords-edit` | Edit Tracked Keywords | Stream History | ✓ | ✓ | | | `public-stats:read` | Read Public Stats Settings | Public Stats | ✓ | ✓ | ✓ | | `public-stats:edit` | Edit Public Stats Settings | Public Stats | ✓ | ✓ | | | `audit-log:read` | Read Audit Log | Audit Log | ✓ | ✓ | | User-scope (`ideas:*`) and admin-scope permissions are separate registries - see [User Roles](./user-roles.md) and [RBAC & Permissions](./rbac-permissions.md#admin-scope-permissions). --- `chat:pin` is an account permission seeded for Owner, Administrator and Moderator. It guards the GraphQL and REST Twitch pin actions; reading the current card uses `chat:read`. The acting moderator also needs a Twitch login with `moderator:manage:chat_messages`. ## Checklist for Adding a New Feature with Permissions Use this checklist whenever you add a new feature that requires access control: - [ ] **1. Define constants** in `crates/lo-auth/src/rbac.rs` (`pub mod account {}`) - `RESOURCE_READ`, `RESOURCE_CREATE`, `RESOURCE_EDIT`, `RESOURCE_DELETE` - Add domain-specific actions if needed (e.g., `RESOURCE_EXECUTE`) - [ ] **2. Add to default roles** in `rbac.rs` - Owner: all new permissions - Administrator: all except `account:delete` and `plan:edit` - Moderator: read + relevant domain actions - [ ] **3. Add GraphQL guards** to all resolvers in `apps/api/src/graphql/` - `#[graphql(guard = "lo_graphql::PermissionGuard::new(\"resource:read\")")]` - [ ] **4. Add REST guards** to all route handlers in `apps/api/src/routes/` - `auth.require_permission(account::RESOURCE_READ).map_err(from_auth_error)?;` - [ ] **5. Register the permission once** in `crates/lo-auth/src/rbac.rs` - Add a `p!(KEY, "Label", "Category")` entry to `account::get_all_account_permission_infos()` - **All pickers derive from it automatically** - no per-picker edits: - Web role editor: REST `GET /v1/roles/permissions` + GraphQL `availablePermissions` (`apps/api/src/{routes,graphql}/roles.rs`) map the registry - **Admin override pickers (ZAF-430):** the per-user (`/admin/users/[id]`) and per-account (`/admin/accounts/[id]`) override pickers fetch `GET /api/assignable-permissions` (→ `adminAssignablePermissions` GraphQL, mapping the same registry). **No hardcoded arrays** - do not reintroduce the old `ALL_USER_PERMISSIONS` / `ALL_ACCOUNT_PERMISSIONS` shadow lists that once drifted. - [ ] **6. Write DB migration** in `apps/api/migrations/` - Backfill permissions for all affected default roles - Use `ON CONFLICT DO NOTHING` for idempotency - [ ] **7. Add translations** in `apps/web/messages/en.json` and `apps/web/messages/de.json` - Permission labels under `"permissions"`: `"resource:read": "Read Resource"` - Category under `"permissions.categories"`: `"Resource": "Resource"` - [ ] **8. Add sidebar nav entry** in `apps/web/src/app/(main)/(app)/shell.tsx` - `{ href: "/dashboard/resource", permission: "resource:read", ... }` - [ ] **9. Wrap page** with `` - [ ] **10. Gate write controls** with `` etc. - [ ] **11. Create Next.js proxy routes** in `apps/web/src/app/api/resource/` - `GET` → `serverGql()` → Rust GraphQL query - `POST` / `PATCH` / `DELETE` → `serverGql()` → Rust GraphQL mutation - [ ] **12. Run lint** - `just lint-all` (clippy + ESLint, zero warnings) --- ## Key Files Reference | File | Purpose | |------|---------| | `crates/lo-auth/src/rbac.rs` | Permission constants, `get_all_account_permission_infos()` registry, default role definitions | | `apps/api/src/graphql/roles.rs` | GraphQL `availablePermissions` picker + role create/update validation | | `apps/api/src/routes/roles.rs` | REST `GET /v1/roles/permissions` picker + validation | | `apps/api/src/db/roles.rs` | `create_default_roles()` - inserts the three default roles per account | | `apps/api/src/graphql/admin.rs` | GraphQL `adminAssignablePermissions` (admin override pickers, ZAF-430) | | `apps/admin/src/app/api/assignable-permissions/route.ts` | Admin proxy serving the registry to the override pickers | | `apps/api/migrations/` | DB migrations for backfilling permissions | | `apps/web/messages/en.json` | English translations for permissions + role names | | `apps/web/messages/de.json` | German translations for permissions + role names | | `apps/web/src/app/(main)/(app)/shell.tsx` | Sidebar nav with permission filtering | | `apps/web/src/lib/translate-role.ts` | `useTranslateRole()` hook for default role names | | `apps/web/src/contexts/permission-context.tsx` | `PermissionProvider`, `usePermissions()`, `useHasPerm()` | | `apps/web/src/components/gate.tsx` | `` declarative permission component | | `apps/web/src/components/permission-error-boundary.tsx` | Page-level access denial component | --- ## Developer Guide / Frontend Permission System Source: https://docs.lumio.vision/developer-guide/frontend-permissions # Frontend Permission System This guide explains how the frontend permission system works and how to use it when developing new features. ## Architecture Permissions flow from the backend to every component: 1. **Server Layout** calls `getMe()` which returns `permissions: string[]` for the active account 2. **PermissionProvider** (React Context) makes permissions available to all client components 3. **Hooks and components** use `usePermissions()`, `useHasPerm()`, ``, and `` to gate UI elements ## API Reference ### `usePermissions(): string[]` Returns the full array of permissions for the current user's active account. ```tsx const permissions = usePermissions(); ``` ### `useHasPerm(permission: string): boolean` Returns whether the current user has a specific permission. The check is `perms.includes("*") || perms.includes(permission)` - the `"*"` wildcard is only ever present on a System-key identity, never on an account role. ```tsx const canEdit = useHasPerm("overlays:edit"); ``` Permissions are granular: there is no `resource:write`. Write actions map to `create` / `edit` / `delete` (plus domain actions such as `overlays:access-grant`). ### `` Conditionally renders children only if the user has the specified permission. Renders nothing otherwise. ```tsx ``` ### `` Renders a "No Access" fallback UI when the user lacks the required permission. Use this to wrap entire page content for direct URL access protection. ```tsx ``` ### `useToast()` Exposes `showToast(message, type)` for displaying toast notifications. Used for 403 error feedback. ```tsx const { showToast } = useToast(); if (res.status === 403) { showToast(t("permissions.forbidden"), "error"); } ``` ## Adding New Permissions When adding a new permission to the system, follow this checklist: 1. **Add constant** in `crates/lo-auth/src/rbac.rs` (in `pub mod account {}`) 2. **Update default roles** in `rbac.rs` (`ROLE_OWNER`, `ROLE_ADMIN`, `ROLE_MODERATOR`) 3. **Register it once** in `account::get_all_account_permission_infos()` (same file) - a `p!(KEY, "Label", "Category")` entry. Every picker and the validation registry derive from that list; there is no second list to edit. 4. **Write DB migration** to add the permission to existing roles 5. **Add GraphQL guard** -- `#[graphql(guard = "lo_graphql::PermissionGuard::new(\"perm:name\")")]` 6. **Add REST guard** -- `auth.require_permission("perm:name").map_err(...)?;` 7. **Add `#[utoipa::path]` description** noting the required permission 8. **Add sidebar mapping** in `apps/web/src/app/(main)/(app)/shell.tsx` 9. **Wrap page** with `` 10. **Gate write controls** with `` / `perm:edit` / `perm:delete` 11. **Update docs** in `apps/docs/docs/api-reference/permissions.md` 12. **Update docs** in `apps/docs/docs/user-guide/roles-and-permissions.md` ## Route-segment gates Hiding a nav item in `shell.tsx` does not close the route - a deep link still renders the page. Two layers cover that: - **Feature flags** - each gated dashboard segment has a `layout.tsx` that wraps `children` in `` (`apps/web/src/components/feature-route-gate.tsx`), so every page and nested route under the segment is covered by one gate. - **Permissions** - page content is wrapped in ``. Wrap the top-level client component of the segment, not individual widgets, so direct URL access is caught too. Sidebar entries in `shell.tsx` carry both a `permission` and (where applicable) a `feature` key; the shell filters items on both and hides sections that end up empty. ## Popout Pages Popout pages (music, events, chat) operate outside the dashboard layout and need their own permission handling. ### Pattern 1. **Server component** fetches permissions: - Popout token: `GET /v1/tokens/me?token=xxx` via `serverFetch()` - Cookie auth: `getMe(config).permissions` 2. **Client wrapper** wraps content in `` + `` 3. **Components** use `` and `useHasPerm()` - same as dashboard 4. **Hooks** pass popout token via `withToken()` to all API calls ## Key Files | File | Purpose | |------|---------| | `apps/web/src/contexts/permission-context.tsx` | PermissionProvider + hooks | | `apps/web/src/contexts/toast-context.tsx` | Toast system | | `apps/web/src/components/gate.tsx` | Declarative permission gate | | `apps/web/src/components/permission-error-boundary.tsx` | Page-level access denial | | `apps/web/src/components/feature-route-gate.tsx` | Feature-flag gate used by segment `layout.tsx` files | | `apps/web/src/app/(main)/(app)/shell.tsx` | Sidebar permission + feature filtering | | `crates/lo-auth/src/rbac.rs` | Permission constants, default roles, and `get_all_account_permission_infos()` registry | | `apps/api/src/graphql/roles.rs` | GraphQL `availablePermissions` picker (maps the registry) | | `apps/api/src/routes/roles.rs` | REST `GET /v1/roles/permissions` picker + role validation | --- ## Developer Guide / Frontend Overlays: Dialogs and Full-Screen Panels Source: https://docs.lumio.vision/developer-guide/frontend-overlays # Frontend Overlays: Dialogs and Full-Screen Panels The `@lumio/ui` design system (`shared/ui`) has two modal surfaces. This page covers how they stack, how Escape and scroll locking compose, and the one rule that keeps a nested dialog visible. | Primitive | Renders | Use for | |-----------|---------|---------| | `Dialog` / `DialogContent` | **Portaled** to ``, or into the panel or dialog that opened it; `fixed inset-0 z-[100]` | Confirmations, warnings, small forms and `variant="sheet"` side sheets | | `FullscreenPanel` | Portaled to `` or the enclosing overlay layer, `fixed inset-0 z-[100]` | Full-page views and the dashboard settings window | | `FullscreenWizard` | `FullscreenPanel` + the `WizardSteps` core (progress bar, Back/Next footer) | Create/edit flows with steps | | `SetupWizard` | `Dialog` + the same `WizardSteps` core | Stepped flows inside a dialog | ## Layer scale `DialogContent variant="sheet"` uses the same Dialog focus trap, Escape stack and scroll lock. It sits on the right at desktop widths and fills a phone screen; `hideClose` suppresses the built-in close button when the sheet header provides one. `AlertDeck` also accepts controlled `selectedKey` and `onSelectedKeyChange`; the dashboard shell's `ConnectionAlertsContext.focusAlert(key)` restores, selects, scrolls to and focuses a connection notice. `FullscreenPanel frame="window"` renders the dashboard settings window as a card up to 1536 × 960 px, with 24 px desktop edge spacing. Below 768 px it fills the screen. The root remains a full-screen overlay layer, while the backdrop and card are children, so nested dialogs and wizards can cover the whole viewport. `onBackdropClick` runs only when pointerdown and click both land on the backdrop and the window was topmost at pointerdown. Nested `FullscreenPanel` and `FullscreenWizard` instances portal into the enclosing layer root; their focus traps count only their own layer's tab stops. Escape closes the topmost layer first, and the shared reference-counted scroll lock remains until the last layer closes. `DialogContent mobile="fullscreen"` fills the screen below `sm` for forms, selections and stepped flows. Its header and footer stay visible while the body scrolls, and footer buttons stack with the primary action first. Confirmations and identity step-up use the default card treatment. Motion preferences reduce the phone entrance to a fade or make it instant. Stacking order, low to high: | z-index | Layer | |---------|-------| | `z-40` | Dropdown/menu backdrops | | `z-50` | Full-screen side panels and inline dropdowns (popout settings, chat-shell menus) | | `z-[100]` | Modal layer: `Dialog`, `FullscreenPanel`, `FullscreenWizard`, editor modals | | `z-[9999]` | Popovers that must float above their own dialog (`ColorPicker`) | | `z-[10000]` | Toasts (`ToastProvider`, portaled to ``), above modals and full-screen editors | | `99999` | Tooltips (`TooltipProvider`) | `ToastProvider` portals the toast stack to `` after mount. An ancestor with its own z-index creates a stacking context that could otherwise trap an inline toast behind a modal or full-screen editor. The body portal keeps toasts above those surfaces and below tooltips. ## Toast dismissal Every success, info and error toast has a close (✕) button, including toasts with an action such as **Undo**. The button sits above `SeasonToastAccent` so the seasonal decoration cannot cover it or intercept clicks. Each toast dismisses automatically after five seconds. Its own countdown pauses while the pointer is over that toast or it has keyboard focus, then resumes with the remaining time. Closing one toast leaves the others' countdowns alone. Clicking ✕ uses the same 150 ms exit fade as automatic dismissal; with Motion Off, the toast disappears immediately. A toast does not register with the Escape dismiss stack: Escape does not close it, even when its close button has focus. Escape remains available to the topmost dialog or panel underneath. ## The stacking rule: nest dialogs inside the panel `DropdownMenu` (the ⋯ menus) portals into the enclosing overlay layer — a `Dialog` or `FullscreenPanel` root such as the settings window — and only falls back to ``. Its `z-50` then counts inside that layer, so a menu opened in a dialog or in the settings window stacks above it instead of behind it. `Dialog` and `FullscreenPanel` sit on the **same** `z-[100]`. On a tie the browser paints by DOM order. A panel root is an overlay layer: a `Dialog` rendered in the panel's React subtree portals into the panel root, so it is a DOM descendant and stacks above the panel. A Dialog rendered as a **sibling** of a panel portals to `` beside it and can be painted **behind** it, invisible: ```tsx // ✗ Wrong — the Dialog is a sibling of the panel and can land behind it. <> {content} … ``` Render every overlay the panel's content opens **inside** the panel. The `layers` slot exists for this: its children render last inside the portal root, so they stack above the panel content: ```tsx // ✓ Right — the Dialog is a DOM descendant of the panel root. {name}} ariaLabelledBy={titleId} closeLabel={tc("close")} onClose={onClose} layers={ <> {pending && setPending(null)}>…} setPendingLink(null)} /> } > {content} ``` Cover it with a test that asserts DOM descendancy (`panel.contains(dialog)` where `panel = document.querySelector("[data-fullscreen-panel]")`), like `apps/web/__tests__/components/info-user-modal-layers.test.tsx` does for the user info card. Absolutely positioned menus are the other trap: inside a scrolling region (`overflow-y-auto`) they are clipped at its edge. Prefer an inline disclosure (the user card's Timeout durations), or a component that portals itself (`Select`, `DropdownMenu`). A box marked `data-popover-boundary` (`POPOVER_BOUNDARY_ATTR` from `@lumio/ui`) bounds the floating lists opened inside it. The settings window card carries it, so a `Select` list in the window stays within the card: it is at most as wide as the card, flips to the trigger's other edge or shifts against the card's sides, and opens upwards when it does not fit below. Outside such a box the viewport bounds the list. Rows truncate by default; `SelectItem wrap` lets a row with an explanation under its label wrap instead (the member role picker). The channel picker that `?account_select=true` shows after a sign-in with several channels sits on `z-[110]`, above the modal layer. While it is up the dashboard shell keeps the settings window closed, even with `#settings…` in the address; picking a channel reloads the same address without the parameter, and the window opens on the chosen channel. ## Why the Dialog root portals `DialogContent` portals its `fixed inset-0` root to `` (or into the enclosing overlay layer, see above). Rendered inline, the root would take on its parents' layout: - a `space-y-*` parent gives it a `margin-bottom`, so the backdrop stops short of the bottom edge and a strip of the page stays sharp and clickable; - an ancestor with `transform`, `filter`, `backdrop-filter` or `contain` becomes its containing block, so the backdrop covers only that ancestor's box; - an ancestor with its own z-index (the app footer's `relative z-10`) traps the dialog's `z-[100]` in its stacking context, below the dashboard sidebar (`sticky z-30`) and the mobile header (`fixed z-50`). Call sites therefore render a `Dialog` wherever its trigger lives; no local `createPortal` is needed. `shared/ui/__tests__/dialog-portal.test.tsx` covers the body and in-panel cases; `apps/web/__tests__/components/cookie-manager-portal.test.tsx` asserts the footer case (`footer.contains(dialog) === false`). ## Dialogs on a phone A centred dialog taller than the viewport starts at the top edge and the backdrop scrolls (the root is `items-start overflow-y-auto`, the panel centres with `my-auto`). Initial focus does not scroll, so a dialog opens at its top. A dialog with a header and a footer band keeps both on screen: the panel is `flex max-h-full flex-col`, header and footer are `shrink-0`, and the middle is `min-h-0 overflow-y-auto`. The Settings/Security frame (`HubDialogContent`, `HubDialogBody`, `HubDialogFooter` in `apps/web/src/components/account-hub/hub-dialog.tsx`) does this for every dialog built on it; a `
` that wraps body and footer needs `flex min-h-0 flex-col` as well. Cap a dialog with `max-h-full` or `dvh` units, not `vh`: in mobile Safari `90vh` is taller than the visible area. ## Escape: only the topmost layer closes Every overlay registers on one shared, ordered dismiss stack (`useEscapeDismiss` in `shared/ui/src/escape-dismiss.ts`), driven by a single capture-phase listener. Escape runs only the topmost layer's handler and is consumed. So with a confirmation open on a panel, the first Escape closes the confirmation and the second closes the panel. Never add an ad-hoc `keydown` Escape listener inside an overlay. Register with `useEscapeDismiss(onDismiss, active)` instead. ## Scroll lock `Dialog` and `FullscreenPanel` share one reference-counted page scroll lock (`useScrollLock` in `shared/ui/src/scroll-lock.ts`). The page is locked while at least one holder is open, so a nested Dialog closing, or a closed Dialog mounting, never releases a panel's lock, whatever order React runs the cleanups in. The lock sets `overflow: hidden` on `` only and never touches ``. If both carry a non-`visible` overflow, `` becomes a scroll container of its own and every `position: sticky` element sticks to it instead of the window: the dashboard sidebar would slide up by the page's scroll offset whenever a dialog opens. Do not pin the body with `position: fixed; top: -scrollY` either; it causes the same jump. While locked, `` is padded by the width of the page scrollbar, so content does not shift sideways on systems with classic scrollbars (nothing is added with overlay scrollbars). Scrolling regions inside an overlay use `overscroll-contain`, so a touch scroll that reaches their end does not move the page behind (the `FullscreenPanel` body region already does). ## `FullscreenPanel` API | Prop | Purpose | |------|---------| | `title` | Header title slot (the caller renders its own heading) | | `actions` | Header actions, right-aligned before the close button | | `closeLabel` | **Required.** Translated accessible name of the close (X) button | | `onClose` | Called by the X button and Escape | | `showClose` | Hide the X (Escape still calls `onClose`) | | `children` / `scrollBody` | Body content; scrolls inside the body region unless `scrollBody={false}` (the wizard manages its own scrolling) | | `footer` | Optional pinned footer row | | `layers` | Nested overlays (see the stacking rule) | | `accentColor` | 2-px accent line along the top edge (e.g. a platform colour) | | `backdrop` | `"opaque"` (default, `bg-background`) or `"blur"` (`bg-background/85` + `backdrop-blur-md`) | | `overlay` | Decorative background slot behind all content (e.g. a platform gradient) | | `ariaLabelledBy` / `ariaLabel` | Turn on dialog semantics: `role="dialog"`, `aria-modal`, focus moved in on open, Tab trapped, focus restored on close | There is **no `open` prop**. The caller mounts the panel only while it should be visible (`if (!open) return null;`), so its scroll lock and Escape layer live for exactly one open lifetime. `FullscreenWizard` follows the same rule. --- ## Developer Guide / Dashboard UI Primitives Source: https://docs.lumio.vision/developer-guide/frontend-ui-primitives # Dashboard UI Primitives ## Overview `@lumio/ui` provides presentation and interaction primitives used by the Stream History list and report. The app supplies translated text, data loading, links and permissions. Import the components and helpers from `@lumio/ui` rather than copying their layout or drawing code into a dashboard feature. | Primitive | What it supplies | Current History use | |-----------|------------------|---------------------| | `Sparkline` and `sparklineGeometry` | A trend line in real SVG pixels, with an optional filled area and a `peak` or `last` marker | Viewer trends in the history table and the start page's recent streams, KPI trends in reports and on the start page | | `StatTile` and `STAT_TILE_FRAME` | A figure tile with `icon`, `label`, `value`, `sub`, `aside` and `footer` slots; the frame constant also fits its loading skeleton | Overview tiles and report KPIs | | `CalendarHeatmap` and its model helpers | A keyboard-accessible year grid with range highlighting, a legend and a day tooltip | Streaming activity calendar | | `Button` toolbar sizes and `TOOLBAR_CONTROL_CLASS` | Matching 36 px controls with rounded corners | History filters, export and sort controls | | `Badge` variants `live` and `accent` | Soft red and brand-tinted labels | Live and multistream indicators | | `PlatformButton` | A brand-coloured button that leads to a platform, with logo, pending state and corner badge | Sign-in, step-up, linking, channel and bot connect, reconnect | | `LumioMark` | The Lumio logo mark, `brand` (teal gradient) or `admin` (grey) | Every app header, footer and sign-in card | ## Trend lines and tiles `Sparkline` accepts numeric `values`, a required pixel `height`, an optional fixed `width`, `area`, `marker` (`peak`, `last` or `none`), and `markerTone` (`line` or `destructive`). Without a width it measures its container, so the line and dots keep their proportions. `sparklineGeometry()` returns the line and area paths, the peak and last coordinates, and `points` (the box coordinates of every value, `null` for a gap); fewer than two finite points return `null`. An optional `hover` (`{ label(index), value(value, index) }`) turns on the hover mode: a pointer move, or tap-and-drag on touch, snaps to the nearest step with a value (`sparklineNearestIndex()`) and shows a vertical rule, a dot and a tooltip with the label and the formatted value. A gap shows "–", never 0. The tooltip uses the popover surface, appears without motion, stays inside the box horizontally and sits above it, so the parent must not clip it. On touch the step stays after the finger lifts and clears on the next tap outside. `activeIndex` and `onActiveIndexChange` make the step controlled, so several sparklines can show the same step. The component stays `aria-hidden` and adds no tab stop: hover mode is a pointer enhancement, and the accessible values belong in a full chart or table next to it. Without `hover` the sparkline is the static line. The graphic is always `aria-hidden` and has no animation. Place the number and its meaning in nearby text. History uses a peak marker for finalized viewer trends and viewer/message KPIs, a red last marker for live viewer trends, and a last marker for follower growth. The per-session viewer trend is one component, `ViewerTrend` (`apps/web/src/components/history/viewer-trend.tsx`), used by both the history table and the start page's recent-stream rows, so the two cannot drift apart; it takes its width from `className`. The start page's KPI tiles plot one value per day, so they keep the area and mark the peak, like the per-step KPIs of a report. `StatTile` renders its label, large value and secondary line with optional top-right `aside` and bottom `footer`. Its `STAT_TILE_FRAME` gives skeletons the same outer dimensions as the populated tile, so loading does not move the surrounding layout. ## Activity calendar `CalendarHeatmap` receives local-day buckets, today's local date, first weekday, locale, translated `labels`, and a `describeCell` callback for accessible day names. `onSelectDay` handles click, Enter and Space. Optional `selectedFrom` and `selectedTo` dim days outside a selection. The grid uses one tab stop and arrow-key navigation; on phones it scrolls horizontally toward today. The grid fills its container's width and available height. Its seven rows share that height, with a width-derived square cell as the minimum: cells stay square in a width-bound card and grow taller when a neighboring card makes the row taller. The legend stays at the bottom. Give the component a height-bearing flex-column parent, as the History list's `HeatmapFrame` does, so the grid can use that extra space. The component uses the shared `Popover` for a day tooltip on hover, focus or tap. The caller supplies its content through `renderTooltip`. History's `ActivityHeatmap` wrapper owns the translated stream details, any data fetch and the report link; the shared grid contains none of those app rules. See [Frontend Overlays](/developer-guide/frontend-overlays) for the floating-panel behavior. The model exports `buildHeatmap`, `toggleDay`, `moveFocus`, `inRange`, `levelFor`, `HEATMAP_WEEKS`, and the `CalendarHeatmapDay` / `HeatmapCell` types. Use them for calendar calculations rather than duplicating date-grid logic. ## Toolbar controls and badges Use `Button` sizes `toolbar` and `toolbar-icon` for filter rows. `TOOLBAR_CONTROL_CLASS` gives a `SelectTrigger` or popover trigger the same 36 px height, `rounded-md` corners and label weight. A `Select` menu is as wide as its widest option, including the full placeholder of a `searchable` menu's search field: never narrower than the trigger, and at most 24rem and the viewport width minus 16 px. Under a compact trigger every option therefore stays on one line. Near a window edge the menu keeps that width and moves instead of shrinking. It opens under the trigger's left edge when it fits there. Otherwise it lines up with the trigger's right edge and opens towards the left. Only when neither edge fits is it shifted to stay 8 px inside the window. `Popover` panels with `align="start"` or `"end"` flip between the two trigger edges the same way. A menu only gets narrower than its content when the viewport itself is narrower, as on a phone. Option rows then stay on one line, are truncated with an ellipsis, and show their full text as a tooltip on hover. The menu never scrolls sideways. The size is fixed when the menu opens, so typing in a `searchable` menu does not resize it. Pass `matchTriggerWidth` to `SelectContent` only when the menu must be exactly as wide as its trigger. `Badge` variant `live` is a soft red indicator; `accent` is a soft brand tint for a neutral tag such as Multi. ## Text fields on phones iOS Safari zooms the whole page in when a text field with a font size below 16 px gets focus. `@lumio/ui/globals.css`, which every app imports, therefore sets every text-entry field to at least 16 px below the `sm` breakpoint (640 px). That covers `` (except checkbox, radio, range, colour, file and button types), `