User guide

Everything you need to go from an encoder on your desk to viewers on any device.

Prefer slides? The whole system as a presentation: /present. Building an app or panel against this server? The full developer reference: /docs/api.

Testing this system? There is a step-by-step guide at /docs/qa — every feature, what to do, what you should see, and what it means if you see something else. It is written to be handed to somebody who has never used ssh101+.

Introduction

ssh101+ takes a live stream from your encoder and delivers it to viewers as HLS (and DASH on paid plans). It runs passthrough: your video is never re-encoded, so the quality your viewers see is exactly the quality you send, and latency stays low.

Your encoderOBS, ffmpeg, hardware
IngestRTMP or SRT
ssh101+segments, never re-encodes
ViewersHLS / DASH

What this means for you: pick your resolution and bitrate in your encoder. That decision is final — we deliver it untouched. If you want multiple qualities, see Multi-bitrate.

Quick start

  1. Create an account. Sign up — the free plan includes one channel and 20 concurrent viewers.
  2. Create a channel. On your dashboard, pick a slug (the public name in your URLs, e.g. gracechapel) and a display name.
  3. Copy your stream key. The dashboard shows an ingest key. Append the rendition label: <key>_720p.
  4. Point your encoder at the RTMP URL with that key. See OBS or ffmpeg.
  5. Watch. Your channel appears at /watch/<slug> and on the portal while you are live.

Your stream key

Your ingest key identifies your channel. The suffix after the underscore is the rendition label — it tells us what quality you are sending.

rtmp://your-host:1935/live/a1b2c3d4e5f6a7b8_720p
                            └──── key ────┘ └label┘

Keep it secret. Anyone with your key can broadcast to your channel. If it leaks, rotate it from the dashboard. The key is for publishing only — viewers never need it.

Label it honestly: if you send 1080p, use _1080p. The label becomes the quality name viewers' players display.

OBS Studio

  1. Open Settings → Stream.
  2. Service: Custom…
  3. Server: rtmp://your-host:1935/live
  4. Stream Key: <your-key>_720p
  5. In Settings → Output, set Keyframe Interval to 2 seconds. This matters — see below.
  6. Click Start Streaming.

Why keyframe interval matters. Segments can only be cut on a keyframe. A 2-second interval gives clean, evenly-sized segments and fast start-up. Leave it at 0 ("auto") and you can get long, ragged segments and players that take ages to start.

ffmpeg

Stream a file in real time:

ffmpeg -re -i input.mp4 \
  -c:v libx264 -preset veryfast -pix_fmt yuv420p -g 60 \
  -c:a aac -ar 44100 -b:a 128k \
  -f flv rtmp://your-host:1935/live/<KEY>_720p

Stream a webcam (Linux):

ffmpeg -f v4l2 -i /dev/video0 -f alsa -i default \
  -c:v libx264 -preset veryfast -tune zerolatency -g 60 \
  -c:a aac -b:a 128k \
  -f flv rtmp://your-host:1935/live/<KEY>_720p

Do not forget -re when the source is a file. Without it ffmpeg pushes as fast as it can decode, flooding the server with hours of video in seconds. -re paces it at real time. Live sources (webcams, capture cards) are already real-time and do not need it.

-g 60 sets a keyframe every 2 seconds at 30 fps (60 frames). At 60 fps, use -g 120.

SRT ingest — Pro and Business

SRT survives packet loss far better than RTMP, so it is the right choice over the public internet or a bonded/cellular link.

ffmpeg -re -i input.mp4 \
  -c:v libx264 -preset veryfast -g 60 -c:a aac \
  -f mpegts "srt://your-host:10000?streamid=<KEY>_720p&latency=200000"

latency is in microseconds — 200000 is 200 ms. Raise it on a lossy link (SRT uses that buffer to re-request lost packets); lower it on a clean one to cut delay.

Multi-bitrate (ABR)

We do not re-encode, so you build the ladder in your encoder and push each rendition with its own label. ssh101 assembles the multi-bitrate playlist automatically.

# two renditions with a filter graph (drawtext/scale), one ffmpeg — the safe template
ffmpeg -re -f lavfi -i testsrc=size=1280x720:rate=30 \
  -filter_complex "\
[0:v] drawtext=text='720p':x=40:y=40:fontsize=32:fontcolor=white:box=1:boxcolor=black@0.6, format=yuv420p [v720]; \
[0:v] scale=640x360, drawtext=text='360p':x=20:y=20:fontsize=18:fontcolor=white:box=1:boxcolor=black@0.6, format=yuv420p [v360]" \
  -map "[v720]" -c:v libx264 -b:v 2500k -maxrate 2750k -bufsize 5000k -preset veryfast -g 60 \
    -f flv rtmp://your-host:1935/live/<KEY>_720p \
  -map "[v360]" -c:v libx264 -b:v 800k  -maxrate 900k  -bufsize 1600k -preset veryfast -g 60 \
    -f flv rtmp://your-host:1935/live/<KEY>_360p

The three ffmpeg traps that break ABR — each of these has produced a real "stream won't play / only one bitrate shows" report:

  1. format=yuv420p (or -pix_fmt yuv420p) on EVERY rendition. Filters like drawtext, overlay and scale output RGB, and libx264 then silently encodes yuv444p (High 4:4:4, CODECS="avc1.F4…") — which no browser hardware-decodes. ssh101 rejects such a push at the handshake and shows the reason on the channel dashboard and in /api/stream-info (ingest_errors); ffmpeg itself only sees a dropped connection.
  2. Per-output options do not use global stream indexes. Each -f flv output contains ONE video stream, so write plain -c:v libx264 -b:v 800k after each -map. Options like -b:v:1 800k on the second output address that output's (non-existent) stream #1 and are silently ignored — your "800k" rendition encodes at x264 defaults.
  3. One process = one failure domain. If either RTMP connection drops, the whole ffmpeg exits and ALL renditions stop. For unattended streams, run one ffmpeg per rendition (or wrap in a restart loop / systemd) so one hiccup cannot take down the ladder.

