Stream All Day API
Drive your VOD library, play queue, and stream status from code. Same operations as the dashboard, exposed as a small REST API and scoped to your account.
Overview
The API is a small set of authenticated REST endpoints under
https://streamallday.live/api. Everything you can
do in the dashboard — list your library, disable/delete VODs,
set the play order — is available through these endpoints.
There's no separate "API plan" or paid tier; it's included.
The base URL is always https://streamallday.live.
All endpoints accept and return JSON. All endpoints are scoped
to your own account — there is no way for one customer's key to
see or touch another customer's data.
Authentication
Every request needs an API key. Create one in your
dashboard under "API access" — give it
a name, copy the key (you'll only see it once), and pass it on
every request with the Authorization header:
curl https://streamallday.live/api/me \
-H "Authorization: Bearer sad_live_abc123..."
Keys are prefixed with sad_live_ so GitHub's secret
scanner can detect leaked keys in public repos. If a key leaks,
revoke it from the dashboard — the prefix is shown so you can
identify which one to kill.
Keys can be revoked at any time. Revoked keys return
401 not_authenticated immediately.
Rate limits
60 requests per minute per key, refilled continuously (about one request per second). Most automations use a fraction of this — the limit is here to prevent runaway loops from hammering the SAD server.
Every response includes a
X-RateLimit-Remaining header. If you exceed the
limit, you'll get 429 rate_limit_exceeded with a
Retry-After header (in seconds) and a JSON body
like
{"error": "rate_limit_exceeded", "retry_after": 12}. Wait that long, then retry.
Errors
Standard HTTP status codes, with a JSON body containing an
error field (a short machine-readable code) and
sometimes a detail field with a human-readable
explanation.
| Status | Meaning |
|---|---|
400 |
Malformed request — missing required field, bad value |
401 |
No key, invalid key, or revoked key |
404 |
Resource not found. For VOD operations this usually means the VOD id you specified isn't in your library. |
429 |
Rate limited — wait Retry-After seconds |
500 |
Something on our end went wrong (often: SAD server briefly unreachable). Retry after a few seconds. |
The VOD library
Your library is the set of mp4 files we've downloaded from your Twitch archive. There are three states a VOD can be in:
| State | Means |
|---|---|
| Enabled (default) | In the rotation. Will play. |
| Disabled | File is still on disk but held out of rotation. You can re-enable anytime. The 4 AM cron will not re-download it. |
| Deleted | File removed from disk. Permanently blocked from being re-downloaded (even if Twitch still has the source). This is irreversible. |
Each VOD has a stable vod_id — the numeric Twitch VOD ID parsed out of the filename. This is what every endpoint uses to identify a VOD.
The play queue
Your stream client reads from a single playlist file. The play order can be:
- Shuffle mode (default) — we randomize the order at startup and reshuffle each time the cycle wraps. Best for "set it and forget it" channels.
- Manual mode — your order is preserved forever. When the cycle wraps, we loop back to the top in the same order. When new VODs are ingested by the daily cron, they're appended to the end of your queue (never inserted randomly).
Any queue edit auto-switches to manual mode. If
you PUT /api/queue or use play-next,
your channel will stay in manual mode until you explicitly call
POST /api/queue/reset.
The cursor (cursor field) is the 1-indexed line
number of the currently-playing entry. It advances each time a
VOD finishes. You can use it to know what's playing right now
and what's coming up.
Shuffle vs. manual
These two modes have different ergonomics, so it's worth being deliberate about which you want.
VOD lifecycle
Every day at 4 AM server time, a cron runs the ingest pipeline. For each customer it:
-
Calls
yt-dlpto fetch your latest Twitch VOD (one per run). - Prunes the oldest mp4 if you're over the 240GB archive limit.
- If no new VOD was available, tries to "reseed" an older VOD you previously had (unless you deleted it via the API).
If you're in manual mode, newly-ingested VODs are appended to your queue. If you're in shuffle mode, the whole playlist is reshuffled on the streamer's next library check.
Twitch deletes non-affiliate VODs after 14 days and affiliate VODs after 60 days. Once they're gone from Twitch, we can't re-download them — but our copy on disk is unaffected and will keep playing for as long as you want.
Endpoints
GET /api/me
Returns your account state: client_code, subscription status, stream status, Twitch live status, recent VODs (with cached titles).
curl https://streamallday.live/api/me \
-H "Authorization: Bearer $SAD_KEY"
GET /api/vods
Returns every VOD in your library (enabled and disabled), newest first, with cached Twitch titles.
{
"client_code": "CX260001",
"vods": [
{
"filename": "20260513_v2770615914.mp4",
"vod_id": "2770615914",
"date": "2026-05-13",
"title": "Marvel Monday",
"disabled": false
},
...
]
}
POST
/api/vods/{vod_id}/disable
Holds the VOD out of rotation without deleting it. The mp4 is
moved to vods/disabled/ on the SAD server.
curl -X POST https://streamallday.live/api/vods/2770615914/disable \
-H "Authorization: Bearer $SAD_KEY"
POST
/api/vods/{vod_id}/enable
Moves the VOD back into the active set. It re-enters rotation on the streamer's next playlist refresh (within ~3 cycle iterations).
DELETE
/api/vods/{vod_id}
Permanently delete. Requires
{"confirm": "DELETE"} in the JSON body so a
one-off curl -X DELETE typo doesn't nuke a VOD by
accident.
curl -X DELETE https://streamallday.live/api/vods/2770615914 \
-H "Authorization: Bearer $SAD_KEY" \
-H "Content-Type: application/json" \
-d '{"confirm": "DELETE"}'
GET /api/queue
Returns the current play order, the cursor (1-indexed line currently playing), and the mode.
{
"client_code": "CX260001",
"mode": "manual",
"cursor": 3,
"queue": [
{ "filename": "...", "vod_id": "...", "date": "...", "title": "..." },
...
]
}
PUT /api/queue
Replace the entire queue with a new order. Body:
{"order": ["file1.mp4", "file2.mp4", ...]} —
basenames only. Every filename must exist in your library
(enabled or disabled). Auto-switches to manual mode.
curl -X PUT https://streamallday.live/api/queue \
-H "Authorization: Bearer $SAD_KEY" \
-H "Content-Type: application/json" \
-d '{"order": ["20260513_v2770615914.mp4", "20260512_v2770550045.mp4"]}'
POST
/api/queue/play-next/{vod_id}
Move a VOD to the position right after the current cursor so it plays next. Auto-switches to manual mode.
POST /api/queue/reset
Switch back to shuffle mode. Your custom order is discarded; the streamer will reshuffle on its next library check.
POST /api/stream/start
Bring your stream online. Starts (or resumes) the PM2 process that runs the ffmpeg loop on the SAD server. Idempotent — safe to call even if the stream is already running.
curl -X POST https://streamallday.live/api/stream/start \
-H "Authorization: Bearer $SAD_KEY"
Returns {"ok": true, "stream_online": true}.
Twitch typically picks up the broadcast within ~30 seconds.
POST /api/stream/stop
Take your stream offline. Stops the PM2 process; Twitch marks
you as not-live within ~30 seconds. The stopped state persists
across SAD server reboots — we
pm2 save after every toggle so a restart won't
sneakily turn your stream back on.
curl -X POST https://streamallday.live/api/stream/stop \
-H "Authorization: Bearer $SAD_KEY"
GET /api/storage
Returns how much of your 240 GB archive cap you're using. Useful as a pre-flight check before uploads.
{
"used_bytes": 142836429120,
"cap_bytes": 257698037760,
"vod_count_active": 18,
"vod_count_disabled": 2
}
POST /api/uploads
Upload an mp4 from your own machine straight into your rotation. Multipart form-data, streamed end-to-end (no buffering) so big files don't blow up the server.
| Field | Required | What |
|---|---|---|
file |
yes |
The .mp4 (Content-Type: video/mp4). Filename must end
.mp4.
|
title |
no | Human title (max 200 chars). Shown in dashboard, queue, and recipes. |
date |
no |
Stream date YYYY-MM-DD. Defaults to today
UTC. Used to sort the VOD in your library.
|
Limits: 20 GB per file, must fit under your
total cap (you'll get a 413 over_quota if not —
free space first by disabling or deleting some VODs).
curl -X POST https://streamallday.live/api/uploads \
-H "Authorization: Bearer $SAD_KEY" \
-F "file=@my_vod.mp4;type=video/mp4" \
-F "title=Marvel Monday Special" \
-F "date=2026-05-24"
Successful response (201):
{
"ok": true,
"filename": "20260524_uABC12345.mp4",
"size_bytes": 5234896127,
"title": "Marvel Monday Special"
}
The returned filename uses the
u-prefix convention (vs v for
Twitch-pulled VODs). Treat it as opaque — pass it to
PUT /api/queue like any other entry.
requests-toolbelt with
MultipartEncoderMonitor, JS's
XMLHttpRequest.upload.onprogress, Go's
io.TeeReader, etc.).
GET /api/stream-key
Returns the masked current stream key and the masked history
of previously-set keys. Full key strings are never returned by
the API — only live_…last4.
{
"masked": "live_1454850409_…iHKm",
"history": [
{
"masked": "live_1454850409_…abcd",
"retired_at": "2026-04-12T10:14:00Z",
"replaced_by_source": "web"
}
]
}
POST /api/stream-key
Rotate the key your stream broadcasts with. Format must match
live_<digits>_<20–40 alnum> (this is
what Twitch issues). The old key is prepended to history.
If your stream is currently on, the streamer is
restarted
(~10 s offline) so the new key takes effect immediately. If
your stream is off, the file is updated but nothing else
changes — start it when you're ready.
curl -X POST https://streamallday.live/api/stream-key \
-H "Authorization: Bearer $SAD_KEY" \
-H "Content-Type: application/json" \
-d '{"key": "live_1454850409_NEW_KEY_HERE"}'
Response:
{
"ok": true,
"masked": "live_1454850409_…HERE",
"restarted": true,
"history": [ ... ]
}
Error codes: 400 bad_key_format (regex didn't
match), 409 no_change (same as current key),
500 stream_key_update_failed.
Recipes
Auto-disable VODs older than 60 days
Cron this to run nightly. Keeps your rotation fresh without deleting anything irreversibly.
#!/usr/bin/env python3
import os, requests
from datetime import date, timedelta
KEY = os.environ["SAD_KEY"]
BASE = "https://streamallday.live"
H = {"Authorization": f"Bearer {KEY}"}
CUTOFF = (date.today() - timedelta(days=60)).isoformat()
vods = requests.get(f"{BASE}/api/vods", headers=H).json()["vods"]
for v in vods:
if not v["disabled"] and v["date"] and v["date"] < CUTOFF:
print(f"disabling {v['vod_id']} from {v['date']}")
requests.post(f"{BASE}/api/vods/{v['vod_id']}/disable", headers=H)
Run a Marvel-themed block at 8 PM
Filter your VODs by title, set them as the queue, and let the streamer play them in order. After it loops once, the next day's cron can reset to shuffle.
#!/usr/bin/env python3
import os, requests
KEY = os.environ["SAD_KEY"]
BASE, H = "https://streamallday.live", {"Authorization": f"Bearer {os.environ['SAD_KEY']}"}
vods = requests.get(f"{BASE}/api/vods", headers=H).json()["vods"]
marvel = [v["filename"] for v in vods
if not v["disabled"] and v.get("title", "").lower().find("marvel") >= 0]
print(f"Setting {len(marvel)} Marvel VODs as the queue")
requests.put(f"{BASE}/api/queue", headers=H, json={"order": marvel})
Mirror "now playing" to Discord
Poll the queue every couple minutes; when the cursor changes, announce the new entry in a Discord channel via webhook.
#!/usr/bin/env python3
import os, time, requests
KEY = os.environ["SAD_KEY"]
DISCORD = os.environ["DISCORD_WEBHOOK"]
H = {"Authorization": f"Bearer {KEY}"}
last_cursor = None
while True:
q = requests.get("https://streamallday.live/api/queue", headers=H).json()
if q["cursor"] != last_cursor and q["queue"]:
nowplaying = q["queue"][q["cursor"] - 1]
title = nowplaying.get("title") or nowplaying["filename"]
requests.post(DISCORD, json={"content": f"Now playing: **{title}**"})
last_cursor = q["cursor"]
time.sleep(120)
Changelog
- v1 (today) — Initial public API. Library, queue, mode toggle, mutations, audit logging on the server side.