Developer API

Everything a third-party app needs to publish, play, provision and observe — the same HTTP surface the built-in consoles run on. This page is generated against the live route table of this exact build; if it is documented here, it exists on this server.

The three auth planes

PlaneAuthWhoBase surface
Viewernone, or a playback ?token=players, embeds, TV apps/hls /dash /vod /key /poster /e /api/playback-token
Broadcastersession cookie (POST /login)your customers' own tools/app/* /api/stream-info
OperatorAuthorization: Bearer <provisioning token> or X-API-Key (basic auth also accepted)control panels, resellers, billing portals/api/*

Every operator call is written to the audit log under the operator identity. The provisioning token is set by the server operator (Admin → Security); treat it like root.

Conventions

CORS

Media and API responses carry CORS headers only when the server runs with -cors-origin (a comma-separated allowlist, or *). If your browser app sees CORS failures, that flag is the conversation to have with the server operator — nothing in your code will fix its absence.

Viewer plane — playback URLs

GET /hls/{slug}/master.m3u8              # ABR master (renditions the encoder pushes)
GET /hls/{slug}/{label}/index.m3u8       # one rendition (e.g. 720p); LL-HLS tags when enabled
GET /hls/{slug}/{label}/init.mp4         # CMAF channels: the EXT-X-MAP init segment
GET /dash/{slug}/manifest.mpd            # DASH (when the channel enables it)
GET /vod/{slug}/{recording}/index.m3u8   # finished recordings
GET /poster/{slug}                       # JPEG poster (public channels always; 404 = none yet)
GET /e/{slug}                            # ready-made responsive player page (iframe it)

Playlists list segments as absolute paths — resolve them against the host per RFC 3986 (every real player library already does; hand-rolled fetchers that string-concatenate will 404).

Playback tokens (secured channels)

Channels with Secure on refuse bare playback with 403. Mint a token and append it to every media URL:

GET /api/playback-token?channel={slug}
→ {"token":"eyJ…","sid":"anon-…","ttl":120}

# then:
/hls/{slug}/master.m3u8?token=eyJ…

Error semantics your player should handle

StatusMeaningRight reaction
401token missing/expired/invalidmint a fresh token, retry once
403policy: geo/referer/IP rules, IP-bound token elsewhere, or channel/account suspendedshow "not available"; do not retry-loop
404 on playlistchannel offline or no such renditionpoll politely (players do); show poster/offline UI
503 + Retry-Afterthe plan's concurrent-viewer cap is fullhonor Retry-After, show "at capacity"
empty live playlist (200, no segments)encoder attached, first segment not sealedkeep polling — standard HLS warm-up

EPG — now / next for players

GET /api/epg/{slug}           → {"now":{title,desc,start,stop,category},"next":{…}}
GET /api/epg/{slug}?day=1     → + "today":[…]        # 404 = no guide configured; 502 = feed unreachable and no cached guide

Public like the poster. Times are RFC3339 UTC. Cache-friendly (60s); poll once a minute at most — the guide changes on program boundaries, not per second.

Broadcaster plane — session auth

curl -c jar -X POST https://host/login -d "email=…" -d "password=…"
curl -b jar https://host/app/…                       # any broadcaster call

The broadcaster surface is the dashboard's own form endpoints — fine for scripting a customer's account with their consent, but panels should use the operator plane: it is designed for machines and fully audited.

Channel management (cookie)

POST /app/channels                    slug=…&name=…            # create
POST /app/channels/{slug}/edit        name=…&secure=on&dvr=60&record=on&lowlatency=on&dash=on&cmaf=on&geo_mode=deny&geo_countries=KP&…
POST /app/channels/{slug}/delete
POST /app/channels/{slug}/rotate-key                           # retire a leaked AES key instantly
POST /app/team/invite | join | remove | leave                  # Business team seats
POST /app/billing/checkout            plan=…&provider=…        # cancel / resume likewise

stream-info — the app-builder's endpoint

GET /api/stream-info/{slug}     (owner cookie, admin, or operator token)
→ { "slug":…, "live":…, "renditions":[{label,width,height,fps,codecs,viewers}…],
    "ingest": {…failover election…},
    "recordings":[{"id":…,"url":"/vod/…/index.m3u8","duration":…}…] }

One call answers "is it live, in what qualities, and what VOD exists" — the backbone of every OTT screen.

Per-user API keys recommended for panels

Two machine credentials exist, and the difference matters:

Provisioning tokenUser API key
Who holds itthe server operatoran account owner (or their panel)
Scopeeverything — every account, deletion, DRM keysthat account's own channels only
Created atAdmin → SecurityDashboard → API keys
Revocable individuallyno (rotating it breaks every integration)yes, per key, instantly
Stored asconfig valueSHA-256 hash — the secret is shown once
curl -H "Authorization: Bearer sk_…" https://host/api/status     # only your channels
curl -H "X-API-Key: sk_…" -X POST https://host/api/channels/mine/cue -d '{"out":true}'
#   403 on someone else's channel · 401 once revoked, or if the account is suspended

Build customer-facing panels on user keys, not the provisioning token: one leaked key then costs one account a revoke, not the platform a rotation.

VOD offload (object storage)

When the server runs with -s3-endpoint, finished recordings move to S3-compatible storage (AWS, R2, B2, Wasabi, MinIO, Ceph) and playback 302-redirects to the object URL — set -s3-public-url to send players at a CDN in front of the bucket instead. Your app needs no changes: the same /vod/{slug}/{rec}/index.m3u8 URL keeps working, it just answers with a redirect once a recording has moved. Follow redirects (every player library does).

Prometheus metrics

GET /metrics    (operator credential)
ssh101_channel_viewers{channel="x"} · _bytes_total · _requests_total · _egress_mbps
ssh101_channel_watch_seconds_total · _startup_ms · _stalls_total · _player_errors_total
ssh101_ingest_live{channel,rendition} · ssh101_ingest_sources
ssh101_users{plan} · ssh101_users_suspended · ssh101_channels · ssh101_build_info{version}

Standard text exposition — point Prometheus at it with bearer_token in the scrape config. No new collection happens here; these are the same numbers the analytics page shows.

Operator plane — token auth

curl -H "Authorization: Bearer $TOKEN" https://host/api/status
curl -H "X-API-Key: $TOKEN"        https://host/api/status     # equivalent
curl -u admin:adminpass            https://host/api/status     # basic auth, equivalent

Operator — users

GET    /api/users                          → {"users":[{id,email,plan,status,acct_state,role,verified,created,channels}…]}
POST   /api/users                          {"email":…,"password":…,"plan":"pro"}
POST   /api/users/{id}/state               {"state":"active|suspended|disabled"}   # suspend = serving+ingest refused, live sessions kicked
POST   /api/users/{id}/plan                {"plan":"free|streamer|pro|business"}   # comp / set
POST   /api/users/{id}/period              {"days":30} or {"end":"RFC3339"}        # paid-through date
DELETE /api/users/{id}                     # CASCADE: channels, live state, disk media, domains, team links

Operator — channels

GET    /api/status                         # every channel: ingest_key, suspended, feature flags, live state, server runtime
GET    /api/channels                       # channel list
POST   /api/channels                       slug=…&name=…
PUT    /api/channels/{slug}                secure=1&low_latency=1&…               # field updates
DELETE /api/channels/{slug}
POST   /api/channels/{slug}/suspend        # instant: viewers 403, session kicked, ingest refused
POST   /api/channels/{slug}/unsuspend
GET    /api/ingest                         # failover election per rendition
GET    /api/streamcheck/{slug}             # server-side probe of a channel's output

Operator — channel settings new in 1.59

Everything the broadcaster's edit form can set, over the API. This is the endpoint a panel needs to finish provisioning a customer without sending them to log in and toggle boxes by hand.

GET   /api/channels/{slug}/settings   → every settable field, plus the owner's plan
PATCH /api/channels/{slug}/settings   any subset of those fields   (POST also accepted)

PATCH semantics. Only the fields you send change. A panel that knows about six fields will never reset the ones it has not heard of. The response carries changed — the fields whose value actually moved — so re-sending identical values is a clean no-op rather than a phantom edit in the audit log.

FieldValuesNotes
name description category languagetextportal metadata
tagscsv or JSON arrayde-duplicated, max 12
listedboolvisibility=public|unlisted also accepted
suspendedboolsame effect as the suspend endpoint
recordboolBusiness
secureboolAES-128 + signed tokens · Business · excludes CMAF/DRM
dvrminutes, 0 disablesdvr_minutes also accepted · Pro ≤ 30, Business ≤ 240
dvr_storageram|diskdisk is Business, and only if the operator allows it
lowlatency dash cmafboollow_latency also accepted · Business
drmboolBusiness · requires cmaf · first enable mints key material
fpskdskd://…FairPlay signaling
failbackboolredundant ingest · Business
geo_modeoff|allow|denyan empty country list auto-disarms the mode
geo_countriesISO-2 csv or arrayBusiness
ip_rulescidr / !cidr linescommas or newlines · max 200 · Business
referershosts, *.example.commax 100 · Business
max_viewersint, 0 = plan defaultruntime cap is min(plan, this)
token_ttlseconds, 30–86400out of range is a 400, never a coerced value
epg_url epg_idXMLTV feed + channel idsee the EPG section

Status codes. 400 the input was malformed, or the combination is incoherent (CMAF+AES-128), or an in-memory DVR window would cost more RAM than the server can safely spend — that last refusal quotes the projected size in GB at the channel's real bitrate and no plan can buy past it. 403 the owner's plan does not include the feature, or a user key aimed at a channel it does not own. 404 no such channel. A refused request changes nothing: the channel is written only after every rule passes.

Which plan applies. The channel owner's, not the caller's — a team member's key and the operator token both get the owner's entitlements, so a panel cannot grant a customer more than they pay for. A channel created by the provisioning token has no owner and therefore no subscription to enforce, so the full feature set applies to it; user keys can never reach those channels.

# read, then change one thing
curl -H "Authorization: Bearer $TOKEN" https://host/api/channels/corp/settings

curl -H "Authorization: Bearer $TOKEN" -X PATCH https://host/api/channels/corp/settings \
     -d "record=1&dvr=30&dvr_storage=disk&geo_mode=deny&geo_countries=KP,RU"

# JSON works everywhere form encoding does
curl -H "X-API-Key: sk_…" -X PATCH https://host/api/channels/corp/settings \
     -H "Content-Type: application/json" \
     -d '{"cmaf":true,"drm":true,"tags":["news","live"]}'
→ {"ok":true,"changed":["cmaf","drm","drm_keys","tags"],"plan":"business",…}

Turning drm on for the first time mints the channel's content key; fetch it from the DRM endpoint for your license gateway. Later edits never rotate it — players hold licenses against that key.

Operator — accounts, domains, seats, keys new in 1.60

The rest of what the dashboard can do. {id} accepts a user id or an email address. These endpoints take a user API key for the caller's own account, except where noted.

GET    /api/users/{id}/domains              → {"domains":[…]}
POST   /api/users/{id}/domains              host=tv.example.com          # Business plan
DELETE /api/users/{id}/domains/{host}

GET    /api/users/{id}/team                 → seats, used, members[], member_of
POST   /api/users/{id}/team/invite          → {"code":…,"join_url":…}    # 7-day signed code
DELETE /api/users/{id}/team/{member_id}

GET    /api/users/{id}/keys                 → id, name, prefix, created, last_use
POST   /api/users/{id}/keys                 name=…    # PROVISIONING TOKEN ONLY
DELETE /api/users/{id}/keys/{key_id}

POST   /api/users/{id}/password             password=…&revoke_keys=1     # PROVISIONING TOKEN ONLY

POST   /api/channels/{slug}/rotate-ingest   → a new stream key
POST   /api/channels/{slug}/rotate-aes      → rolls the AES-128 content key

Why minting a key needs the root token. A key that can mint keys survives its own revocation: an attacker holding a leaked credential quietly creates a sibling, and revoking the leaked one changes nothing. Creation is therefore an operator act. Listing and revoking stay available to the account, because a customer who suspects a leak must never have to wait for the operator to act.

Password changes and sessions. Sessions are stateless signed cookies, so a password change does not end sessions already issued — they run out their remaining lifetime. To end them now, disable the account (POST /api/users/{id}/state with state=disabled), which the session check honors on the next request. revoke_keys=1 drops the account's API keys in the same call, which is usually what a leak actually requires.

Rotation is disruptive on purpose. rotate-ingest invalidates the stream key and drops the live session; the encoder must be reconfigured before it can publish again. rotate-aes advances the content key and clears the rewind window so no already-listed segment stays decryptable with the retired key — it applies only to AES-128 channels (400 otherwise); for DRM channels the key lives behind the DRM endpoint.

# seat a colleague on a customer's account
curl -H "Authorization: Bearer $TOKEN" -X POST https://host/api/users/corp@example.com/team/invite
→ {"ok":true,"code":"…","join_url":"https://host/app?invite=…","seats":5,"used":2}

# a leaked stream key, dealt with
curl -H "X-API-Key: sk_…" -X POST https://host/api/channels/corp/rotate-ingest
→ {"ok":true,"ingest_key":"9f1c…","note":"the encoder must be reconfigured…"}

Support tickets (operator)

GET  /api/tickets?status=open      → {"tickets":[{id,email,slug,subject,status,source,last_by,updated}…]}
GET  /api/tickets/{id}             → {"ticket":…,"messages":[…]}
POST /api/tickets/{id}             {"body":"…"} to reply (emails the customer) · {"status":"open|closed"}

Viewer problem reports (source viewer-report) are filed from the player with context attached; anyone can also file one programmatically: POST /report/{slug} with desc + ctx form fields (rate-limited per IP).

Ad markers (SCTE-35)

POST /api/channels/{slug}/cue  {"out":true,"duration":30,"event_id":1234}   # open a break at the next segment
POST /api/channels/{slug}/cue  {"out":false}                                # return to content
→ {"ok":true,"renditions":2,...}   409 when the channel is not live

The playlist then carries EXT-X-DATERANGE, EXT-X-CUE-OUT[:duration], EXT-X-CUE-OUT-CONT for mid-break segments, and EXT-X-CUE-IN on return — on TS and CMAF/DRM channels alike. SRT feeds carrying their own SCTE-35 PID pass through automatically; RTMP cannot carry markers.

EPG (program guide)

GET /api/epg/{slug}          → {"now":{title,desc,start,stop,category},"next":{…}}
GET /api/epg/{slug}?day=1    → + "today":[…]     (public, cached 60s; 404 = no guide configured)

Operator — DRM (CBCS)

GET  /api/channels/{slug}/drm     → {"kid":…,"key":…,"iv":…,"scheme":"cbcs","pattern":"1:9","widevine_pssh_b64":…,"fairplay_skd":…}
POST /api/channels/{slug}/drm     {"kid":…,"key":…,"iv":…}   # import existing key material

This is how a license server learns a channel's content key: your Widevine gateway (or FairPlay KSM) reads the key for a KID here, then answers license requests from players. Reading this endpoint is possession of the content key — it is operator-token only and every access is audited. DRM channels' playlists carry SAMPLE-AES key lines with the Widevine KEYFORMAT (pssh in a data: URI) and, when configured, a FairPlay skd:// line; the init segment carries pssh+tenc. Players need nothing from this API — only your license infrastructure does.

Operator — billing

POST /api/billing/sweep                    # run the subscription-expiry sweep now (also hourly)
# provider webhooks (Stripe/PayPal/Square) are inbound: see the admin guide's Payments section

Operator — analytics & observability

GET /api/analytics                         # live snapshot: per-channel concurrents, bandwidth, geo
GET /api/analytics/history?channel={slug}  # per-minute rollups (restart-proof)
GET /api/analytics/qoe[?channel={slug}]    # player-reported quality: startup p50/p95, rebuffer ratio, errors by cause
GET /api/audit                             # the audit log
GET /healthz                               # liveness (200 "ok")
GET /api/db/status                         # database backend + reachability

Operator — recordings

Recordings were listable and playable from the dashboard but had no endpoint at all, so an external panel could not enumerate them, size them or remove them without scraping HTML or reaching into the data directory. Same bearer token as the rest; an operator token sees every channel, a user key only its owner's.

GET    /api/recordings[?channel={slug}]   # list, newest first
GET    /api/recordings/{slug}/{rec}        # one recording
DELETE /api/recordings/{slug}/{rec}        # remove it and its segments

Each entry carries id, channel, started_at (RFC3339), duration_s, segments, bytes, playlist_url and embed_url. Durations and sizes are numbers, not display strings — the page renders "2 h 14 m", the API does not, because nobody should have to parse a display format back into a number.

A recording still being written reports recording: true and playable: false. Deleting one is refused with 409 rather than pulled out from under the recorder, which would leave a half-written asset and a confusing log. Stop the broadcast, then delete. Deletions are audited.

Recording — windows, programmes and catch-up

Three ways to record, from the bluntest to the most useful.

1. Everything (the switch)

record=true on a channel records every session, whole, for as long as it is on air. Right for "keep it all", wrong for anything selective.

2. A clock window

POST /api/recordings/{slug}/schedules
     start=20:00&stop=21:00[&days=1&days=2][&label=Evening news][&date=2026-08-20]

GET    /api/recordings/{slug}/schedules
DELETE /api/recordings/{slug}/schedules/{id}

3. A programme, from the guide

POST /api/recordings/{slug}/schedules  match=Evening News[&days=1&days=2]

Records whatever the guide calls that programme, using the programme's own boundaries instead of a clock. Matching is case-insensitive substring, so news catches "Evening News" and "News at Ten". No clock fields are needed or used.

4. Catch-up — one asset per programme, continuously

PATCH /api/channels/{slug}/settings   catchup=true

Cuts the recording wherever the guide says one programme ends and the next begins, naming each asset after what is in it (20260812-200000-evening-news). This is what makes recordings browsable catch-up rather than a list of timestamps.

How the guide is treated

As evidence, not as a clock, because XMLTV arrives late, is revised after the fact, and disagrees with what is actually transmitted:

In the web interface

Channels → (channel) → Edit → Recording schedule. Add a window with the start/stop pickers and day checkboxes, or type a programme name to match. The table lists what is scheduled, marks a window that runs past midnight, and deletes with confirmation. Recordings appear under Recordings on the same page with their duration and a delete control; both are visible only to the channel's owner.

What is recorded, and where it goes

GET    /api/recordings[?channel={slug}]
GET    /api/recordings/{slug}/{rec}
DELETE /api/recordings/{slug}/{rec}

Each carries started_at, duration_s, segments, bytes, playlist_url and embed_url. A recording still being written reports recording: true / playable: false, and deleting it is refused with 409 rather than pulled out from under the recorder. If the channel's owner has configured their own S3 (see below), finished recordings are uploaded there.

nDVR — rewind on a live channel

A DVR window lets a viewer scrub backwards through what has already been transmitted, without any recording being made.

PATCH /api/channels/{slug}/settings   dvr=30&dvr_storage=ram

Nothing is needed on the player side: the window simply appears in the playlist and any standard HLS player can seek within it. The rewind depth grows until it reaches the configured window and then slides.

In the web interface: Channels → (channel) → Edit → DVR window.

This is a moving window, not an archive. For "watch last Tuesday", use catch-up or a recording schedule above.

Operator — bring your own storage

VOD offload used to go to one bucket: the operator's, shared by every customer on the box. A user can instead point their channels at their own S3-compatible storage, and their recordings are uploaded there.

GET  /api/storage     # what is configured (never the secret)
POST /api/storage     # endpoint= bucket= access_key= secret_key= [region=] [public_url=] [vhost=1]

Why it exists: the storage bill follows the recordings, a customer's video sits in an account they control and can audit, and a customer who leaves can walk away with their assets instead of starting a migration project.

Getting a real certificate (Let's Encrypt)

ssh101 -port 443 -domain ssh101plus.bozztv.com -email ops@tulix.com \
  -rtmp :1935 -srt :9000 -data /var/lib/ssh101

-autocert defaults to on, so it does not need to be given. Certificates and the ACME account are cached in -data, so that directory must be persistent — wiping it between deploys means re-issuing, and Let's Encrypt rate limits that.

Four conditions, all required

  1. Port 443. Autocert only engages on 443. On any other port the server falls back to a self-signed certificate — it now says so in the log instead of letting you discover it from a browser warning.
  2. No -http. That flag serves plain HTTP for use behind a reverse proxy and issues no certificate at all. If both are given, the server now warns that -domain is being ignored.
  3. Port 80 reachable from the internet. ACME validates over HTTP-01: the server opens :80 to answer the challenge and redirects everything else to HTTPS. If 80 is firewalled or held by another process, issuance fails.
  4. DNS already pointing here. ssh101plus.bozztv.com must resolve to this machine's public address before starting, because validation happens on the first request for that name.

Binding 443 and 80 without running as root

setcap 'cap_net_bind_service=+ep' /usr/local/bin/ssh101
# or, in the systemd unit:
AmbientCapabilities=CAP_NET_BIND_SERVICE

Checking it worked

The log line to look for is https (autocert) listening on :443 for ssh101plus.bozztv.com. The certificate itself is fetched lazily on the first TLS handshake for that name, so hit the site once and then:

curl -vI https://ssh101plus.bozztv.com/healthz 2>&1 | grep -E "issuer|subject|expire"
ls /var/lib/ssh101/    # the cached certificate appears here after issuance

Serving several names — including any forwarded elsewhere with -vhost — means listing all of them: -domain ssh101plus.bozztv.com,utopia.bozztv.com. This process holds the certificate for every name it terminates TLS for.

Operator — scheduled ad breaks

Inserting a break has been a button and an API call: right for live sport, useless for a linear channel that breaks at :15 and :45 of every hour, where somebody would sit pressing a button all day.

GET    /api/channels/{slug}/adbreaks
POST   /api/channels/{slug}/adbreaks   at=15:00&every_min=30&duration_s=60[&days=1&days=2][&label=]
DELETE /api/channels/{slug}/adbreaks/{id}

Not implemented: an ad-decision server. This schedules the break; what fills it is a downstream splicer's business.

Standing up a lineup in one call

Bulk settings change channels that already exist. This creates them and applies one profile to all of them, so "ready for OTT" is a single decision rather than fifty repetitions of it.

POST /api/channels/provision
  slugs=news24,sport1,movies    # the channels to create
  profile=ott                   # or "bare"
  protection=aes                # aes (default) | drm | none
  dvr=30                        # minutes
  owner=<user id>               # operator only

The ott profile sets recording, catch-up, low latency, a public listing and the DVR window, plus AES-128 by default. Each result carries the channel's playlist URL, its embed URL, a ready-to-paste embed code and the URL of its generated player code, so a lineup can be handed to a front end without a second pass.

AES-128 and CMAF cannot be combined — AES encrypts whole TS segments while fMP4 needs sample encryption — so protection=aes and protection=drm are alternatives, not a scale. Asking for both is refused, which is how the first version of this preset was found to be wrong.

ABR is not in the profile. It costs the operator's GPUs, so it stays a per-channel operator decision (POST /api/channels/{slug}/abr).

A name already taken is reported for that channel and does not undo the rest; the call answers 207 when anything was refused. Re-running after fixing two names is safe.

One server, several front ends

A single ssh101+ server can serve several VODOTT systems (or any mix of front ends). Each gets its own API key, and that key is the tenant boundary.

# one key per front end, per account
POST /api/users/{id}/keys   name=vodott-east

With a tenant key, every one of these is scoped to that account's channels:

Another tenant's channel answers 404, not 403: to that tenant it does not exist, and "forbidden" would confirm that a name is taken on a shared server.

Do not give a front end the operator token

This is the trap. The operator credential deliberately passes everything — the admin console needs that — so a front end holding it sees and can delete every tenant's channels. Until 2.64.0 the list and create calls answered only to the operator token, which meant a multi-tenant deployment had no other option; that is fixed, and a tenant key is now the right credential for a front end in every case.

GET /api/capabilities is per-server and answers to any valid credential: what the software can be told does not vary by tenant.

Control API — recording and encryption from a front end

For a front end (VODOTT and anything like it) that manages channels here, so an operator does not configure everything twice and watch the two copies drift apart.

GET /api/capabilities                     # what this server can be told
GET /api/channels/{slug}                  # dvr, dvr_days, encrypted
PUT /api/channels/{slug}/dvr              enabled=1&retention_days=7
PUT /api/channels/{slug}/encryption       enabled=1

dvr_days is retention, not the rewind window

They are different numbers answering different questions, and confusing them is the expensive mistake:

A front end told "7" because the rewind window said 240 would advertise a week of catch-up and every tile older than four hours would fail when tapped. Both are reported, each under its own name, so nobody has to guess. dvr_days: 0 means recordings are kept until deleted — not zero days.

Idempotent by construction

These state a desired condition rather than applying a change, so re-pushing the same values after a re-import or a retry answers 200 and does nothing. A retry that fails because it worked the first time is the worst kind of failure to debug.

Errors say what to do

Every refusal carries {"ok": false, "error": "..."} with text meant to be shown to an operator verbatim — "recording is not included in this account's plan", "only 0.4 GB free on the recording volume" — rather than "invalid request", which tells them nothing.

The content key never crosses this API

PUT .../encryption switches AES on and off and nothing more. The key stays on this server and reaches viewers through the front end's own key service. An admin request carrying a content key would leave it in request logs, proxy logs and error reports for no benefit.

Encryption is refused with a reason on a channel set to fMP4 (CMAF): AES-128 encrypts whole segments, fMP4 needs sample encryption. Use DRM there instead.

An unknown slug carries the envelope

Every channel-scoped path answers an unknown slug with 404 {"ok": false, "error": "no such channel"}, never a bare 404.

The distinction is load-bearing. A bare 404 is indistinguishable from "this endpoint does not exist on this server", so a caller reading it as "not upgraded yet" — the sensible reading — would tell an operator who mistyped a slug that their server needs upgrading, and they would go and check a version number instead of their spelling. A 404 carrying ok is a real answer about a real endpoint; a bare one is a missing endpoint.

retention_days=0 sets keep-until-deleted, including as a reset: sending 0 to a channel currently on 14 days clears the window rather than being read as "no value supplied".

Conventions

Operator — GPU fleet capacity

GET /api/gpu/capacity

gpunode 1.51 re-sizes its own capacity: a governor raises and lowers slots from observed behaviour, with a minute of agreeing evidence before it moves and five minutes between moves. So the number that mattered a minute ago may not be the number now, and asking each node at the moment work appears is no longer enough.

The fleet is polled every 20 seconds and dispatch picks by headroom — slots minus in-flight minus queued — not by least load. A node at 1 of 2 slots and one at 1 of 6 both report in_use: 1, and only one of them has room; choosing on absolute load is how a fleet ends up with one saturated card and one idle one. Queued work counts against headroom because a node with a free slot and three jobs waiting is between jobs, not free.

Each node reports whether its capacity is auto, the governor's own stated reason, and how many times it has changed. A node that re-sizes itself is working as designed; one that re-sizes constantly is usually thermally limited or sharing the card with something else.

Nodes that are unreachable, draining, not accepting, or whose role excludes the workload are skipped before work is offered, so a dispatch does not pay a connect timeout to learn what the last poll already knew. When nothing has room the refusal names each node and why.

Operator — server-side ABR on a GPU node

ssh101+ does not transcode. A broadcaster wanting several bitrates sends each one itself with a labelled ingest key — right when the encoder is capable, and wrong when it is a phone sending a single 1080p stream. Server-side ABR encodes the ladder on a gpunode instead.

GET  /api/channels/{slug}/abr          # state, ladder, and where it is encoding
POST /api/channels/{slug}/abr  enabled=true

Operator only. The encode costs the operator's GPUs, not the customer's, so it is on the operator plane and in the admin console under Capacity — a self-service account cannot claim a GPU slot by ticking a box. The channel's owner sees it is on and cannot change it.

The ladder

RungBitrateProduced when
2160p4.5 Mb/sonly when the source is 4K
1080p3.5 Mb/ssource is 1080p or better
720p1.8 Mb/ssource is 720p or better
480p0.8 Mb/ssource is 480p or better
360p0.4 Mb/salways — the rung a viewer on a train falls back to

Rungs above the source are omitted, not upscaled. Upscaling spends a GPU to invent detail that is not there, produces a rung that looks worse than the one below it at a higher bitrate, and — the reason it matters most — hides an encoder that has quietly dropped to 720p, because the ladder would keep emitting 1080p whatever arrived. A source smaller than every rung is passed through at the lowest bitrate rather than dropped.

A source whose height is not yet known gets the ladder without the 4K rung: guessing 4K is the one wrong answer that is expensive rather than merely wrong. When the encoder reconnects at a different resolution the session is replaced with the ladder that source deserves.

When no node will take it

The channel is delivered exactly as the encoder sent it, and the reason is in the log and in why_not on the status endpoint. ABR is an improvement on single-rendition delivery, not a precondition for it.

Operator — configuring an OTT lineup at once

Every setting is per channel, which is right for three channels and wrong for three hundred. Turning AES on across a lineup used to be three hundred PATCHes, each able to fail on its own, with no record of which had taken.

# AES-128 across every channel you own
POST /api/channels/settings   channels=*&secure=true

# or a named set, with any other settings in the same call
POST /api/channels/settings   channels=news,sport,movies&secure=true&dvr=30

# rotate the content key on a whole lineup
POST /api/channels/rotate-aes  channels=*

# and the question that used to take one request per channel
GET  /api/channels/encryption

It is not a transaction, deliberately. Channels differ in plan and in combination — a Pro account cannot take disk-backed DVR, a channel without CMAF cannot take DRM — and rolling back two hundred and ninety-nine good channels because one is on the wrong plan means an operator can never make progress. Each channel is applied independently and the response says what happened to each: changed, unchanged, or refused with the reason.

A partial apply answers 207, never 200. A 200 on a partial apply is how somebody comes to believe encryption is on everywhere when it is on most places. Re-running is safe: a channel already in the desired state reports no changes.

channels has no default — a bulk apply with no selection is the request most likely to be an accident.

Any access rule makes a channel token-protected

This catches people out, so it is worth stating plainly: a channel requires a short-lived playback token if any of these is set, not only when it is encrypted:

The reason is that the token carries the session the rules are applied to: a viewer cap has to count something, and a referer or geography rule has to be attached to a request the origin can recognise on the next segment. So setting a cap on an otherwise-open channel changes how it is played — a direct HLS URL that worked before will answer 403 afterwards.

GET /api/channels/{slug}/player knows this and generates token handling whenever it applies, naming which rule caused it. If you set an access rule, re-fetch the player code.

AES covers live and VOD together

One setting per channel protects both. A recording made from an encrypted channel is served with the same EXT-X-KEY line, and its key needs the same short-lived playback token as the live stream — so a mixed live/VOD OTT lineup does not need a second mechanism, and there is no window where the recording of a protected channel is served in the clear.

GET /api/channels/encryption reports every channel as aes-128, drm-cbcs or none, with totals, so "is anything serving unprotected" is one call rather than a survey.

Encryption and player code — generated, not documented

Turning encryption on is one field. The part that was never simple is the other end, so this server writes that for you:

GET /api/channels/{slug}/player                      # JSON
GET /api/channels/{slug}/player  -H "Accept: text/plain"   # paste-ready

Returns working integration code for web (hls.js), iOS/tvOS (AVPlayer), Android (ExoPlayer/Media3), Roku (BrightScript) and ffplay/VLC, with this channel's real URLs already in it. There are no placeholders to fill in, which is deliberate: a template is a thing an integrator adapts, and the adaptation is where token handling gets dropped — it works in testing, because a fresh token outlasts a demo, and fails an hour into production.

Turning encryption on

PATCH /api/channels/{slug}/settings   secure=true    # AES-128
PATCH /api/channels/{slug}/settings   drm=true       # CBCS (Widevine/FairPlay)

What the generated code handles for you

In the web interface: the same pack is available per channel at /app/player/{slug}.

Operator — analytics across every customer

GET /api/admin/analytics

The whole box added up, then split by customer. The customer-facing analytics are owner-scoped — a customer sees their own channels — so an operator, who usually owns none, saw an empty page and had no way to answer "how much is this box doing, and who is doing it".

Attributed by channel owner, which this server knows, rather than anything a viewer sends. live_channels counts channels with something actually publishing, not channels that merely exist — an operator reading a live count wants the number that costs them bandwidth. Watch hours and unique viewers are today in UTC; concurrent viewers and egress are current.

Also rendered at /app/admin/analytics ("All customers" in the admin navigation).

Operator — upgrade rehearsal

GET /api/upgrade-check

Answers whether every channel this server holds survives this build: each one is round-tripped through the store the way a restart reloads it, and compared field by field. lost names a field that had a value and no longer does — data this build cannot carry forward, and a reason to stop. changed is normally a new column taking its default.

It also reports the backend and any schema column this build expects and the database does not have, read from information_schema rather than assumed from the migration list: the question is whether the database has been migrated, and answering it from the code that would have done the migrating is circular.

Answers 409 when something was lost or the schema is incomplete, so a deploy script can gate on the status alone. The same check runs offline as ssh101 -upgrade-check against a restored backup, which is the form to use before touching production.

Operator — node internals

These exist for the operator console and for node-to-node work. They are documented so the parity audit can hold at zero, not because a panel should build on them: several change shape with the deployment.

GET  /api/alerts                     # what this box has noticed about itself (operator session)
GET  /api/transcode                  # whether server-side transcoding is available on this node
GET  /api/edge                       # edge fleet view
GET  /api/edge/changes               # what an edge must invalidate
POST /api/maint                      # maintenance mode
POST /api/maint/service/restart      # restart a managed service
GET  /api/db/status                  # database backend and reachability
POST /api/db/setup                   # first-run database bootstrap
POST /api/db/install                 # install the schema
POST /api/db/mariadb                 # configure the MariaDB connection

/api/alerts answers 403 to anyone who is not an operator, and the dashboard's banner stops polling on that response rather than retrying.

Settings not covered above

CBR service identity — a known limitation. cbr_provider and the service name reach the SDT correctly, and are what most headends read. But cbr_service_id and cbr_tsid do not change the PAT: the CBR path reinserts the PAT and PMT produced by your encoder rather than building its own, so the PAT keeps that encoder's programme number and transport stream id — usually 1 and 0. Measured with cbr_service_id=1234: the SDT says 1234 and the PAT says programme 1.

If your headend matches the SDT service id against the PAT programme number, they will disagree. Until the PAT is rewritten, set your encoder to emit the programme number you want, and use cbr_service_id to match it.

Sharing one IP with another service

Yes — ssh101+ runs happily as a virtual host beside something else on the same machine and the same address, e.g. ssh101plus.bozztv.com next to a playout on utopia.bozztv.com. The web side virtual-hosts normally; the streaming side mostly cannot, and knowing which is which saves an afternoon.

Can two daemons just share the port?

No — and the way it fails is worth understanding, because there is a flag that appears to make it work.

A second bind() on a port another process holds is refused outright with EADDRINUSE. That is the good case: it fails immediately and visibly. But Linux also offers SO_REUSEPORT, which lets several processes bind the same port on purpose — and then the kernel hands each incoming connection to whichever socket its hash selects. It is a load-balancing primitive for identical workers, not a routing one. Nothing looks at the Host header, because the connection is assigned before a single byte of the request has been read.

Measured on this machine: two daemons sharing one port with SO_REUSEPORT, then twenty requests all carrying Host: utopia.bozztv.com — twelve went to one process and eight to the other. In production that is a hostname that works, then does not, then does, with nothing in any log to explain it.

So exactly one process owns the port. Three arrangements do work:

  1. ssh101+ owns it and forwards the rest-vhost, described below. One process, one port, one certificate story.
  2. The other service owns it and forwards to ssh101+ — the nginx setup further down, or the playout's own proxy if it has one. Right choice if that service needs proxy features ssh101+ does not have.
  3. A second IP address — bind each daemon to its own, and both can use 443. The only option where neither process sees the other's traffic at all.

If the port is taken, ssh101+ says all of this in the error rather than just "address already in use".

Without a separate proxy — ssh101+ fronts the machine

You do not need nginx. ssh101+ already terminates TLS, obtains certificates for several domains and routes on Host, so it can own 80/443 itself and hand the names it does not serve to whatever local service does:

ssh101 -port 443 -autocert -domain ssh101plus.bozztv.com,utopia.bozztv.com \
  -email ops@tulix.com \
  -vhost "utopia.bozztv.com=http://127.0.0.1:8088" \
  -rtmp :1935 -srt :9000 -data /var/lib/ssh101

Requests for utopia.bozztv.com are forwarded to the playout on its loopback port; everything else is served by ssh101+. The playout sees the name the viewer asked for in Host, plus X-Forwarded-For / -Proto, so the URLs it generates come out right instead of pointing at 127.0.0.1. Certificates cover both names — put every name in -domain, including the forwarded ones, because this process is the one holding the certificate for them. ACME challenges are always answered locally and never forwarded, so renewal cannot break on a backend that knows nothing about ACME.

Details worth knowing:

The reverse-proxy setup below remains valid and is the better choice if you already run one, or if the other service needs proxy features ssh101+ does not have.

With nginx in front

Both names resolve to the one IP and a reverse proxy routes on the Host header (TLS on SNI). Bind ssh101+ to a loopback port and let the proxy own 80/443:

ssh101 -http -port 7500 -rtmp :1935 -srt :9000 \
  -public-url https://ssh101plus.bozztv.com \
  -trusted-proxies 127.0.0.1/32 -data /var/lib/ssh101
server {
    listen 443 ssl;
    server_name ssh101plus.bozztv.com;
    # ssl_certificate ... (the proxy terminates TLS for both names)

    location / {
        proxy_pass http://127.0.0.1:7500;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_http_version 1.1;          # keep-alive for segment fetches
        proxy_buffering off;             # do not stall low-latency HLS
        proxy_read_timeout 3600s;        # long-lived players and WHEP
    }
}
server {
    listen 443 ssl;
    server_name utopia.bozztv.com;
    location / { proxy_pass http://127.0.0.1:7600; proxy_set_header Host $host; }
}

Three settings do the work. -public-url makes every copy-paste URL — playlist, player link, embed snippet — come out as the public name instead of the loopback address the process actually sees. -trusted-proxies lets ssh101+ believe the forwarded headers from that proxy, so viewer geography is the viewer's and not 127.0.0.1; forwarded headers from anywhere else are ignored, because a client that can assert its own scheme can rewrite your URLs. And proxy_buffering off matters more than it looks: with buffering on, nginx holds segments back and low-latency HLS stops being low-latency.

Do not run ssh101+'s built-in Let's Encrypt when a proxy owns 443 — two things cannot both hold the port. Terminate TLS at the proxy for both names.

RTMP and SRT — need their own ports

This is the part that is not like HTTP. RTMP carries no Host header and SRT has no SNI, so a hostname cannot select between two servers on one port. If the playout also ingests RTMP, the two must differ somewhere:

SRT is the same story: distinct ports, or distinct addresses. SRT's streamid identifies the channel, not the server.

What to check once it is up

Operator — live channel analytics

What the origin knows and a player cannot: whether the source is healthy, what the encoder is actually sending, and why a channel dropped off air. Same bearer token as /api/status — no separate credential. An operator token sees every channel; a user API key sees only its owner's.

GET /api/analytics/live                                   # A: per-channel state now
GET /api/analytics/live/history?slug={slug}&from=&to=      # B: bucketed history
GET /api/analytics/live/events?since=&limit=               # D: ingest event log
GET /api/analytics/live/audience?slug={slug}               # C: aggregates only

Every response is {"ok":true, …, "meta":{…}}, and the meta block is the part worth reading first: it states how viewers is counted, the sampling interval, and the exact retention, so a panel never has to guess what a number means.

What the fields mean

Retention

5-minute buckets for 7 days, rolled up to hourly and kept 90 days; ingest events for 30 days. Held on the origin's own disk, so it survives a restart but is per node — a panel spanning several origins must ask each one. Daily-for-two-years is not implemented; meta.retention.daily_retention_days reports 0 rather than implying a depth that does not exist.

Fields you will not find

A zero is a measurement, so anything unmeasured is omitted rather than sent as zero. audio_kbps_in is absent because the ingest reports audio presence, not its bitrate. A history bucket with no predecessor carries no delta rather than reporting its running total as one. Section C returns aggregates only — no per-viewer rows and no IP addresses, by request.

Ordering and paging

Events are returned oldest first, guaranteed and stated in meta.events_order. Page by passing the last event's at back as since. History accepts interval=300 (native) or interval=3600 (aggregated on read); any other value is answered 400 rather than silently substituted, and the interval used is echoed in the response.

curl -H "Authorization: Bearer $T" "https://host/api/analytics/live"
curl -H "Authorization: Bearer $T" \
  "https://host/api/analytics/live/history?slug=news&from=2026-08-01T00:00:00Z&to=2026-08-02T00:00:00Z"
curl -H "Authorization: Bearer $T" "https://host/api/analytics/live/events?limit=200"

Poll Section A every 30 s; it reads only in-memory state and touches neither disk nor database. If you are already scraping Prometheus, much of Section A is there too and scraping costs less than polling.

Recipe — a web player with token refresh

<video id="v" controls muted playsinline></video>
<script src="https://host/hls.min.js"></script>
<script>
const HOST="https://host", CH="mychannel";
let token="", hls;
async function mint(){
  const r = await fetch(HOST+"/api/playback-token?channel="+CH, {credentials:"include"});
  if(!r.ok) throw new Error("token "+r.status);
  const j = await r.json(); token=j.token;
  setTimeout(mint, Math.max((j.ttl-20)*1000, 15000));   // refresh before expiry
}
mint().then(()=>{
  const url = HOST+"/hls/"+CH+"/master.m3u8?token="+token;
  const v = document.getElementById("v");
  if (v.canPlayType("application/vnd.apple.mpegurl")) { v.src = url; }   // Safari
  else { hls = new Hls({ xhrSetup: (x,u)=>{                              // everyone else:
      // keep the token FRESH on every playlist/segment/key request
      x.open("GET", u.replace(/token=[^&]*/, "token="+token), true); }});
    hls.loadSource(url); hls.attachMedia(v); }
});
</script>

