> ## 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 (it's required on every call), 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. 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.                                                                                                                                                                                                                                      |
| `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>
  **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) 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>

## 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 with the same filters — the feed is the *pull* form, the stream the *push* form. The key difference: the feed always 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. Use the feed when polling is simpler for you or you're not on Enterprise; use the stream for real-time, low-latency delivery without managing a poll loop. 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, 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)
