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
Section titled “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
Section titled “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.
# Page 1curl 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 1curl 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" }'import osimport 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,})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
Section titled “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.
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.
More than a few hundred jobs
Section titled “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 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
Section titled “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 and Sync patterns.