Or skip all of it: <iframe src="https://host/e/mychannel" allowfullscreen></iframe> — the built-in player already does tokens, refresh, LL-HLS, DVR and quality menus.

Recipe — report QoE from your own player

The analytics page's startup / stall / error cards are fed by three tiny beacons. The built-in player sends them; any player can, and then your metrics cover your app too. Drop this next to whatever player you use:

function qoe(video, channel, host) {
  var t0 = Date.now(), started = false, stallAt = 0;
  function send(ev, extra) {
    var b = Object.assign({ ev: ev, ch: channel }, extra || {});
    var body = JSON.stringify(b), url = (host || "") + "/api/player-beacon";
    if (navigator.sendBeacon) navigator.sendBeacon(url, new Blob([body], {type:"application/json"}));
    else fetch(url, { method: "POST", body: body, keepalive: true }).catch(function(){});
  }
  video.addEventListener("playing", function () {
    if (!started) { started = true; send("start", { t: Date.now() - t0 }); }  // time to first frame
    stallAt = 0;
  });
  video.addEventListener("waiting", function () {          // debounce: a real stall, not a seek
    if (!started || stallAt) return;
    stallAt = Date.now();
    setTimeout(function () { if (stallAt && video.readyState < 3) send("stall"); stallAt = 0; }, 500);
  });
  video.addEventListener("error", function () {
    send("fatal", { ec: video.error ? video.error.code : 0 });
  });
}
qoe(document.getElementById("v"), "mychannel");

