Four steps: call the keyless sandbox, read the response, repeat the call with your own key, then build your own query. The first step needs no signup.
1. Call the sandbox (no key)
Section titled “1. Call the sandbox (no key)”The sandbox has the same request and response shape as POST /v1/jobs/search. It needs no key, charges nothing and calls no provider. It returns fixed illustrative data.
curl https://api.betterjobs.cc/v1/sandbox/jobs/search \ -H "Content-Type: application/json" \ -d '{ "filters": { "title_or": ["Head of RevOps"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7 }, "limit": 10 }'import requests
resp = requests.post( "https://api.betterjobs.cc/v1/sandbox/jobs/search", json={ "filters": { "title_or": ["Head of RevOps"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7, }, "limit": 10, },)resp.raise_for_status()body = resp.json()for job in body["data"]: print(job["title"], "@", job["company"]["name"], [s["provider"] for s in job["sources"]])const resp = await fetch('https://api.betterjobs.cc/v1/sandbox/jobs/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filters: { title_or: ['Head of RevOps'], country_code_or: ['DE', 'AT', 'CH'], posted_within_days: 7, }, limit: 10, }),});if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`);const body = await resp.json();for (const job of body.data) { console.log(job.title, '@', job.company.name, job.sources.map((s: { provider: string }) => s.provider));}2. Read the response
Section titled “2. Read the response”This is the sandbox response. Data is illustrative.
{ "data": [ { "id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "title": "Head of Revenue Operations", "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "location": { "city": "Berlin", "region": "Berlin", "country_code": "DE", "remote": false }, "employment_type": "full_time", "seniority": "lead", "job_family": "operations", "salary": { "min": 110000, "max": 135000, "currency": "EUR", "period": "year", "origin": "declared" }, "description": "Acme Robotics is hiring a Head of Revenue Operations to own forecasting, CRM hygiene and the GTM tool stack across DACH.", "apply_url": "https://jobs.acme-robotics.example/revops-lead/apply", "posted_at": "2026-10-08T00:00:00Z", "first_seen_at": "2026-10-08T06:40:00Z", "last_seen_at": "2026-10-11T06:10:00Z", "last_verified_at": "2026-10-11T06:10:00Z", "status": "open", "closed_reason": null, "repost_count": 0, "p_real": 0.94, "sources": [ { "provider": "betterjobs", "provider_job_id": "bj_idx_5521907", "url": "https://jobs.acme-robotics.example/revops-lead", "first_seen_at": "2026-10-08T06:40:00Z", "last_seen_at": "2026-10-11T06:10:00Z", "fields": ["title", "description", "apply_url", "location", "employment_type", "posted_at"] }, { "provider": "theirstack", "provider_job_id": "ts_88213377", "url": "https://jobs.acme-robotics.example/revops-lead", "first_seen_at": "2026-10-08T07:12:00Z", "last_seen_at": "2026-10-11T04:02:00Z", "fields": ["salary", "seniority"] }, { "provider": "techmap", "provider_job_id": "tm_3f9a2c71", "url": "https://boards.example/acme-robotics/revops-lead", "first_seen_at": "2026-10-08T09:30:00Z", "last_seen_at": "2026-10-10T23:40:00Z", "fields": ["job_family"] } ], "license": { "display": true, "resale": false } } ], "next_cursor": null, "metadata": { "request_id": "req_sandbox_0001", "status": "complete", "credits_charged": 0, "jobs_already_paid": 0, "duplicates_merged": 2, "credits_remaining": 0, "providers": { "tried": ["betterjobs", "techmap", "theirstack"], "hit": ["betterjobs", "techmap", "theirstack"], "failed": [], "contributions": { "betterjobs": 1, "techmap": 0, "theirstack": 0 } }, "field_coverage": { "salary": 1.0, "seniority": 1.0, "location.remote": 1.0, "description": 1.0 }, "latency_ms": 12 }}The response has three blocks.
data: canonical jobs
Section titled “data: canonical jobs”Each item is one real opening, merged from every source that saw it. Three providers saw this job. You get it once.
idis stable across sources and requests. Store it. Re-reading a job you already paid for is free.sources[]lists each provider that saw the job, its own id and URL, when it saw it, and whichfieldsit contributed. Here the BetterJobs index gave the title and description, TheirStack gavesalaryandseniority, and Techmap gavejob_family.p_realis the probability (0 to 1) that this is a real open req, from cross-source corroboration. See Provenance and confidence.salary.originsays whether pay wasdeclaredin the posting orinferredby a source.licensesays whether you may show the job to end users (display) and resell it as data (resale). See Licensing.
next_cursor: paging
Section titled “next_cursor: paging”null means this is the last page. Otherwise pass it back as cursor with the same filters to get the next page. See Pagination.
metadata: what happened
Section titled “metadata: what happened”| Field | Meaning |
|---|---|
request_id |
Unique id for this request. Also in the X-Request-Id header. Quote it to support. |
status |
complete, or partial when at least one provider failed or timed out. You are billed only for returned jobs. |
credits_charged |
Credits this request cost. 0 in the sandbox. |
jobs_already_paid |
Returned jobs you had paid for before. Free. |
duplicates_merged |
Provider records merged into a canonical job. Free. Here, 2. |
credits_remaining |
Credits left on your account. 0 in the sandbox, which has no account. |
providers.tried / hit / failed |
Which sources were asked, which matched, and which failed (provider_timeout or provider_error). |
providers.contributions |
Unique jobs each source contributed first. |
field_coverage |
Share (0 to 1) of returned jobs with a non-null value, per field. This is measured live on every response. |
latency_ms |
Time the request took. |
3. Use your own key
Section titled “3. Use your own key”-
Sign up at betterjobs.cc. The Free plan has 1,000 credits on the BetterJobs index. No card.
-
Create an API key in your dashboard. Live keys start with
bj_live_. Store it in an environment variable, never in client-side code. See Authentication.Terminal window export BETTERJOBS_API_KEY="bj_live_..." -
Ask for a free estimate first.
dry_run: truereturns the expected job count, the providers it would call and a credit range. Nothing is fetched or charged.Terminal window 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": 25,"dry_run": true}'import osimport requestsresp = requests.post("https://api.betterjobs.cc/v1/jobs/search",headers={"Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}","BetterJobs-Version": "2026-10-01",},json={"filters": {"title_or": ["Head of RevOps"],"country_code_or": ["DE", "AT", "CH"],"posted_within_days": 7,},"limit": 25,"dry_run": True,},)resp.raise_for_status()print(resp.json()["estimate"])const resp = await fetch('https://api.betterjobs.cc/v1/jobs/search', {method: 'POST',headers: {Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`,'BetterJobs-Version': '2026-10-01','Content-Type': 'application/json',},body: JSON.stringify({filters: {title_or: ['Head of RevOps'],country_code_or: ['DE', 'AT', 'CH'],posted_within_days: 7,},limit: 25,dry_run: true,}),});if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`);console.log((await resp.json()).estimate); -
Run the real search. Drop
dry_runand addwaterfall.max_creditsas a hard cap. Results stop at the cap.Terminal window curl -i 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},"waterfall": { "strategy": "cheapest_first", "max_credits": 25 },"limit": 25}'import osimport requestsresp = requests.post("https://api.betterjobs.cc/v1/jobs/search",headers={"Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}","BetterJobs-Version": "2026-10-01",},json={"filters": {"title_or": ["Head of RevOps"],"country_code_or": ["DE", "AT", "CH"],"posted_within_days": 7,},"waterfall": {"strategy": "cheapest_first", "max_credits": 25},"limit": 25,},)resp.raise_for_status()print(resp.headers["X-Credits-Charged"], "credits charged,", resp.headers["X-Credits-Remaining"], "left")const resp = await fetch('https://api.betterjobs.cc/v1/jobs/search', {method: 'POST',headers: {Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`,'BetterJobs-Version': '2026-10-01','Content-Type': 'application/json',},body: JSON.stringify({filters: {title_or: ['Head of RevOps'],country_code_or: ['DE', 'AT', 'CH'],posted_within_days: 7,},waterfall: { strategy: 'cheapest_first', max_credits: 25 },limit: 25,}),});if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`);console.log(resp.headers.get('X-Credits-Charged'), 'credits charged,', resp.headers.get('X-Credits-Remaining'), 'left');
1 credit = 1 unique job returned. Duplicates and empty searches are free. Run the same search again and the jobs you already have come back free, counted in metadata.jobs_already_paid.
4. Build your own query
Section titled “4. Build your own query”Change the fields. The request updates live in every format, including a Clay HTTP column body and an MCP tool call.
Unknown filter fields return 400 unknown_filter, never a silent empty result. All filters are listed in Filters.
Next steps
Section titled “Next steps”- Authentication: live and test keys, rotation.
- The waterfall: strategies, budget caps and partial results.
- Credits and billing: what is charged and how to prove it.
- Choose your path: Clay, agents, data pipelines or recruiting.
- Search jobs API reference