User guide
Everything you need to go from an encoder on your desk to viewers on any device.
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.
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
- Create an account. Sign up — the free plan includes one channel and 20 concurrent viewers.
- Create a channel. On your dashboard, pick a slug (the public name in your URLs, e.g.
gracechapel) and a display name. - Copy your stream key. The dashboard shows an ingest key. Append the rendition label:
<key>_720p. - Point your encoder at the RTMP URL with that key. See OBS or ffmpeg.
- 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
- Open Settings → Stream.
- Service: Custom…
- Server:
rtmp://your-host:1935/live - Stream Key:
<your-key>_720p - In Settings → Output, set Keyframe Interval to 2 seconds. This matters — see below.
- 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.
# three renditions from one source, three pushes
ffmpeg -re -i input.mp4 \
-map 0:v -map 0:a -s 1920x1080 -b:v 5000k -c:v libx264 -preset veryfast -g 60 -c:a aac \
-f flv rtmp://your-host:1935/live/<KEY>_1080p \
-map 0:v -map 0:a -s 1280x720 -b:v 2800k -c:v libx264 -preset veryfast -g 60 -c:a aac \
-f flv rtmp://your-host:1935/live/<KEY>_720p \
-map 0:v -map 0:a -s 854x480 -b:v 1200k -c:v libx264 -preset veryfast -g 60 -c:a aac \
-f flv rtmp://your-host:1935/live/<KEY>_480p
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.
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.
Playback URLs
| What | URL |
|---|---|
| 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 with an iframe:
<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.
| What | URL |
|---|---|
| Recording playlist | /vod/<slug>/<id>/index.m3u8 |
| Embed a recording | /e/<slug>?vod=<id> |
<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.
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.
Secure channels — Pro and Business
Turning on Secure encrypts segments with AES-128 and requires a signed playback token. The token is HMAC-signed, expires, and can be bound to the viewer's IP.
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
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).
| Plan | Price | Concurrent viewers |
|---|---|---|
| Free | $0 | 20 |
| Streamer | $19/mo | 50 |
| Pro | $79/mo | 100 |
| Business | $249/mo | 200 |
You may set a lower limit per channel, never a higher one. Upgrade anytime.
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
| Method | Endpoint | Returns |
|---|---|---|
| GET | /healthz | ok — liveness. Point your load balancer here. |
| GET | /hls/{slug}/master.m3u8 | Master playlist (multi-bitrate). |
| GET | /hls/{slug}/{label}/index.m3u8 | One rendition's media playlist. |
| GET | /hls/{slug}/{label}/seg{n}.ts | An MPEG-TS segment. |
| GET | /dash/{slug}/manifest.mpd | DASH manifest (Business). |
| GET | /vod/{slug}/{rec}/index.m3u8 | A recording's playlist. |
| GET | /poster/{slug}.jpg | Live 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}, ...]}
Everything is HTTP/3-capable. Playlists, segments, manifests and these JSON endpoints all ride the same server, so a QUIC-capable client negotiates HTTP/3 automatically via the Alt-Svc header. Nothing to configure.
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.
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /signup | email, password — create an account. |
| POST | /login | email, password — start a session. |
| POST | /logout | End the session. |
| GET | /app | Dashboard (HTML) — your channels and stream keys. |
| POST | /app/channels | slug, name, listed — create a channel. |
| POST | /app/channels/{slug}/edit | Update channel settings. |
| POST | /app/channels/{slug}/delete | Delete 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/checkout | plan, provider — start checkout. |
| POST | /app/billing/cancel | Cancel 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).
| Method | Endpoint | Returns |
|---|---|---|
| GET | /api/status | Version, uptime, cache, channels, and a runtime block (goroutines, cores, heap, open streams). |
| GET | /api/analytics | Per-channel viewers, peak, Mbps, requests, bytes, countries. |
| GET | /api/analytics/history | Historical minute buckets. |
| GET | /api/ingest | Live ingest sessions. |
| GET | /api/audit | Audit log — logins, role grants, settings changes, refunds. |
| GET | /api/db/status | Database connection state. |
| POST | /api/channels | Create a channel as the operator. |
| GET | /api/edge | Edge cache statistics. |
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.
| Endpoint | Provider |
|---|---|
POST /webhooks/stripe | Stripe — Stripe-Signature |
POST /webhooks/paypal | PayPal — transmission headers |
POST /webhooks/square | Square — X-Square-Hmacsha256-Signature |
Status codes worth knowing
| Code | Meaning |
|---|---|
200 | Fine. |
403 | Missing or invalid playback token, geo/IP rule, or admin gate. |
404 | No such channel — or the channel is offline (playlists, posters). |
503 | The 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.
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.