A synchronous search returns at most 100 jobs per page. For backfills and market sizing, start an async search instead. It runs in the background, collects up to 10,000 unique jobs, and tells you when it is done.
POST /v1/searcheswith yourfilters. You get202and a searchid(srch_...).- Wait. Poll
GET /v1/searches/{id}, or passwebhook_urland receivesearch.completed. - Read the results page by page with
GET /v1/searches/{id}?cursor=.... Reading is free.
Start a search
Section titled “Start a search”The body takes the same filters and waterfall as POST /v1/jobs/search. limit is the total number of unique jobs to collect: 1 to 10000, default 1000.
curl https://api.betterjobs.cc/v1/searches \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" \ -H "Idempotency-Key: 7b8f2c4e-1a3d-4f5b-9c6e-0d2a4b6c8e1f" \ -H "Content-Type: application/json" \ -d '{ "filters": { "title_or": ["Data Engineer", "Analytics Engineer"], "country_code_or": ["DE", "FR", "NL", "ES", "PL"], "posted_within_days": 30 }, "waterfall": { "strategy": "max_coverage", "max_credits": 5000 }, "limit": 5000, "webhook_url": "https://hooks.northwind.example/betterjobs" }'import osimport uuidimport requests
API = "https://api.betterjobs.cc/v1"HEADERS = { "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01",}
resp = requests.post( f"{API}/searches", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json={ "filters": { "title_or": ["Data Engineer", "Analytics Engineer"], "country_code_or": ["DE", "FR", "NL", "ES", "PL"], "posted_within_days": 30, }, "waterfall": {"strategy": "max_coverage", "max_credits": 5000}, "limit": 5000, }, timeout=30,)resp.raise_for_status()search_id = resp.json()["id"] # "srch_..."const API = "https://api.betterjobs.cc/v1";const HEADERS = { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, "BetterJobs-Version": "2026-10-01",};
const res = await fetch(`${API}/searches`, { method: "POST", headers: { ...HEADERS, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() }, body: JSON.stringify({ filters: { title_or: ["Data Engineer", "Analytics Engineer"], country_code_or: ["DE", "FR", "NL", "ES", "PL"], posted_within_days: 30, }, waterfall: { strategy: "max_coverage", max_credits: 5000 }, limit: 5000, }),});if (res.status !== 202) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`);const { id: searchId } = await res.json(); // "srch_..."The 202 body is the search itself, with status: queued, jobs_found: 0, empty data and metadata: null.
Status
Section titled “Status”A search moves through six states. Three of them are final.
status |
Meaning | What to do |
|---|---|---|
queued |
Accepted, not started yet. | Wait. |
running |
Collecting jobs. jobs_found counts unique jobs so far. |
Wait. Show jobs_found as progress. |
on_hold |
Out of credits. Collection is paused. | Top up or upgrade. The search resumes on its own. |
completed |
Finished. Every planned provider answered. | Read results. |
partial |
Finished, but at least one provider failed or timed out. | Read results. metadata.providers.failed names the provider. You pay only for collected jobs. |
failed |
Finished without results. | Check metadata if present, then start a new search. |
completed, partial and failed are final. data holds results only once the status is completed or partial. It is empty in every other state.
Poll with a deadline
Section titled “Poll with a deadline”Polling is free, but it counts against your rate limit. Start with a short interval, back off, and give up at a deadline you choose. Treat on_hold as a signal to a human, not as a reason to poll forever.
curl https://api.betterjobs.cc/v1/searches/srch_2Vd9KqL4mN \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01"import time
FINAL = {"completed", "partial", "failed"}
def wait_for_search(search_id: str, deadline_s: float = 900) -> dict: """Poll until the search reaches a final status. Raises on on_hold or deadline.""" deadline = time.monotonic() + deadline_s delay = 2.0 while True: resp = requests.get(f"{API}/searches/{search_id}", headers=HEADERS, timeout=30) if resp.status_code == 429: time.sleep(int(resp.headers["Retry-After"])) continue resp.raise_for_status() search = resp.json() if search["status"] in FINAL: return search if search["status"] == "on_hold": raise RuntimeError(f"{search_id} is on_hold: out of credits. Top up and it resumes.") if time.monotonic() + delay > deadline: raise TimeoutError(f"{search_id} still {search['status']} after {deadline_s}s") time.sleep(delay) delay = min(delay * 1.5, 30)
def collect(search: dict) -> list[dict]: """Read every result page of a finished search. Free.""" jobs = list(search["data"]) cursor = search["next_cursor"] while cursor: resp = requests.get( f"{API}/searches/{search['id']}", headers=HEADERS, params={"cursor": cursor, "limit": 100}, timeout=30, ) resp.raise_for_status() page = resp.json() jobs.extend(page["data"]) cursor = page["next_cursor"] return jobs
search = wait_for_search(search_id)if search["status"] == "failed": raise RuntimeError(f"Search {search_id} failed")if search["status"] == "partial": print("Missing providers:", search["metadata"]["providers"]["failed"])jobs = collect(search)const FINAL = new Set(["completed", "partial", "failed"]);const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Poll until the search reaches a final status. Throws on on_hold or deadline. */async function waitForSearch(searchId: string, deadlineMs = 900_000) { const deadline = Date.now() + deadlineMs; let delay = 2_000; while (true) { const res = await fetch(`${API}/searches/${searchId}`, { headers: HEADERS }); if (res.status === 429) { await sleep(Number(res.headers.get("Retry-After")) * 1000); continue; } if (!res.ok) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`); const search = await res.json(); if (FINAL.has(search.status)) return search; if (search.status === "on_hold") throw new Error(`${searchId} is on_hold: out of credits. Top up and it resumes.`); if (Date.now() + delay > deadline) throw new Error(`${searchId} still ${search.status} at deadline`); await sleep(delay); delay = Math.min(delay * 1.5, 30_000); }}
/** Read every result page of a finished search. Free. */async function collect(search: { id: string; data: unknown[]; next_cursor: string | null }) { const jobs = [...search.data]; let cursor = search.next_cursor; while (cursor) { const url = `${API}/searches/${search.id}?${new URLSearchParams({ cursor, limit: "100" })}`; const res = await fetch(url, { headers: HEADERS }); 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; } return jobs;}
const search = await waitForSearch(searchId);if (search.status === "failed") throw new Error(`Search ${searchId} failed`);if (search.status === "partial") console.warn("Missing providers:", search.metadata.providers.failed);const jobs = await collect(search);The Python and TypeScript tabs reuse API and HEADERS from the create example above.
Or get a webhook
Section titled “Or get a webhook”Pass webhook_url when you create the search. When the search reaches a final status, BetterJobs POSTs a search.completed event to that URL. watch_id is null, because no watch produced it.
{ "id": "evt_8Re5TyU1oP", "type": "search.completed", "created_at": "2026-10-11T09:06:45Z", "watch_id": null, "data": { "search": { "id": "srch_2Vd9KqL4mN", "status": "completed", "jobs_found": 3184 } }}The event fires for completed, partial and failed. Branch on data.search.status, then read results with GET /v1/searches/{id} as in collect() above. Verify the signature first. See Webhooks.
Use both if you can: the webhook for speed, and a slow poll as a safety net in case a delivery is missed.
What it costs
Section titled “What it costs”- Jobs are charged as the search collects them: 1 credit per unique job you have not paid for before. Duplicates and already-paid jobs are free.
- Reading results with
GET /v1/searches/{id}is free, however many times you read them. waterfall.max_creditscaps the whole search. Set it to the most you are willing to spend.- If credits run out, the search moves to
on_holdinstead of failing. It resumes after a top-up.
POST /v1/searches has no dry_run, and unknown body fields return 400 invalid_request. To size a search before you start, send the same filters and waterfall to POST /v1/jobs/search with "limit": 100 and "dry_run": true. It is free. expected_unique_jobs_range counts every matching job, across all pages; credits_range prices one page of 100 only. Expect the async search to cost at most the smallest of expected_unique_jobs_range.max, your limit and waterfall.max_credits. max_credits is the only hard bound. See Credits and billing.