Skip to content

API & tokens

Building a third-party client, script, or integration against Lumastra means dealing with the token surface, how auth and scopes work, and the contract every response follows.

Lumastra exposes three distinct HTTP surfaces, each with its own authentication model. The right choice depends on who’s calling and from where.

SurfacePath prefixAuth methodWho uses it
Browser BFF/api/*Browser session: forward auth, built-in login, or OIDCBrowser UI only
Token API/api/v1/*Bearer token (JWT, PAT, service token)External clients, TV apps, scripts
Device flow/oauth/device, /oauth/tokenPublic, rate-limitedOAuth device-code handshake

/api/* and /api/v1/* are separate surfaces, not a mirror of each other. Only /api/v1/* is the external token surface. Many endpoints share handler logic underneath; their paths and available operations differ. The split exists as an edge auth boundary, so the identity provider’s forward-auth middleware can gate /api/* while /api/v1/* handles its own Bearer auth without being redirected to a login page.

The /api/v1 contract evolves additively. A response may gain new optional fields as the server learns to expose more information; clients must ignore object properties they do not recognize and continue decoding the fields they understand. The schemas in the OpenAPI reference describe the fields a client can use, not a requirement that every response object contain only those keys.

Adding a response field is therefore not a breaking API change. Removing or renaming a field, changing its type or meaning, or tightening an existing requirement is breaking and is handled separately. Clients should still treat known-field type mismatches and required-field omissions as errors, and should handle unknown discriminator or enum values defensively.

Check the x-stability of each operation in your instance’s schema:

LabelWhat it promises
stableNo breaking change without the deprecation process below. New optional fields, documented-open enum values and new operations can appear at any time.
betaSupported, but its shape may still change in a minor release. Changes are listed in the changelog.
experimentalMay change or disappear in any release.
protocolA byte or streaming endpoint (media files, HLS playlists and segments, images, OAuth). Its format follows the media or OAuth protocol; use the URL a JSON response gives you.
deprecatedStill works, names its replacement and a removal date. Move off it.

From 0.1 on, stable operations and their documented fields keep working through the 0.x series unless they go through deprecation: an operation stays marked deprecated for at least two minor releases or 90 days, whichever is longer, and is removed only in a release whose changelog says so.

A client notices a deprecation two ways. The spec marks the operation deprecated: true with x-deprecation: { since, sunset, replacement }. At runtime every response of a deprecated operation carries Deprecation: @<unix-seconds> (RFC 9745), Sunset: <HTTP-date> (RFC 8594) once a removal date is set, and Link: <replacement URL>; rel="successor-version". Log a warning when you see Deprecation.

The API reference groups its tags into sections in the order a client needs them: Getting started (Server, Authentication, Me, Live updates), Library (Media, Browse, Series, People, Search, Discovery, Requests, Library sync, Images), Playback (Playback sessions, Streaming, Timeline, Downloads, Network), Music (Music library, Music discovery & radio), Audiobooks, Live TV, Your data (Watch state, Ratings, Watchlist, Your music, Playlists), Social (Watch together, Community, Notifications), Devices (Devices, Remote control, Client diagnostics), Integrations (Webhooks, Service feeds) and Administration. Every operation sits in exactly one tag.

Each operation has a hand-picked operationId such as getMediaItem, startPlaybackSession or listLiveTvStations. Generated clients use it as the method name, and it does not change when a path changes.

Use Authorization: Bearer <token> for resource calls and Content-Type: application/json for JSON request bodies. Public resource fields use camelCase; OAuth uses snake_case. Send only fields declared by the operation: many request schemas reject unknown keys. An omitted optional value selects that operation’s default; do not send null unless its schema permits it. The normal declared JSON body cap is 256 KiB, with endpoint-specific exceptions such as uploads and bulk APIs.

A read PAT or service grant allows GET/HEAD only. A device media:read grant has the same method restriction: even POST /media/{id}/playability needs a full media device grant or a write-capable PAT/service token, despite its endpoint-level media:read gate. Read-only device mutations answer 403 forbidden with reason: scope_insufficient; do not refresh the token to resolve that denial.

Session positions, durations, heartbeat intervals and timeline values use milliseconds. Fields ending in Bps are bits/second; Kbps are kilobits/second. Follow the field name for exceptions: music startPositionSeconds, OAuth expires_in/interval, and retryAfter/Retry-After use seconds. Treat issued delivery and reporting hrefs as opaque, and use the method and interval in the reporting descriptor.

/api/v1 is a resource-oriented API with a polymorphic catalog spine.

Catalog media use the public vocabulary movie | series | season | episode | track | album | artist | audiobook. series is the public word for a TV show; storage uses tv. Individual endpoints support subsets of these types. Other resources, such as people, devices and playlists, have their own schemas; a type field is not required on every API object.

Treat returned id values as opaque resource handles. A media summary may include catalogId (the library UUID, or null for a discovery-only title) and an open ids map of provider values. Keep the provider and media type together: the same numeric provider ID can identify unrelated movies and series.

Media routes that support external resolution accept a handle such as tmdb:653:movie; percent-encode it as one path segment. GET /media/{id} also supports an externalId query override. Do not assume that every {id} accepts provider handles: playback start requires a library mediaId UUID, while device, playlist-entry, grant, rendition and task-run IDs belong to different resources. Resolve a title with GET /media/{id} before starting playback. List library movies and series through GET /media?type=movie or ?type=series; /movies is retired.

See API routes reference for the full catalog, images, and sizing contract.

Resource API errors, including authentication failures and unknown paths, use application/problem+json. HLS manifest errors from GET /playback/{id}/hls do too: a startup failure is 503 dependency_unavailable with reason transcode_start_timeout, transcode_start_failed or transcode_start_interrupted. The code field is the machine-parseable contract; error text is human-readable and must not be parsed by clients. reason refines some codes, such as invalid_grant. Handle a code unknown to your client as an error using the HTTP status; do not crash while decoding it.

{
"code": "validation_failed",
"error": "Request validation failed",
"status": 422,
"details": [{ "field": "limit", "message": "Must be > 0" }],
"requestId": "req_abc123", // optional
"retryAfter": 30 // optional, seconds
}

When retryAfter is present it is also emitted as the Retry-After HTTP response header.

codeHTTPMeaning
unauthorized401No credential or session
invalid_token401Bearer token present but invalid or expired
invalid_grant401Playback grant invalid, expired or revoked, or its playback session ended. reason: expired, revoked, session_ended or invalid
forbidden403Authenticated but lacking permission. A grant used on a URL that is not one of its media URLs answers with reason: wrong_scope
ip_mismatch403An IP-pinned grant used from another network address. For a receiver on another network, start the session with castTarget: true
scope_required403Token present but required scope not granted
not_found404Resource does not exist, or feature is off
conflict409Resource exists / operation already in-flight
gone410Resource gone; a video grant orphaned by restart uses reason: session_restarted and permits a fresh authenticated start
route_retired410Legacy route permanently retired; use the replacement named by that route
payload_too_large413Request body exceeds the endpoint cap
validation_failed422Request syntactically valid but semantically wrong
quota_exceeded422Request quota exhausted for the bucket
rate_limited429Too many requests
internal_error500Unhandled server error
bad_gateway502Upstream returned an unexpected error
dependency_unavailable503Required dependency (DB, Valkey, external API, stream proxy) is down. Honour Retry-After when present
shutting_down503The server instance is stopping (rolling update) and did not run the request, or its services stopped while the request ran. Sent with Retry-After: 2 on any route and method except the /api/health and /api/health/ready probes. Retry idempotent requests; the router sends the retry to a running instance once it has dropped the stopping one
stream_limit422 / 503VOD admission refusal (422); Live TV or SSE capacity full (503)
transcode_not_allowed, source_incompatible, ip_restricted, plugin_denied422Playback policy or source refusal; inspect structured details
prefetch_requires_current_session422Music prefetch needs an admitted current session on the same user and device; fall back to purpose: "play"
cap_unavailable503Requested Live TV bitrate ceiling could not be honored
server_id_unavailable503The server’s persistent identity is not available yet; retry after Retry-After
channel_offline, provider_auth_failed, geo_blocked, no_video, all_feeds_failed, upstream_stalled502Live TV start or playback failures; see Live TV playback
encoder_failed500Live TV: the server could not encode the channel
provider_connection_limit503Live TV: the provider has no free connection
provider_unreachable504Live TV: the provider cannot be reached

Feature-off is 404 not_found, not 403 forbidden: a disabled feature does not exist from the client’s perspective.

A required dependency failure uses 503 dependency_unavailable. The details field may name the failing component. Aggregators such as /feed can still return 200 while omitting an unavailable section. Grant failures also send an X-Playback-Grant-Reason header with the same reason.

Once a byte or stream response has started, errors are transport-native: an SSE error event frame, an HLS 410 or 503 on the next segment fetch, or the connection closing. Before the stream starts, resource API errors use application/problem+json, including the HLS startup failures above. OAuth has its own error envelope, described below. Use the actual HTTP status: some endpoints override a code’s default status, and byte requests can also return protocol statuses such as 206 or 416.

Paginated collections use this envelope:

{
"items": [ /* … */ ],
"page": {
"limit": 20,
"offset": 0,
"hasMore": true,
"total": 142 // when supplied by this endpoint
}
}