Align your keyframes. Use the same -g on every rendition so players can switch quality cleanly at segment boundaries. Mismatched GOPs cause visible stutter on switches.

Then verify like a strict player would: ssh101 -hlscheck https://your-host/hls/<slug>/master.m3u8 while live. A healthy ABR master lists every rendition with RESOLUTION, CODECS and a peak BANDWIDTH; the built-in player then shows the quality menu (Auto + each rendition) top-right.

Viewers' players pick the right rendition automatically from the master playlist. Push one rendition and you simply have a single-quality stream — that is perfectly fine.

Plan note: pushing multiple labelled renditions of one channel (client-side ABR) is a Business feature. On other plans a channel carries exactly one live rendition — a second, different label is refused at the handshake; pushing the same label again (encoder reconnect, redundancy) is always allowed.

Playback URLs

WhatURL
Watch page/watch/<slug>
HLS master playlist/hls/<slug>/master.m3u8
One rendition/hls/<slug>/720p/index.m3u8
DASH manifest (Pro+)/dash/<slug>/manifest.mpd
Embeddable player/e/<slug>

Hand the master playlist to VLC, Safari, hls.js, a Roku channel, or any standard player. Nothing proprietary.

The portal lists live channels only. A public channel appears on /watch while it is broadcasting and drops off shortly after you stop. Your direct links keep working — they just show the offline state.

Embedding

Drop the player into any page. The responsive snippet keeps a 16:9 player filling whatever container it lives in — use this one unless you specifically need a fixed size (a bare fixed-size iframe was the #1 embed complaint in QA: it sits small in the corner of a modern page and never adapts):

<div style="position:relative;padding-top:56.25%">
  <iframe src="https://your-host/e/<slug>"
          style="position:absolute;inset:0;width:100%;height:100%;border:0"
          allowfullscreen allow="autoplay; fullscreen; picture-in-picture"></iframe>
</div>

Fixed-size variant, when the layout demands exact pixels:

<iframe src="https://your-host/e/<slug>"
        width="960" height="540"
        frameborder="0" allowfullscreen
        allow="autoplay; fullscreen; picture-in-picture"></iframe>

Or use the playlist directly with your own player:

<video id="v" controls playsinline></video>
<script src="https://your-host/hls.min.js"></script>
<script>
  var url = "https://your-host/hls/<slug>/master.m3u8", v = document.getElementById('v');
  if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; }   // Safari, iOS
  else if (Hls.isSupported()) { var h = new Hls(); h.loadSource(url); h.attachMedia(v); }
</script>

DVR & recordings — Pro and Business

DVR lets viewers rewind while you are still live: 30 minutes on Pro, 240 on Business. Enable it per channel on the dashboard; the seek bar extends backwards automatically.

Recording saves each broadcast as VOD. Past recordings are listed on your channel's watch page once the stream ends.

DVR and recording both consume disk. A 6 Mbps stream writes roughly 2.7 GB per hour. Budget accordingly for long broadcasts.

Live to VOD

Turn Recording on for a channel (Business plan) and every broadcast is saved. When the stream ends, the recording appears on the channel's watch page and stays playable.

WhatURL
Recording playlist/vod/<slug>/<id>/index.m3u8
Embed a recording/e/<slug>?vod=<id>

Where do I get <id>? Two places:

  • Dashboard: the Recordings table on your channel page shows each recording's ID and click-to-copy full .m3u8 URL and embed snippet.
  • API: GET /api/stream-info/<slug> (owner, admin or provisioning token) returns a recordings array — each entry carries id, ready-made playlist and embed paths, duration, and whether it is still being written.
<iframe src="https://your-host/e/<slug>?vod=<id>"
        width="640" height="360" frameborder="0" allowfullscreen></iframe>

The watch page lists each recording with a play link, its .m3u8, and a copyable embed snippet. Recordings use the same player and the same token rules as live — a recording is just another HLS playlist. A live stream autoplays muted; a recording waits for the viewer to press play.

What VOD does not do yet, stated plainly so you can plan around it:

  • No scheduling. There is no way to say "play this recording at 8pm" or to build a 24/7 loop from a playlist. Recording is automatic; playout is on demand only.
  • No DASH for recordings. Live has DASH on Business; VOD is HLS only.
  • No trimming or chaptering. A recording is the whole broadcast as it happened.

Recording — four ways, from bluntest to most useful

All of these are on Channels → your channel → edit.

  1. Record everything. Tick Record broadcasts to VOD. Every session is kept whole, from the moment the encoder connects until it stops. Good for "keep it all", useless for anything selective.
  2. A clock window. Under Recording schedule, set a start and stop and tick the days. 20:00–21:00 every weekday records that hour whether or not the programme runs to time. A stop earlier than the start runs past midnight — 23:00 to 01:00 is one recording, filed under the day it started.
  3. A programme, by name. Type a name in match instead of times and the guide supplies the boundaries. Typing news catches "Evening News" and "News at Ten" — it is a plain substring, not a pattern language.
  4. Catch-up. Tick Cut recordings at programme boundaries and each programme becomes its own asset, named from the guide, continuously. This is what turns a pile of recordings into something a viewer can browse.

Every schedule gets 30 seconds before and 60 after by default, because encoders drift and programmes overrun — and overruns are commoner than early finishes. Options 3 and 4 need an EPG source; without one they record nothing rather than guessing, because an asset containing the wrong hour is worse than no asset.

nDVR — letting viewers rewind

Set DVR window in minutes on the edit page and pick RAM or disk. That is the whole configuration: the window appears in the playlist and any ordinary HLS player can scrub back through it. Nothing is needed in the player.

