The full Kiln binary registers 69 routes in four credential classes: public endpoints, session-or-token endpoints, session-only endpoints, and playback endpoints. The list below also includes admin console routes that are not expanded in the README.
Credential model
Four credentials, four responsibilities, no overlap.
| Credential | Shape | How it travels | Used for |
|---|---|---|---|
| None | None | None | Health probes, metrics, EPG, logos, login |
| Session JWT | Ed25519 (EdDSA) signed JWT | Authorization: Bearer <jwt>; playback endpoints also accept ?token= |
Admin console and interactive use |
| Admin API token | kiln_v1_ prefix plus 48 base62 characters |
Authorization: Bearer kiln_v1_... |
Scripts and automation |
| Playback key | v1 prefix plus 126 base62 characters |
Path-based /p/{token}/... |
Player-facing distribution links |
How credentials travel
The Authorization: Bearer header carries both session JWTs and admin API tokens. The server first checks whether the value matches the kiln_v1_ shape and treats it as an API token if it does; otherwise it parses it as a session JWT. No extra header or parameter is needed to disambiguate.
Playback endpoints accept two additional forms:
- The
?token=<jwt>query parameter, which takes precedence over theAuthorizationheader. - The path form
/p/{token}/, where{token}is a playback key. This form reads no request headers at all: the link itself is the credential.
Session JWTs
POST /v1/auth/login exchanges a username and password for a JWT. Tokens are signed with Ed25519 and include issuer, audience, exp, iat, nbf, and jti, with 30 seconds of clock leeway during validation. Lifetime comes from auth.token_ttl_hours and defaults to 24 hours. Each token also includes role and channels and is bound to the account’s auth_revision, so changing login credentials invalidates every previously issued token.
Admin endpoints additionally require role to be admin. A non-admin session gets 403 forbidden.
Admin API tokens
The plaintext is returned exactly once, at creation and on rotation. The server stores only a SHA-256 digest and a display prefix. Tokens carry four permissions:
| Permission | Covers |
|---|---|
read |
Listing, detail, status, and log reads |
write |
Creates and updates |
delete |
Deletes and revocations |
refresh |
Probes, warmups, previews, refreshes, connection tests, session teardown |
An API token can only reach registered routes. The registry holds 43 entries, which are exactly the rows marked with a permission in the “Session or API token endpoints” and “Admin endpoints” sections below. Anything else is denied, with one of these reasons:
| Reason | Status | Meaning |
|---|---|---|
revoked |
401 | The token is disabled or revoked |
expired |
401 | The token has expired |
session_required |
403 | The route accepts a login session only |
route_not_available |
403 | The route is not registered for API tokens |
missing_scope |
403 | The token lacks the permission the route requires |
Every API token request is written to the audit log, allowed or denied. Read it back with GET /v1/admin/api-token-logs.
Playback keys
Playback keys are created under Playback Access Control in the admin console. They can cover all channels or a specific list of channel IDs, have an optional expiration date, and be revoked at any time. Every use through /p/{token}/ is recorded in the playback access log. The first 10 characters serve as the display prefix, and both the access log and the request log keep only that prefix.
Shared conventions
Error responses
Every error uses the same JSON shape:
{
"error": {
"code": "invalid_request",
"message": "invalid json body"
}
}code is a stable machine-readable identifier; message is for humans. Internal errors never leak their cause: the message is always internal server error.
code |
Typical status | When |
|---|---|---|
invalid_request |
400 | Body, parameter, or field validation failed |
unauthorized |
401 | Missing, invalid, or expired credential |
forbidden |
403 | Valid credential, insufficient authority |
not_found |
404 / 410 | Resource missing or session gone |
conflict |
409 | Optimistic concurrency or state conflict |
upstream_error |
502 | Upstream fetch or probe failed |
unavailable |
503 | Media part not ready yet |
not_ready |
502 / 503 | Playlist or compatibility engine not ready |
too_many_requests |
429 | Rate limit tripped |
internal |
500 | Server-side failure |
current_password_invalid |
422 | Wrong current password on a credential change |
username_taken |
409 | Target username already in use |
Request and response headers
Every response carries X-Request-ID, echoed from the request when present and generated otherwise. By default, responses also set X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY, a Content-Security-Policy, and Cache-Control: no-store, no-cache, must-revalidate. EPG, logo, and immutable media endpoints use their own caching policies.
OPTIONS requests return 204 immediately. CORS headers follow security.cors_origins and are omitted entirely when it is unset. When security.public_hosts is configured, requests whose Host is not on the list get 403 forbidden; /healthz and /readyz from a loopback address are exempt.
Optimistic concurrency
Resources that need concurrency safety use a revision number plus the If-Match header. GET /v1/admin/channels/{id} returns the current revision in ETag; other resources expose it as a revision field in the response body. A mismatch returns 409 conflict.
| Endpoint | If-Match |
|---|---|
PUT /v1/admin/settings and PUT /v1/admin/egress |
Required; missing means 409 |
PUT and DELETE on /v1/admin/egress/proxies/{id} and /v1/admin/egress/rules/{id} |
Required; missing means 409 |
PUT and DELETE /v1/admin/epg/sources/{id} |
Required; missing returns 428 (except deleting a preset source) |
Update, rotate, revoke, and delete on /v1/admin/api-tokens/{id} |
Required; missing means 409 |
POST, PUT, and DELETE on the /v1/admin/channels family |
Optional; validated when supplied |
Revoke and delete on /v1/admin/access-tokens/{id} |
Optional; validated when supplied |
PUT /v1/admin/channels/reorder |
Replaced by the revisions map in the body; missing means 409 |
Rate limits
POST /v1/auth/login is rate limited per client IP over a one-minute window. The quota comes from auth.login_rate_per_min and defaults to 20 per minute. Exceeding it returns 429 too_many_requests.
PUT /v1/me/credentials shares the same limiter, keyed by username plus client IP.
Public endpoints
No credential required.
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
Service info, and the fallback for unmatched GET paths |
GET |
/healthz |
Liveness probe |
GET |
/readyz |
Readiness probe |
GET |
/metrics |
Prometheus metrics |
GET |
/admin, /admin/ |
Admin console assets |
POST |
/v1/auth/login |
Exchange credentials for a session JWT (rate limited) |
GET |
/v1/epg.xml |
XMLTV guide |
GET |
/v1/epg.xml.gz |
Gzipped XMLTV guide |
GET |
/v1/logo/{id} |
Channel logo |
GET /
Redirects to /admin with a 302 when Accept contains text/html, otherwise returns JSON:
{ "name": "kiln", "version": "1.0.0", "commit": "dev", "admin": "/admin" }This pattern also catches every unmatched GET path, which returns 404 not_found.
GET /healthz
Always returns {"status":"ok"}.
GET /readyz
If a DASH ingress channel resolves to the FFmpeg compatibility engine, readiness checks that ffmpeg is available and returns 503 not_ready when it is not. Otherwise it returns {"status":"ready"}.
GET /metrics
Returns 404 when observe.enabled is false. When enabled, it serves Prometheus text as text/plain; version=0.0.4.
GET /admin and GET /admin/
Serves the embedded admin console. Assets under /admin/assets/ support gzip negotiation and ETag conditional requests.
POST /v1/auth/login
Request body; unknown fields are rejected:
{ "username": "admin", "password": "..." }On success, 200:
{
"token": "...",
"expires_at": "2026-01-01T00:00:00Z",
"username": "admin",
"role": "admin"
}A bad username or password returns 401 unauthorized.
GET /v1/epg.xml and GET /v1/epg.xml.gz
Return the XMLTV guide for all channels; the compressed variant uses Content-Type: application/gzip. With no EPG source configured, the response is a well-formed but empty document. When the EPG cache is disabled, each request first triggers an on-demand refresh.
GET /v1/logo/{id}
Tries the built-in candidate sources in order, keyed on the channel’s EPG name (falling back to its title). On success it returns the image bytes with Cache-Control: public, max-age=3600, stale-if-error=86400 and X-Kiln-Logo-Source. If every candidate fails, it returns 502 upstream_error.
Session or API token endpoints
Reachable with a login session or an admin API token holding the listed permission. Admin role is not required.
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET |
/v1/me |
read |
Describe the current credential |
GET |
/v1/channels |
read |
Visible channel list |
GET |
/v1/status |
read |
Runtime snapshot |
GET /v1/me
{
"username": "admin",
"role": "admin",
"channel_ids": [],
"credential": "session",
"scopes": null
}credential is session or api_token. For an API token, username holds the token name and scopes lists its permissions.
GET /v1/channels
Returns {"channels": [...]}. Each entry carries id, title, group, logo_url, epg_id, epg_name, epg_source, ingress, on_demand, autostart, source_url, upstream, path, disabled, prefer_height, preferred_audio_languages, sort_order, revision, and play_url.
A non-admin session with a channel scope sees only the channels it declares.
GET /v1/status
Returns uptime_sec, bytes_in, bytes_out, requests, errors, goroutines, session_count, and a sessions array. Each session carries channel_id, mode, engine, pack_mode, fallback_reason, started_at, last_touch, state, errors, last_error, and an optional packager counter block.
A non-admin session with a channel scope sees sessions and session_count filtered to that scope.
Session-only endpoints
These accept a login session only. An admin API token is refused: /v1/me/credentials, /v1/admin/api-tokens/*, and /v1/admin/api-token-logs are denied with session_required, while /v1/playlist.m3u is denied with route_not_available. This boundary is what keeps a token from escalating itself.
| Method | Path | Purpose |
|---|---|---|
GET |
/v1/playlist.m3u |
Playlist carrying the session token |
PUT |
/v1/me/credentials |
Change login credentials |
GET |
/v1/admin/api-tokens |
List admin API tokens |
POST |
/v1/admin/api-tokens |
Create an admin API token |
PUT |
/v1/admin/api-tokens/{id} |
Update name, note, permissions, enabled state, and expiration |
POST |
/v1/admin/api-tokens/{id}/rotate |
Issue new plaintext |
POST |
/v1/admin/api-tokens/{id}/revoke |
Revoke |
DELETE |
/v1/admin/api-tokens/{id} |
Delete |
GET |
/v1/admin/api-token-logs |
Token audit log |
GET /v1/playlist.m3u
Returns application/vnd.apple.mpegurl with playback URLs prefixed by /v1/play/ and the caller’s session token appended to each one. When EPG sources are configured, the playlist header carries x-tvg-url pointing at /v1/epg.xml.gz.
PUT /v1/me/credentials
Requires an admin session. Request body; unknown fields are rejected:
{
"current_password": "...",
"username": "newname",
"new_password": "..."
}At least one of username and new_password must be present. New passwords are 8 to 72 bytes; usernames are at most 64 characters and may not contain control characters. On success the response matches the login shape, meaning a brand new session token: the old one stops working immediately. A wrong current password returns 422 current_password_invalid; a taken username returns 409 username_taken.
GET /v1/admin/api-tokens
{
"tokens": [
{
"id": "...",
"name": "ci",
"token_prefix": "kiln_v1_ab12cd34",
"scopes": ["read", "write"],
"enabled": true,
"created_by": "admin",
"created_at": 0,
"expires_at": 0,
"last_used_at": 0,
"revision": 1,
"updated_at": 0
}
],
"available_scopes": ["read", "write", "delete", "refresh"]
}POST /v1/admin/api-tokens
Body: { "name": "ci", "note": "", "scopes": ["read"], "expires_in_sec": 0 }. name is required, at least one permission must be granted, and expires_in_sec of 0 means no expiration. The maximum is 10 years. Returns 201:
{
"token": "kiln_v1_...",
"credential": { "id": "...", "token_prefix": "kiln_v1_ab12cd34" },
"warning": "store this token now; it will not be shown again"
}PUT /v1/admin/api-tokens/{id}
All body fields are optional: name, note, scopes, enabled, expires_at. Returns {"credential": {...}}.
POST /v1/admin/api-tokens/{id}/rotate
Issues new plaintext and invalidates the old one. The response matches the create shape.
POST /v1/admin/api-tokens/{id}/revoke
Returns {"ok": true}.
DELETE /v1/admin/api-tokens/{id}
Returns 204 with no body.
GET /v1/admin/api-token-logs
Returns the 100 most recent audit records:
{
"logs": [
{
"id": 1,
"token_id": "...",
"token_prefix": "kiln_v1_ab12cd34",
"method": "GET",
"path": "/v1/admin/channels",
"required_scope": "read",
"decision": "allow",
"status": 200,
"remote": "127.0.0.1",
"user_agent": "curl/8",
"request_id": "...",
"created_at": 0
}
]
}Denied records carry an extra reason field.
Playback endpoints
Playback has two parallel path families: /v1/play/ for session and preview tokens, and /p/{token}/ for playback keys. The three file shapes map one to one across both.
| Method | Path | Credential | Purpose |
|---|---|---|---|
GET |
/v1/play/{id}/index.m3u8 |
Session or preview token | Channel entry playlist |
GET |
/v1/play/{id}/live/{file} |
Session or preview token | Locally packaged output: media playlists, segments, CMAF parts |
GET |
/v1/play/{id}/u/{upstream} |
Session or preview token | Signed upstream fetch proxy |
GET |
/p/{token}/playlist.m3u |
Playback key in the path | Playlist scoped to that key |
GET |
/p/{token}/play/{id}/index.m3u8 |
Playback key in the path | Channel entry playlist |
GET |
/p/{token}/play/{id}/live/{file} |
Playback key in the path | Locally packaged output |
GET |
/p/{token}/play/{id}/u/{upstream} |
Playback key in the path | Signed upstream fetch proxy |
The three file shapes
index.m3u8is the entry point. HLS ingress fetches the upstream playlist and rewrites its URLs; DASH ingress serves the locally packaged master playlist.live/{file}serves locally packaged output. File names may not contain path separators. When the publication generation changes, the request is redirected with a 307 carrying agquery parameter; a stale generation returns 410 withRetry-After. In low-latency mode the_HLS_msn,_HLS_part, and_HLS_skipdirectives are parsed and block for up to 15 seconds. Immutable segments carryCache-Control: private, max-age=31536000, immutable.u/{upstream}proxies an upstream fetch. The target URL is encoded and carries an HMACsig; a mismatch returns 403, and the target host must also pass the egress allowlist. Playlist responses are rewritten further; everything else is streamed through untouched.
Authorization behavior
Whether /v1/play/ requires a credential is controlled by security.play_require_auth, which defaults to on. The credential can arrive as ?token= or as Authorization: Bearer, and both accept session JWTs and preview tokens. Preview tokens are issued only by POST /v1/admin/channels/{id}/preview, last 5 minutes, cover a single channel, and are rejected on every non-playback endpoint.
The /p/{token}/ family always validates the playback key in the path, regardless of security.play_require_auth. An invalid, disabled, revoked, or expired key returns 401 unauthorized; a valid key requesting an out-of-scope channel returns 403 forbidden.
Viewer limits
When a channel sets max_viewers, the first index.m3u8 request mints a signed viewer lease and issues a 307 back to the same URL with a viewer query parameter. Later requests renew the lease with that parameter. A bad lease signature returns 403, and exceeding the limit is rejected by the session layer.
Admin endpoints
Everything below lives under /v1/admin and requires the admin role. Both sessions and admin API tokens can reach these routes; tokens need the permission listed in each table.
Channels
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET |
/v1/admin/channels |
read |
List every channel, disabled included |
GET |
/v1/admin/channels/{id} |
read |
Channel detail, response carries ETag |
POST |
/v1/admin/channels |
write |
Create a channel |
PUT |
/v1/admin/channels/{id} |
write |
Update a channel |
DELETE |
/v1/admin/channels/{id} |
delete |
Delete a channel |
POST |
/v1/admin/channels/enable-all |
write |
Enable in bulk |
POST |
/v1/admin/channels/disable-all |
write |
Disable in bulk |
PUT |
/v1/admin/channels/reorder |
write |
Reorder the list |
GET /v1/admin/channels returns {"channels": [...]} with the same fields as GET /v1/channels.
GET /v1/admin/channels/{id} returns:
{
"channel": { "id": "demo-hls", "title": "Demo HLS" },
"egress_binding": { "mode": "auto" },
"effective_user_agent": "Kiln/1.0.0",
"revision": 3,
"updated_at": 0
}Sensitive request headers are blanked in the response: authorization, proxy-authorization, cookie, and any header whose name contains token, secret, or api-key. Leaving them blank on write keeps the stored value. egress_binding.mode is auto, direct, or profile, the last of which also carries profile_id.
POST and PUT take a channel object with an optional egress block:
{
"id": "demo-hls",
"title": "Demo HLS",
"ingress": "hls",
"source_url": "https://example.com/live/index.m3u8",
"egress": { "mode": "profile", "profile_id": "eu-1" }
}egress.new_proxy creates a proxy profile inline in the same request, but cannot be combined with an existing profile_id. The response is {"ok": true, "id": "demo-hls", "egress_profile_id": "..."}. A revision mismatch returns 409; validation failures return 400.
enable-all and disable-all return {"ok": true, "changed": 12, "channel_ids": [...]}.
reorder takes {"ids": [...], "revisions": {"demo-hls": 3}}, and both fields are required.
Sessions, probes, and previews
| Method | Path | Permission | Purpose |
|---|---|---|---|
POST |
/v1/admin/channels/{id}/probe |
refresh |
Probe a saved channel’s source |
POST |
/v1/admin/source-probes |
refresh |
Probe an unsaved channel draft |
POST |
/v1/admin/channels/{id}/warmup |
refresh |
Warm up a channel session |
POST |
/v1/admin/channels/{id}/preview |
refresh |
Mint a preview playback URL |
DELETE |
/v1/admin/sessions/{id} |
refresh |
Tear down a channel session |
Probing a non-DASH channel returns {"ok": true, "status": 200, "content_type": "...", "final_url": "...", "dur_ms": 42}, and source-probes adds proxy_id. Returned URLs are stripped of credentials, query strings, and fragments.
Probing a DASH channel returns {"ok": true, "dur_ms": 1200, "inspection": {...}}, where inspection reports whether the native engine can handle the manifest, the suggested engine, and the compatibility reason. Without global media keys configured, it returns 400.
source-probes takes the same body as a channel write, plus an optional egress block selecting the route to test with. Use it to validate connectivity before saving.
warmup returns 202 {"state": "starting"}.
preview returns 201:
{
"play_url": "https://kiln.example.com/v1/play/demo-hls/index.m3u8?token=...",
"expires_at": "2026-01-01T00:05:00Z"
}DELETE /v1/admin/sessions/{id} returns 204.
EPG
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET |
/v1/admin/epg/presets |
read |
Built-in source presets |
GET |
/v1/admin/epg/sources |
read |
Configured sources and their status |
POST |
/v1/admin/epg/sources |
write |
Add a source |
PUT |
/v1/admin/epg/sources/{id} |
write |
Update a source |
DELETE |
/v1/admin/epg/sources/{id} |
delete |
Delete a source; presets are hidden instead |
GET |
/v1/admin/epg/matches |
read |
Channel-to-guide match results |
POST |
/v1/admin/epg/refresh |
refresh |
Refresh every enabled source now |
GET /v1/admin/epg/sources returns {"sources": [...], "statuses": [...]}. Each source entry is {"source": {...}, "enabled": true, "revision": 1, "updated_at": 0}. Each status carries source_id, last_attempt, last_success, stale, error, channel_count, programme_count, available, and metadata.
The source write body is {"id": "...", "name": "...", "url": "...", "timezone": "...", "proxy": "direct", "enabled": true}, and unknown fields are rejected. id is required; the URL must be http or https; timezone must be a valid IANA zone; proxy is auto or the ID of a configured route, defaulting to direct. For a preset source you can submit only the fields you want to override; the rest fall back to preset values. Creation returns 201 and updates return 200, both with {"ok": true, "source": {...}}.
GET /v1/admin/epg/matches returns {"matches": [...]}, where each entry carries channel_id, status, and optional match, candidates, and logo_candidates.
POST /v1/admin/epg/refresh returns 409 when no source is enabled, otherwise {"ok": true, "statuses": [...]}. If some sources failed, ok is false and the per-source detail is in statuses.
Upstreams
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET |
/v1/admin/upstreams |
read |
List upstreams defined in the config file |
Returns {"upstreams": [{"id": "main", "base_url": "https://example.com/live"}]}.
Playback keys
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET |
/v1/admin/access-tokens |
read |
List playback keys |
POST |
/v1/admin/access-tokens |
write |
Create a playback key |
POST |
/v1/admin/access-tokens/{id}/revoke |
delete |
Revoke |
DELETE |
/v1/admin/access-tokens/{id} |
delete |
Delete |
The list returns {"access_tokens": [...]} with id, name, token_prefix, scope, enabled, note, created_at, last_used_at, revoked_at, expires_at, and revision. Plaintext is never included.
The create body is {"name": "...", "note": "...", "channel_ids": ["demo-hls"], "expires_in_sec": 0}. An empty channel_ids means all channels, and expires_in_sec of 0 means no expiration. The maximum is 10 years. Returns 201:
{
"id": "...",
"name": "living-room",
"token": "v1...",
"token_prefix": "v1AbCdEfGh",
"scope": "...",
"playlist_url": "https://kiln.example.com/p/v1.../playlist.m3u",
"created_at": 0,
"expires_at": 0,
"warning": "store this token now; it will not be shown again"
}Revoke and delete both return {"ok": true}.
Playback access logs
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET |
/v1/admin/access-logs |
read |
Query the playback access log |
DELETE |
/v1/admin/access-logs |
delete |
Clear the playback access log |
The query accepts limit and token_id and returns {"access_logs": [...]}, where each record carries id, token_id, token_prefix, path, channel_id, status, remote, and created_at. Keys in the path are already truncated to their display prefix. Clearing returns {"deleted": 128}.
Records are pruned according to access_log_retention_days, which defaults to 30 days.
Settings
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET |
/v1/admin/settings |
read |
Read runtime settings |
PUT |
/v1/admin/settings |
write |
Write runtime settings |
The read returns the config-file values listen, cors_origins, public_hosts, and play_require_auth, plus the runtime-editable public_base_url and access_log_retention_days and the current revision.
The write body is {"public_base_url": "https://kiln.example.com", "access_log_retention_days": "30"}. Both fields are strings, and retention must be between 1 and 3650. If-Match is required; without it the request returns 409.
Import and export
| Method | Path | Permission | Purpose |
|---|---|---|---|
POST |
/v1/admin/import/m3u |
write |
Preview or apply an M3U import |
POST |
/v1/admin/exports/m3u |
write |
Export a playlist file |
The import body is {"content": "#EXTM3U...", "apply": false, "revisions": {}}. With apply false the request only parses and previews; with apply true it writes, and revisions must carry the channel revisions observed during the preview. The response is {"preview": true, "count": 30, "created": 12, "updated": 3, "skipped": 15, "entries": [...]}. If any channel changed since the preview, the request returns 409.
The export returns 201 with Content-Type: application/vnd.apple.mpegurl and Content-Disposition: attachment; filename="kiln-playlist.m3u". It mints a playback key named M3U export and embeds that in the URLs, so neither the session token nor an admin token ever leaks into the file.
Network egress
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET |
/v1/admin/egress |
read |
Read the egress configuration |
PUT |
/v1/admin/egress |
write |
Replace the egress configuration |
POST |
/v1/admin/egress/proxies |
write |
Create a proxy profile |
PUT |
/v1/admin/egress/proxies/{id} |
write |
Update a proxy profile |
DELETE |
/v1/admin/egress/proxies/{id} |
delete |
Delete a proxy profile |
POST |
/v1/admin/egress/rules |
write |
Create a routing rule |
PUT |
/v1/admin/egress/rules/{id} |
write |
Update a routing rule |
DELETE |
/v1/admin/egress/rules/{id} |
delete |
Delete a routing rule |
POST |
/v1/admin/egress/test |
refresh |
Connection test |
The read returns:
{
"default": "direct",
"playlist_policy": "rewrite",
"docker_proxy_host": "host.docker.internal",
"proxies": [
{
"id": "eu-1",
"name": "eu-1",
"url": "http://proxy.example.com",
"disabled": false,
"credential_configured": true,
"revision": 2
}
],
"rules": [],
"source": "sqlite",
"revision": 5
}Proxy URLs are reduced to scheme and host in responses; whether credentials are stored is reported by credential_configured. On write, if the scheme and host are unchanged and no credentials are supplied, the stored credentials are preserved.
A proxy profile needs an id and a url. Schemes are limited to http, https, socks5, and socks5h, and the id cannot be the reserved value direct. Rule kind is one of host_suffix, host_exact, channel_id, host_regex, or url_regex; regular expressions are validated before the rule is saved. Rules cannot reference a missing or disabled profile. playlist_policy is rewrite, passthrough, or auto. Deleting a profile also removes the rules that reference it and resets the default to direct when necessary.
Single-item writes read the current configuration, apply the change, validate the whole document, and write it back, so they are covered by the same If-Match requirement.
The connection test body:
{
"target": "custom",
"url": "https://example.com/live/index.m3u8",
"channel_id": "demo-hls",
"proxy_id": "eu-1"
}A target of bing uses the built-in public probe URL. source and custom require url, and the destination must be a public address. With no target and no url, the built-in probe is used. proxy_url tests an unsaved route directly, and draft tests an entire unsaved configuration. The response is always 200; the outcome lives in the fields:
{
"ok": true,
"reachable": true,
"outcome": "success",
"status": 200,
"proxy_id": "eu-1",
"via_proxy": "eu-1",
"reason": "rule:eu",
"rewrite": true,
"final_url": "https://example.com/live/index.m3u8",
"dur_ms": 180,
"target": "custom"
}On failure ok is false and outcome is one of blocked, dns, timeout, tls, proxy, proxy_auth, http_error, or network, with an error description alongside.
Lite variant
The Lite binary registers 7 routes. There is no admin console, no admin API, no EPG, no metrics, and no path-based playback keys. Channels come from a static catalog in the config file, and SQLite is not used.
| Method | Path | Credential |
|---|---|---|
GET |
/healthz |
None |
GET |
/readyz |
None |
POST |
/v1/auth/login |
None (rate limited) |
GET |
/v1/playlist.m3u |
Session token |
GET |
/v1/play/{id}/index.m3u8 |
Session token |
GET |
/v1/play/{id}/live/{file} |
Session token |
GET |
/v1/play/{id}/u/{upstream} |
Session token |
What differs:
/readyzalways reports ready and runs no compatibility-engine check./v1/playlist.m3uuses playback authorization rather than session authorization, so it followssecurity.play_require_authand accepts either anAuthorization: Bearerheader or a?token=query parameter.- The error response format is identical to the full build.
- Responses set only
X-Content-Type-Options,Referrer-Policy,Cache-Control, and CORS. There is noX-Request-ID, no CSP, and noX-Frame-Options. - Host allowlist checks and
OPTIONShandling match the full build.