23 KiB
REST API Documentation
The unshackle REST API allows you to control downloads, search services, drive remote downloads from a thin client, and (optionally) co-host the pywidevine/pyplayready CDM. Start the server with unshackle serve and access the interactive Swagger UI at http://localhost:8786/api/docs/.
The server is built on aiohttp (not FastAPI). Implementation lives in unshackle/commands/serve.py and unshackle/core/api/ (routes.py, handlers.py, session_store.py, input_bridge.py, download_manager.py, download_worker.py).
Quick Start
# Start the server (no authentication)
unshackle serve --no-key
# Start with authentication (api_secret in unshackle.yaml)
unshackle serve
# Serve only the REST API (no pywidevine/pyplayready CDM)
unshackle serve --api-only
# Serve only the remote-dl session endpoints (CORS/Cloudflare friendly)
unshackle serve --remote-only
# Disable just one CDM
unshackle serve --no-widevine
unshackle serve --no-playready
# Verbose error responses (tracebacks/stderr in JSON)
unshackle serve --debug-api
serve flags:
| Flag | Description |
|---|---|
-h, --host |
Bind host (default 127.0.0.1) |
-p, --port |
Bind port (default 8786) |
--caddy |
Also launch Caddy using Caddyfile next to the unshackle config |
--api-only |
REST API only; skip the bundled pywidevine/pyplayready CDM endpoints |
--no-widevine |
Disable Widevine CDM endpoints |
--no-playready |
Disable PlayReady CDM endpoints |
--no-key |
Disable API key authentication entirely |
--debug-api |
Include tracebacks/stderr in error responses |
--debug |
Enable DEBUG-level logging for API operations |
--remote-only |
Expose only /api/health, /api/services, /api/search, and /api/session/* (implies --api-only) |
Authentication
When api_secret is set in unshackle.yaml, all API requests require the X-Secret-Key header. There is no query-parameter fallback. /api/health is always reachable without authentication. --no-key disables auth entirely (not recommended for public-facing servers).
# unshackle.yaml
serve:
api_secret: "your-master-secret" # falls back to global users map below
remote_only: false # also toggleable via --remote-only
services: ["EXAMPLE1", "EXAMPLE2"] # optional global service allowlist
users:
user-secret-1:
username: alice
devices: ["my_widevine_l3"] # Widevine WVD names this user may use
playready_devices: ["my_pr_sl2000"] # PlayReady PRD names; defaults to [] (no access)
services: ["EXAMPLE1"] # optional per-user allowlist (intersected with global)
user-secret-2:
username: bob
devices: []
playready_devices: []
Service allowlists
config.serve.services is the global allowlist; users.<key>.services further narrows it per key. The effective set is the intersection. Endpoints affected: /api/services, /api/search, /api/list-titles, /api/list-tracks, /api/download, and all /api/session/* routes.
CDM access (server-side decryption)
There is no separate "tier" flag. Whether the server can return KID:KEY for a session-mode download depends solely on the device lists configured for the calling user key:
- Empty
devicesandplayready_devices-> server can only proxy CDM challenges; the client must run its own CDM and parse the license. - Populated lists -> the client may set
mode: "server_cdm"on/api/session/{id}/licenseand receive{ "keys": { "<track_id>": { "<KID>": "<KEY>" } } }instead of raw license bytes.
Per-service CDM type can be pinned via config.cdm (widevine/playready) or per-service cdm_type; otherwise the server picks the type the user has devices for.
Endpoint Map
Standard endpoints (suppressed in --remote-only mode are marked R):
| Method | Path | R |
|---|---|---|
| GET | /api/health |
ok |
| GET | /api/services |
ok |
| POST | /api/search |
ok |
| POST | /api/list-titles |
hidden |
| POST | /api/list-tracks |
hidden |
| POST | /api/download |
hidden |
| GET | /api/download/jobs |
hidden |
| GET | /api/download/jobs/{job_id} |
hidden |
| DELETE | /api/download/jobs/{job_id} |
hidden |
| POST | /api/session/create |
ok |
| GET | /api/session/{session_id} |
ok |
| DELETE | /api/session/{session_id} |
ok |
| GET | /api/session/{session_id}/titles |
ok |
| POST | /api/session/{session_id}/tracks |
ok |
| POST | /api/session/{session_id}/segments |
ok |
| POST | /api/session/{session_id}/license |
ok |
| GET | /api/session/{session_id}/prompt |
ok |
| POST | /api/session/{session_id}/prompt |
ok |
CDM endpoints (/{wvd}/..., /playready/{prd}/...) are exposed unless --api-only / --remote-only / --no-widevine / --no-playready is set, and use pywidevine / pyplayready's own auth scheme.
Endpoints
GET /api/health
Health check with version and update information. Always reachable without auth.
curl http://localhost:8786/api/health
{
"status": "ok",
"version": "4.0.0",
"update_check": {
"update_available": false,
"current_version": "4.0.0",
"latest_version": null
}
}
GET /api/services
List all available streaming services (filtered by the effective allowlist for the caller).
curl -H "X-Secret-Key: $KEY" http://localhost:8786/api/services
Returns {"services": [...]}. Each entry has tag, aliases, geofence, title_regex, url (from cli.short_help), help (full docstring), and cli_params describing the service-level Click parameters.
POST /api/search
Search for titles from a streaming service.
Required parameters:
| Parameter | Type | Description |
|---|---|---|
service |
string | Service tag |
query |
string | Search query |
Optional parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
profile |
string | null |
Profile for credentials/cookies |
proxy |
string | null |
Proxy URI or country code |
no_proxy |
boolean | false |
Disable all proxy use |
curl -X POST http://localhost:8786/api/search \
-H "X-Secret-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"service": "EXAMPLE1", "query": "example show"}'
{
"results": [
{
"id": "abc123def456",
"title": "Example Show",
"description": null,
"label": "TV Show",
"url": "https://example.com/show/abc123def456"
}
],
"count": 1
}
POST /api/list-titles
Get available titles (seasons/episodes/movies) for a service and title ID. Disabled in --remote-only mode.
Required parameters:
| Parameter | Type | Description |
|---|---|---|
service |
string | Service tag |
title_id |
string | Title ID or URL |
curl -X POST http://localhost:8786/api/list-titles \
-H "X-Secret-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"service": "EXAMPLE1", "title_id": "abc123def456"}'
POST /api/list-tracks
Get video, audio, and subtitle tracks for a title. Disabled in --remote-only mode.
Required parameters:
| Parameter | Type | Description |
|---|---|---|
service |
string | Service tag |
title_id |
string | Title ID or URL |
Optional parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
wanted |
array | all | Episode filter (e.g., ["S01E01"]) |
profile |
string | null |
Profile for credentials/cookies |
proxy |
string | null |
Proxy URI or country code |
no_proxy |
boolean | false |
Disable all proxy use |
Returns video, audio, and subtitle tracks with codec, bitrate, resolution, language, and DRM information.
POST /api/download
Start a download job. Returns immediately with a job ID (HTTP 202). Disabled in --remote-only mode.
Required parameters:
| Parameter | Type | Description |
|---|---|---|
service |
string | Service tag |
title_id |
string | Title ID or URL |
Quality and codec parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
quality |
array[int] | best | Resolution(s) (e.g., [1080, 2160]) |
vcodec |
string or array | any | Video codec(s): H264, H265/HEVC, VP9, AV1, VC1, VP8 |
acodec |
string or array | any | Audio codec(s): AAC, AC3, EC3, AC4, OPUS, FLAC, ALAC, DTS, OGG |
vbitrate |
int | highest | Video bitrate in kbps |
abitrate |
int | highest | Audio bitrate in kbps |
range |
array[string] | ["SDR"] |
Color range(s): SDR, HDR10, HDR10+, HLG, DV, HYBRID |
channels |
float | any | Audio channels (e.g., 5.1, 7.1) |
no_atmos |
boolean | false |
Exclude Dolby Atmos tracks |
split_audio |
boolean | null |
Create separate output per audio codec |
sub_format |
string | null |
Output subtitle format: SRT, VTT, ASS, SSA, TTML |
Episode selection:
| Parameter | Type | Default | Description |
|---|---|---|---|
wanted |
array[string] | all | Episodes (e.g., ["S01E01", "S01E02-S01E05"]) |
latest_episode |
boolean | false |
Download only the most recent episode |
Language parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
lang |
array[string] | ["orig"] |
Language for video and audio (orig = original) |
v_lang |
array[string] | [] |
Language override for video tracks only |
a_lang |
array[string] | [] |
Language override for audio tracks only |
s_lang |
array[string] | ["all"] |
Language for subtitles |
require_subs |
array[string] | [] |
Required subtitle languages (skip if missing) |
forced_subs |
boolean | false |
Include forced subtitle tracks |
exact_lang |
boolean | false |
Exact language matching (no variants) |
Track selection:
| Parameter | Type | Default | Description |
|---|---|---|---|
video_only |
boolean | false |
Only download video tracks |
audio_only |
boolean | false |
Only download audio tracks |
subs_only |
boolean | false |
Only download subtitle tracks |
chapters_only |
boolean | false |
Only download chapters |
no_video |
boolean | false |
Skip video tracks |
no_audio |
boolean | false |
Skip audio tracks |
no_subs |
boolean | false |
Skip subtitle tracks |
no_chapters |
boolean | false |
Skip chapters |
audio_description |
boolean | false |
Include audio description tracks |
Output and tagging:
| Parameter | Type | Default | Description |
|---|---|---|---|
tag |
string | null |
Override group tag |
repack |
boolean | false |
Add REPACK tag to filename |
tmdb_id |
int | null |
Use specific TMDB ID for tagging |
imdb_id |
string | null |
Use specific IMDB ID (e.g., tt1375666) |
animeapi_id |
string | null |
Anime database ID via AnimeAPI (e.g., mal:12345) |
enrich |
boolean | false |
Override show title and year from external source |
no_folder |
boolean | false |
Disable folder creation for TV shows |
no_source |
boolean | false |
Remove source tag from filename |
no_mux |
boolean | false |
Do not mux tracks into container |
output_dir |
string | null |
Override output directory |
Download behavior:
| Parameter | Type | Default | Description |
|---|---|---|---|
profile |
string | null |
Profile for credentials/cookies |
proxy |
string | null |
Proxy URI or country code |
no_proxy |
boolean | false |
Disable all proxy use |
workers |
int | null |
Max threads per track download |
downloads |
int | 1 |
Concurrent track downloads |
slow |
boolean | false |
Add 60-120s delay between titles |
best_available |
boolean | false |
Continue if requested quality unavailable |
skip_dl |
boolean | false |
Skip download, only get decryption keys |
export |
boolean | false |
Export manifest, track URLs, keys, and subtitles to JSON in the exports directory |
cdm_only |
boolean | null |
Only use CDM (true) or only vaults (false) |
no_cache |
boolean | false |
Bypass title cache |
reset_cache |
boolean | false |
Clear title cache before fetching |
Example:
curl -X POST http://localhost:8786/api/download \
-H "X-Secret-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"service": "EXAMPLE1",
"title_id": "abc123def456",
"wanted": ["S01E01"],
"quality": [1080, 2160],
"vcodec": ["H265"],
"acodec": ["AAC", "EC3"],
"range": ["HDR10", "SDR"],
"split_audio": true,
"lang": ["en"]
}'
{
"job_id": "504db959-80b0-446c-a764-7924b761d613",
"status": "queued",
"created_time": "2026-02-27T18:00:00.000000"
}
GET /api/download/jobs
List all download jobs with optional filtering and sorting. Disabled in --remote-only mode.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
status |
string | all | Filter by status: queued, downloading, completed, failed, cancelled |
service |
string | all | Filter by service tag |
sort_by |
string | created_time |
Sort field: created_time, started_time, completed_time, progress, status, service |
sort_order |
string | desc |
Sort order: asc, desc |
curl -H "X-Secret-Key: $KEY" "http://localhost:8786/api/download/jobs?status=completed"
GET /api/download/jobs/{job_id}
Get detailed information about a specific download job including progress, parameters, and error details.
{
"job_id": "504db959-80b0-446c-a764-7924b761d613",
"status": "completed",
"created_time": "2026-02-27T18:00:00.000000",
"service": "EXAMPLE1",
"title_id": "abc123def456",
"progress": 100.0,
"parameters": { },
"started_time": "2026-02-27T18:00:01.000000",
"completed_time": "2026-02-27T18:00:15.000000",
"output_files": [],
"error_message": null,
"error_details": null
}
DELETE /api/download/jobs/{job_id}
Cancel a queued or running download job. Returns 400 if the job has already terminated.
Remote Service Sessions
These endpoints back the RemoteService adapter in unshackle/core/remote_service.py. They let a thin dl client (or any consumer) authenticate against a service on the server, fetch titles/tracks/manifests, and either proxy CDM challenges or have the server resolve KID:KEY directly. The dl command's RemoteService adapter replaces the old remote_dl command. These endpoints are the only /api/* routes available in --remote-only mode (in addition to health, services, and search).
POST /api/session/create
Authenticate against a service and open a session. Body fields:
| Field | Type | Description |
|---|---|---|
service |
string | Service tag (required) |
title_id |
string | Title ID/URL (required) |
credentials |
object | Auth credentials forwarded to Service.authenticate |
cookies |
string | Cookie blob (Netscape or JSON) |
proxy |
string | Proxy URI or country code |
no_proxy |
bool | Force-disable proxies |
profile |
string | Profile name |
cache |
object | Optional pre-warmed title cache payload |
If the service requires interactive input during authentication, poll GET /api/session/{id}/prompt and submit responses via POST /api/session/{id}/prompt until status is authenticated.
Request:
{
"service": "EXAMPLE1",
"title_id": "abc123def456",
"credentials": {"username": "alice", "password": "hunter2"},
"cookies": "# Netscape HTTP Cookie File\n...",
"proxy": "us",
"no_proxy": false,
"profile": "default",
"cache": {}
}
Response (202-style; auth runs asynchronously):
{
"session_id": "f1c4a8b2-9c7e-4d2a-bf91-2d3e4f5a6b7c",
"service": "EXAMPLE1",
"status": "authenticating"
}
GET /api/session/{session_id}
Returns session metadata. 404 if expired or unknown.
{
"session_id": "f1c4a8b2-9c7e-4d2a-bf91-2d3e4f5a6b7c",
"service": "EXAMPLE1",
"valid": true,
"expires_in": 3600,
"track_count": 0,
"title_count": 0
}
DELETE /api/session/{session_id}
Tears down the session, cancels any pending prompts, and returns any updated per-session cache files (base64-encoded, zlib-compressed) so the client can re-warm next time.
{
"status": "ok",
"cache": {
"tokens": "eJzLSM3JyVcozy/KSVGo5AIAGgQEvQ=="
}
}
GET /api/session/{session_id}/titles
Returns the resolved titles list.
{
"session_id": "f1c4a8b2-9c7e-4d2a-bf91-2d3e4f5a6b7c",
"titles": [
{
"type": "episode",
"name": "Pilot",
"series_title": "Example Show",
"season": 1,
"number": 1,
"year": 2024,
"id": "ep-0001",
"language": "en"
},
{
"type": "movie",
"name": "Example Movie",
"year": 2024,
"id": "mov-0001",
"language": "en"
}
]
}
POST /api/session/{session_id}/tracks
Request:
{"title_id": "ep-0001"}
Response:
{
"title": {
"type": "episode",
"name": "Pilot",
"series_title": "Example Show",
"season": 1,
"number": 1,
"year": 2024,
"id": "ep-0001",
"language": "en"
},
"video": [
{
"id": "v-1080p-h264",
"codec": "H264",
"codec_display": "H.264",
"bitrate": 6000,
"width": 1920,
"height": 1080,
"resolution": "1920x1080",
"fps": "23.976",
"range": "SDR",
"range_display": "SDR",
"language": "en",
"drm": [
{
"type": "widevine",
"pssh": "AAAAW3Bzc2gAAAAA7e+...",
"kids": ["abcdef0123456789abcdef0123456789"],
"license_url": "https://license.example.com/widevine"
}
],
"descriptor": "DASH",
"url": "https://cdn.example.com/manifest.mpd"
}
],
"audio": [
{
"id": "a-en-eac3",
"codec": "EC3",
"codec_display": "Dolby Digital Plus",
"bitrate": 640,
"channels": "5.1",
"language": "en",
"atmos": false,
"descriptive": false,
"drm": null,
"descriptor": "DASH",
"url": "https://cdn.example.com/manifest.mpd"
}
],
"subtitles": [
{
"id": "s-en-vtt",
"codec": "WebVTT",
"language": "en",
"forced": false,
"sdh": false,
"cc": false,
"descriptor": "DASH",
"url": "https://cdn.example.com/subs/en.vtt"
}
],
"chapters": [
{"timestamp": "00:00:00.000", "name": "Chapter 1"}
],
"attachments": [],
"manifests": [
{
"type": "dash",
"url": "https://cdn.example.com/manifest.mpd",
"data": "eJzNVk1v2zAM/Ss..."
}
],
"session_headers": {
"User-Agent": "Mozilla/5.0 ..."
},
"session_cookies": {
"session": "abc123"
},
"server_cdm_type": "widevine"
}
POST /api/session/{session_id}/segments
Request:
{"track_ids": ["v-1080p-h264", "a-en-eac3"]}
Response:
{
"tracks": {
"v-1080p-h264": {
"descriptor": "DASH",
"url": "https://cdn.example.com/manifest.mpd",
"drm": [
{
"type": "widevine",
"pssh": "AAAAW3Bzc2gAAAAA7e+...",
"kids": ["abcdef0123456789abcdef0123456789"],
"license_url": "https://license.example.com/widevine"
}
],
"headers": {"User-Agent": "Mozilla/5.0 ..."},
"cookies": {"session": "abc123"},
"data": {}
},
"a-en-eac3": {
"descriptor": "DASH",
"url": "https://cdn.example.com/manifest.mpd",
"drm": null,
"headers": {"User-Agent": "Mozilla/5.0 ..."},
"cookies": {"session": "abc123"},
"data": {}
}
}
}
POST /api/session/{session_id}/license
Two modes, selected by the mode field.
mode: "proxy" (default) -- forward a client-built CDM challenge to the service's license endpoint.
Request:
{
"mode": "proxy",
"track_id": "v-1080p-h264",
"challenge": "CAESxQEK...",
"drm_type": "widevine",
"pssh": "AAAAW3Bzc2gAAAAA7e+..."
}
Response:
{"license": "CAIS3wIK..."}
mode: "server_cdm" -- the server uses its own CDM to license the track and extract keys. Single-track form takes track_id; batch form takes track_ids. Requires the calling user key to have a matching device (devices for Widevine, playready_devices for PlayReady) in unshackle.yaml.
Request (batch):
{
"mode": "server_cdm",
"track_ids": ["v-1080p-h264", "a-en-eac3"],
"drm_type": "widevine"
}
Response:
{
"keys": {
"v-1080p-h264": {
"abcdef0123456789abcdef0123456789": "00112233445566778899aabbccddeeff"
},
"a-en-eac3": {
"abcdef0123456789abcdef0123456789": "00112233445566778899aabbccddeeff"
}
},
"drm_type": "widevine"
}
GET /api/session/{session_id}/prompt
Polled by the client during interactive authentication (OTP, PIN, device codes). Backed by the InputBridge in unshackle/core/api/input_bridge.py; Service.request_input() blocks server-side until the client posts a response.
Pending input:
{"status": "pending_input", "prompt": "Enter OTP code: "}
Other states:
{"status": "authenticating"}
{"status": "authenticated"}
{"status": "failed", "error": "Invalid credentials"}
POST /api/session/{session_id}/prompt
Unblocks the server-side request_input() call.
Request:
{"response": "123456"}
Response:
{"status": "accepted"}
Error Responses
All endpoints return consistent error responses:
{
"status": "error",
"error_code": "INVALID_PARAMETERS",
"message": "Invalid vcodec: XYZ. Must be one of: H264, H265, VP9, AV1, VC1, VP8",
"timestamp": "2026-02-27T18:00:00.000000+00:00",
"details": { }
}
Common error codes:
INVALID_INPUT-- malformed request bodyINVALID_PARAMETERS-- invalid parameter valuesMISSING_SERVICE-- service tag not providedINVALID_SERVICE-- service not found or not in the caller's allowlistSERVICE_ERROR-- service initialization or runtime errorAUTH_FAILED-- authentication failureNOT_FOUND/TRACK_NOT_FOUND/ session not found -- job/session/track/title missingINTERNAL_ERROR-- unexpected server error
When --debug-api is enabled, error responses include additional debug_info with tracebacks and stderr output.
Authentication errors from the auth middleware are returned as {"status": 401, "message": "..."} (not the standard error envelope).
Download Job Lifecycle
queued -> downloading -> completed
\-> failed
queued -> cancelled
downloading -> cancelled
Jobs are retained for 24 hours after completion. The server supports up to 2 concurrent downloads by default.
Remote sessions are managed by SessionStore (unshackle/core/api/session_store.py); idle sessions and their InputBridge instances are cleaned up by a background loop started/stopped with the app lifecycle.