Skip to main content
The episode stream is a long-lived Server-Sent Events (SSE) connection that pushes episodes to you the moment they reach a chosen stage of ingestion — no polling. Open one connection, pick the level of hydration you care about, optionally filter to the podcasts you follow, and receive each new episode as it crosses that point.
The episode stream is an Enterprise feature. Access requires an API key belonging to an organization on the Enterprise plan. Authenticate exactly as you do elsewhere — the X-API-Key header (recommended) or an Authorization: Bearer token.
Use GET for no filter, a popularity threshold, or a small explicit set of podcasts. Use POST (with a JSON body) when you want to filter on a large podcast_ids set — a long list would exceed query-string limits on a GET. The two forms are otherwise identical.

Pick a milestone

Episodes move through ingestion in stages. You subscribe to exactly one milestone and receive each episode once, when it reaches that stage. The milestones are strictly ordered — each builds on the previous — so picking a later milestone means you wait longer but the episode arrives with more data already populated. If you don’t pass milestone, you get transcribed. Choose the single milestone that matches the data you need — a later one implies all earlier stages already happened. Expect real latency between stages: transcription and enrichment take minutes to hours.

Filter the podcasts

By default the stream delivers every episode in the catalog. Narrow it two ways, which combine as a union (an episode is delivered if it matches either):
  • podcast_ids — an explicit set of podcasts, each given as a slug (pivot) or ID. An episode is delivered if its podcast is in the set. Unknown values are ignored, so a single bad slug won’t break the stream — but if none of the supplied ids match a known podcast, the request fails immediately with an error event rather than leaving you waiting on a stream that can never produce anything.
  • popularity_threshold — a number in (0, 1). Podcast popularity is normalized 0–1 across the catalog (a percentile), so 0.9 ≈ the top 10% most popular podcasts. Use this to follow “the popular stuff” without enumerating ids.
Pass a large podcast_ids set via the POST body (see below). On GET, podcast_ids is capped at 100; beyond that you’ll get an error event telling you to use POST.

Parameters

milestone, cursor, since, and include are always query parameters. podcast_ids and popularity_threshold are query parameters on GET and JSON body fields on POST.

Open the stream

A simple GET — all transcribed episodes, live:
Filtered by popularity, or by a handful of shows (GET):
A large explicit set (POST with a JSON body):
With no cursor or since, the stream is live-only: you receive episodes that reach your milestone from the moment you connect forward.

Event format

Each message is an SSE event. There are two event types. event: episode — an episode reached your milestone. The data is a JSON envelope:
The episode object is the same list-shaped representation returned by list episodes and the feed, hydrated to the level implied by your milestone (has_transcript, segment_count, etc. reflect the stage reached). For the full per-episode detail — topics, all entities, videos — fetch GET /v1/podcasts/episodes/{id}, or embed the heavy relations inline with include. event: error — a terminal error (e.g. a filter that matched no podcasts, too many ids for a GET, or an invalid cursor). The server sends one and closes the connection:

Hydrate the payload

By default each episode carries only its metadata, counts, and flags (has_transcript, segment_count, clip_count) — the heavy relations are not shipped, so a consumer that only needs to know an episode reached a milestone never pays for transcript bytes. To embed those relations directly — and avoid a follow-up request per delivered episode — pass include: Combine values with commas: include=transcript,clips. A relation can only be embedded at a milestone that guarantees it. Each becomes available at the milestone above, and because milestones are ordered, you can only embed what your milestone has reached. Asking for clips at milestone=transcribed is a contradiction — you’d be woken before clips exist — and is rejected with a terminal error event. all is milestone-relative: it expands to exactly the relations your milestone guarantees, so it never conflicts (e.g. all at transcribed embeds just the transcript).
Word-level transcripts are paginated and can’t be embedded inline; fetch them from GET /v1/podcasts/episodes/{id}/transcript/words.

Manage the stream lifecycle

Programming against the stream is mostly about three things: store the cursor, dedupe on episode id, and reconnect.
Disconnections are normal — design for automatic reconnection from day one. A long-lived stream will be interrupted periodically, and most often it’s not your network: we ship frequently, and every deploy does a rolling restart of the serving pods, which closes all open streams. Idle proxies and load balancers also recycle long connections. This is routine operation, not an error and not data loss — the durable log + your cursor guarantee a gap-free resume.Treat “the connection ended” as an ordinary, expected event your client handles silently, not an exception to alert on. Build the reconnect-with-backoff loop in from your very first implementation (see A resilient consumer); a client that assumes one connection stays open indefinitely will break during the next deploy.

The cursor

Every episode event carries an opaque cursor. Treat it as a black box — don’t parse it. Persist the cursor of the last event you have fully processed. It’s your resume point.

Delivery is at-least-once

You may occasionally receive the same episode more than once — most commonly right after a reconnect. Dedupe on episode.id and make your processing idempotent. You will not silently miss episodes (see below), but you should expect the rare duplicate rather than assume exactly-once.

Resuming after a disconnect

Connections end — network blips, your deploys, our rolling restarts. To resume without gaps, reconnect and pass the last cursor you stored as ?cursor=:
The stream first replays every episode after that cursor (catch-up), then transitions seamlessly to live. (On POST, send the same body and the updated ?cursor=.) If you’ve never connected before and want history, use since instead of cursor.
If your consumer falls too far behind to keep up, the server ends the connection deliberately. This is not data loss: reconnect from your last stored cursor and the catch-up replay fills the gap. The golden rule is simply always reconnect from your last processed cursor.

A resilient consumer

The pattern in any language: connect → on each episode event, dedupe and process, then store its cursor → on error or disconnect, back off and reconnect with the stored cursor. Use exponential backoff with jitter, capped at a ceiling (e.g. 1s → 30s), and reset the delay to its minimum after a connection stays up and delivers — so a routine deploy reconnects within a second or two, while a sustained outage doesn’t hammer the API.
JavaScript
parseSSE is any standard SSE line parser (split on blank lines; read event: and data: fields). Persisting cursor to durable storage lets you resume cleanly across process restarts, not just transient drops.

Stream vs. poll

Not on Enterprise, or prefer polling to a long-lived connection? The episode feed is the all-plans pull alternative — the same episodes, milestones, and filters, returned by a resumable GET you poll on your own schedule. Reach for the stream when you want push-based, low-latency delivery without managing a poll loop. For plain catalog browsing, list episodes (which also accepts fully_ingested=true) is simpler still.
  • Episodes — the same episode shape, by query or by ID
  • Transcripts — dialogue available once an episode reaches transcribed
  • Segments & clips — available at segmented and fully_ingested