> The Markdown version of https://staticradio.app/developers, for tools and AI agents. Also see https://staticradio.app/llms.txt and https://staticradio.app/openapi.json.

# 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](https://staticradio.app/developers#ground-rules) before you ship.

## Quick start

1. Fetch the schedule from `https://data.staticradio.app/schedule-wear.json`.
2. Work out [what's live](https://staticradio.app/developers#whats-live) on a station: which episode, and how far into it. It's about 30 lines of code.
3. [Stream that episode](https://staticradio.app/developers#playing) 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](https://staticradio.app/developers#bulletins) come with the schedule; then add [art](https://staticradio.app/developers#art) and [listener counts](https://staticradio.app/developers#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](https://staticradio.app/developers.md), and [llms.txt](https://staticradio.app/llms.txt) lists every public link in one place.

### Use Static from an AI assistant

Static has a read-only [MCP](https://modelcontextprotocol.io) 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.

```text
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.json` the `url` alone is enough.
- **Claude API**: pass the URL in `mcp_servers` with an `mcp_toolset`, using the MCP connector.

```sh
claude mcp add --transport http static-radio https://staticradio.app/mcp
```

```json
{
  "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](https://staticradio.app/developers#whats-live), 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.

```text
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-revalidate` and an `ETag`. Keep your copy, and check for a new one no more than every 15 minutes with `If-None-Match`: an unchanged file comes back as a tiny `304`.
- In a browser, don't add `If-None-Match` yourself. It makes the request need a CORS preflight, which this host refuses. The browser's own cache revalidates for you.
- Send a `User-Agent` that names your app. Some generic ones, Python's default among them, get a `403`.

The format is `schema_version` 8. There's a [JSON Schema](https://staticradio.app/schemas/schedule-wear.v8.json) 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](https://staticradio.app/developers#whats-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](https://staticradio.app/developers#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](https://staticradio.app/developers#bulletins). |
| `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](https://staticradio.app/developers#support). |
| `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.

```json
{
  "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`.

curl:

```sh
# 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.json
```

JavaScript:

```js
const 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 304
```

Python:

```python
import 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 304
```

## Working 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 `durationInSeconds` is 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 `startOffsetSeconds` to place episodes. It leaves out the gaps, so it drifts 1.5 seconds further off with every episode.

```text
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:

JavaScript:

```js
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");
```

Python:

```python
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](https://github.com/madewith-bestpractice/static-squeezebox/blob/main/StaticRadio/Schedule.pm), which follows our phone apps exactly. Its [t/live_test.pl](https://github.com/madewith-bestpractice/static-squeezebox/blob/main/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 `audioUrl` and seek to the offset. It makes the `Range` request 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](https://github.com/madewith-bestpractice/static-squeezebox/blob/main/StaticRadio/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.

curl:

```sh
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
```

JavaScript:

```js
// 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 on
```

Python:

```python
import 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 on
```

### Things that trip players up

- **Follow redirects.** Many `audioUrl`s pass through a show's measurement service first, some through several hops.
- **Ask for `audioUrl` itself every time**, not the address it redirects to, so the show's own stats count the play.
- **A few `audioUrl`s are plain `http`.**
- **Send real headers.** Node's `fetch` sends `Accept-Language: *`, which at least one big podcast host answers with a 404, so the JavaScript samples send `Accept-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](https://staticradio.app/post-bulletin): 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`, an `episodeId` that starts `bulletin-` and a `placementId` naming its break;
- `title` `Community Bulletin`, `feedTitle` `Static Radio Bulletin Board` and an empty `feedUrl`;
- its audio on `data.staticradio.app` or `bulletin.staticradio.app`, its art in `imageUrl`, and the link it points listeners to in `episodeUrl`;
- a `durationInSeconds` of 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 `imageUrl` is usually `https://data.staticradio.app/art/<hash>.webp`, a WebP copy of its cover art. It's sent with `Access-Control-Allow-Origin: *` and `Cache-Control: public, max-age=31536000, immutable`: an art URL never changes, so cache it as long as you like.
- A few `imageUrl`s aren't web addresses (SHEILA B's point to art built into our apps). Use your own fallback for anything that doesn't start with `https://`.
- For colors, there's each station's `palette`, and `image_analysis` on 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, namespace `https://podcastindex.org/namespace/1.0`).
- Older schedule files don't have `supportUrl` at all. If no episode in your file has one, read the tag from the show's `feedUrl` yourself. Fetch each feed at most once a day, not on every episode change, and send the same User-Agent and `Accept-Language: en`.
- Label the link "Support", whatever the feed calls it, so it reads the same everywhere.

curl:

```sh
# 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/&amp;/\&/g'
```

JavaScript:

```js
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(/&amp;/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");
```

Python:

```python
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:

```sh
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
# 3
```

JavaScript:

```js
const 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)
```

Python:

```python
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-Agent` that names your app** and its version, like `YourApp-Static/1.0`.
- **Call your integration unofficial** unless we've agreed otherwise. Credit Static, link to [staticradio.app](https://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](https://staticradio.app/openapi.json) (3.1): the schedule and listener counts, with real example responses. `https://staticradio.app/openapi.json`
- [JSON Schema](https://staticradio.app/schemas/schedule-wear.v8.json) (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](https://staticradio.app/developers.md) and [llms.txt](https://staticradio.app/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](https://staticradio.app/developers#mcp) for AI assistants at `https://staticradio.app/mcp`.
- **2026-11-11:** this page, the OpenAPI spec, the JSON Schema, the agent prompt, `developers.md` and `llms.txt`. The schedule is `schema_version` 8.
- **2026-11-11:** added the optional `supportUrl` to episodes in `schedule-wear.json`, from the next schedule build on. No version change.
- **2026-11-02:** bulletins in `schedule-wear.json` gained `placementId`. 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:txt` with `purpose="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](https://github.com/madewith-bestpractice/static-squeezebox) (Lyrion Music Server, Perl). The reference for the timeline (`Schedule.pm`) and MP3 seeking (`MP3Seek.pm`).
- [Static for Playdate](https://github.com/madewith-bestpractice/static-playdate) (C). Its own HTTP over TLS, MP3 and AAC decoding, and keeping in sync on a small device.
- [Static for Chumby One](https://github.com/madewith-bestpractice/static-chumby) (C), for a 2009 internet radio.

## Questions

[hello@staticradio.app](mailto:hello@staticradio.app)

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.
