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
| Plane | Auth | Who | Base surface |
|---|---|---|---|
| Viewer | none, or a playback ?token= | players, embeds, TV apps | /hls /dash /vod /key /poster /e /api/playback-token |
| Broadcaster | session cookie (POST /login) | your customers' own tools | /app/* /api/stream-info |
| Operator | Authorization: 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
- Responses are JSON unless the endpoint serves media or HTML. Errors are plain text with an honest status code.
- Write endpoints accept both form encoding and JSON bodies — send whichever your HTTP library favors.
- Paths are stable; new response FIELDS may appear at any time. Parse maps, not positions.
- All timestamps are RFC3339 UTC.
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…
- Minting is public but policy-checked: geo rules, referer rules, IP rules and the viewer cap apply at mint time too.
- Tokens can be IP-bound (channel setting): a token minted for one viewer dies on another's connection.
- Refresh before
ttlexpires and reuse the returnedsid(the cookie does this automatically in browsers). A fresh random sid per refresh counts as a brand-new concurrent viewer and eats the plan's viewer slots with phantoms. - AES-128 decryption is transparent: the playlist carries
EXT-X-KEYand players fetch/key/{slug}/{id}(token-gated) themselves. Your app never touches key bytes.
Error semantics your player should handle
| Status | Meaning | Right reaction |
|---|---|---|
| 401 | token missing/expired/invalid | mint a fresh token, retry once |
| 403 | policy: geo/referer/IP rules, IP-bound token elsewhere, or channel/account suspended | show "not available"; do not retry-loop |
| 404 on playlist | channel offline or no such rendition | poll politely (players do); show poster/offline UI |
503 + Retry-After | the plan's concurrent-viewer cap is full | honor Retry-After, show "at capacity" |
| empty live playlist (200, no segments) | encoder attached, first segment not sealed | keep 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 token | User API key | |
|---|---|---|
| Who holds it | the server operator | an account owner (or their panel) |
| Scope | everything — every account, deletion, DRM keys | that account's own channels only |
| Created at | Admin → Security | Dashboard → API keys |
| Revocable individually | no (rotating it breaks every integration) | yes, per key, instantly |
| Stored as | config value | SHA-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.
| Field | Values | Notes |
|---|---|---|
name description category language | text | portal metadata |
tags | csv or JSON array | de-duplicated, max 12 |
listed | bool | visibility=public|unlisted also accepted |
suspended | bool | same effect as the suspend endpoint |
record | bool | Business |
secure | bool | AES-128 + signed tokens · Business · excludes CMAF/DRM |
dvr | minutes, 0 disables | dvr_minutes also accepted · Pro ≤ 30, Business ≤ 240 |
dvr_storage | ram|disk | disk is Business, and only if the operator allows it |
lowlatency dash cmaf | bool | low_latency also accepted · Business |
drm | bool | Business · requires cmaf · first enable mints key material |
fpskd | skd://… | FairPlay signaling |
failback | bool | redundant ingest · Business |
geo_mode | off|allow|deny | an empty country list auto-disarms the mode |
geo_countries | ISO-2 csv or array | Business |
ip_rules | cidr / !cidr lines | commas or newlines · max 200 · Business |
referers | hosts, *.example.com | max 100 · Business |
max_viewers | int, 0 = plan default | runtime cap is min(plan, this) |
token_ttl | seconds, 30–86400 | out of range is a 400, never a coerced value |
epg_url epg_id | XMLTV feed + channel id | see 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}
- Times are the server's local time; the response reports the timezone.
- A stop earlier than the start wraps past midnight and belongs to the day it started — a Saturday 23:00–01:00 schedule records into Sunday morning without Sunday being listed.
daysare 0 (Sunday) to 6; omit for every day.datepins a one-off.- Padding defaults to 30 s before, 60 s after — encoders drift and programmes overrun, and overruns are commoner than early finishes.
- Impossible schedules are refused when saved, not left to silently never fire.
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:
- A match is only acted on for the programme the guide says is on now.
- A boundary cut is committed when observed, so a guide revised afterwards cannot retitle an asset already written.
- No guide means no recording. Silence is a legible failure; an asset containing the wrong hour is not.
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
dvris the window in minutes;0disables it and serves a live-edge playlist only.dvr_storageisramordisk. RAM is faster and is charged against a memory budget; disk allows a much longer window.- Plan limits apply and are enforced, not merely advertised: Pro allows up to 30 minutes, RAM only; Business up to 240 minutes, RAM or disk.
- A RAM window is cost-projected before it is accepted, so a channel cannot be configured into an out-of-memory. A window that would not fit is refused with the arithmetic in the message.
- Disk-backed DVR pauses at the disk floor rather than filling the volume.
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.
- The secret key is encrypted at rest and never returned by any endpoint —
not masked, not partially.
GETreportssecret_set: trueand no more. - A remote plaintext endpoint is refused:
http://to anything but loopback would send your keys and your video in the clear. - Validated when saved, not when an upload fails hours later — by which time the local copy may have been pruned.
- Users who configure nothing keep using the server's storage, as before.
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
- 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.
- 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-domainis being ignored. - Port 80 reachable from the internet. ACME validates over HTTP-01: the
server opens
:80to answer the challenge and redirects everything else to HTTPS. If 80 is firewalled or held by another process, issuance fails. - DNS already pointing here.
ssh101plus.bozztv.commust 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}
atis the server's local time;every_minrepeats through the day from there, so ":15 then every 30" is one row rather than forty-eight.- A break more than 90 seconds late is skipped, not inserted. By then a splicer downstream has committed to the programme, and a late marker puts the advert over it — worse than a missing one.
- A repeat interval shorter than the break itself is refused: the next break would start before this one finished and the channel would never leave the ad.
- A break on a channel that is off air is not an error and not logged: there is nothing to mark.
- The marker is the same SCTE-35 splice the button and
/cueproduce, so a scheduled break and a manual one cannot behave differently.
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:
GET /api/channels— lists only that tenant's channelsPOST /api/channels— the new channel belongs to that tenant, and the owner comes from the credential, never from the requestPUT/DELETE /api/channels/{slug}, the control endpoints, settings, analytics and stream-info — all refuse another tenant's channel
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:
dvr_days— how long a recording is kept. Enforced by an hourly sweep on this server, so the number reported is the number honoured.rewind_window_minutes— how far back a viewer can scrub a live stream. Minutes, bounded by RAM.
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
- Same bearer token as everything else; 401/403 when it is wrong or does not control that channel.
- Slugs are validated as
[A-Za-z0-9._-]{1,120}here as well as by the caller — a value that becomes a filesystem path is checked on this side too. - A capability is listed only when it is implemented. Advertising one that is not is worse than omitting it: the caller stops reading a failure as "not upgraded yet".
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
| Rung | Bitrate | Produced when |
|---|---|---|
| 2160p | 4.5 Mb/s | only when the source is 4K |
| 1080p | 3.5 Mb/s | source is 1080p or better |
| 720p | 1.8 Mb/s | source is 720p or better |
| 480p | 0.8 Mb/s | source is 480p or better |
| 360p | 0.4 Mb/s | always — 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:
secure— AES-128geo_mode— a geographic allow or deny listip_rules— playback IP rulesreferers— a referer allow-listmax_viewers— a concurrent-viewer cap
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)
- AES-128 (
secure): the playlist carries anEXT-X-KEYline and the key is served from this origin behind the same short-lived playback token as the media. Nothing else to configure — no keys to generate, copy or store. - CBCS DRM (
drm): enabling it mints the content key material on the spot — key ID, key and IV. When a licence gateway is configured it decides the key instead, because a key the licence server has never heard of protects the media from its own audience. The key ID is public; the key leaves this server only through the operator DRM API.
What the generated code handles for you
- Fetching a playback token and carrying it on the playlist, the segments and the key request.
- Refreshing before expiry, and recovering from a 403 mid-session instead of leaving the viewer a dead player.
- The per-platform differences that are easy to get wrong: hls.js needs an
xhrSetup; AVPlayer cannot decorate its own sub-requests, so the token rides the playlist URL and this origin applies it onward; ExoPlayer can decorate every request and does. - An unencrypted channel is given none of this. Token plumbing for a channel that does not need it is code someone copies to a channel that does, assuming it is complete.
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.
record_days— how long recordings are kept, in days; 0 keeps them until deleted. Enforced by an hourly sweep. Not to be confused withdvr, which is the live rewind window in minutes: one is how long yesterday's programme stays on the site, the other is how far back a viewer can scrub a live stream.
transcode— an ABR ladder such as720p:2500k,480p:1200k. Server-side transcoding is not implemented: the setting is refused with an explanation telling you to push each rendition yourself with a labelled key (_1080p,_720p), which ssh101+ assembles into an ABR playlist. CheckGET /api/transcodebefore relying on it.srt_passphrase— SRT ingest encryption passphrase; write-only, never echoed back.restream_url— comma-separated RTMP push destinations; returned masked.epg_provider,epg_key— guide source and its credential, for providers that need one.ip_rules,referers— playback access control.
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:
- ssh101+ owns it and forwards the rest —
-vhost, described below. One process, one port, one certificate story. - 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.
- 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:
- Only exact, configured hostnames are forwarded, to configured backends. There are no wildcards and no way for a request to influence where it goes — an open proxy on a public streaming server is abused within hours.
- A malformed
-vhostmapping refuses to start rather than warning and carrying on. A typo means a domain quietly serves the wrong site. - Responses are streamed, not buffered, so a playout's segments and any long-lived response are not held back.
- If the other service is down, that hostname answers 502 with a message naming the cause — it does not look like ssh101+ failing.
- Binding 443 as a non-root user needs
setcap 'cap_net_bind_service=+ep' /usr/local/bin/ssh101, orAmbientCapabilities=CAP_NET_BIND_SERVICEin the systemd unit.
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:
- Different ports — the simple answer: ssh101+ on
1935, the other on1936. Publishers are configured with a URL anyway, so a port is no harder than a name. - Different addresses — if the machine has a second IP, bind each service to one and both keep 1935.
- Route by application name — an RTMP-aware proxy can split on the app path. Workable, and one more moving part in the ingest path.
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
- Every URL on the dashboard and the watch page shows the public name — no
127.0.0.1, no port. /app/admin/healthis green; it warns if forwarded headers arrive from a peer that is not trusted, which is the misconfiguration that makes every viewer look like they are in the server's own city.- A player actually plays from the public name; a proxy that buffers or times out shows up here and nowhere else.
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
- viewers — distinct viewer sessions that fetched a segment in the last 30 seconds. One cookie/token-identified client counts once however many segments it pulls; a rendition switch keeps the same session. It includes every consumer of this origin — apps, embeds, third-party players and restreamers — and they cannot be separated here.
- source_state —
receiving,stalledorabsent.stalledmeans the publisher is still connected and frames have stopped: the case that looks fine on a dashboard and is black on a television.reconnectingis deliberately not reported — this origin cannot distinguish a mid-reconnect encoder from one that has gone, and inventing the distinction would be worse than omitting it. - bitrate_in_kbps / resolution_in / fps_in — what the encoder sent, taken only from renditions a publisher is connected for. The earliest warning of an upstream problem. Omitted entirely when no publisher-backed rendition can be identified — a gap, never a fallback to the produced ladder.
- bitrate_out_kbps / resolution_out / fps_out — the top rung this server
produces, which is what a viewer on the best connection receives. On a
passthrough channel these equal the
_infields; on a transcoded one they do not, and that difference is the point: an encoder that quietly dropped to 720p stays visible in_ineven while the ladder still emits a 1080p rung. Each entry inrenditionscarriesingested: true|falsefor the same reason. - viewers is omitted when nothing is measuring it, rather than sent as
0. "Nobody is watching" and "nothing is counting" must not look identical. - viewers_avg — viewer-seconds ÷ bucket seconds, not an average of
concurrency samples. Sampling would miss everyone who arrived and left between
two samples and would disagree with
minutes_watched, which is what a bill is based on. - uptime_pct — share of the bucket's samples in which the source was receiving. Sampled every 5 s, so a drop-and-return inside 5 s is not seen.
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
- Local stack in one line:
./ssh101 -http -port 8080 -rtmp :1935 -admin-pass test -billing-dev -data ./data - Money-free billing: with
-billing-dev, checkout offers a dev provider that flips plans instantly — the entire entitlement pipeline without a merchant account. Drive the full lifecycle: checkout → cancel →POST /api/users/{id}/period {"end":"2020-01-01T00:00:00Z"}→POST /api/billing/sweepand watch the clamp. - A test stream:
ffmpeg -re -f lavfi -i testsrc2=size=1280x720:rate=30 -f lavfi -i sine=frequency=440 -c:v libx264 -pix_fmt yuv420p -c:a aac -f flv rtmp://localhost:1935/live/KEY_720p - Validate your output like Safari would:
./ssh101 -hlscheck http://localhost:8080/hls/ch/master.m3u8— TS or fMP4, encrypted or not. - The whole system's own proof:
qa/e2e.shin the repository — 34 end-to-end checks, exit 0 = green. Run it against your build before blaming your app.
Debugging
- Start at
/app/admin/health— TLS, database, GeoIP, SMTP, HTTP/3, each red item says how to fix it. - Which build answered?
curl -sI https://host/hls.min.js | grep -i etag→"ssh101-<version>". - Log greps that answer questions (
journalctl -u ssh101 | grep …):"rtmp publish rejected"(wrong key or pixel format — the reason is spelled out),"ingest rejected"(suspended channel/account),"webhook rejected"(bad signature/secret),"billing sweep","upgrade:"(zero-downtime handover trace). - 403 on media but the channel is public? Check geo rules, referer rules and account state — the audit log names every moderation action.
- Playlist loads, segments 404? You are string-concatenating absolute segment paths. Resolve, don't concatenate.
- Tokens keep expiring mid-play? You are minting per request with a random sid. Reuse
sid, refresh onttl.