> ## 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.

# Paginate through everything

> How cursor pagination works on every list endpoint: the envelope, the loop that fetches every page in curl, JavaScript, and Python, what limit means, and which endpoints paginate.

Every list endpoint returns results in pages. When a request matches more records than one page holds, the response carries a `cursor`; pass it back to get the next page, and stop when it is gone.

## The envelope

Request the two most recent episodes of The Daily:

```bash theme={"dark"}
curl "https://api.particle.pro/v1/podcasts/episodes?podcast_id=the-daily&limit=2" \
  -H "X-API-Key: $PARTICLE_API_KEY"
```

```jsonc Response (truncated) theme={"dark"}
{
  "data": [
    { "id": "2pFNqyEdrMGd90MUEVTiY", "title": "‘Buy Now, Pay Later’: A New Wave of Consumer Debt", "published_at": "2026-09-08T09:45:00Z" },
    { "id": "66vY4VpBTZ2d18KMnyemWP", "title": "Classical Music Is in Crisis. Gustavo Dudamel Is Here to Save It.", "published_at": "2026-09-06T10:00:00Z" }
  ],
  "has_more": true,
  "cursor": "r.4gfFC6"
}
```

Three fields, the same on every list endpoint: `data` holds the page, `has_more` says whether another page exists, and `cursor` is the token for it (on the [episode feed](/podcasts/feed) it is also the position to resume from later, so it is present even when `has_more` is false). Send the cursor back with the same parameters:

```bash theme={"dark"}
curl "https://api.particle.pro/v1/podcasts/episodes?podcast_id=the-daily&limit=2&cursor=r.4gfFC6" \
  -H "X-API-Key: $PARTICLE_API_KEY"
```

```jsonc Response (truncated) theme={"dark"}
{
  "data": [
    { "id": "7Xf0Vz5gEzs6dTzD2IJFbV", "title": "The Intertwined Legacies of Gloria Steinem and Dolly Parton", "published_at": "2026-09-04T09:47:52Z" },
    { "id": "2rjVN01wDnbJfiY4Eklnr", "title": "A.I. Is Outsmarting Its Creators", "published_at": "2026-09-03T09:47:13Z" }
  ],
  "has_more": true,
  "cursor": "r.4gfFC8"
}
```

The last page has `has_more: false` and no `cursor`.

## The loop

