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
(
imgvsimage), extra non-spec keys, chapters missing a title, and the occasional URL that 404s or returns malformed JSON. A naive fetch-and-JSON.parsethrows 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 img → image 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.