RAM is faster and is charged against a memory budget — a window that would not fit is refused when you set it, with the arithmetic in the message, rather than becoming an out-of-memory at 2am. Disk allows a much longer window and pauses if the volume gets tight. Pro allows up to 30 minutes (RAM); Business up to 240 (RAM or disk).

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

Pushing to YouTube, Facebook, Twitch

Paste the platform's RTMP URL — the one with your stream key on the end — into Push to a platform on the edit page. Several destinations, separated by commas, go out at once. The push starts when the channel goes on air and stops when it goes off.

The key is a secret, so after saving the field shows it masked. Leave the mask alone to keep the destination; paste a whole new URL to change it; empty the field to stop pushing. A destination that keeps failing is retried on a widening interval rather than every few seconds, because a wrong key is not fixed by trying harder and hammering a platform with a bad credential is how an account gets suspended.

Player code for your site or app

Channels → your channel → player code. Ready-to-paste integrations for web (hls.js), iOS/tvOS, Android, Roku and ffplay/VLC, each carrying this channel's real URLs. Copy the one you need.

If the channel is encrypted, the code already fetches a playback token, carries it on the playlist, the segments and the key request, refreshes it before it expires and recovers if one runs out mid-session. That last part is why this is generated rather than written in a manual: it works in testing either way, and only fails an hour into production.

Encryption — one field

AES-128: tick secure. Keys are handled for you and the playlist carries them; there is nothing to generate, copy or store.

DRM (Business): tick drm and the content key material is minted on the spot. The key ID is public; the key itself only ever leaves this server to a licence gateway.

Either way, take the code from the player code page afterwards — it changes depending on which you chose.

Using your own S3 bucket

API keys → Object storage. Give an endpoint, bucket and credentials and your recordings are uploaded there instead of staying on this server. The bill, the retention policy and the audit trail become yours, and if you ever leave you walk away with your own assets rather than starting a migration.

The secret key is encrypted before it is stored and is never shown again — the page reports only that one is set. A remote endpoint must be https: plain HTTP would put your keys and your video on the wire in the clear. Submit the form with every field empty to go back to this server's storage.

Inserting an ad break

On the edit page there is a duration box and an Insert break now button. It puts an SCTE-35 marker into the live stream at that moment, which downstream splicers and the HLS playlist both see. The channel has to be on air.

fMP4 / CMAF segments Business

Channel → Edit → fMP4/CMAF segments switches the channel from MPEG-TS to fragmented-MP4 HLS: the playlist gains an EXT-X-MAP init segment and segments serve as .m4s. Same URLs, same players (hls.js, Safari, ffplay/VLC) — different container.

Why you would: CMAF is the container real DRM lives in. Widevine and FairPlay encrypt samples inside fMP4 (CBCS), not whole TS segments — flipping this toggle is step one of a DRM pipeline. Sample encryption itself lands in an upcoming release; until then CMAF channels cannot combine with the AES-128 toggle (whole-segment CBC has no valid meaning for fMP4, and the edit page will tell you so).

Scope notes: RTMP and SRT ingest both feed CMAF. Recording works: a CMAF channel records fMP4 assets with the same init segment and fragments viewers received — for DRM channels the recording is encrypted too, and plays with the same license. The DASH side-output remains TS-fed, so a CMAF channel with DASH enabled logs the fact and streams HLS only. Failover, warm restart and zero-downtime upgrades all carry the init segment with the window.

Support tickets

Support in the dashboard opens a thread with the operator: subject, optional channel, message — replies land back in the same thread and in your email when the server has SMTP configured. Viewers never need accounts to be heard: the player's error screen carries a Report a problem button that files a report onto the channel owner's ticket list with the player context attached — the error, the position, the player state — so a report arrives already knowing what broke. Reports are rate-limited per address.

Ad markers (SCTE-35) & SSAI

Two ways to mark an ad break, both ending in standards-compliant HLS tags that any stitcher or SSAI service understands:

Either path produces EXT-X-DATERANGE (with the original splice bytes when they exist), EXT-X-CUE-OUT / EXT-X-CUE-IN, and EXT-X-CUE-OUT-CONT on every mid-break segment so late joiners and stitchers know exactly where they are. A splice_insert carrying a duration closes itself if no explicit return arrives. Markers work on MPEG-TS and fMP4/CMAF channels alike — including DRM ones, which is the combination SSAI customers actually buy.

Analytics, compared honestly

What the analytics page measures, next to what the paid observability products in this space measure — so you know exactly where this stands:

Metricssh101+Mux DataCF StreamAWS IVSWowza
Concurrent viewers (live + peak w/ time)
Unique viewers (daily)add-on
Watch time & avg sessionadd-on
Geo: country + city map✓ (own GeoIP)country
Device / OS / player split✓ (player-aware UA engine: Roku, VLC, TVs)partialpartial
Rendition actually watched
QoE: startup time, stalls, fatal errors✓ (built-in player beacons)✓ (SDK per player)partial
Ingest health (failover election, per source)partial
History survives restarts✓ (per-minute rollups in DB)
Where the data livesyour servertheir cloudtheir cloudtheir cloudmixed
Priceincludedper-view feesbundledusage feeslicense

Honest limits: Mux Data's per-player SDKs collect QoE from ANY player you instrument — ssh101+'s QoE beacons come from its built-in player (third-party players can POST the same tiny JSON to /api/player-beacon; the format is three fields). Cross-CDN comparison dashboards and per-title A/B tooling are Mux specialties this does not chase. Everything else in the table ships in the binary, on hardware you own, at no per-view price.

Program guide (EPG) — XMLTV

Attach a program schedule to any channel: Channel → Edit → Program guide, paste an XMLTV feed URL (or a server-local file path) and the <channel id> to match. TitanTV and Gracenote deliver their licensed guide data as XMLTV exports — they work here as-is; so does every open XMLTV aggregator. ssh101+ licenses no guide data itself: you point it at the feed you are entitled to.

