1 credit = 1 unique job returned. Duplicates and empty searches are free. You pay per unique job you get back, not per provider asked and not per record a provider returns.
BetterJobs is built so you can know the cost before you spend, cap it while you spend, and prove it after. This page covers all four parts of that system: estimate, cap, receipt and ledger.
What costs a credit
Section titled “What costs a credit”| Action | Cost |
|---|---|
A unique job returned by POST /v1/jobs/search or an async search, that you have not paid for before |
Cost: 1 credit / unique job |
| The same job returned again, by any request, any strategy, any day | Cost: Freealready paid |
| Duplicate provider records merged into one job | Cost: Free |
| An empty search or empty page | Cost: Free |
dry_run: true estimate |
Cost: Free |
GET /v1/jobs/{id} for a job you have not paid for |
Cost: 1 credit / job |
GET /v1/companies/{domain} hiring profile |
Cost: 1 credit / profile |
job.opened event delivered by a watch |
Cost: 1 credit / eventFree if you already paid for that job. |
| Every other event, retries and replays | Cost: Free |
| Reading async results, events, watches, account, ledger, providers | Cost: Free |
Sandbox (POST /v1/sandbox/jobs/search) |
Cost: Freefixed illustrative data |
Every operation in the API reference shows its cost. The spec carries it as x-credit-cost on each operation, so the reference, the MCP tools and this table agree.
You never pay twice for a job
Section titled “You never pay twice for a job”Once you pay for a job, every later read of the same id is free: another search, a different strategy, GET /v1/jobs/{id}, a page you re-fetch after a crash.
This works because BetterJobs bills canonical jobs, not provider records. Three providers seeing the same opening is one job and one credit. A repost keeps its id, so it is still one credit.
1. Estimate first: dry_run
Section titled “1. Estimate first: dry_run”Send the real request with dry_run: true. You get a free estimate and nothing is fetched or charged.
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 }, "waterfall": { "strategy": "max_coverage" }, "limit": 100, "dry_run": true }'import osimport requests
resp = 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": "max_coverage"}, "limit": 100, "dry_run": True, },)resp.raise_for_status()estimate = resp.json()["estimate"]print(estimate["credits_range"], estimate["providers_planned"])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: 'max_coverage' }, limit: 100, dry_run: true, }),});if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`);const { estimate } = await resp.json();console.log(estimate.credits_range, estimate.providers_planned);The response (illustrative):
{ "request_id": "req_3Kd8PwZ1uY", "estimate": { "expected_unique_jobs_range": { "min": 40, "max": 75 }, "providers_planned": ["betterjobs", "reqbeat", "signalsapi", "theirstack", "jobspipe", "coresignal", "techmap"], "credits_range": { "min": 40, "max": 75 } }}credits_range is a range, not a quote. Jobs you already paid for are free, so the real charge can come in lower. Agents using the MCP server call the same estimate through the estimate_search tool.
2. Cap the spend: max_credits
Section titled “2. Cap the spend: max_credits”waterfall.max_credits is a hard cap on what one request may charge. Results stop at the cap. The request never charges more, whatever the providers return.
{ "filters": { "title_or": ["Head of RevOps"], "country_code_or": ["DE", "AT", "CH"] }, "waterfall": { "strategy": "max_coverage", "max_credits": 100 }}Set it on every production request, including async searches. A good default is the top of the credits_range from your dry run.
3. Read the receipt: metadata
Section titled “3. Read the receipt: metadata”Every search response says what it cost:
"metadata": { "request_id": "req_7Hc2LmQ9xT", "status": "complete", "credits_charged": 1, "jobs_already_paid": 0, "duplicates_merged": 2, "credits_remaining": 9841}| Field | Meaning |
|---|---|
credits_charged |
Credits this request charged. |
jobs_already_paid |
Returned jobs you had paid for earlier. Free. |
duplicates_merged |
Provider records merged into canonical jobs. Free. |
credits_remaining |
Credits left on your account. |
The same numbers come as headers on every billable response: X-Credits-Charged and X-Credits-Remaining. Quote X-Request-Id to support.
4. Check the proof: the ledger
Section titled “4. Check the proof: the ledger”GET /v1/billing/ledger lists every charge, newest first. Filter by job_id to see the full history of one job. It is free.
curl "https://api.betterjobs.cc/v1/billing/ledger?job_id=job_01JC8X4M2Q7RV3T9KD5W6YH0AB" \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01"import osimport requests
resp = requests.get( "https://api.betterjobs.cc/v1/billing/ledger", headers={ "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", }, params={"job_id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB"},)resp.raise_for_status()for entry in resp.json()["data"]: print(entry["created_at"], entry["operation"], entry["credits"], entry["reason"])const url = new URL('https://api.betterjobs.cc/v1/billing/ledger');url.searchParams.set('job_id', 'job_01JC8X4M2Q7RV3T9KD5W6YH0AB');const resp = await fetch(url, { headers: { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, 'BetterJobs-Version': '2026-10-01', },});if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`);const { data } = await resp.json();for (const e of data) console.log(e.created_at, e.operation, e.credits, e.reason);One charge, then two free re-reads (illustrative):
{ "data": [ { "id": "led_0Zc5XvB9nM", "job_id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "request_id": "req_1Mn4BvC7xZ", "operation": "GET /v1/jobs/{id}", "credits": 0, "reason": "already_paid", "created_at": "2026-10-11T12:40:00Z" }, { "id": "led_8Yb4WuA3mL", "job_id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "request_id": "req_6Lk3AzX2wY", "operation": "POST /v1/jobs/search", "credits": 0, "reason": "already_paid", "created_at": "2026-10-11T11:05:00Z" }, { "id": "led_2Xa3VtZ1kK", "job_id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "request_id": "req_7Hc2LmQ9xT", "operation": "POST /v1/jobs/search", "credits": 1, "reason": "charged", "created_at": "2026-10-11T09:12:00Z" } ], "next_cursor": null}reason is charged or already_paid. Each entry links back to the request that caused it through request_id.
Retries never double-charge
Section titled “Retries never double-charge”Send an Idempotency-Key header on every POST. A retry with the same key and body returns the first response and never charges again. A 500 internal_error is safe to retry this way. See Idempotency.
When you run out of credits
Section titled “When you run out of credits”| Where | What happens | What to do |
|---|---|---|
Sync search, GET /v1/jobs/{id}, GET /v1/companies/{domain} |
402 with error.code: insufficient_credits, plus credits_needed and upgrade_url. |
Top up or upgrade at upgrade_url, or lower waterfall.max_credits. |
| Async search | The search moves to status: on_hold. Jobs are charged as they are collected, so nothing past your balance is charged. |
Top up. The search resumes. Poll GET /v1/searches/{id} and branch on status. |
{ "error": { "type": "billing_error", "code": "insufficient_credits", "message": "This request needs at least 25 credits; 3 remain.", "credits_needed": 25, "upgrade_url": "https://betterjobs.cc/pricing", "doc_url": "https://docs.betterjobs.cc/platform/errors/#insufficient_credits", "request_id": "req_6Ek5FuM9wX" }}See insufficient_credits and Async searches. GET /v1/account returns credits_remaining and credits_reset_at, so you can check before a big run.
| Plan | Price / month | Credits / month | $ / 1k credits | Providers | Includes |
|---|---|---|---|---|---|
| Free | $0 | 1,000 | — | Own job sources only | 1,000 credits to test, No card required |
| Starter | $19 | 5,000 | $3.80 | Own job sources only | No partner providers |
| Growth | $49 | 10,000 | $4.90 | Own sources + 2 partner providers | API, CSV, MCP |
| Pro | $199 | 60,000 | $3.32 | Max coverage: all six providers | Everything in Growth, plus Webhooks, CRM sync |
| Scale | $599 | 250,000 | $2.40 | All six providers | Everything in Pro, plus Bring your own provider keys, Priority support |
| Enterprise | from $1,500 | Custom | — | Contact sales | — |
Free and Starter route to the BetterJobs index only. Growth adds 2 partner providers. Pro and above add all six. See the waterfall for what each plan can route to. The same plan data is machine-readable at /.well-known/pricing.json.
Estimate your monthly cost
Section titled “Estimate your monthly cost”Enter how many unique jobs you expect per month. Duplicates and empty searches stay free whatever you enter.
Related
Section titled “Related”- The waterfall:
max_creditsandtimeout_ms - Canonical jobs: why one opening is one credit
- Get the billing ledger
- Errors