Podcast RSS Feeds Explained: What’s Actually in a <podcast:chapters> Tag

Every podcast is, under the hood, an RSS feed. Most developers know that. What fewer know is that modern podcast feeds carry a whole extra layer of structured data through the Podcasting 2.0 namespace — a set of <podcast:*> tags that ordinary RSS readers ignore. Two of the most useful are <podcast:transcript> and <podcast:chapters>. This post digs into chapters: what the tag actually holds, why it surprises people, and how to consume it without writing a pile of parsing code.

The tag doesn’t contain the chapters

Here’s the thing that trips people up: <podcast:chapters> doesn’t hold the chapter list inline. It’s a pointer. Inside an episode’s <item> it looks like this:

<podcast:chapters
  url="https://example.com/ep42/chapters.json"
  type="application/json+chapters" />

The actual chapters live in that external JSON file, which you fetch separately. It looks roughly like:

{
  "version": "1.2.0",
  "chapters": [
    { "startTime": 0,    "title": "Intro" },
    { "startTime": 128,  "title": "Interview begins", "img": "https://.../ch2.jpg" },
    { "startTime": 1450, "title": "Listener questions", "url": "https://.../link" }
  ]
}

Every chapter has a startTime in seconds; title, img, and url are optional.

Why it’s messier than it looks

Two realities make this harder than “fetch a JSON file”:

  • Not every podcast publishes chapters. The tag is optional; plenty of feeds omit it, and you can’t know without parsing each episode’s <item>.
  • When it’s there, the JSON isn’t perfectly consistent. The spec is loose, so across hosting platforms you’ll see fields named differently (img vs image), extra non-spec keys, chapters missing a title, and the occasional URL that 404s or returns malformed JSON. A naive fetch-and-JSON.parse throws on the first bad file.

Doing it properly means: scan each item for the tag, fetch the external file, tolerate format drift, normalize field names, and handle failures without breaking the whole request.

How PodKit handles it

PodKit does that work behind one endpoint. GET /v1/episode/{id}/chapters reads the <podcast:chapters> URL from the episode, fetches the linked file, validates and normalizes it into a consistent shape — startTime, title, image, url (mapping variations like imgimage as it goes) — and returns clean JSON:

curl "https://podkitapp.com/v1/episode/42/chapters" -H "x-api-key: pk_your_key"

{
  "episodeId": 42,
  "chapters": [
    { "startTime": 0,   "title": "Intro",            "image": null, "url": null },
    { "startTime": 128, "title": "Interview begins", "image": "https://.../ch2.jpg", "url": null }
  ]
}

Crucially, it degrades gracefully. If the source file is missing or malformed you don’t get a hard failure — you get a 200 with chapters: null plus the raw sourceUrl and parseError: true, so you can fall back to the original file yourself. An episode with no chapters tag at all returns 404. Parsed chapters are cached for 7 days.

The Podcasting 2.0 namespace is a quiet upgrade to a decades-old format, and chapters are one of its most practical pieces. If you’d rather consume them as clean JSON than maintain the fetch-and-normalize logic yourself, that’s exactly what the endpoint is for.

See the Chapters API →   Read the docs

← All posts