What you get: a now/next overlay in the built-in player (fades with the controls, re-checks exactly when the current programme ends), a now/next line on the watch page, and GET /api/epg/{slug} (add ?day=1 for the full day) for your own apps. Feeds refresh hourly and a failed refresh keeps serving the last good guide — stale beats blank on a live player.

DASH on CMAF channels

Turn on both fMP4/CMAF and DASH and the manifest is generated directly from the same segments HLS serves — same init.mp4, same .m4s files, no packager process, no second copy on disk, and no way for the two outputs to drift apart. Encrypted channels advertise their ContentProtection (common encryption plus Widevine, with the pssh inline) so a DASH player can request a licence without fetching a segment first.

DRM — CBCS sample encryption Business

With CMAF on, the DRM toggle encrypts the media itself: CBCS ('cbcs') sample encryption with the standard 1:9 pattern for video and full-sample protection for audio, a per-channel content key minted on first enable, and playlists that advertise SAMPLE-AES with the Widevine key format (plus an optional FairPlay skd:// line for your key server). The init segment carries pssh and tenc, so any standard CDM knows exactly what to ask your license server for.

What this is and isn't: the channel's Key ID is public — it names the key. The content key itself never appears in any playlist or segment; it leaves the server only through the operator DRM API, to the license infrastructure (a Widevine license gateway, a FairPlay KSM) that decides who gets to play. AES-128 and DRM are different tools: AES-128 gates access with tokens; DRM binds decryption to a licensed player. A channel uses one or the other.

Analytics — what you get, compared honestly

Every number on the analytics page is measured at THIS server's delivery path or reported by real players — no sampling, no third-party pixel, no extra per-view fee. What that buys you next to the usual suspects:

Metricssh101+Mux DataCF StreamAWS IVSWowza Engine
Concurrent viewers, live + per-minute history✔ built-in✔ (paid add-on)partial (logs)
Unique viewers / day
Watch time & average session length
Geo: country and city map✔ (your MaxMind db)countrycountry
Device / OS / player breakdown (TVs, VLC, Roku…)✔ player-aware✔ browser-centricpartial
Rendition split (which quality is actually watched)
QoE: startup time, stalls, fatal errors✔ via built-in player beacons✔ (their SDK in your app)partial
Ingest health (failover election, per-source age)partial
Survives restarts; data stays on YOUR servertheir cloudtheir cloudtheir cloud
Price of all of the aboveincludedper-view feesbundled, their infrabundled, their infralicense + DIY

The honest limits: viewer counts are session-based (a viewer with cookies blocked on two devices counts twice); QoE arrives only from plays through the built-in /e player or any player you point at the beacon endpoint; and "the whole internet's" percentile benchmarks (Mux's genuinely valuable cross-customer QoE percentiles) require being Mux — one server measures itself, not the industry.

Program guide (EPG) — TitanTV, Gracenote & any XMLTV feed

Point a channel at an XMLTV feed and name the channel id inside it — that is the whole setup (Channel → Edit → Program guide). TitanTV exports XMLTV directly; Gracenote-licensed guide data is delivered as XMLTV by every major distributor; open aggregators speak nothing else. ssh101+ licenses no guide data itself: you point it at the feed you are entitled to.

What you get: the public /api/epg/{slug} endpoint (now / next, ?day=1 for the full day), and a now/next overlay in the embedded player that appears on play or mouse move and fades away. Feeds refresh hourly; a feed that goes unreachable keeps serving its last good guide (stale beats blank under a live stream) and retries.

Multi-channel & team seats

Channels per plan: Free and Streamer include 1 channel, Pro includes 3, Business includes 10. The New-channel form tells you when you reach your plan's limit.

Team seats (Business): give up to 5 teammates their own login to manage your channels — create, edit, stream keys, analytics — while your billing stays private to you.

  1. Dashboard → Team seatsGenerate invite code (codes are signed and expire after 7 days).
  2. Your teammate signs in (or signs up) with their own account, then pastes the code into Join a team on their dashboard.
  3. They now see and manage your channels with your plan's features. Remove a member any time; they can also leave themselves.

Rules: one team per account, no nesting (members can't invite members), and accounts that own channels can't join a team — whose channels their dashboard shows must never be ambiguous. Seats are enforced when a code is used, so hoarded codes can't oversubscribe a team.

Redundant ingest failover — Business

Push the same stream key and label from two encoders and the second becomes a hot standby. If the primary disconnects — or freezes with its socket still open — the standby takes the air within the stall window and viewers see one seamless discontinuity, not a dead stream.

# encoder A (primary — higher priority wins)
ffmpeg -re -i main-feed  … -f flv "rtmp://your-host:1935/live/<KEY>_720p?p=10"

# encoder B (hot standby, e.g. from a second machine or uplink)
ffmpeg -re -i backup-feed … -f flv "rtmp://your-host:1935/live/<KEY>_720p?p=5"

Custom domains — Business

Serve your channels on your own domain: add it under Custom domains on the dashboard, then point DNS at this server:

Once live, your domain's landing page lists your public channels with your branding and none of the ssh101 marketing.

Low-latency HLS & DASH — Business

OutputURL
HLS (and LL-HLS when enabled)https://your-host/hls/<slug>/master.m3u8
DASHhttps://your-host/dash/<slug>/manifest.mpd

LL-HLS uses the same playlist URL — a low-latency-capable player negotiates the partial segments automatically; every other player gets standard HLS from the same address. Both URLs appear on your channel's Share & play card when the plan enables them.

Geo access rules — Pro and Business

On the channel's edit page: choose allow (only listed countries may watch) or deny (listed countries are blocked) and enter ISO country codes (US, DE, GE). Works with the same playback token flow — geo-blocked viewers receive a clear 403.

Unknown country fails open. If the server has no GeoIP database, or an address cannot be resolved (VPN exits, brand-new ranges), the viewer is allowed. The alternative — an allow-list silently blocking 100% of the audience because the .mmdb is missing — is worse. Treat geo rules as a compliance tool, not a security boundary; for hard access control use AES-128 + signed tokens. The operator Health page flags a missing GeoIP database.

Privacy & security

Visibility

A public channel is listed on the portal while live. An unlisted channel never appears in the directory but plays for anyone with the link.

AES-128 + signed tokens: the full tutorial — Pro and Business

1 · Configure it (server side — two clicks)

  1. Channel → Edit → check Secure (AES-128 + token required) → Save. That is the whole configuration: a random 128-bit key is generated for the channel automatically and kept server-side; nothing to create, upload or paste.
  2. From that moment every .ts segment is AES-128-CBC encrypted on the fly, the playlist carries the #EXT-X-KEY tag, and every playlist, segment and key request requires a valid ?token=. Requests without one answer 403.
  3. Rotating the key (leaked link, ended event, staff change): the Rotate AES key button under the Secure checkbox retires the old key instantly — the playlist advertises the new key id, current viewers re-join automatically within a segment or two, and old key material decrypts nothing new.

2 · How it works (30 seconds of theory)

Two locks, one door. The token is a signed, expiring capability in the URL — it gates ACCESS to playlists, segments and the key endpoint. The AES-128 key encrypts the media itself — so a segment file that leaks (CDN log, disk cache, proxy) is ciphertext without the key, and the key is only served to token holders. The player never shows any of this to the viewer: HLS players read #EXT-X-KEY:METHOD=AES-128,URI="…", fetch the 16-byte key from that URI, and decrypt transparently. The IV is the media sequence number (the HLS default — no IV attribute needed).

3 · Use it from the client side

Easiest — the built-in player. The share/embed links from the dashboard handle everything: the player page requests a short-lived token itself and refreshes it during playback. Nothing to build:

<div style="position:relative;padding-top:56.25%">
  <iframe src="https://your-host/e/<slug>"
          style="position:absolute;inset:0;width:100%;height:100%;border:0"
          allowfullscreen allow="autoplay"></iframe>
</div>

Your own site / app — mint a token, append it. Get a token from the API (as the channel owner, an admin, or with the provisioning token), then hand the viewer a tokened playlist URL. One URL works in hls.js, Safari, VLC, ffplay, Roku, smart TVs — the token rides the query string into every sub-request:

# server-to-server: mint a playback token (default TTL 120 s, IP-bound to the requester)
curl -s -H "Authorization: Bearer <provisioning-token>" \
  "https://your-host/api/playback-token?channel=<slug>"
# → {"token":"eyJ…","ttl":120,"sid":"…"}

# hand the viewer:
https://your-host/hls/<slug>/master.m3u8?token=eyJ…

Custom hls.js player with a token — note the xhrSetup trick that keeps the token on every internal request even if a URL slips through without it:

<video id="v" controls playsinline></video>
<script src="https://your-host/hls.min.js"></script>
<script>
  const token = "<TOKEN-FROM-YOUR-BACKEND>";
  const url = "https://your-host/hls/<slug>/master.m3u8?token=" + token;
  const v = document.getElementById("v");
  if (v.canPlayType("application/vnd.apple.mpegurl")) {
    v.src = url;                            // Safari/iOS: native, decrypts automatically
  } else if (Hls.isSupported()) {
    const h = new Hls({
      xhrSetup: (xhr, reqUrl) => {          // belt & suspenders: token on EVERY request
        if (!reqUrl.includes("token=")) {
          xhr.open("GET", reqUrl + (reqUrl.includes("?") ? "&" : "?") + "token=" + token, true);
        }
      }
    });
    h.loadSource(url); h.attachMedia(v);
  }
</script>

VLC / ffplay / set-top boxes: just open the tokened URL — the player follows the EXT-X-KEY URI (which carries the token) and decrypts by itself:

ffplay "https://your-host/hls/<slug>/master.m3u8?token=eyJ…"
vlc    "https://your-host/hls/<slug>/master.m3u8?token=eyJ…"

4 · Token lifetime strategy

5 · Verify it is actually on

curl -s "https://your-host/hls/<slug>/720p/index.m3u8?token=…" | grep EXT-X-KEY
#  #EXT-X-KEY:METHOD=AES-128,URI="/key/<slug>/1?token=…"
curl -s -o /dev/null -w "%{http_code}\n" "https://your-host/hls/<slug>/720p/index.m3u8"
#  403   ← tokenless requests refused
Why does a token link play in VLC without a login?

Because the token is the credential. It is a signed, expiring capability — the server verifies the signature and serves the stream. That is exactly what lets VLC, Safari, smart TVs and set-top boxes play a protected stream: none of them can log in to a web session.

The security properties come from the token, not from a password prompt: it expires (short TTL), it can be IP-bound so a copied link fails from another address, and it cannot be forged without the server secret. Treat a live token like a house key: short-lived, and only handed to people you want inside.

Geo & IP rules — Pro and Business

Allow-list or deny-list countries, restrict to CIDR ranges, or require playback to originate from your own domains (referer rules).

Viewer limits

Over the cap, the playlist request answers 503 with Retry-After: 15 and a clear "concurrent-viewer limit" message — a full stream is a temporary condition, so players and monitors should retry, not treat it as auth failure. The effective cap is the SMALLER of your plan's ceiling and the optional per-channel Viewer cap on the channel's edit page. One playback token = one viewer session: a shared link counts once per token holder.

Each plan carries a ceiling on concurrent viewers, counted per account across all your channels. Beyond the cap, additional viewers receive an HTTP 503 until a slot frees up (idle sessions expire in about 30 seconds).

PlanPriceConcurrent viewers
Free$020
Streamer$19/mo50
Pro$79/mo100
Business$249/mo200

You may set a lower limit per channel, never a higher one. Upgrade anytime.

Analytics

Business plan unlocks the full audience view: a city-level world map (needs a GeoLite2-City database on the server), device / player / OS breakdowns and per-country and per-city tables. All plans see live viewer counts, egress, request totals, viewer & bandwidth history charts and ingest health. Data refreshes every 5 seconds while the page is open.

Analytics in the dashboard shows, per channel, live and refreshed every 5 seconds:

The "live" dimensions (devices, cities, countries) count viewers active in the last 30 seconds — the same window as the viewer counter. A viewer who stops watching drops out of them within half a minute.

Everything on the page is also available as JSON at /app/stats/<slug> (session auth) for your own tooling.

Plans & features

Every plan runs the same single-binary engine; plans differ in what a channel may switch on. The table below is generated from the same entitlement table the server enforces, so it cannot drift from reality.

FeatureFreeStreamer $19Pro $79Business $249
Channels1111 (plans are per channel)
Concurrent viewers2050100200
RTMP ingest
SRT ingest
DVR rewindRAM, up to 30 minRAM or disk, up to 240 min
Pushed ABR renditions
AES-128 + signed tokens
GeoIP / IP / referer rules
Recording to VOD
Low-latency HLS
DASH/CMAF output
Redundant ingest + failback
White-label custom domains
Audience map, devices & players✔ (all plans keep live metrics & charts)

Attempting to enable a feature above your plan returns a clear message naming the plan that includes it — nothing is ever silently dropped or silently allowed.

API

Everything the dashboard does is plain HTTP, so you can automate it. There are three kinds of endpoint, distinguished by how they authenticate.

1. Public — no authentication

MethodEndpointReturns
GET/healthzok — liveness. Point your load balancer here.
GET/hls/{slug}/master.m3u8Master playlist (multi-bitrate).
GET/hls/{slug}/{label}/index.m3u8One rendition's media playlist.
GET/hls/{slug}/{label}/seg{n}.tsAn MPEG-TS segment.
GET/dash/{slug}/manifest.mpdDASH manifest (Business).
GET/vod/{slug}/{rec}/index.m3u8A recording's playlist.
GET/poster/{slug}.jpgLive thumbnail. 404 when offline or secure.
GET/e/{slug}Embeddable player page.
curl -s https://your-host/api/stream-info/mychannel
# {"live":true,"width":1280,"height":720,"fps":30,"bitrate":2846000,...}

curl -s https://your-host/api/streamcheck/mychannel | jq .
# {"ok":true,"checks":[{"name":"master reachable","ok":true}, ...]}

Delivery protocols: pages and JSON APIs advertise HTTP/3 (QUIC) and clients upgrade automatically; media deliberately stays on TCP (HTTP/1.1 + HTTP/2) so playback can never be stranded by a network that drops UDP — the failure mode that stalls Safari. Operators can opt media into h3 with -h3-media after proving UDP reachability. All media paths also send CORS headers (Access-Control-Allow-Origin), so hls.js/dash.js players embedded on other sites work without proxying segments.

Diagnostics are not public. /api/stream-info/{slug}, /api/streamcheck/{slug} and /api/postercheck/{slug} expose ingest state, bitrates and check results. That is reconnaissance for a stranger, so they require the channel's owner, an admin, or the operator credential. Anonymous callers get 403.

2. Session — log in first

These are the dashboard's own endpoints. Authenticate by posting to /login and keeping the session cookie.

MethodEndpointPurpose
POST/signupemail, password — create an account.
POST/loginemail, password — start a session.
POST/logoutEnd the session.
GET/appDashboard (HTML) — your channels and stream keys.
POST/app/channelsslug, name, listed — create a channel.
POST/app/channels/{slug}/editUpdate channel settings.
POST/app/channels/{slug}/deleteDelete a channel.
GET/api/playback-token?ch={slug}Mint a signed token for a secure channel.
GET/app/stats/{slug}JSON stats for one channel.
GET/api/stream-info/{slug}Live state, resolution, fps, bitrate. Owner/admin only.
GET/api/streamcheck/{slug}12-point playback self-diagnostic. Owner/admin only.
GET/api/postercheck/{slug}Explains why a channel has no thumbnail. Owner/admin only.
POST/app/billing/checkoutplan, provider — start checkout.
POST/app/billing/cancelCancel at period end.
# log in, create a channel, read back the stream key
JAR=$(mktemp)
curl -s -c "$JAR" --data "email=you@example.com&password=secret" https://your-host/login
curl -s -b "$JAR" -L --data "slug=mychannel&name=My+Channel&listed=on"      https://your-host/app/channels
curl -s -b "$JAR" https://your-host/app | grep -oE '[a-f0-9]{16}' | head -1

3. Operator — HTTP basic auth

Server-level endpoints, authenticated with the operator credentials (-admin-user/-admin-pass).

MethodEndpointReturns
GET/api/statusVersion, uptime, cache, channels, and a runtime block (goroutines, cores, heap, open streams).
GET/api/analyticsPer-channel viewers, peak, Mbps, requests, bytes, countries.
GET/api/analytics/historyHistorical minute buckets.
GET/api/ingestLive ingest sessions.
GET/api/auditAudit log — logins, role grants, settings changes, refunds.
GET/api/db/statusDatabase connection state.
POST/api/channelsCreate a channel (idempotent; booleans accept 1/on/true/yes).
PUT/api/channels/{slug}Create-or-update; accepts a caller-supplied stream_key (16–64 hex) so a restore keeps encoders working. Re-running the same body is a no-op, never a 409.
GET/api/channelsChannel export — add ?include=keys for a restorable snapshot with ingest keys.
DELETE/api/channels/{slug}Remove a channel.
GET/api/edgeEdge cache statistics.

Provisioning survives restarts: channels and keys persist in the data directory (or MariaDB) — see the admin guide's Provisioning API section for the full snapshot → restore workflow.

curl -s -u admin:secret https://your-host/api/status | jq .runtime
# {"goroutines":41,"cpu_cores":8,"heap_mb":9.4,"open_streams":12,...}

curl -s -u admin:secret https://your-host/api/analytics | jq '.channels[] | {channel, concurrent, mbps}'

Webhooks (inbound)

Register these with your payment provider; they are the source of truth for subscription state. Each verifies a provider signature and rejects anything unsigned.

EndpointProvider
POST /webhooks/stripeStripe — Stripe-Signature
POST /webhooks/paypalPayPal — transmission headers
POST /webhooks/squareSquare — X-Square-Hmacsha256-Signature

Status codes worth knowing

CodeMeaning
200Fine.
403Missing or invalid playback token, geo/IP rule, or admin gate.
503 + Retry-AfterConcurrent-viewer limit reached — temporary; players should retry.
404No such channel — or the channel is offline (playlists, posters).
503The account hit its plan's concurrent-viewer limit.

Code examples

The same four operations in three languages. Everything is ordinary HTTP — there is no SDK to install and nothing proprietary on the wire.

# --- 1. Is a channel live? (public, no auth)
curl -s https://your-host/api/stream-info/mychannel

# --- 2. Operator: server health + capacity headroom (basic auth)
curl -s -u admin:secret https://your-host/api/status | jq .runtime

# --- 3. Operator: who is watching right now
curl -s -u admin:secret https://your-host/api/analytics \
  | jq '.channels[] | {channel, concurrent, mbps}'

# --- 4. Broadcaster: log in, create a channel, read the stream key
JAR=$(mktemp)
curl -s -c "$JAR" --data "email=you@example.com&password=secret" \
     https://your-host/login
curl -s -b "$JAR" -L --data "slug=mychannel&name=My+Channel&listed=on" \
     https://your-host/app/channels
curl -s -b "$JAR" https://your-host/app | grep -oE '[a-f0-9]{16}' | head -1
<?php
$host = 'https://your-host';

function get($url, $auth = null, $jar = null) {
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    if ($auth) curl_setopt($ch, CURLOPT_USERPWD, $auth);   // "admin:secret"
    if ($jar)  { curl_setopt($ch, CURLOPT_COOKIEJAR, $jar); curl_setopt($ch, CURLOPT_COOKIEFILE, $jar); }
    $body = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    return [$code, $body];
}

function post($url, $fields, $jar = null, $auth = null) {
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields));
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    if ($auth) curl_setopt($ch, CURLOPT_USERPWD, $auth);
    if ($jar)  { curl_setopt($ch, CURLOPT_COOKIEJAR, $jar); curl_setopt($ch, CURLOPT_COOKIEFILE, $jar); }
    $body = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    return [$code, $body];
}

// --- 1. Is a channel live? (public)
list($code, $body) = get("$host/api/stream-info/mychannel");
$info = json_decode($body, true);
echo $info['live'] ? "LIVE {$info['width']}x{$info['height']}\n" : "offline\n";

// --- 2. Operator: capacity headroom (basic auth)
list($code, $body) = get("$host/api/status", 'admin:secret');
$st = json_decode($body, true);
printf("goroutines=%d streams=%d heap=%.1fMB\n",
    $st['runtime']['goroutines'], $st['runtime']['open_streams'], $st['runtime']['heap_mb']);

// --- 3. Operator: current viewers
list($code, $body) = get("$host/api/analytics", 'admin:secret');
foreach (json_decode($body, true)['channels'] as $c) {
    printf("%-20s %4d viewers  %.2f Mbps\n", $c['channel'], $c['concurrent'], $c['mbps']);
}

// --- 4. Broadcaster: log in and create a channel (session cookie)
$jar = tempnam(sys_get_temp_dir(), 'ssh101');
post("$host/login", ['email' => 'you@example.com', 'password' => 'secret'], $jar);
post("$host/app/channels", ['slug' => 'mychannel', 'name' => 'My Channel', 'listed' => 'on'], $jar);
list($code, $dash) = get("$host/app", null, $jar);
preg_match('/[a-f0-9]{16}/', $dash, $m);
echo "stream key: {$m[0]}\n";
echo "push to:    rtmp://your-host:1935/live/{$m[0]}_720p\n";
#!/usr/bin/env python3
"""ssh101+ API examples. Only needs `requests`."""
import re
import requests

HOST = "https://your-host"
OPERATOR = ("admin", "secret")

# --- 1. Is a channel live? (public, no auth)
info = requests.get(f"{HOST}/api/stream-info/mychannel", timeout=10).json()
print(f"live={info['live']} {info.get('width')}x{info.get('height')} @ {info.get('fps')}fps")

# --- 2. Operator: server health + capacity headroom (basic auth)
st = requests.get(f"{HOST}/api/status", auth=OPERATOR, timeout=10).json()
rt = st["runtime"]
print(f"goroutines={rt['goroutines']} cores={rt['cpu_cores']} "
      f"heap={rt['heap_mb']:.1f}MB streams={rt['open_streams']}")
print("features:", st["features"])   # posters / http3 / ffmpeg availability

# --- 3. Operator: who is watching right now
an = requests.get(f"{HOST}/api/analytics", auth=OPERATOR, timeout=10).json()
for c in an.get("channels", []):
    print(f"{c['channel']:20s} {c['concurrent']:4d} viewers  {c['mbps']:.2f} Mbps")

# --- 4. Broadcaster: log in, create a channel, read the stream key
s = requests.Session()   # the session cookie is the credential
s.post(f"{HOST}/login", data={"email": "you@example.com", "password": "secret"}, timeout=10)
s.post(f"{HOST}/app/channels",
       data={"slug": "mychannel", "name": "My Channel", "listed": "on"}, timeout=10)
dash = s.get(f"{HOST}/app", timeout=10).text
key = re.search(r"[a-f0-9]{16}", dash).group(0)
print("stream key:", key)
print("push to:   ", f"rtmp://your-host:1935/live/{key}_720p")
print("watch at:  ", f"{HOST}/hls/mychannel/master.m3u8")

# --- 5. Health check for monitoring (Nagios/Icinga style exit codes)
import sys
try:
    r = requests.get(f"{HOST}/healthz", timeout=5)
    sys.exit(0 if r.status_code == 200 and r.text.strip() == "ok" else 2)
except Exception as e:
    print("CRITICAL:", e)
    sys.exit(2)

API test script

Ships with every install: ssh101-api-check.sh exercises the API surface from any machine, local or remote — no ffmpeg needed, so you can run it from a laptop or a monitoring box.

# public endpoints only
./ssh101-api-check.sh https://your-host

# include the operator API
./ssh101-api-check.sh https://your-host admin secret

It checks liveness, the public read API, signup and login, channel create/edit/delete, stream-key retrieval, plan entitlement enforcement, the viewer-limit gate, admin gating, 404 and 403 behaviour, and — with operator credentials — status, analytics, ingest and audit. Exit code 0 means the whole API surface behaves. Use ssh101-check.sh instead when you also want to push real video and measure stream quality.

Troubleshooting

The player says the stream is offline, but I am streaming

Check that your encoder actually connected — the dashboard shows a live badge within a few seconds of a successful push. Then confirm your stream key includes the rendition label (<key>_720p, not just <key>). A wrong key is accepted by RTMP at the socket level but never becomes a channel.

VLC plays for a few seconds and stops

Almost always the encoder, not the server. Confirm a keyframe interval of 2 seconds (-g 60 at 30 fps) and that you used -re when streaming a file. Then open VLC → Tools → Messages, set verbosity to 2, and look for the segment it fails on. Run the self-check script against your channel — it verifies the exact playlist properties strict players require.

Safari will not play, other browsers are fine

Safari is the strictest HLS client. It requires CODECS in the master playlist, EXT-X-PROGRAM-DATE-TIME to anchor the live timeline, and TARGETDURATION at least as large as the longest segment. ssh101 emits all three — so if Safari fails, suspect something in front of the server: a proxy rewriting content types, or an https page loading http playlists (mixed content, which Safari blocks silently).

Playback works locally but not in production

Check the scheme. If your site is https, every playlist and segment URL must be https too — browsers block mixed content and playback dies with no useful error. Behind a TLS-terminating proxy, make sure it forwards X-Forwarded-Proto: https.

Viewers get "reached its concurrent-viewer limit"

You have hit your plan's ceiling (see Viewer limits). Idle sessions expire in about 30 seconds; a sustained audience needs a bigger plan.

Thumbnails are not showing on the portal

Ask the server rather than guessing — as the channel's owner or an admin:

curl -s -u admin:secret https://your-host/api/postercheck/SLUG | jq .

It answers in one call, listing exactly what is wrong: channel not public, channel secure (posters are deliberately withheld so a thumbnail cannot bypass the token), ffmpeg missing, not live, no sealed segment yet, or a decode failure. If "ok": true the poster exists and the URL is in the response.

Do not paste the angle brackets. <slug> in documentation means "put your slug here" — but in a shell, < is a redirect, so bash answers syntax error near unexpected token. Write the slug plainly: /api/postercheck/mychannel.

curl -s -u admin:secret https://your-host/api/status | jq .features gives the server-wide answer: whether posters, ffmpeg and HTTP/3 are available at all.

The stream looks soft or blocky

We deliver exactly what you send — quality is set by your encoder's bitrate. For 1080p30 aim for 4.5–6 Mbps, for 720p30 aim for 2.5–3 Mbps. Raise the bitrate or lower the resolution; the veryfast preset is a good speed/quality balance.

One-line stream test

The fastest way to prove a server accepts a push and plays it back — one command, no scripts, no files:

# push a 30-second test pattern with tone
ffmpeg -re -f lavfi -i testsrc2=size=1280x720:rate=30 \
       -f lavfi -i sine=frequency=1000 \
       -c:v libx264 -preset veryfast -g 60 -pix_fmt yuv420p \
       -c:a aac -ar 44100 -b:a 128k -t 30 \
       -f flv rtmp://your-host:1935/live/<KEY>_720p

Then, in another terminal, confirm it plays back and measure it:

ffplay https://your-host/hls/<slug>/master.m3u8

# or, without a window — prints resolution, fps and bitrate
ffprobe -v error -show_entries stream=width,height,r_frame_rate,bit_rate \
        -of default=noprint_wrappers=1 https://your-host/hls/<slug>/master.m3u8

Two things that catch people out. -re is required when the source is a file or a lavfi pattern — without it ffmpeg pushes as fast as it can encode and floods the server. And the filter is testsrc2 or smptebars; there is no filter called smpte, and ffmpeg's error for that scrolls past easily, leaving you staring at a player with nothing to play.

Self-check script

Every install ships with ssh101-check.sh. Run it from your own machine against any ssh101 server: it creates a throwaway channel, pushes a test stream, and verifies the whole playback path plus stream quality.

./ssh101-check.sh https://your-host rtmp://your-host:1935/live

It checks reachability, signup, channel creation, master and variant playlists, content types, CODECS, PROGRAM-DATE-TIME, TARGETDURATION, segment fetch and the TS sync byte — then measures resolution, frame rate, bitrate, audio presence, corrupt packets and continuous playback duration. Exit code 0 means everything passed. Paste the output to support and we can usually pinpoint the fault immediately.