> ## Documentation Index
> Fetch the complete documentation index at: https://docs.particle.pro/llms.txt
> Use this file to discover all available pages before exploring further.

# Episode feed (polling)

> Resumable, strictly-ordered polling for newly ingested episodes — the all-plans alternative to the real-time stream.

The episode feed is a poll endpoint for "what's new." Each call returns the episodes that reached a chosen ingestion milestone **since your cursor**, in strict ingestion order, along with a cursor to resume from on your next poll. It's available on every plan.

<Note>
  **Which endpoint do I want?**

  * [**List episodes**](/podcasts/episodes) — browse/search the catalog by podcast, entity, company, date, etc. Ordered by publish date.
  * **Episode feed** (this page) — poll for *newly ingested* episodes, resumably, on any plan.
  * [**Episode stream**](/podcasts/stream) — the same data pushed in real time over SSE. Enterprise.
</Note>

```
GET https://api.particle.pro/v1/podcasts/episodes/feed
```

The response shape is identical to [list episodes](/podcasts/episodes) — a `data` array of episodes plus `has_more` and `cursor`. The difference is the *ordering and cursor*: the feed is strictly monotonic in ingestion time with a keyset cursor, so polling never skips or re-counts episodes the way an offset list can when new rows arrive.

## How polling works

Polling forward from now takes **at least two calls**: the first only mints a cursor; every call after it delivers episodes. (Backfilling with `since` is the exception — that first call returns a backlog straight away; see [Cold starts](#cold-starts-existing-data-or-a-lost-cursor).)

1. **First call** — omit `cursor` and `since`. The page is **always empty** — its only job is to return a `cursor` marking "now" to poll forward from. You still pass your podcast filter (required on every call, except for [Enterprise organizations](#the-feed-covers-at-most-100-podcasts)), but it has no effect on this empty page: the cursor is a position in the *global* ingestion log, not a per-filter bookmark.
2. **Subsequent calls** — pass the `cursor` from the previous response, and re-send the same filter (the cursor doesn't carry it). You get every episode that reached your milestone since then, oldest-first, up to `limit`, and a new `cursor`.
3. **Persist the cursor** and keep polling. Poll again immediately while `has_more` is true (you're catching up); poll on your own interval once `has_more` is false (you're caught up).

Advancing your cursor after each page gives you each episode exactly once. A small safety lag means an episode appears a few seconds after it's ingested — the trade for never skipping one.

<Warning>
  **Once you have a `cursor`, resume from it — never from an episode's timestamps.** The feed is ordered by **ingestion time** (when an episode reached your milestone), which is unrelated to **publish time** — a back-catalog episode transcribed today enters the feed today, old publish date and all (bulk archive imports are the one exception — see [Historical imports](#historical-imports-are-not-delivered)). The `cursor` is the exact high-water mark in that ingestion log. Rebuilding a start position from the episodes you consumed (their `published_at` or any other field) would silently skip episodes that were ingested after your last poll but published earlier — and the risk compounds the moment your filter covers more than one podcast.

  `since` is the lone exception and applies only to the **first** call (it's ignored once a `cursor` is set): a backfill anchor, also measured in ingestion time. See [Cold starts](#cold-starts-existing-data-or-a-lost-cursor) for the right value to give it.
</Warning>

## Milestones and filters

Pick one `milestone` (default `transcribed`) — episodes are returned once they reach that stage. The milestones work as they do for the [stream](/podcasts/stream#pick-a-milestone), but the feed caps the podcast set it covers (see below):

| Parameter              | Description                                                                                                                                                                                                                                                                                                                       |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `milestone`            | `discovered`, `transcribed` (default), `segmented`, or `fully_ingested`.                                                                                                                                                                                                                                                          |
| `podcast_ids`          | Slugs or IDs to filter to (comma-separated). **Hard limit of 100 per request.** Unknown values are ignored; if no filter matches any podcast, the call returns `404`.                                                                                                                                                             |
| `popularity_threshold` | Number in `(0,1)`; covers the **most-popular** podcasts at/above this popularity percentile. Omit (or pass `0`) for no popularity filter.                                                                                                                                                                                         |
| `topic_ids`            | One or more topics (comma-separated): a slug path (e.g. `sports/football/fantasy-football`), ID, or ancestry hash. A topic also matches its descendants, and multiple topics are unioned. Scopes the covered podcasts to those that **regularly cover** the topic. **Hard limit of 20 per request.** Unknown topic returns `404`. |
| `cursor`               | High-water mark from a prior response — the exact ingestion-log position to resume from. Omit (with no `since`) to start from now.                                                                                                                                                                                                |
| `since`                | First-call-only backfill anchor (ISO 8601); used only when no `cursor` is supplied, ignored once one is. Measured in **ingestion** time (when episodes entered the feed), not publish time — see [Cold starts](#cold-starts-existing-data-or-a-lost-cursor).                                                                      |
| `limit`                | Max episodes per page (1–100, default 25).                                                                                                                                                                                                                                                                                        |
| `include`              | Heavy relations to embed in each episode (comma-separated): `transcript`, `segments`, `clips`, or `all`. Omitted by default. See [Hydrate the payload](#hydrate-the-payload).                                                                                                                                                     |

### The feed covers at most 100 podcasts

The feed is the bounded, all-plans poll — it never fans out to an unbounded podcast set. You **must** supply `podcast_ids`, `topic_ids`, `popularity_threshold`, or a combination (a request with none returns `422`); the feed can't be polled across every podcast. Whatever you supply, the feed covers **at most 100 podcasts**:

* Your `podcast_ids` are always included (capped at 100 per request).
* `popularity_threshold` then fills any remaining slots with the **most-popular** podcasts above the threshold, dropping the least-popular tail to stay within 100. A low threshold can qualify many times more than 100 podcasts; the feed trims that to the top 100 by popularity — it doesn't reject the request.
* `topic_ids` scopes that fill to one or more topics: the slots go to the most-popular podcasts that **regularly cover** the topic — a show that merely mentions it in passing (one fantasy-football episode out of hundreds of interviews) is excluded, while shows that genuinely focus on it are kept. Use it alone to follow the top shows in a niche, or with `popularity_threshold` to also apply a global popularity floor. The popularity threshold stays a *global* percentile, so for a niche topic prefer `topic_ids` on its own.

If you need real-time delivery across an unbounded podcast set (e.g. *every* podcast above a popularity floor), use the [stream](/podcasts/stream) (Enterprise) — that's what it's for.

<Note>
  **Enterprise organizations are exempt from these bounds.** On an Enterprise plan the feed behaves like the pull form of the stream: you may poll with **no filter at all** (the full firehose), and `popularity_threshold` covers *every* qualifying podcast rather than the top 100. Explicit `podcast_ids` remain capped at 100 per request (a query-string limit), and `topic_ids` keeps its bounded top-100 discovery on every plan.
</Note>

<Note>
  **Prefer an explicit, stable set?** The feed's `topic_ids` and `popularity_threshold` are *dynamic* — each poll re-derives the covered podcasts (the most-popular shows in the topic), so the set drifts as charts and topic coverage move. To pin a **fixed** set instead, curate it once with the [catalog endpoint](/podcasts/overview#curate-by-popularity-and-topic) and pass the result as `podcast_ids`:

  ```
  GET /v1/podcasts?topic_id=sports/football/fantasy-football&popularity_threshold=0.75
  ```

  That returns the podcasts where the topic carries a meaningful share of episodes *and* that clear a global popularity percentile. Take their IDs, pass them to the feed as `podcast_ids`, and you'll poll exactly that list — explicit and stable — rather than whatever currently tops the charts. As on the feed, `popularity_threshold` there is **global** (a percentile across all charting podcasts, not within the topic), so widen or drop it if a narrow topic returns too few podcasts.
</Note>

## Historical imports are not delivered

The feed carries episodes as we discover them. When a show's back catalogue is imported in bulk — onboarding a podcast we didn't previously cover, or extending an existing show's coverage further back — those older episodes are added to the catalogue but are not delivered here. A show's 2024 archive landing in your feed today would be indistinguishable from three hundred new releases, and you'd have no way to tell which were actually new.

Those episodes are fully available through [episode search](/podcasts/episodes) and the per-podcast episode listings — they're absent from the change feed, not from the API. If you need a show's complete history, page its episodes directly; use the feed for what changes from here on.

**This is not a rule about publish dates.** An episode we merely discover late — an old episode that turns up in a feed and gets transcribed today — **is** delivered, old publish date and all, and that is exactly why you resume from a `cursor` rather than from timestamps. The exclusion applies only to episodes brought in by a deliberate bulk import, which are marked as such when they're imported. Two episodes with the same old publish date can therefore differ: the one we found through normal ingestion arrives, the one from an archive import doesn't.

## Hydrate the payload

Each episode carries only its metadata, counts, and flags (`has_transcript`, `segment_count`, `clip_count`) by default — the heavy relations are **not** shipped. To embed them and avoid a follow-up request per episode, pass `include`:

| `include` value | Embeds                                                                                                                   | Available at milestone |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------- |
| `transcript`    | `episode.transcript` — the dialogue transcript, identical to `GET /v1/podcasts/episodes/{id}/transcript?format=dialogue` | `transcribed`          |
| `segments`      | `episode.segments`                                                                                                       | `segmented`            |
| `clips`         | `episode.clips`                                                                                                          | `fully_ingested`       |
| `all`           | everything available at the chosen milestone                                                                             | —                      |

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` returns `422`. `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`.

## Example: follow five podcasts

Say you want a row in your store every time **Acquired**, **All-In**, **No Priors**, **My First Million**, or **The Tim Ferriss Show** drops a new episode. Pass them as `podcast_ids` on every call.

<CodeGroup>
  ```bash curl theme={"dark"}
  PODS="acquired,all-in,no-priors,my-first-million,the-tim-ferriss-show"

  # 1. First call — no cursor. The page is always empty; you're here for the cursor.
  curl "https://api.particle.pro/v1/podcasts/episodes/feed?milestone=transcribed&podcast_ids=$PODS" \
    -H "X-API-Key: $PARTICLE_API_KEY"
  # -> { "data": [], "has_more": false, "cursor": "g3Qk9m8..." }

  # 2. Poll with that cursor (re-send the same podcast_ids). Nothing new yet, so
  #    still empty — you've confirmed the loop and the cursor holds at "now".
  curl "https://api.particle.pro/v1/podcasts/episodes/feed?milestone=transcribed&podcast_ids=$PODS&cursor=g3Qk9m8..." \
    -H "X-API-Key: $PARTICLE_API_KEY"
  # -> { "data": [], "has_more": false, "cursor": "g3Qk9m8..." }

  # 3. Hours later, poll again with your saved cursor. Acquired and No Priors each
  #    shipped an episode meanwhile, so they return oldest-first with a fresh cursor.
  curl "https://api.particle.pro/v1/podcasts/episodes/feed?milestone=transcribed&podcast_ids=$PODS&cursor=g3Qk9m8..." \
    -H "X-API-Key: $PARTICLE_API_KEY"
  # -> { "data": [ {…acquired…}, {…no-priors…} ], "has_more": false, "cursor": "k2Wn8x1..." }
  ```

  ```python Python theme={"dark"}
  import time, httpx, os

  PODCAST_IDS = "acquired,all-in,no-priors,my-first-million,the-tim-ferriss-show"

  cursor = load_saved_cursor()  # None on the very first run
  while True:
      params = {"milestone": "transcribed", "limit": 100, "podcast_ids": PODCAST_IDS}
      if cursor:
          params["cursor"] = cursor
      res = httpx.get(
          "https://api.particle.pro/v1/podcasts/episodes/feed",
          params=params,
          headers={"X-API-Key": os.environ["PARTICLE_API_KEY"]},
      ).json()

      for episode in res["data"]:
          handle_episode(episode)  # idempotent

      cursor = res["cursor"]
      save_cursor(cursor)          # advance only after processing

      if not res["has_more"]:
          time.sleep(30)           # caught up — poll on an interval
  ```
</CodeGroup>

Want the *most-popular* podcasts rather than a named set? Swap `podcast_ids` for `popularity_threshold` (e.g. `0.9`) — everything else is identical. Want the top shows **in a topic** — say fantasy football? Use `topic_ids=sports/football/fantasy-football` (alone, or with `popularity_threshold`); you poll up to the 100 most-popular shows on that topic without curating a list, and new shows that rise into it are picked up automatically.

## Feed vs. stream

The feed and the [stream](/podcasts/stream) deliver the same episodes at the same milestones — the feed is the *pull* form, the stream the *push* form. The key difference: the feed requires a filter and bounds its coverage to 100 podcasts, while the stream (Enterprise) can fan out to an unbounded set above a popularity floor. On Enterprise the feed drops those bounds too (see the note above), so the choice mostly comes down to pull vs. push — use the feed when a poll loop on your own schedule is simpler, the stream for real-time, low-latency delivery. Two filter differences remain either way: only the feed supports `topic_ids`, and explicit `podcast_ids` are capped at 100 on the feed (a GET query-string limit) versus 1000 on the stream's POST form. Cursors are not interchangeable between them.

## Cold starts: existing data or a lost cursor

Most callers don't begin from a blank slate — you already hold some episodes, or you lost the cursor you were persisting. The cursor is the only *exact* resume point, so once you have one, keep it. To (re)start without one, pick one of two clean paths:

* **Start at "now" and move on.** Make a cursorless call for a fresh cursor and poll forward. You'll never miss a *future* episode; you just won't replay the window you were away. Reconcile that window, if you need it, with [list episodes](/podcasts/episodes) — it's built for publish-ordered catalog backfill, which the feed's `since` is not.
* **Replay a window with `since`.** Set `since` to a **wall-clock lower bound** — the time of your last good sync, or "now minus the longest you might have been offline." Because `since` is ingestion time, `since=<7 days ago>` means *everything that entered the feed in the last 7 days*. Unlike the cursorless call, this first call returns that backlog right away (page through `has_more`), and you resume from the cursor it returns. Idempotent writes absorb any overlap with episodes you already have.

```bash theme={"dark"}
PODS="acquired,all-in,no-priors,my-first-million,the-tim-ferriss-show"

# Lost the cursor — replay everything ingested since your last good sync,
# then keep polling with the cursor this returns:
curl "https://api.particle.pro/v1/podcasts/episodes/feed?milestone=transcribed&podcast_ids=$PODS&since=2026-05-01T12:00:00Z" \
  -H "X-API-Key: $PARTICLE_API_KEY"
```

**Prefer a wall-clock value over an episode's publish date.** It's tempting to take the newest `published_at` you already hold and pass it as `since`. It *usually* works as a no-gap lower bound — an episode is ingested at or after it's published, so anything newer lands beyond your mark — but it has two rough edges: it re-delivers back-catalog episodes that were *published* earlier yet *ingested* after your mark (bulk archive imports stay excluded — they never entered the feed, so no `since` value brings them back), and it breaks outright if any feed carries a **future** publish date (some do), which pushes `since` ahead of real ingestion times and skips episodes. A wall-clock sync time has neither problem, so reach for it first.

If you've lost the cursor *and* have no record of when you last synced, the newest `published_at` you hold is a reasonable **last resort** — it still won't skip future episodes (barring a feed that dates an episode in the future), but you *will* get back episodes you already have, so make your writes idempotent and let the overlap fall away.

## Related

* [Episodes](/podcasts/episodes) — browse and filter the catalog
* [Episode stream](/podcasts/stream) — real-time SSE delivery (Enterprise)
