Developers
Last updated 2026-11-11
Anyone can build a player for Static. Here's the public data, and how to play it so your listeners land on the same live moment as everyone else.
Static is one shared live timeline. Every station plays the same moment for every listener, whether they're on a phone, the web, a Squeezebox or a Playdate. You don't pick an episode: you tune in and land where everyone else is. Everything on this page is public and free to use, with no key or sign-up. Please read the ground rules before you ship.
Quick start #
- Fetch the schedule from
https://data.staticradio.app/schedule-wear.json. - Work out what's live on a station: which episode, and how far into it. It's about 30 lines of code.
- Stream that episode straight from the show's own host, starting at that point, and move on to the next episode at its scheduled time.
That's a radio. Bulletins come with the schedule; then add art and listener counts.
AI assistants and agents #
Ask an AI assistant what's on Static with our MCP server, or build your own player with a coding agent and the prompt below. Agents can read this page as Markdown, and llms.txt lists every public link in one place.
Use Static from an AI assistant #
Static has a read-only MCP server. Add it to Claude, ChatGPT, Cursor or any assistant that takes a remote MCP server, then ask what's on now, what's next on a station, or whether anything about birds is coming up. Answers name the show, link the episode's page and the show's Support page, and end with a link that tunes in live. No key or sign-in.
https://staticradio.app/mcp- Claude (claude.ai and the desktop app): go to Customize, then Connectors, choose Add, then Add custom connector. Name it Static Radio, paste the URL and choose No sign-in.
- Claude Code: run the command below.
- Cursor, VS Code and others: add it by URL as a remote (Streamable HTTP)
server. Many, including Claude Code's
.mcp.json, take a config like the one below; in Cursor's~/.cursor/mcp.jsontheurlalone is enough. - Claude API: pass the URL in
mcp_serverswith anmcp_toolset, using the MCP connector.
claude mcp add --transport http static-radio https://staticradio.app/mcp{
"mcpServers": {
"static-radio": { "type": "http", "url": "https://staticradio.app/mcp" }
}
}Its tools are list_stations, whats_on, whats_next,
find_on_now and show_details. They read only the public data on this page,
follow the same timeline rules, and describe community bulletins as bulletins, not shows.
Assistants don't play episodes on demand: on Static you don't pick an episode, you tune in.
Each address gets 60 requests a minute.
Agent prompt #
Building with a coding agent like Claude Code, Cursor or Copilot? Paste this prompt into it. It has the rules from this page, the pitfalls we've hit, and how to check the result against our reference player.
You're helping me build an unofficial player for Static Radio (https://staticradio.app): shared
live radio made of independent podcasts, where everyone tuned to a station hears the same moment.
Follow these instructions exactly.
SOURCES (the only public interface; read the guide first)
- Guide: https://staticradio.app/developers.md (Markdown; HTML at https://staticradio.app/developers)
- OpenAPI 3.1 spec: https://staticradio.app/openapi.json
- Schedule JSON Schema: https://staticradio.app/schemas/schedule-wear.v8.json
- Schedule: GET https://data.staticradio.app/schedule-wear.json (schema_version 8)
- Listener counts: GET https://staticradio.app/api/counts and https://staticradio.app/api/counts/{stationId}
- Reference implementation: https://github.com/madewith-bestpractice/static-squeezebox
(StaticRadio/Schedule.pm and t/live_test.pl)
DO NOT USE
- Any other endpoint, host or file, including ones found in Static's apps or network traffic.
They're internal and change without notice.
- schedule.json (the apps' internal file). Use schedule-wear.json.
- Any POST, PUT, PATCH or DELETE request. This integration is read-only.
WHAT'S LIVE (implement exactly; all times in milliseconds)
1. Stations are schedule.categories, keyed by station id. Order them by sort_index.
2. episodes = the station's episodes in array order, leaving out any with durationInSeconds <= 0.
3. slot(e) = e.durationInSeconds * 1000 + 1500. Every episode is followed by 1.5 s of silence.
4. loop = station.loop_duration_seconds * 1000 + 1500 * (number of episodes).
5. epoch = daily_epoch_utc parsed as a UTC instant. It's often in the future.
6. pos = ((now - epoch) % loop + loop) % loop. It must never be negative.
7. Walk the episodes in order: if pos < slot(e), e is live and pos is the offset into it;
otherwise pos = pos - slot(e) and continue.
8. Audio stops at e.durationInSeconds * 1000, or at 30000 if e.isBulletin is true, whichever is
less. From there to the end of the slot, play silence.
9. The next episode starts at now - offset + slot(e). Switch then, even if the file is still
playing. If a file ends early, stay silent until then. Never skip ahead.
10. Never use startOffsetSeconds for timing. It leaves out the 1.5 s gaps.
11. Use an accurate clock (NTP), and compute the live point for the moment playback will
actually start (about 1 s ahead).
PLAYING
- Stream e.audioUrl straight from the show's own host. Follow redirects. Request audioUrl
itself every time, never the URL it redirects to, so the show's stats count the play.
Some audioUrls are plain http.
- Start mid-episode by seeking (most players send the HTTP Range request themselves), or send
"Range: bytes=N-" with N = fileSize * offset / (durationInSeconds * 1000), taking fileSize
from the Content-Range of a "Range: bytes=0-0" request.
HARD REQUIREMENTS
- Never cache, store, re-host or proxy audio. Buffering ahead for smooth playback is fine.
- Play bulletins (isBulletin: true) where they fall. Never skip, mute or replace them.
- While an episode plays, show a link labeled "Support" to e.supportUrl whenever it's present
(the show's own support page; never on bulletins). If no episode in the schedule has a
supportUrl (older files), take the url attribute of the first channel-level
<podcast:funding> element (namespace https://podcastindex.org/namespace/1.0) in the show's
RSS feed at e.feedUrl instead, fetching each feed at most once a day.
- Keep it live. Users tune to a station and land on the live point. No choosing episodes,
seeking, rewinding or skipping.
- Fetch the schedule at most every 15 minutes, sending If-None-Match with the last ETag
(304 means unchanged). Fetch listener counts at most once a minute.
- Send a User-Agent that names the app and its version, like YourApp-Static/1.0.
- Call the integration unofficial. Credit Static Radio with a link to https://staticradio.app,
and link each episode to its episodeUrl when it isn't empty.
KNOWN PITFALLS
- Python's default User-Agent gets HTTP 403 from data.staticradio.app. Always set your own.
- In a browser, don't add If-None-Match yourself: it triggers a CORS preflight that
data.staticradio.app refuses. The browser's cache revalidates on its own.
- Node's fetch sends "Accept-Language: *", and Captivate-hosted audio answers it with 404.
Send "Accept-Language: en".
- Some imageUrls (SHEILA B's) are relative paths to art bundled in Static's apps. Use a fallback
image for any imageUrl that doesn't start with https://.
- Ignore fields you don't know. If schema_version isn't 8, log a warning and check the guide.
VERIFY
- Check your what's-live function against the reference: in a clone of static-squeezebox,
`T=$PWD/t perl t/live_test.pl schedule-wear.json <unix_ms>` prints
station|audioUrl|offset_seconds|is_bulletin for every station.
- Write a test that runs your function and the published liveNow() (the JavaScript under
"Working out what's live" in the guide) on one saved schedule-wear.json at several
timestamps, including one before daily_epoch_utc and one inside a 1.5 s gap, and asserts
they give the same episode and offset.The schedule #
One JSON file holds every station's loop for the broadcast day:
GET https://data.staticradio.app/schedule-wear.json
- It's about 190 KB, served gzipped (about 45 KB).
- It's rebuilt at least once a day. The new day's file usually lands around 7 a.m. US Central.
- It's served with
Access-Control-Allow-Origin: *, so a web page on any site can read it. - It's sent with
Cache-Control: public, max-age=14400, must-revalidateand anETag. Keep your copy, and check for a new one no more than every 15 minutes withIf-None-Match: an unchanged file comes back as a tiny304. - In a browser, don't add
If-None-Matchyourself. It makes the request need a CORS preflight, which this host refuses. The browser's own cache revalidates for you. - Send a
User-Agentthat names your app. Some generic ones, Python's default among them, get a403.
The format is schema_version 8. There's a JSON Schema
for it. You may also spot schedule.json next to it: that's the full file our phone apps
read, several times bigger, with internal fields that change without notice. Please use
schedule-wear.json.
Top level #
| Field | Type | What it is |
|---|---|---|
schema_version | integer | The format version: 8. We'll give notice before it changes. |
daily_epoch_utc | string | When the loops started (ISO 8601): midnight US Central at the start of the broadcast day this file was built for. Often in the future; see what's live. |
generated_at_utc | string | When the file was built (ISO 8601). |
categories | object | The stations, keyed by station id. |
Stations #
Each entry in categories. Today's ids are nature_01, work_01,
learn_01, news_01, weird_01, unite_01,
make_01, sheila_01 and sleep_01, but read the list from the file
rather than hard-coding it.
| Field | Type | What it is |
|---|---|---|
id | string | The station id, the same as its key. |
display_name | string | The station's name, like News or SHEILA B. |
u_tag | string | The name as it appears on the dial, in capitals. |
is_active | boolean | Always true here; stations off the air are left out. |
sort_index | integer | Position on the dial, from 0. |
loop_duration_seconds | integer | Length of the loop in seconds, not counting the 1.5 s gaps. It's the sum of the episodes' durations. |
palette | object | The station's colors: bg, and accents a and b (hex). |
episodes | array | The loop, in airing order. |
Episodes #
| Field | Type | What it is |
|---|---|---|
episodeId | string | A stable id. Bulletins are bulletin-<id> and can appear several times in a loop. |
audioUrl | string | The audio, on the show's own host. Stream it from here. |
durationInSeconds | integer | How long the episode holds the air. Its slot is this plus 1.5 s. |
startOffsetSeconds | integer | The earlier episodes' durations added up, without the gaps. Don't use it for timing. |
title | string | The episode's title (Community Bulletin for bulletins). |
feedTitle | string | The show's name. |
episodeUrl | string | The episode's web page, or a bulletin's link. Can be empty. |
feedUrl | string | The show's RSS feed. Empty for bulletins. |
imageUrl | string | Cover art. See art. |
gainDb | number | Loudness correction in dB (EBU R128), so shows sit at a similar level. Apply it if your player can. |
isBulletin | boolean | true for a community bulletin. |
supportUrl | string | Optional. The show's own support page, from its feed's podcast:funding tag. Left out when there isn't one, and on bulletins. See Support links. |
placementId | string | Bulletins only: an id for the break it fills. |
image_analysis | object | Colors from the cover art (vibrant_color, darkest_color, lightest_color). On one episode per station only. |
New fields can turn up at any time without a version change, so ignore any you don't know. Here's today's Make station, trimmed to two episodes: a show with a Support link, then a bulletin.
{
"schema_version": 8,
"daily_epoch_utc": "2026-11-12T06:00:00+00:00",
"generated_at_utc": "2026-11-11T13:37:12.499349+00:00",
"categories": {
"make_01": {
"id": "make_01",
"display_name": "Make",
"u_tag": "MAKE",
"is_active": true,
"sort_index": 6,
"loop_duration_seconds": 73772,
"palette": { "bg": "#101820", "a": "#10B981", "b": "#F59E0B" },
"episodes": [
{
"episodeId": "b0b7e2187e5ff80f3993c802d9722795a018dfcf",
"audioUrl": "https://www.buzzsprout.com/1118069/episodes/19800392-festival-of-quilts-and-exploring-england.mp3",
"startOffsetSeconds": 51937,
"durationInSeconds": 2668,
"imageUrl": "https://data.staticradio.app/art/d92ec187e05adcfdb277da6085f35a3978c15b83.webp",
"title": "Festival of Quilts and Exploring England",
"feedTitle": "A Quilting Life Podcast",
"episodeUrl": "https://www.buzzsprout.com/1118069",
"feedUrl": "https://rss.buzzsprout.com/1118069.rss",
"gainDb": 0.87,
"isBulletin": false,
"supportUrl": "https://www.buzzsprout.com/1118069/support"
},
{
"episodeId": "bulletin-sample-hq-patreon-thanks",
"audioUrl": "https://data.staticradio.app/sample-bulletins/hq-patreon-thanks-30s.mp3",
"startOffsetSeconds": 54605,
"durationInSeconds": 30,
"imageUrl": "https://data.staticradio.app/art/1c37afe29bb75d3c3363d36be440ff0738fce3af.webp",
"title": "Community Bulletin",
"feedTitle": "Static Radio Bulletin Board",
"episodeUrl": "https://patreon.com/staticradio",
"feedUrl": "",
"gainDb": 0.3,
"isBulletin": true,
"placementId": "bulletin-sample-hq-patreon-thanks-block-gap-17"
}
]
}
}
}Fetching it #
Each of these keeps the ETag and asks again with If-None-Match.
# First fetch: keep the file and its ETag
curl -sS --compressed -A "YourApp-Static/1.0" \
-D headers.txt -o schedule-wear.json \
https://data.staticradio.app/schedule-wear.json
ETAG=$(grep -i '^etag:' headers.txt | cut -d' ' -f2- | tr -d '\r')
# Later: ask again with the ETag. 304 means your copy is still current.
curl -sS --compressed -A "YourApp-Static/1.0" \
-H "If-None-Match: $ETAG" -o schedule-new.json \
-w '%{http_code}\n' \
https://data.staticradio.app/schedule-wear.jsonconst URL = "https://data.staticradio.app/schedule-wear.json";
let cached = null; // { etag, schedule }
async function getSchedule() {
const headers = { "User-Agent": "YourApp-Static/1.0" };
if (cached) headers["If-None-Match"] = cached.etag;
const res = await fetch(URL, { headers });
if (res.status === 304) return cached.schedule; // unchanged
if (!res.ok) throw new Error(`schedule: HTTP ${res.status}`);
cached = { etag: res.headers.get("ETag"), schedule: await res.json() };
return cached.schedule;
}
const schedule = await getSchedule();
console.log(schedule.schema_version, Object.keys(schedule.categories));
await getSchedule(); // the second call is a 304import gzip, json, urllib.error, urllib.request
URL = "https://data.staticradio.app/schedule-wear.json"
# A User-Agent is required: the default Python one gets a 403.
HEADERS = {"User-Agent": "YourApp-Static/1.0", "Accept-Encoding": "gzip"}
cached = None # (etag, schedule)
def get_schedule():
global cached
headers = dict(HEADERS)
if cached:
headers["If-None-Match"] = cached[0]
try:
req = urllib.request.Request(URL, headers=headers)
with urllib.request.urlopen(req, timeout=30) as r:
body = r.read()
if r.headers.get("Content-Encoding") == "gzip":
body = gzip.decompress(body)
cached = (r.headers["ETag"], json.loads(body))
except urllib.error.HTTPError as e:
if e.code != 304: # 304: unchanged, keep what we have
raise
return cached[1]
schedule = get_schedule()
print(schedule["schema_version"], list(schedule["categories"]))
get_schedule() # the second call is a 304Working out what's live #
Every station is a loop that started at daily_epoch_utc and repeats until the next
file arrives. Each episode holds the air for its durationInSeconds, then there's
1.5 seconds of silence before the next one. These are the rules every Static player follows:
- Leave out episodes whose
durationInSecondsis 0. - An episode's slot is its
durationInSeconds× 1000 + 1500 milliseconds. - The loop is
loop_duration_seconds× 1000 + 1500 × the number of episodes, in milliseconds. - Your position in the loop is (now −
daily_epoch_utc) mod loop. A new day's file goes on the air the morning before its epoch, so now − epoch is often negative: use a modulo that never returns a negative number. - Walk the episodes in order, taking each slot off the position, until the position fits inside one. That's the live episode, and the position is how far into it you are.
- If the position is past the episode's audio, you're in the gap: stay quiet until the next one.
- Bulletins play for 30 seconds at most, even if the file runs longer.
- Don't use
startOffsetSecondsto place episodes. It leaves out the gaps, so it drifts 1.5 seconds further off with every episode.
GAP = 1500 ms
episodes = the station's episodes in order, minus any with durationInSeconds 0
slot(e) = e.durationInSeconds × 1000 + GAP
loop = loop_duration_seconds × 1000 + GAP × (number of episodes)
position = (now − daily_epoch_utc) mod loop # in ms, never negative
for e in episodes:
if position < slot(e):
play e.audioUrl from position ms
stop the audio at durationInSeconds (at 30 s for a bulletin)
and stay quiet until the next episode, at now − position + slot(e)
done
position = position − slot(e)Two more things keep you in step with everyone else:
- Use an accurate clock. A device clock a few seconds out puts you a few seconds off everyone else; sync it with NTP if you can.
- Work out the live point for the moment playback will actually start, not the moment you asked. The Squeezebox plugin aims one second ahead.
As a function you can copy:
const GAP_MS = 1500; // silence after every episode
const BULLETIN_CAP_MS = 30000; // bulletins stop at 30 s
// What's on `stationId` at `nowMs`, and how far into it.
function liveNow(schedule, stationId, nowMs = Date.now()) {
const station = schedule.categories[stationId];
const episodes = station.episodes.filter((e) => e.durationInSeconds > 0);
if (!episodes.length || station.loop_duration_seconds <= 0) return null;
const epochMs = Date.parse(schedule.daily_epoch_utc);
const padMs = episodes.length * GAP_MS;
const loopMs = station.loop_duration_seconds * 1000 + padMs;
const totalMs = episodes.reduce((t, e) => t + e.durationInSeconds * 1000, 0) + padMs;
// A modulo that never goes negative: the epoch is often in the future.
let pos = (((nowMs - epochMs) % loopMs) + loopMs) % loopMs;
pos %= totalMs;
for (const episode of episodes) {
const slotMs = episode.durationInSeconds * 1000 + GAP_MS;
if (pos < slotMs) {
let audioMs = episode.durationInSeconds * 1000;
if (episode.isBulletin) audioMs = Math.min(audioMs, BULLETIN_CAP_MS);
return {
episode,
offsetMs: pos, // start the audio here
inGap: pos >= audioMs, // past the audio: stay quiet
audioMs, // stop the audio here
startedAtMs: nowMs - pos, // when this episode started
nextAtMs: nowMs - pos + slotMs, // when the next one starts
};
}
pos -= slotMs;
}
return null;
}
// With the schedule from above:
const live = liveNow(schedule, "news_01");
console.log(live.episode.title, Math.round(live.offsetMs / 1000), "s in");import time
from datetime import datetime
GAP_MS = 1500 # silence after every episode
BULLETIN_CAP_MS = 30_000 # bulletins stop at 30 s
def live_now(schedule, station_id, now_ms=None):
"""What's on station_id at now_ms, and how far into it."""
if now_ms is None:
now_ms = int(time.time() * 1000)
station = schedule["categories"][station_id]
episodes = [e for e in station["episodes"] if e["durationInSeconds"] > 0]
if not episodes or station["loop_duration_seconds"] <= 0:
return None
epoch_ms = int(datetime.fromisoformat(schedule["daily_epoch_utc"]).timestamp() * 1000)
pad_ms = len(episodes) * GAP_MS
loop_ms = station["loop_duration_seconds"] * 1000 + pad_ms
total_ms = sum(e["durationInSeconds"] * 1000 for e in episodes) + pad_ms
# Python's % never goes negative, which matters: the epoch is often in the future.
pos = (now_ms - epoch_ms) % loop_ms % total_ms
for episode in episodes:
slot_ms = episode["durationInSeconds"] * 1000 + GAP_MS
if pos < slot_ms:
audio_ms = episode["durationInSeconds"] * 1000
if episode["isBulletin"]:
audio_ms = min(audio_ms, BULLETIN_CAP_MS)
return {
"episode": episode,
"offset_ms": pos, # start the audio here
"in_gap": pos >= audio_ms, # past the audio: stay quiet
"audio_ms": audio_ms, # stop the audio here
"started_at_ms": now_ms - pos, # when this episode started
"next_at_ms": now_ms - pos + slot_ms, # when the next one starts
}
pos -= slot_ms
return None
# With the schedule from above:
live = live_now(schedule, "news_01")
print(live["episode"]["title"], live["offset_ms"] // 1000, "s in")The reference implementation is the Squeezebox plugin's Schedule.pm, which follows our phone apps exactly. Its t/live_test.pl checks the math against the published schedule without a server, and the functions above give the same answers it does.
Playing from the middle #
Start the live episode at the live point with an HTTP Range request to the show's own
host, then let it play.
- If your player can seek, the easy way is to hand it
audioUrland seek to the offset. It makes theRangerequest itself. - If not, work out a byte position. The samples below go by proportion of the file's size, which is right for constant-bitrate MP3 and close for most other files. To be exact with variable-bitrate MP3, read the file's Xing header, as the Squeezebox plugin's MP3Seek.pm does.
- MP4 and M4A files need their index before they can play, and some keep it at the end of the file. A player library handles that for you.
URL='https://example.com/episode.mp3' # the episode's audioUrl
OFFSET_S=1500 # where the live moment is, in seconds
DURATION_S=3600 # the episode's durationInSeconds
# 1. The file's size, from Content-Range (-L follows the host's redirects)
SIZE=$(curl -sSL -A "YourApp-Static/1.0" -r 0-0 -D - -o /dev/null "$URL" \
| grep -i '^content-range:' | tail -1 | cut -d/ -f2 | tr -d '\r')
# 2. The rest of the file, from about OFFSET_S in
curl -sSL -A "YourApp-Static/1.0" -r "$(( SIZE * OFFSET_S / DURATION_S ))-" \
-o from-live.mp3 "$URL" # or pipe it into your player// Node's fetch sends "Accept-Language: *" by default, and at least one big
// podcast host answers that with a 404, so send a real one.
const UA = { "User-Agent": "YourApp-Static/1.0", "Accept-Language": "en" };
// Start an episode offsetMs in, straight from the show's own host.
async function openAt(episode, offsetMs) {
// 1. The file's size. Always ask for audioUrl itself (not where it
// redirects), so the show's own stats see the play.
const probe = await fetch(episode.audioUrl, { headers: { ...UA, Range: "bytes=0-0" } });
const size = Number(probe.headers.get("Content-Range")?.split("/")[1]);
await probe.body?.cancel();
if (!size) return fetch(episode.audioUrl, { headers: UA }); // no ranges: start at the top
// 2. A byte position by proportion: right for constant-bitrate MP3,
// close for most other files.
const start = Math.floor((size * offsetMs) / (episode.durationInSeconds * 1000));
return fetch(episode.audioUrl, { headers: { ...UA, Range: `bytes=${start}-` } });
}
// With the live result from above:
const res = await openAt(live.episode, live.offsetMs);
console.log(res.status); // 206: the audio from about the live moment onimport urllib.request
UA = {"User-Agent": "YourApp-Static/1.0"}
def open_at(episode, offset_ms):
"""Start an episode offset_ms in, straight from the show's own host."""
# 1. The file's size. Always ask for audioUrl itself (not where it
# redirects), so the show's own stats see the play.
probe = urllib.request.Request(episode["audioUrl"], headers={**UA, "Range": "bytes=0-0"})
with urllib.request.urlopen(probe, timeout=30) as r:
content_range = r.headers.get("Content-Range") or ""
if "/" not in content_range: # no ranges: start at the top
plain = urllib.request.Request(episode["audioUrl"], headers=UA)
return urllib.request.urlopen(plain, timeout=30)
size = int(content_range.split("/")[1])
# 2. A byte position by proportion: right for constant-bitrate MP3,
# close for most other files.
start = size * offset_ms // (episode["durationInSeconds"] * 1000)
req = urllib.request.Request(episode["audioUrl"], headers={**UA, "Range": f"bytes={start}-"})
return urllib.request.urlopen(req, timeout=30) # 206, audio from about offset_ms on
# With the live result from above:
with open_at(live["episode"], live["offset_ms"]) as audio:
print(audio.status) # 206: the audio from about the live moment onThings that trip players up #
- Follow redirects. Many
audioUrls pass through a show's measurement service first, some through several hops. - Ask for
audioUrlitself every time, not the address it redirects to, so the show's own stats count the play. - A few
audioUrls are plainhttp. - Send real headers. Node's
fetchsendsAccept-Language: *, which at least one big podcast host answers with a 404, so the JavaScript samples sendAccept-Language: en. - Keep to the clock, not the file. A file can run longer or shorter than
durationInSeconds. Move on at the scheduled time anyway. If a file ends early, stay quiet until the next episode starts; never skip ahead, or you'll be ahead of everyone else. - Some hosts stitch their own ads into each download, so one listener's copy of a file can differ a little from another's. Keep to the clock and it evens out at the next episode.
Community bulletins #
Community bulletins are 30-second spots that anyone can book: a lost cat, a gig, a podcaster plugging their show. They play only between episodes, never inside one, and they're part of the schedule.
In the schedule, a bulletin is an episode like any other, with:
isBulletin: true, anepisodeIdthat startsbulletin-and aplacementIdnaming its break;titleCommunity Bulletin,feedTitleStatic Radio Bulletin Boardand an emptyfeedUrl;- its audio on
data.staticradio.apporbulletin.staticradio.app, its art inimageUrl, and the link it points listeners to inepisodeUrl; - a
durationInSecondsof 30, plus the usual 1.5 s gap.
Every player has to play them: where they fall, for up to 30 seconds, without skipping, muting or replacing them. The same bulletin can appear several times in a loop.
Art #
- An episode's
imageUrlis usuallyhttps://data.staticradio.app/art/<hash>.webp, a WebP copy of its cover art. It's sent withAccess-Control-Allow-Origin: *andCache-Control: public, max-age=31536000, immutable: an art URL never changes, so cache it as long as you like. - A few
imageUrls aren't web addresses (SHEILA B's point to art built into our apps). Use your own fallback for anything that doesn't start withhttps://. - For colors, there's each station's
palette, andimage_analysison one episode per station.
Support links #
Many shows have a page where listeners can support them: a Patreon, a membership, a tip jar. While a
show is playing, show a Support link to that page, if it has one, next to the
episodeUrl link. Our apps do the same. It's how listeners help the people who make the
shows.
- Use the episode's
supportUrl. It's left out when the show has no support page, and bulletins never have one. - It comes from the show's RSS feed: the first channel-level
<podcast:funding url="…">tag (Podcasting 2.0, namespacehttps://podcastindex.org/namespace/1.0). - Older schedule files don't have
supportUrlat all. If no episode in your file has one, read the tag from the show'sfeedUrlyourself. Fetch each feed at most once a day, not on every episode change, and send the same User-Agent andAccept-Language: en. - Label the link "Support", whatever the feed calls it, so it reads the same everywhere.
# Today's Support links, from the schedule
curl -sS --compressed -A "YourApp-Static/1.0" https://data.staticradio.app/schedule-wear.json \
| jq -r '[.categories[].episodes[] | select(.supportUrl) | "\(.feedTitle): \(.supportUrl)"] | unique[]'
# For an older schedule file without supportUrl: the first <podcast:funding url="...">
# before the first <item> in the show's feed
FEED='https://example.com/feed.xml' # the episode's feedUrl
curl -sL -A "YourApp-Static/1.0" -H "Accept-Language: en" "$FEED" \
| sed '/<item[ >]/q' \
| grep -o '<podcast:funding[^>]*url="[^"]*"' | head -1 \
| sed 's/.*url="//; s/"$//; s/&/\&/g'const FEED_HEADERS = { "User-Agent": "YourApp-Static/1.0", "Accept-Language": "en" };
const DAY_MS = 24 * 3600 * 1000;
const feedCache = new Map(); // feedUrl -> { url, at }
// Older schedule files predate supportUrl. In those, read it from the feed.
const hasSupportUrls = (schedule) =>
Object.values(schedule.categories).some((s) => s.episodes.some((e) => "supportUrl" in e));
// The first channel-level <podcast:funding url="..."> in a show's RSS feed,
// or null. Cached for a day per feed.
async function supportUrlFromFeed(feedUrl) {
if (!feedUrl) return null; // bulletins have no feed
const hit = feedCache.get(feedUrl);
if (hit && Date.now() - hit.at < DAY_MS) return hit.url;
let url = null;
try {
const res = await fetch(feedUrl, { headers: FEED_HEADERS });
if (res.ok) {
const channel = (await res.text()).split(/<item[\s>]/)[0]; // tags before the first episode
const m = channel.match(/<podcast:funding\b[^>]*\burl\s*=\s*["']([^"']+)["']/);
if (m) url = m[1].trim().replace(/&/g, "&");
}
} catch { /* feed unreachable: no link for now */ }
feedCache.set(feedUrl, { url, at: Date.now() });
return url;
}
// With the schedule and live result from above:
const support = live.episode.supportUrl
?? (hasSupportUrls(schedule) ? null : await supportUrlFromFeed(live.episode.feedUrl));
console.log(support ? `Support: ${support}` : "This show has no Support link");import html, re, time, urllib.request
FEED_HEADERS = {"User-Agent": "YourApp-Static/1.0", "Accept-Language": "en"}
FUNDING = re.compile(r"""<podcast:funding\b[^>]*\burl\s*=\s*["']([^"']+)["']""")
DAY_S = 24 * 3600
feed_cache = {} # feed_url -> (url, fetched_at)
def has_support_urls(schedule):
"""Older schedule files predate supportUrl. In those, read it from the feed."""
return any("supportUrl" in e for s in schedule["categories"].values() for e in s["episodes"])
def support_url_from_feed(feed_url):
"""The first channel-level <podcast:funding url="..."> in a show's RSS feed,
or None. Cached for a day per feed."""
if not feed_url: # bulletins have no feed
return None
hit = feed_cache.get(feed_url)
if hit and time.time() - hit[1] < DAY_S:
return hit[0]
url = None
try:
req = urllib.request.Request(feed_url, headers=FEED_HEADERS)
with urllib.request.urlopen(req, timeout=20) as r:
text = r.read().decode("utf-8", "replace")
channel = re.split(r"<item[\s>]", text, maxsplit=1)[0] # tags before the first episode
m = FUNDING.search(channel)
if m:
url = html.unescape(m.group(1).strip())
except Exception: # feed unreachable: no link for now
pass
feed_cache[feed_url] = (url, time.time())
return url
# With the schedule and live result from above:
episode = live["episode"]
support = episode.get("supportUrl") or (
None if has_support_urls(schedule) else support_url_from_feed(episode["feedUrl"]))
print(f"Support: {support}" if support else "This show has no Support link")Listener counts #
GET https://staticradio.app/api/counts
GET https://staticradio.app/api/counts/{stationId}
How many people are listening to each station right now. The first gives every station as
{"counts": {"news_01": 3, …}}; the second gives one station's bare number. A count is
null when it isn't known. Both are sent with Cache-Control: public, max-age=5
and Access-Control-Allow-Origin: *; asking once a minute is plenty.
Listeners are counted through our official apps and ports.
curl -sS -A "YourApp-Static/1.0" https://staticradio.app/api/counts
# {"counts":{"nature_01":0,"work_01":2,...,"news_01":3,...}}
curl -sS -A "YourApp-Static/1.0" https://staticradio.app/api/counts/news_01
# 3const res = await fetch("https://staticradio.app/api/counts", {
headers: { "User-Agent": "YourApp-Static/1.0" },
});
const { counts } = await res.json();
console.log(counts.news_01); // listeners on News right now (null if unknown)import json, urllib.request
req = urllib.request.Request("https://staticradio.app/api/counts",
headers={"User-Agent": "YourApp-Static/1.0"})
with urllib.request.urlopen(req, timeout=15) as r:
counts = json.load(r)["counts"]
print(counts["news_01"]) # listeners on News right now (None if unknown)Ground rules #
- Stream every episode straight from the show's own host (its
audioUrl), so each listen counts as a normal download for the podcaster. - Never cache, re-host or proxy the audio. Buffering ahead to play smoothly is fine; storing files to play later isn't.
- Play bulletins as scheduled. Don't skip, mute or replace them.
- Show a Support link to the show's own support page (
supportUrl) while it plays, whenever there is one. It's how listeners help the podcasters. - Keep it live. Tuning in lands everyone on the same moment. You don't pick an episode, and there's no rewinding or skipping ahead.
- Respect the cache headers. Check the schedule no more than every 15 minutes, with
If-None-Match, and the listener counts about once a minute. - Send a
User-Agentthat names your app and its version, likeYourApp-Static/1.0. - Call your integration unofficial unless we've agreed otherwise. Credit Static,
link to staticradio.app, and link each episode to its show's
page (
episodeUrl) so listeners can find the shows. - Stick to what's on this page. Other endpoints our apps use aren't public. They can change without notice, and some feed the reports bulletin buyers get, so please leave them alone.
OpenAPI spec and JSON Schema #
For tools and code generators:
- OpenAPI spec (3.1): the schedule and listener counts, with real
example responses.
https://staticradio.app/openapi.json - JSON Schema (2020-12) for
schedule-wear.json, version 8.https://staticradio.app/schemas/schedule-wear.v8.json
Both are served with Access-Control-Allow-Origin: *, so you can load them straight into
an API viewer or validator. For AI tools there's also this page as
Markdown and llms.txt, an index of these links.
Changes #
We'll give notice here before schema_version changes. New fields can be added at any time
without a version change, so ignore any you don't know.
- 2026-11-11: a read-only MCP server for AI assistants at
https://staticradio.app/mcp. - 2026-11-11: this page, the OpenAPI spec, the JSON Schema, the agent prompt,
developers.mdandllms.txt. The schedule isschema_version8. - 2026-11-11: added the optional
supportUrlto episodes inschedule-wear.json, from the next schedule build on. No version change. - 2026-11-02: bulletins in
schedule-wear.jsongainedplacementId. No version change.
Podcasting 2.0 #
Static finds shows through the Podcast Index, using its open database and its search API. From each show's feed we read:
podcast:funding, which becomes the Support button beside the show.podcast:podroll, which tells us which shows you recommend. When shows already on the dial recommend a show, it gets a listen, with the same checks as any other show.podcast:remoteItem: we read the entries inside it by feed URL, or by feed GUID through the Podcast Index.podcast:txtwithpurpose="ai-content"set to true, which keeps a feed off the dial, because Static plays only human-made shows.podcast:guid, which is how we match your show across feeds and podrolls.
We don't read chapters, transcripts or value tags yet.
Example players #
Our own ports are open source (MIT):
- Static for Squeezebox
(Lyrion Music Server, Perl). The reference for the timeline (
Schedule.pm) and MP3 seeking (MP3Seek.pm). - Static for Playdate (C). Its own HTTP over TLS, MP3 and AAC decoding, and keeping in sync on a small device.
- Static for Chumby One (C), for a 2009 internet radio.
Questions #
Building something? Tell us about it. Questions, bugs in this page and ideas are all welcome, and we usually reply within a couple of days.