Three event names matter: start (with t in milliseconds), stall, fatal. No auth — beacons are anonymous and rate-bounded; anything else in the payload is ignored. Debouncing the stall is not optional: fire on every waiting and you will count seeks as rebuffers and your numbers will lie.

Recipe — a reseller panel provisions a customer

# 1. create the account (comped onto a plan you bill yourself)
curl -H "Authorization: Bearer $T" -X POST https://host/api/users \
     -d '{"email":"client@corp.tv","password":"…","plan":"business"}'
# 2. create their publishing point
curl -H "Authorization: Bearer $T" -X POST https://host/api/channels -d "slug=corp&name=Corp TV"
# 3. read the ingest key, hand them encoder settings
curl -H "Authorization: Bearer $T" https://host/api/status   # → ingest_key for slug corp
#    → rtmp://host:1935/live/<KEY>_720p
# 4. their player page
#    → https://host/e/corp   (or your own domain white-labeled)
# 5. watch it live in your panel
curl -H "Authorization: Bearer $T" https://host/api/analytics
# suspend on non-payment, restore on payment:
curl -H "Authorization: Bearer $T" -X POST https://host/api/users/ID/state -d '{"state":"suspended"}'

Recipe — a TV / OTT app

Everything a Roku/Tizen/webOS/Android-TV shell needs, in three calls:

GET /api/stream-info/{slug}    # live? renditions? recordings? (operator token from your backend)
GET /poster/{slug}             # tile artwork
GET /hls/{slug}/master.m3u8    # hand to the platform player (+?token= if secure; mint server-side and proxy)

For secured channels, mint tokens in YOUR backend (operator or per-viewer) and pass them to the device — do not embed the provisioning token in an app binary, ever.

Testing your integration

Debugging