hasMore drives paging without requiring a total count. Follow each operation’s parameters and page schema: includeTotal=true is not a universal parameter, and some bounded listener rows use { limit, truncated } instead of hasMore. Unpaged collections (seasons, groups, libraries, your tokens) return the whole set: page.limit equals the item count and hasMore is false.

Endpoints with high churn (run history, event logs, feeds) use cursor pagination instead of offset: "page": { "limit": 50, "nextCursor": "eyJpZCI6…", "hasMore": true }. Pass ?cursor=<opaque>&limit=50 on subsequent requests. Use only the paging parameters the endpoint declares. A cursor-eligible endpoint may return nextCursor alongside an initial offset page to let the next request switch to cursor paging; never parse or manufacture a cursor.

Three token types are accepted on /api/v1/*. The middleware checks the prefix to route validation:

TypePrefixCreated byPermissionsExpiry
JWT access token(none, compact JWT)OAuth device flowFixed device grant plus user accessJWT_ACCESS_TOKEN_TTL_SECONDS (default 3 600 s)
Personal Access Tokenpat_User (settings UI or /api/v1/me/pats)User’s groups, scopedUser choice
Service tokensvc_Admin only (/api/v1/service-tokens)Configurable scope, optional IP allowlistAdmin choice

PAT and service-token secrets are stored as SHA-256 hashes and shown only when created. JWTs are signed credentials. Refresh-token lookup uses hashes; the server also keeps a short-lived rotation replay value so a lost response can be retried. Store credentials in platform secure storage and keep them out of logs and URLs.

Media URLs use a fourth credential, the playback grant (see Play media from your own client). POST /api/v1/play-sessions mints it and embeds it in its media delivery URLs as ?grant=. Reporting and grant-management hrefs still need the account credential. A grant is opaque and bound to one item and one playback session. It works only on media delivery URLs (manifests, segments, files, subtitles, previews, music and audiobook audio, Live TV HLS), never on JSON calls. Its normal lifetime is at most 24 hours for video and audiobooks, 6 hours for music and Live TV, and 1 hour for previews. The bound session can end it sooner. It is pinned to the IP address that started the session unless the start asked for castTarget and the server’s policy allows it; unpinned grants last at most 6 hours. Renew it with POST /api/v1/playback/grants/{id}/renew and { "token": "…" }. The response is the grant object with the extended expiry. Renewal never shortens expiry, and no grant lives longer than 7 days after its first start. GET and DELETE on /api/v1/playback/grants/{id} read and revoke it.

A grant ends with its playback session. Ending the session (a COMPLETED report on PUT /api/v1/play-sessions/{id}, or DELETE /api/v1/play-sessions/{id}), a newer start on the same device, or reaping makes every URL on that grant normally answer 401 invalid_grant with reason: session_ended. A video session orphaned by a server restart instead answers 410 gone, reason: session_restarted, with X-Lumastra-Session-Terminal: false. In either case, obtain a fresh authenticated playback start rather than replaying old URLs.

Issued by the OAuth 2.0 Device Authorization flow (RFC 8628). Short-lived (default 1 hour). Refresh tokens (90-day default) rotate on every use. Require FEATURE_DEVICE_AUTH (on by default) and JWT_SECRET (derived from LUMASTRA_MASTER_KEY when unset).

Example JWT payload (treat access tokens as opaque credentials; group claims alone do not make a device token administrative):

{
"username": "alice",
"client_id": "my-tv-app",
"scope": "media",
"groups": ["admin"],
"device_session_id": "…",
"token_type": "access",
"iat": 1766258109,
"exp": 1766261709,
"iss": "Lumastra"
}

Format: pat_ + 48 hex chars.

  • Inherit the creating user’s groups and library access.
  • Accepted scopes: read, write, admin, or either exclusive jellyfin_compat / jellyfin_compat_read scope. Fine-grained scopes are expanded permissions and cannot be requested independently. See the scope tree below.
  • Max 10 active tokens per user.
  • Usage (last-used timestamp, IP, count) is tracked per token.
  • Revocation propagates cluster-wide via Valkey: each replica caches a validated token for 30 minutes, and a revocation writes a Valkey marker with the same 30-minute TTL that other replicas check on every cache hit, so the only gap is Valkey being unreachable, not a fixed ~30-second window.
  • A PAT is only ever treated as admin when both are true: the owning user is an admin, and the token itself carries an explicit admin-equivalent scope (admin or tasks:admin). A low-scope PAT created by an admin user is not admin. Scope the token; don’t rely on the owner’s group.
  • jellyfin_compat* scopes are exclusive to PATs. They enable the /j Jellyfin-compatibility surface and are never granted to device JWTs or service tokens.

Require FEATURE_USER_PATS=true and JWT_SECRET.

Create a PAT in the signed-in web UI, with FEATURE_USER_PATS enabled. The self-service POST /api/v1/me/pats requires an interactive session; a device JWT, PAT or service token cannot mint through it (403 forbidden, reason: session_required). The request body is:

{
"name": "Home display",
"description": "Read current playback",
"scopes": ["read"],
"expiresInDays": 365
}

The response body includes the token field (shown once, store it immediately). Manage PATs at GET/POST /api/v1/me/pats and DELETE /api/v1/me/pats/{id} (204).

Format: svc_ + 48 hex chars.

  • Admin-created only (POST /api/v1/service-tokens). Creating one needs an administrator or a token with the admin scope.
  • Accepted scopes: read, write, admin, integrations:read and diagnostics:read; fine-grained permissions come from alias expansion.
  • Support optional IP allowlist, path/method restrictions, and expiry.
  • Support user impersonation via the actAsUser field set at creation time. Impersonation target is database-bound. It cannot be changed via request headers at call time.
  • An optional forceNonAdmin: true flag at creation guarantees the token can never resolve to an admin principal, regardless of scopes granted or the impersonated user’s own group.
Terminal window
# Create (requires admin session or tokens:admin service token)
curl -X POST https://lumastra.example.com/api/v1/service-tokens \
-H "Authorization: Bearer svc_your_admin_token" \
-H "Content-Type: application/json" \
-d '{
"name": "Grafana",
"scopes": ["read"],
"actAsUser": "alice",
"allowedIps": ["192.168.1.50"],
"expiresInDays": 30
}'

Manage service tokens at GET/POST /api/v1/service-tokens and GET/DELETE /api/v1/service-tokens/{id}.

A service token with the admin scope can mint further service tokens, for example an automation agent that hands each bot its own narrow token.

  • Owner. The new token belongs to the person who owns the minting token, not to the minting token. Its createdBy is that person, and createdViaTokenId / createdViaTokenName name the minting token. The admin token list records the owner and minting token. Revoking the minting token also revokes its descendants. The child also stops working when its owner is disabled or deleted.

  • No escalation. The new token can never do more than the token that minted it. The request is refused (403) unless:

    • its scopes are ones the minting token holds;
    • if the minting token is limited to some paths, methods or IPs, the new token sets the same or a narrower list;
    • if the minting token has forceNonAdmin, the new token sets it too;
    • if the minting token expires, the new token expires no later;
    • if the minting token acts as a user, the new token sets actAsUser to that same user.

    Otherwise actAsUser follows the usual rules.

Earlier versions stored tokens minted this way without a real owner, and every request with them was refused with 401. The upgrade repairs every such token that stays within the token that minted it. Revoke and re-create any token it could not repair; the server log lists them at upgrade time.

read, write, and admin are coarse aliases that expand deterministically when building the request principal:

  • admin → every scope in the tree.
  • write → read + media:read + media:write + sessions:read + requests:read. It does not grant tasks:run, webhooks:write, or any *:admin scope. Those need admin.
  • read → media:read + sessions:read + requests:read. It does not grant tasks:read or runs:read. Task/ops routes, run history included, need admin even for read access.

Several fine-grained *:admin scopes also imply their :read counterpart: users:admin, library:admin, providers:admin, media:admin (implies both media:read and media:write), and playback:admin all include read. tasks:admin additionally implies tasks:read, runs:read, and metrics:read.

Tokens can be minted only with read, write or admin, plus the exclusive integrations:read or diagnostics:read grants on service tokens. integrations:read is limited to integration feeds. diagnostics:read is limited to the admin debug report, requires expiry (at most 90 days), and must be admin-minted. Neither grant makes the credential a general administrator. The fine-grained names below are gates that admin covers; they cannot be minted on their own.

ScopeGrants access to
readGeneral read access
writeGeneral write access (not tasks, webhooks, or admin ops)
adminEverything
tasks:readRead task definitions and run history
tasks:runEnqueue and cancel runs
tasks:adminPause/resume tasks, manage queues and plugins
runs:readRead run history and stream run logs (operator data: comes with tasks:admin)
webhooks:writeCreate, update, and delete webhooks
media:readRead catalog items
media:writeStart/report playback and mutate supported user media data
media:adminRescan, delete, or manage individual items
sessions:readRead permitted play sessions; reading every user still needs admin access
sessions:writeLegacy scope name; current playback cancellation checks media:write plus ownership/admin
requests:readRead own requests and quota
requests:adminRead all requests across users
users:readRead user list and profiles
users:adminManage user lifecycle and per-user config
library:readRead library / path / group configuration
library:adminCRUD library paths, groups, validate, and reports
tokens:adminMint/list/revoke PATs and service tokens on behalf of others
livetv:adminSources, channels, categories, proxy config
providers:readRead metadata-provider config
providers:adminManage provider config and integration keys
playback:readTranscode capabilities and quality stats
playback:adminReset playback quality, cancel ffmpeg jobs
metrics:readSystem metrics, task metrics, Prometheus scrape
access:adminAccess groups and library-group permissions

Scope note: task/ops routes (/tasks, /runs, /queues) accept only pat_/svc_ or an admin session, not device-flow JWTs.

All three account token types use the standard Authorization: Bearer header on /api/v1/*:

Terminal window
curl -H "Authorization: Bearer pat_your_token_here" \
https://lumastra.example.com/api/v1/media/550e8400-e29b-41d4-a716-446655440000

Never put your account token in a media URL. Players that cannot set request headers (Apple AVPlayer, VLC, Kodi, cast receivers) use the URLs that POST /api/v1/play-sessions returns. Each one already carries a ?grant= credential that is bound to one item and one playback session, expires, and works only on media URLs, never on JSON calls:

/api/v1/playback/{id}/hls?PlaySessionId=<playbackSessionId>&grant=<opaque grant>

api_key and stream_token query parameters are no longer accepted on /api and /api/v1 media routes. See Play media from your own client.

This flow lets TV apps and headless clients authenticate without a keyboard. It follows RFC 8628.

Prerequisites: FEATURE_DEVICE_AUTH on (the default since v0.1) and a JWT_SECRET (derived from LUMASTRA_MASTER_KEY when unset).

The device-code and token exchange endpoints are public; the browser approval page requires sign-in. They must be excluded from your reverse proxy’s forward-auth middleware. See Authentication guide for Authentik / Traefik / nginx examples.

Lumastra implements the Device Authorization Grant only. It is not an OIDC provider: there is no authorization-code grant, no /.well-known/openid-configuration, and no JWKS endpoint. (Lumastra consumes OIDC as a relying party for browser login, but does not provide it.)

POST /oauth/device
Content-Type: application/json
{
"client_id": "my-tv-app",
"device_name": "Living Room TV",
"scope": "media"
}

client_id is a free-form label: there is no client registration or client secret. Security rests on the user’s explicit approval in step 2.

"media" is read and write: browse, play and report. "media:read" gives a read-only token that cannot start playback (403 forbidden, reason: scope_insufficient for device JWTs). When scope is absent or not recognized, the default is "media" (read and write).

An optional device_id (a stable UUID you generate and persist per physical device) lets the server reuse the same device_session_id across re-authorizations of that device, which preserves playback state across a re-link. Omit it and every authorization gets a fresh session.

Response (RFC 8628 §3.2):

{
"device_code": "abc123…",
"user_code": "WDJB-MJHT",
"verification_uri": "https://lumastra.example.com/device",
"verification_uri_complete": "https://lumastra.example.com/device?code=WDJB-MJHT",
"expires_in": 600,
"interval": 5
}

Display user_code (or the verification_uri_complete as a QR code) to the user. The endpoint is rate-limited to 10 requests per minute per IP.

The user visits /device in a browser (this path goes through forward auth). They enter or confirm the code, which marks the device code as authorized.

Start polling POST /oauth/token at the interval returned in step 1. The wire format follows RFC 8628 (snake_case, not the JSON contract used by the rest of /api/v1):

POST /oauth/token
Content-Type: application/json
{
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": "abc123…",
"client_id": "my-tv-app"
}

application/x-www-form-urlencoded is also accepted for off-the-shelf OAuth library compatibility.

Polling responses:

HTTPerror fieldAction
200(none)Success: use the returned tokens
400authorization_pendingKeep polling at the given interval
400slow_downBack off: increase interval by at least 5s, then keep polling
400access_deniedUser denied: restart flow
400expired_tokenDevice code expired: restart flow
400invalid_requestMissing/malformed parameters, or the feature is disabled
400unsupported_grant_typegrant_type value not recognized
400invalid_grantBad/revoked device_code, or client_id mismatch: abort
429slow_downIP-level poll-rate limit: back off further
500server_errorTransient server fault: back off and retry

Error responses from /oauth/* use the RFC 8628 { error, error_description } shape, not application/problem+json, because OAuth client libraries hard-code those field names.

Success response:

{
"access_token": "eyJhbGci…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "…",
"scope": "media"
}

Store both values securely (Keychain on Apple platforms). The device_code itself is consumed on success and cannot be reused.

POST /oauth/token
Content-Type: application/json
{
"grant_type": "refresh_token",
"client_id": "my-tv-app",
"refresh_token": "xyz789…"
}

Refresh returns an access token and a replacement refresh token. Persist the pair securely before treating refresh as complete, and serialize refresh calls per device. The old token is superseded, rather than immediately revoked: retrying a lost response can return the same replacement from a 90-second replay cache. Without that cache it can issue another valid replacement. A later successful rotation retires older superseded tokens. Reuse does not revoke the entire device session.

Refresh proactively using expires_in (seconds), and at most once after an API 401. Send the original client_id with refresh; omission is accepted for older clients, but a mismatched value answers 400 invalid_grant, code: client_mismatch. An expired/revoked refresh token also answers invalid_grant, rather than expired_token. Use the stable /oauth/token code extension to distinguish terminal refresh failures from rate_limited or server_error. Keep credentials on transient network/5xx failures; back off and retry. See the complete OAuth client guide for polling and storage details.

SurfaceLimit
POST /oauth/device10 requests / minute / IP
POST /oauth/token30 requests / minute / IP, plus the advertised per-code poll interval (default 5 s)
PUT /play-sessions/{id} + POST /play-sessions/{id}/beaconNo endpoint-level rate limit
POST /play-sessions60 requests / minute / user
POST /playback/grants/{id}/renew30 requests / minute / user
GET /events30 connection attempts / minute / user, plus concurrent SSE limits

Other operations have their own limits. Handle 429 and honour Retry-After in seconds.

VariableDefaultDescription
FEATURE_DEVICE_AUTHtrueThe Public API: /api/v1/*, the OAuth device flow and JWT token issuance. false makes feature-gated /api/v1/* endpoints answer 404 not_found; OAuth device authorization answers 400
FEATURE_USER_PATSfalseAllow users to create PATs via the settings UI
JWT_SECRET(required when either feature is on)Signs JWTs; required by token configuration. Min 32 chars. Derived from LUMASTRA_MASTER_KEY if unset. Generate: openssl rand -hex 32
JWT_ACCESS_TOKEN_TTL_SECONDS3600JWT access token lifetime
JWT_REFRESH_TOKEN_TTL_DAYS90Refresh token lifetime
DEVICE_CODE_TTL_SECONDS600Device code expiry
DEVICE_CODE_POLL_INTERVAL_SECONDS5Minimum client polling interval
DEVICE_VERIFICATION_BASE_URL(request origin)Canonical base URL for verification_uri. Set this when the endpoint is reachable directly to prevent host-header injection.

/api/v1/* and /oauth/* handle their own authentication. These paths must bypass your identity provider’s forward-auth middleware, or all external API calls will be redirected to a login page.

These locations skip forward auth entirely, so they must explicitly blank the identity headers. Otherwise a caller could set them directly and spoof an identity (see SSO with Authentik for where this rule comes from).

# nginx — skip forward auth for the token surface and device flow
location /api/v1/ {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-authentik-username "";
proxy_set_header X-authentik-groups "";
proxy_pass http://backend;
}
location /oauth/ {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-authentik-username "";
proxy_set_header X-authentik-groups "";
proxy_pass http://backend;
}

For Authentik Proxy Provider, add to “Unauthenticated Paths”:

^/oauth/.*
^/api/v1/.*

See Authentication for full Traefik and Caddy examples.

Lumastra also implements two separate compatibility APIs that use their own authentication schemes and are not part of the /api/v1 token surface:

  • Subsonic / OpenSubsonic at /subsonic/rest/*: used by music clients (Ultrasonic, DSub, etc.)
  • Jellyfin compat at /j/*: used by TV clients that speak the Jellyfin API. Works without a Jellyfin server; JELLYFINURL/JELLYFINAPIKEY are optional and only affect background metadata sync.

Both are documented in Connecting third-party apps.

A running instance publishes its own API documentation at /api-docs once FEATURE_DEVICE_AUTH is on: an interactive Swagger UI, plus the same spec as raw YAML (?format=yaml), JSON (?format=json), or Markdown (?format=md). The same content is also available, unauthenticated by design, as plain text at /llms.txt for LLM-agent tooling; returns 404 when the public API is disabled.

The full interactive reference on this site, /reference/, is built from a vendored public spec snapshot and can lag the installed server. Use the instance schema for its exact public operations, parameters and responses; internal-only routes are excluded. Third-party integrations should target endpoints marked x-stability: stable in the spec (see Stability labels and deprecation).