# Pagination

> How do I page through results with next_cursor?

Source: https://docs.betterjobs.cc/platform/pagination/

Every list in the API is paged with an opaque cursor. Each page returns `next_cursor`. Send it back to get the next page. When `next_cursor` is `null`, you have the last page.

## Where the cursor goes

| Endpoint                 | Send the cursor as        | Page size                            |
| ------------------------ | ------------------------- | ------------------------------------ |
| `POST /v1/jobs/search`   | `cursor` in the JSON body | `limit`, `1` to `100`, default `25`  |
| `GET /v1/searches/{id}`  | `cursor` query parameter  | `limit`, `1` to `100`, default `100` |
| `GET /v1/watches`        | `cursor` query parameter  | Set by the server                    |
| `GET /v1/billing/ledger` | `cursor` query parameter  | Set by the server                    |
| `GET /v1/events`         | `since` query parameter   | `limit`, `1` to `100`, default `100` |

Treat the cursor as an opaque string, for example `cur_8fJ2kQ`. Do not parse it or build one yourself.

## Page through a search

For `POST /v1/jobs/search`, send the **same body** on every page and add `cursor`. Keep `filters`, `waterfall` and `limit` unchanged, so each page continues the same result set.

**curl**

```bash
# Page 1
curl https://api.betterjobs.cc/v1/jobs/search \
  -H "Authorization: Bearer $BETTERJOBS_API_KEY" \
  -H "BetterJobs-Version: 2026-10-01" \
  -H "Content-Type: application/json" \
  -d '{
    "filters": { "title_or": ["Head of RevOps"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7 },
    "limit": 100
  }'


# Page 2: same body plus the next_cursor from page 1
curl https://api.betterjobs.cc/v1/jobs/search \
  -H "Authorization: Bearer $BETTERJOBS_API_KEY" \
  -H "BetterJobs-Version: 2026-10-01" \
  -H "Content-Type: application/json" \
  -d '{
    "filters": { "title_or": ["Head of RevOps"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7 },
    "limit": 100,
    "cursor": "cur_8fJ2kQ"
  }'
```

**Python**

```python
import os
import requests


API = "https://api.betterjobs.cc/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}",
    "BetterJobs-Version": "2026-10-01",
}




def search_all(body: dict, max_pages: int = 20) -> list[dict]:
    """Collect every page of a search. max_pages bounds the loop and the spend."""
    jobs: list[dict] = []
    cursor = None
    for _ in range(max_pages):
        page_body = {**body, "cursor": cursor} if cursor else body
        resp = requests.post(f"{API}/jobs/search", headers=HEADERS, json=page_body, timeout=60)
        resp.raise_for_status()
        page = resp.json()
        jobs.extend(page["data"])
        cursor = page["next_cursor"]
        if cursor is None:
            break
    return jobs




jobs = search_all({
    "filters": {"title_or": ["Head of RevOps"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7},
    "waterfall": {"max_credits": 300},
    "limit": 100,
})
```

**TypeScript**

```ts
const API = "https://api.betterjobs.cc/v1";
const HEADERS = {
  Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`,
  "BetterJobs-Version": "2026-10-01",
  "Content-Type": "application/json",
};


/** Collect every page of a search. maxPages bounds the loop and the spend. */
async function searchAll(body: Record<string, unknown>, maxPages = 20): Promise<unknown[]> {
  const jobs: unknown[] = [];
  let cursor: string | null = null;
  for (let i = 0; i < maxPages; i++) {
    const res = await fetch(`${API}/jobs/search`, {
      method: "POST",
      headers: HEADERS,
      body: JSON.stringify(cursor ? { ...body, cursor } : body),
    });
    if (!res.ok) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`);
    const page = await res.json();
    jobs.push(...page.data);
    cursor = page.next_cursor;
    if (cursor === null) break;
  }
  return jobs;
}


const jobs = await searchAll({
  filters: { title_or: ["Head of RevOps"], country_code_or: ["DE", "AT", "CH"], posted_within_days: 7 },
  waterfall: { max_credits: 300 },
  limit: 100,
});
```

## What paging costs

Each page is billed like any search: 1 credit per unique job on that page that you have not paid for before. Jobs you already paid for, duplicates and empty pages are free. `metadata.credits_charged` on each page tells you what that page cost. See [Credits and billing](https://docs.betterjobs.cc/concepts/credits-and-billing.md).

`waterfall.max_credits` caps one request, so it caps one page. To cap a whole run, bound the number of pages, as the examples above do, or add up `metadata.credits_charged` and stop at your budget.

> One Idempotency-Key per page
>
> Each page has a different body, because the `cursor` differs. If you send `Idempotency-Key`, use a new key per page and reuse it only when retrying that same page. Reusing a key with a different body returns `409 idempotency_conflict`. See [Idempotency](https://docs.betterjobs.cc/platform/idempotency.md).

## More than a few hundred jobs

A synchronous search returns at most 100 jobs per page. For a backfill or market sizing run, use an [async search](https://docs.betterjobs.cc/platform/async-searches.md) instead. It collects up to 10,000 jobs in the background, and reading its pages with `GET /v1/searches/{id}` is free.

## The event feed uses `since`

`GET /v1/events` returns events oldest first. Store the `next_cursor` from the last page you processed, then pass it as `since` on your next call to resume where you stopped. Omit `since` to start from the oldest retained event. This makes the feed a reliable backup for missed webhooks. See [Webhooks](https://docs.betterjobs.cc/platform/webhooks.md#recover-missed-events) and [Sync patterns](https://docs.betterjobs.cc/guides/sync-patterns.md).

## Related

- [Async searches](https://docs.betterjobs.cc/platform/async-searches.md)
- [Filters](https://docs.betterjobs.cc/platform/filters.md)
- [Search jobs API reference](/api/operations/searchjobs/)