<CodeGroup>
  ```bash curl theme={"dark"}
  URL="https://api.particle.pro/v1/podcasts/episodes?podcast_id=the-daily&limit=100"
  CURSOR=""
  while :; do
    # -f makes curl exit non-zero on a 4xx or 5xx, so a 429 or an outage stops
    # the walk instead of ending it quietly with a partial result.
    RESP=$(curl -fsS "$URL${CURSOR:+&cursor=$CURSOR}" -H "X-API-Key: $PARTICLE_API_KEY") || exit 1
    echo "$RESP" | jq -r '.data[] | .title'
    [ "$(echo "$RESP" | jq -r '.has_more')" = "true" ] || break
    CURSOR=$(echo "$RESP" | jq -r '.cursor')
  done
  ```

  ```js JavaScript theme={"dark"}
  const base = new URL("https://api.particle.pro/v1/podcasts/episodes");
  base.searchParams.set("podcast_id", "the-daily");
  base.searchParams.set("limit", "100");

  const episodes = [];
  let cursor;
  let hasMore = true;
  while (hasMore) {
    const url = new URL(base);
    if (cursor) url.searchParams.set("cursor", cursor);
    const res = await fetch(url, { headers: { "X-API-Key": process.env.PARTICLE_API_KEY } });
    if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); // a 429 carries Retry-After
    const page = await res.json();
    episodes.push(...page.data);
    hasMore = page.has_more;
    cursor = page.cursor;
  }

  console.log(`fetched ${episodes.length} episodes`);
  ```

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

  headers = {"X-API-Key": os.environ["PARTICLE_API_KEY"]}
  params = {"podcast_id": "the-daily", "limit": 100}

  episodes = []
  cursor = None
  while True:
      res = httpx.get(
          "https://api.particle.pro/v1/podcasts/episodes",
          params={**params, **({"cursor": cursor} if cursor else {})},
          headers=headers,
      )
      res.raise_for_status()  # a 429 carries Retry-After; do not swallow it
      page = res.json()
      episodes.extend(page["data"])
      if not page["has_more"]:
          break
      cursor = page["cursor"]

  print(f"fetched {len(episodes)} episodes")
  ```
</CodeGroup>

The same loop works on every endpoint whose envelope is `data`, `has_more`, and `cursor`; only the parameters change. Three endpoints paginate a nested collection under their own key and are listed at the end of this page. Stop on `has_more`, not on the presence of `cursor`: the [episode feed](/podcasts/feed) keeps returning a cursor after `has_more` turns false, because that cursor is the position to resume from on your next poll, and a loop that stops only when the cursor disappears would fetch empty pages forever. Once the feed says `has_more: false`, store the cursor and poll again on your own interval.

## The cursor

* **Treat it as opaque.** Do not build, edit, or store cursors long term; take them from the previous response.
* **Stop on `has_more`.** On ordinary lists `cursor` disappears with the last page; on the episode feed it persists as your resume point, so `has_more` is the signal on both.
* **Keep the parameters the same.** A cursor continues the request that produced it. To change a filter, start over without a cursor.
* **Cursors belong to their endpoint.** A cursor from `/v1/podcasts/episodes` does not continue `/v1/podcasts/mentions`.
* **Persist before you advance.** In an import, write a page's records before saving its cursor as the checkpoint, and key writes by canonical ids so a replayed page is harmless. A walk over a changing catalog is not a snapshot.
* **Budget the walk.** Each page is one request. Cap the pages a job may fetch, and treat a hit cap as a partial result to report rather than a silent stop.
* **A cursor the server cannot read** is rejected with `422` (`validation_error`) on the episode feed and the advertising placements list, and treated as the first page on the other lists. Either way, take cursors only from responses; if a walk fails or restarts unexpectedly, check that the cursor was passed through unchanged.

## What `limit` means

`limit` is the page size, from 1 to 100 (default 25). Ask for 100 when you intend to read everything, so the walk takes as few requests as possible. Each page is one request for rate limiting and metering.

A few endpoints scoped to one episode return the whole set when `limit` is omitted, because the set is small: segments, speakers, entities, and topics of an episode. `GET /v1/podcasts/episodes/{id}/transcript/words` accepts a `limit` up to 5,000.

## Which endpoints paginate

Every endpoint that returns a list: podcasts, episodes, the episode feed, search results, mentions, clips, segments, guests, rankings, ratings, sponsors and ad placements, publishers, companies and their people, entities, topics, and alerts and their matches. Their pages share the envelope above.

Three endpoints paginate a nested collection under their own key rather than `data`, with the same `has_more` and `cursor` semantics: the entity mentions of one episode (`GET /v1/podcasts/episodes/{id}/transcript/mentions`, collection `entities`), the word-level transcript (`GET /v1/podcasts/episodes/{id}/transcript/words`, collection `words`), and the matches embedded in one alert delivery (`GET /v1/alerts/deliveries/{id}`, collection `matches` with `matches_has_more` and `matches_cursor`). Read the collection key from the response and the loop above applies unchanged.

Endpoints that return one object (a podcast, an episode, a company, a summary, a timeseries) return it whole. Timeseries endpoints such as [`GET /v1/podcasts/mentions/timeseries`](/podcasts/mentions) return every bucket in the window in one response (up to 1,000 buckets, so pick the interval to fit the window), which is why they are the right tool for "how often over time" questions instead of paging through mentions.

## Related

* [Concepts](/concepts) for the identifier and error conventions the loop relies on
* [Track a company across podcasts](/recipes/track-a-company) for a walk that ends in an alert instead of a loop
