This is the full developer documentation for BetterJobs # All the job data. One request. > BetterJobs sends your job search to our own index and up to six job-data providers, depending on your plan. We merge the results, remove duplicates, and give you one clean list. One key. One bill. You pay only for unique jobs we return. Diagram: one search request goes to 7 sources: BetterJobs index (contributed fields), Reqbeat (duplicate, merged), SignalsAPI (no match), TheirStack (contributed fields), JobsPipe (duplicate, merged), Coresignal (no match), Techmap (contributed fields). Results are merged and deduplicated into one canonical job whose sources array lists betterjobs, theirstack, techmap. Illustrative: one request, seven sources, one canonical job. Duplicates are merged and free. Without BetterJobs, every job-data provider means a separate signup, key and invoice. With BetterJobs you send one request. It goes to the BetterJobs index and to Reqbeat, SignalsAPI, TheirStack, JobsPipe, Coresignal and Techmap, as your plan allows. The same opening seen by three providers comes back as one canonical job, with every source listed in `sources[]`. **1 credit = 1 unique job returned. Duplicates and empty searches are free.** See [Credits and billing](/concepts/credits-and-billing/). v1 preview The API is a preview. Endpoints and fields may change before general availability. The [OpenAPI spec](/openapi.yaml) is the source of truth. ## Pick your path [Clay and no-code](/getting-started/choose-your-path/#clay-and-no-code)Add a job-search column in Clay, n8n, Make, Zapier or Google Sheets. No code. [Developer](/getting-started/choose-your-path/#developer)Call the REST API from your backend. curl, Python and TypeScript. [AI agent builder](/getting-started/choose-your-path/#ai-agent-builder)Connect Claude, Cursor or your own agent over MCP, with credit costs per tool. [Data team](/getting-started/choose-your-path/#data-team)Backfill up to 10,000 jobs per search, then keep a table in sync. [Recruiter](/getting-started/choose-your-path/#recruiter)Find companies hiring for a role and get told when they start or stop. [Coming from another provider](/getting-started/choose-your-path/#coming-from-another-provider)Translate TheirStack, Coresignal, Techmap or JobsPipe queries and fields. ## Hello world Three calls cover most of what people do with BetterJobs. The first needs no key. Search jobs Cost: FreeKeyless sandbox. Fixed illustrative data. Head of RevOps in DACH, posted in the last 7 days. Runs against the keyless sandbox. ```bash 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}' ``` [Quickstart](/getting-started/quickstart/) · [API reference](/api/operations/searchjobs/) Check if a company is hiring Cost: 1 credit / profile `is_hiring.value` is `true`, `false` or `null`. `null` means unknown, never “not hiring”. ```bash curl https://api.betterjobs.cc/v1/companies/acme-robotics.example \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` [Find companies hiring](/guides/find-companies-hiring/) · [API reference](/api/operations/getcompany/) Watch a company Cost: 1 credit / job.openedCreating the watch is free. Other events, and jobs you already paid for, are free. Get a signed webhook when the company opens jobs or starts or stops hiring. Webhooks are listed on the Pro plan and above. ```bash curl https://api.betterjobs.cc/v1/watches \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" \ -H "Content-Type: application/json" \ -d '{"type":"company","domain":"acme-robotics.example","webhook_url":"https://hooks.northwind.example/betterjobs"}' ``` [Detect hiring changes](/guides/detect-hiring-changes/) · [API reference](/api/operations/createwatch/) All example companies use fictional `.example` domains. Sample responses are illustrative. ## Learn the model [The waterfall](/concepts/waterfall/)How one request fans out to up to seven sources and comes back as one list. [Canonical jobs](/concepts/canonical-jobs/)What one job means when five sources saw it. [Provenance and confidence](/concepts/provenance-and-confidence/)Who saw each job, and how sure we are it is real. [Providers](/providers/)What each of the six partner providers and the BetterJobs index brings. ## For machines * [`/openapi.yaml`](/openapi.yaml): the full OpenAPI 3.1 spec. * [`/llms.txt`](/llms.txt): an index of these docs for AI agents. See [llms.txt and Markdown](/agents/llms-txt/). * Replace the trailing `/` of a page URL with `.md` to get it as Markdown, for example `/concepts/waterfall/` → [`/concepts/waterfall.md`](/concepts/waterfall.md). This page is [`/index.md`](/index.md). API reference pages under `/api/` have no Markdown twin; read `/openapi.yaml` instead. * [`/.well-known/pricing.json`](/.well-known/pricing.json): plans and credits as JSON. # Authentication > How do I authenticate, and what is the difference between live, test and keyless sandbox access? Every request except the sandbox sends an API key as a Bearer token: ```http Authorization: Bearer bj_live_... ``` A missing or invalid key returns `401 unauthorized`. See [Errors](/platform/errors/#unauthorized). ## Three ways in | Access | Key | Data | Credits | Use it for | | --------------- | ------------- | --------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------ | | Keyless sandbox | None | Fixed illustrative data | Never charged | A first call, demos, docs examples. Only `POST /v1/sandbox/jobs/search`. | | Test key | `bj_test_...` | Sandbox data | Never charged | Development, CI, integration tests. | | Live key | `bj_live_...` | Live results from the BetterJobs index and the providers on your plan | Charged per unique job | Production. | All three use the same base URL, `https://api.betterjobs.cc/v1`, and the same request and response shapes. Switching from test to live is a key change, not a code change. Sandbox data is not real Sandbox and test responses use fictional companies on `.example` domains. Do not draw conclusions about coverage or freshness from them. ## Send the key Read the key from an environment variable. Also pin the API version with `BetterJobs-Version`, so new versions never change your responses. See [Versioning](/platform/versioning/). * curl ```bash export BETTERJOBS_API_KEY="bj_live_..." curl https://api.betterjobs.cc/v1/account \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python import os import requests session = requests.Session() session.headers.update({ "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", }) account = session.get("https://api.betterjobs.cc/v1/account") account.raise_for_status() print(account.json()["plan"], account.json()["credits_remaining"]) ``` * TypeScript ```ts const apiKey = process.env.BETTERJOBS_API_KEY; if (!apiKey) throw new Error('BETTERJOBS_API_KEY is not set'); const resp = await fetch('https://api.betterjobs.cc/v1/account', { headers: { Authorization: `Bearer ${apiKey}`, 'BetterJobs-Version': '2026-10-01', }, }); if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`); const account = await resp.json(); console.log(account.plan, account.credits_remaining); ``` `GET /v1/account` is free. It returns your plan, credits left, when they reset, the providers your key can use and your rate limit. Use it as a health check after you set or rotate a key. See [Get your account](/api/operations/getaccount/). ## Keep keys server-side A live key spends your credits. Anyone who has it can run up your bill. * Never put a key in browser JavaScript, a mobile app, a public repo or a shared spreadsheet. * Call BetterJobs from your backend. If a frontend needs job data, proxy the request through your server and keep the key there. * No-code tools such as Clay, n8n, Make and Zapier store the key in their server-side connection or header settings. Put it there, not in a cell or a URL. * Set `waterfall.max_credits` on every search, so even a leaked key cannot drain your account in one request. Never put the key in a URL Keys go in the `Authorization` header only. URLs end up in logs, browser history and referrer headers. ## Rotate a key Rotate on a schedule and whenever someone with access leaves. 1. Create a new key in your dashboard. The old one keeps working. 2. Deploy the new key to every place that uses the old one. 3. Call `GET /v1/account` with the new key to confirm it works. 4. Revoke the old key in your dashboard. If a key leaks, revoke it first and rotate after. Then check `GET /v1/billing/ledger` for charges you do not recognise. Each entry names the `request_id` and `operation` that charged it. See [Get the billing ledger](/api/operations/getledger/). ## Agents and MCP The MCP server takes the same key as a Bearer token, or OAuth where your client supports it. See [MCP server](/agents/mcp-server/). ## Related * [Quickstart](/getting-started/quickstart/) * [Rate limits](/platform/rate-limits/) * [Idempotency](/platform/idempotency/) # Choose your path > Which BetterJobs path fits how I work: no-code, developer, AI agent, data team, recruiter, or migrating from another provider? Every path calls the same API and pays the same way: 1 credit per unique job returned. Pick the one that matches how you work, and read its pages in order. ## Clay and no-code You build lists in Clay, n8n, Make, Zapier or Google Sheets and do not want to write code. Each tool calls BetterJobs through its generic HTTP request module. 1. [Clay](/integrations/clay/): add BetterJobs as an HTTP API column. 2. [n8n](/integrations/n8n/), [Make](/integrations/make/), [Zapier](/integrations/zapier/) or [Google Sheets](/integrations/google-sheets/). 3. [Credits and billing](/concepts/credits-and-billing/): what a 1,000-row table costs. 4. [Filters](/platform/filters/): every field you can put in the request body. ## Developer You call the REST API from your own backend. 1. [Quickstart](/getting-started/quickstart/): first response from the keyless sandbox. 2. [Authentication](/getting-started/authentication/): live and test keys. 3. [The waterfall](/concepts/waterfall/): strategies, budget caps and partial results. 4. [Filters](/platform/filters/), [Pagination](/platform/pagination/) and [Errors](/platform/errors/). 5. [Idempotency](/platform/idempotency/) and [Rate limits](/platform/rate-limits/): safe retries. 6. [API reference](/api/) and [OpenAPI and SDKs](/platform/openapi-and-sdks/). ## AI agent builder You want Claude, Cursor or your own agent to search jobs and check hiring. 1. [MCP server](/agents/mcp-server/): connect a client, see each tool’s cost. 2. [Agent quickstart](/agents/agent-quickstart/): one prompt that wires BetterJobs into your repo. 3. [llms.txt and Markdown](/agents/llms-txt/): let agents read these docs cheaply. 4. [Credits and billing](/concepts/credits-and-billing/): why agents should estimate before they search. Note MCP is included from the Growth plan. ## Data team You load job data into a warehouse and keep it current. 1. [Async searches](/platform/async-searches/): up to 10,000 jobs per search. 2. [Sync patterns](/guides/sync-patterns/): backfill, daily upsert, closed jobs. 3. [Canonical jobs](/concepts/canonical-jobs/): the stable `id` to dedup on. 4. [Field dictionary](/data/field-dictionary/) and [Taxonomies](/data/taxonomies/). 5. [Samples](/data/samples/): JSON and CSV files to test your loader, no signup. 6. [Freshness and lifecycle](/concepts/freshness-and-lifecycle/): the timestamps and when a job closes. ## Recruiter You look for companies that are hiring for roles you fill, and want to know when that changes. 1. [Find companies hiring](/guides/find-companies-hiring/): search a role, group by company. 2. [Detect hiring changes](/guides/detect-hiring-changes/): watch companies and act on events. 3. [Companies](/data/companies/): what `is_hiring` and `hiring_pulse` mean. 4. [Google Sheets](/integrations/google-sheets/): run it from a spreadsheet. ## Coming from another provider You already use one of the partner providers and want one key for all of them. * [Migrate from TheirStack](/guides/migrate-from-theirstack/) * [Migrate from Coresignal](/guides/migrate-from-coresignal/) * [Migrate from Techmap](/guides/migrate-from-techmap/) * [Migrate from JobsPipe](/guides/migrate-from-jobspipe/) ## Evaluating BetterJobs You want to know if it fits before you build. * [Providers](/providers/): what each source brings, in its own words. * [When not to use BetterJobs](/resources/when-not-to-use/) * [Licensing](/concepts/licensing/): what you may display and resell. ## Plans | 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 | — | # Quickstart > How do I make my first BetterJobs request in under a minute? 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) 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 ```bash 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 }' ``` * Python ```python 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"]]) ``` * TypeScript ```ts 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 This is the sandbox response. Data is illustrative. ```json { "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 Each item is one real opening, merged from every source that saw it. Three providers saw this job. You get it once. * `id` is 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 which `fields` it contributed. Here the BetterJobs index gave the title and description, TheirStack gave `salary` and `seniority`, and Techmap gave `job_family`. * `p_real` is the probability (0 to 1) that this is a real open req, from cross-source corroboration. See [Provenance and confidence](/concepts/provenance-and-confidence/). * `salary.origin` says whether pay was `declared` in the posting or `inferred` by a source. * `license` says whether you may show the job to end users (`display`) and resell it as data (`resale`). See [Licensing](/concepts/licensing/). null is not false `null` means unknown. `[]` means verified none. A missing field is not part of that payload, for example the lifecycle-only job in a `job.closed` event. Never read `null` as “no”. ### `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](/platform/pagination/). ### `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 1. Sign up at [betterjobs.cc](https://betterjobs.cc). The Free plan has 1,000 credits on the BetterJobs index. No card. 2. 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](/getting-started/authentication/). ```bash export BETTERJOBS_API_KEY="bj_live_..." ``` 3. Ask for a free estimate first. `dry_run: true` returns the expected job count, the providers it would call and a credit range. Nothing is fetched or charged. * curl ```bash 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 }' ``` * Python ```python import os import 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, }, "limit": 25, "dry_run": True, }, ) resp.raise_for_status() print(resp.json()["estimate"]) ``` * TypeScript ```ts 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); ``` 4. Run the real search. Drop `dry_run` and add `waterfall.max_credits` as a hard cap. Results stop at the cap. * curl ```bash 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 }' ``` * Python ```python import os import 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": "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") ``` * TypeScript ```ts 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`. Free and Starter plans These plans search the BetterJobs index only. `cheapest_first` already skips providers your plan does not include. Naming a partner in `waterfall.providers` returns `403 plan_required`. See [Errors](/platform/errors/#plan_required). ## 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](/platform/filters/). ## Next steps * [Authentication](/getting-started/authentication/): live and test keys, rotation. * [The waterfall](/concepts/waterfall/): strategies, budget caps and partial results. * [Credits and billing](/concepts/credits-and-billing/): what is charged and how to prove it. * [Choose your path](/getting-started/choose-your-path/): Clay, agents, data pipelines or recruiting. * [Search jobs API reference](/api/operations/searchjobs/) # Agent quickstart > What prompt do I paste into my coding agent to integrate BetterJobs? Paste the prompt below into Claude Code, Cursor, or any coding agent that can read files and fetch URLs. It points the agent at the docs and the spec, and lists the traps it must handle. 1. Set your key where the agent’s shell and your app can read it. A `bj_test_` key is enough while you build. See [Authentication](/getting-started/authentication/). ```bash export BETTERJOBS_API_KEY="bj_test_..." ``` 2. Open your repo in the agent and paste this prompt. Prompt ```text Integrate the BetterJobs job-data API into this repository. Context - BetterJobs sends one job search to several job-data providers plus its own index, merges duplicates and returns canonical jobs. 1 credit per unique job returned. - Docs index: https://docs.betterjobs.cc/llms.txt. Follow its "Rules for AI agents". - Exact contract: https://docs.betterjobs.cc/openapi.yaml. Use its field names, paths and error codes verbatim. Do not guess fields. Fetch the .md version of any docs page you need: Replace the trailing / of a docs URL with .md (e.g. /concepts/waterfall/ → /concepts/waterfall.md). API reference pages under /api/ have no Markdown twin; read /openapi.yaml instead. Requirements 1. Put the client in one module that matches this repo's existing structure and HTTP library. No new dependencies unless the repo has no HTTP client. 2. Read the key from the BETTERJOBS_API_KEY environment variable. Fail at startup if it is not set. Never hard-code a key, never log it, never send it to a browser. Add BETTERJOBS_API_KEY to the repo's env example file with an empty value. 3. Base URL https://api.betterjobs.cc/v1. Send on every request: Authorization: Bearer BetterJobs-Version: 2026-10-01 4. Implement searchJobs(filters, options) on POST /jobs/search: - Always set waterfall.max_credits. Expose it as a required argument. - Support dry_run: true (free estimate). Call it first when a search may cost more than 50 credits. - Page with next_cursor until it is null or the caller's limit is reached. - Send a fresh Idempotency-Key (UUID) per logical request and reuse it on retries. 5. Handle results correctly: - metadata.status "partial" is a success. Log metadata.providers.failed; do not retry the whole search. - Deduplicate and store jobs by their canonical id. Re-reading a paid job is free. - null means unknown, [] means verified none. Never coerce null to false or 0. 6. Handle errors by error.code, not by message text: - rate_limited: wait Retry-After seconds, then retry. - internal_error: retry with the same Idempotency-Key and backoff. - unknown_filter / invalid_request: raise with error.param. Do not retry. - insufficient_credits / plan_required: raise with upgrade_url. Do not retry. Log X-Request-Id on every failure. 7. Tests: use the keyless sandbox https://api.betterjobs.cc/v1/sandbox/jobs/search (no auth, fixed illustrative data, nothing charged) or a bj_test_ key. Never call the API with a bj_live_ key in tests. 8. Run the repo's existing lint, type check and tests. Then show me the diff and one example call I can run. ``` 3. Review the diff. Check the list below before you merge. 4. Swap in a `bj_live_` key in production. Agents guess field names Models trained on other job APIs often write `job_title` or `company_domain` from memory. BetterJobs rejects unknown filter fields with `400 unknown_filter`, so the bug shows up at once. The prompt tells the agent to read the spec instead. ## Review checklist * [ ] The key comes from `BETTERJOBS_API_KEY` and the app fails at startup without it. * [ ] Every request sends `BetterJobs-Version: 2026-10-01`. * [ ] Every search sets `waterfall.max_credits`. * [ ] Large searches call `dry_run: true` first. * [ ] `metadata.status: partial` is treated as success. * [ ] Jobs are stored by canonical `id`. * [ ] `null` is never turned into `false`, `0` or an empty string. * [ ] Errors branch on `error.code`. `rate_limited` honours `Retry-After`. * [ ] Retries reuse the same `Idempotency-Key`. * [ ] Tests never use a `bj_live_` key. Each item links back to one page: [The waterfall](/concepts/waterfall/), [Canonical jobs](/concepts/canonical-jobs/), [Errors](/platform/errors/), [Idempotency](/platform/idempotency/), [Rate limits](/platform/rate-limits/), [Versioning](/platform/versioning/). ## Let the agent use BetterJobs live The prompt writes code that calls the API. If you also want the agent itself to search jobs or check companies while it works, connect the [MCP server](/agents/mcp-server/). Its tools carry credit costs and follow the same consent rule. ## Related * [llms.txt and Markdown](/agents/llms-txt/) * [Quickstart](/getting-started/quickstart/) * [OpenAPI and SDKs](/platform/openapi-and-sdks/) # llms.txt and Markdown > How can an AI agent read these docs efficiently? HTML pages are written for people. Agents read better from plain text with no navigation, scripts or styling. These docs publish every page in both forms, plus indexes built for language models. ## What is published | URL | What it is | Use it when | | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | [`/llms.txt`](/llms.txt) | Short index: what BetterJobs is, the Rules for AI agents, links to key pages, and links to the full and abridged sets. | An agent needs to find the right page. Start here. | | [`/llms-full.txt`](/llms-full.txt) | Every docs page in one Markdown file. | You want to load the whole docs into context once. | | [`/llms-small.txt`](/llms-small.txt) | The same, abridged. | Context is tight. | | `.md` | One page as Markdown. | An agent needs one topic, not all of them. | | [`/openapi.yaml`](/openapi.yaml) | The OpenAPI 3.1 spec. | Generating a client or checking an exact field, path or error code. | | [`/.well-known/pricing.json`](/.well-known/pricing.json) | Plans, prices and credits as JSON. | An agent needs current prices. Never trust prices from training data. | All of these are built from the same sources as the HTML pages on every deploy, so they never drift from what people see. ## Markdown twins Replace the trailing `/` of a docs URL with `.md` (e.g. `/concepts/waterfall/` → `/concepts/waterfall.md`). API reference pages under `/api/` have no Markdown twin; read `/openapi.yaml` instead. | Page | Markdown | | ------------------------------ | ------------------------------------------------------------------ | | `/getting-started/quickstart/` | [`/getting-started/quickstart.md`](/getting-started/quickstart.md) | | `/agents/mcp-server/` | [`/agents/mcp-server.md`](/agents/mcp-server.md) | | `/` (home) | [`/index.md`](/index.md) | Each file starts with the page title, the question it answers and its canonical URL. Tables, code samples and callouts come through as Markdown. Code tabs become one labelled block per language. Interactive widgets such as the Query Builder and cost estimator are left out. Every page also has **Copy page as Markdown** and **View .md** buttons next to the title. Use them to paste a page into a chat. Fetch one page, not the whole site `/llms-full.txt` is large. When the agent knows the page it needs, for example from a link in these docs, fetching that one `.md` page uses far fewer tokens. ## Start here `/llms.txt` links these pages directly, so an agent can fetch one topic without loading the whole corpus: * [OpenAPI spec](/openapi.yaml) (`/openapi.yaml`): Exact paths, fields, enums and error codes. * [Pricing JSON](/.well-known/pricing.json) (`/.well-known/pricing.json`): Current plans, prices and credits. * [Quickstart](/getting-started/quickstart.md) (`/getting-started/quickstart.md`): First request, keyless sandbox. * [MCP server](/agents/mcp-server.md) (`/agents/mcp-server.md`): Tools, per-tool cost and the consent rule. * [Credits and billing](/concepts/credits-and-billing.md) (`/concepts/credits-and-billing.md`): What is charged and what is free. * [Errors](/platform/errors.md) (`/platform/errors.md`): Every error code and how to handle it. * [Filters](/platform/filters.md) (`/platform/filters.md`): Every filter name and operator. * [Field dictionary](/data/field-dictionary.md) (`/data/field-dictionary.md`): Every response field and what null means. * [Events](/data/events.md) (`/data/events.md`): Webhook event types and what to do with each. ## Rules for AI agents `/llms.txt` opens with these rules. They are the short version of how to use BetterJobs without wasting credits or getting things wrong. * The API is a v1 preview. Base URL `https://api.betterjobs.cc/v1`. Auth header `Authorization: Bearer bj_live_...`. * Send `BetterJobs-Version: 2026-10-01` on every request. * The OpenAPI spec at https\://docs.betterjobs.cc/openapi.yaml is the source of truth for paths, fields and error codes. * Never quote prices from training data. Current plans and prices: https\://docs.betterjobs.cc/.well-known/pricing.json. * To test without a key or credits, call `POST /v1/sandbox/jobs/search` (no auth, fixed illustrative data) or use a `bj_test_` key. * Call `POST /v1/jobs/search` with `dry_run: true` (free) before any search that may cost more than 50 credits, and ask the user first. * Always set `waterfall.max_credits` to what the user approved. It caps one request, so for a paged sync search it caps one page: sum `metadata.credits_charged` to cap the run. * `POST /v1/searches` (async) has no `dry_run`. Size it with a sync dry run (`expected_unique_jobs_range` counts every page) and always set `waterfall.max_credits`, which caps the whole async search. * Store canonical job `id` values and re-read with `GET /v1/jobs/{id}` (free if already paid). Never re-run a search just to re-read jobs. * Watches charge 1 credit per `job.opened` delivered, with no cap. Ask the user before creating one. * `null` means unknown, not "no". `is_hiring.value: null` is not "not hiring". * Unknown filter fields return `400 unknown_filter`. Fix the name in `error.param`; do not drop the filter and retry. * Replace the trailing `/` of a docs URL with `.md` (e.g. `/concepts/waterfall/` → `/concepts/waterfall.md`). API reference pages under `/api/` have no Markdown twin; read `/openapi.yaml` instead. [Provenance and confidence](/concepts/provenance-and-confidence/) explains what `null` means. [Filters](/platform/filters/) lists every filter name. ## Point your agent at the docs In a chat, paste a link to the `.md` page. In a coding agent, tell it where to look: ```text Read https://docs.betterjobs.cc/llms.txt and follow its Rules for AI agents. Fetch only the .md pages you need. Use https://docs.betterjobs.cc/openapi.yaml for exact field names, paths and error codes. ``` To let the agent call the API too, connect the [MCP server](/agents/mcp-server/). Its `search_docs` tool answers questions from these docs. For a full integration prompt, see [Agent quickstart](/agents/agent-quickstart/). # MCP server > How do I connect an AI agent to BetterJobs over MCP, and what does each tool cost? BetterJobs runs a remote MCP server. An agent connected to it can search jobs, check whether a company is hiring and watch companies, at the same credit prices as the REST API. | Setting | Value | | --------- | ------------------------------- | | URL | `https://mcp.betterjobs.cc/mcp` | | Transport | Streamable HTTP | | Auth | Bearer API key | | Plan | Growth and above | Use a `bj_live_` key. See [Authentication](/getting-started/authentication/). ## Connect a client * Claude Code Run once in your terminal. It adds the server to your Claude Code config. ```bash claude mcp add --transport http betterjobs https://mcp.betterjobs.cc/mcp \ --header "Authorization: Bearer $BETTERJOBS_API_KEY" ``` Check it with `claude mcp list`. * Claude Desktop Add this to `claude_desktop_config.json` and restart Claude Desktop. The `mcp-remote` bridge connects the desktop app to a remote server. claude\_desktop\_config.json ```json { "mcpServers": { "betterjobs": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.betterjobs.cc/mcp", "--header", "Authorization:${BETTERJOBS_AUTH}" ], "env": { "BETTERJOBS_AUTH": "Bearer bj_live_..." } } } } ``` * Cursor Add this to `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for all projects. Cursor reads the key from your environment. .cursor/mcp.json ```json { "mcpServers": { "betterjobs": { "url": "https://mcp.betterjobs.cc/mcp", "headers": { "Authorization": "Bearer ${env:BETTERJOBS_API_KEY}" } } } } ``` Keep the key out of shared config A project-level config file is often committed. Reference an environment variable instead of pasting `bj_live_...` into it. ## Tools Each tool maps to one REST endpoint and costs the same. Tool descriptions are written for the agent: when to call, what to pass, and what cheaper or lighter tool to try first. | Tool | Call it when | Inputs | Cost | Ask the user first? | Cheaper or lighter sibling | REST | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------- | | `search_jobs` | Find jobs matching filters. Call estimate\_search first if it may cost more than 50 credits, and ask the user. | Same body as POST /v1/jobs/search: filters (required), waterfall, limit (1 to 100), cursor. | 1 credit per unique job | Ask above 50 credits (estimate first) | estimate\_search (free) to price it first | `POST /v1/jobs/search` | | `estimate_search` | Before any large search. Returns expected jobs, providers and credit range. | filters (required), waterfall, limit. | Free | No | None. It is free. | `POST /v1/jobs/search with dry_run: true` | | `get_job` | Fetch one job by id. | id: a canonical job id (job\_...). | Free if already paid, else 1 credit | No | None. Free for jobs you already paid for. | `GET /v1/jobs/{id}` | | `company_hiring` | Full hiring profile for a company domain. | domain: company domain, no scheme or path. | 1 credit, every call | Ask before looping over many domains (1 credit each) | is\_hiring returns the same profile call. Call one, not both; each call is charged. | `GET /v1/companies/{domain}` | | `is_hiring` | Yes / no / unknown answer for one company. | domain: company domain, no scheme or path. | 1 credit, every call | Ask before looping over many domains (1 credit each) | company\_hiring hits the same endpoint. Call one, not both; each call is charged. | `GET /v1/companies/{domain}` | | `watch_company` | Get told when a company opens jobs or starts or stops hiring. Needs webhooks (Pro and above); otherwise returns plan\_required. | domain, webhook\_url, events (optional; defaults to all job and company events). | Free to create; 1 credit per job.opened delivered (free if already paid) | Always ask: ongoing charge with no cap | is\_hiring for a one-off check | `POST /v1/watches` | | `list_events` | Read the event feed for watches and async searches. | since (cursor from the last next\_cursor), limit (1 to 100). | Free | No | None. It is free. | `GET /v1/events` | | `search_docs` | Answer questions about the API from these docs. | query: a question in plain English. | Free | No | None. It is free. | `—` | `search_jobs` takes exactly the `POST /v1/jobs/search` body, so the [Filters](/platform/filters/) page applies as-is. The [Query Builder](/getting-started/quickstart/#4-build-your-own-query) prints a ready `search_jobs` call. ## The consent rule Agents spend your credits. The server enforces your plan and your `max_credits`. The rest is a rule the agent should follow: 1. Before any search that may cost more than 50 credits, call `estimate_search`. It is free. 2. Show the user `credits_range` and `providers_planned` and ask before running it. 3. Run `search_jobs` with `waterfall.max_credits` set to what the user approved. 4. Keep the canonical job `id` values. `get_job` on a job you already paid for is free, so never search again just to re-read a job. 5. Never call `watch_company` without asking. Each `job.opened` it delivers costs 1 credit, and a watch has no credit cap. 6. `is_hiring` and `company_hiring` call the same endpoint and each call costs 1 credit. Call one, not both, and ask before looping over many domains. Put the rule in your system prompt or project instructions: ```text When using the BetterJobs MCP server: before any search_jobs call that may cost more than 50 credits, call estimate_search, show me credits_range and providers_planned, and wait for my approval. Always set waterfall.max_credits. Never call watch_company without asking: each job.opened it delivers costs 1 credit, with no cap. Treat is_hiring.value null as unknown, never as false. ``` null is unknown `is_hiring` and `company_hiring` return `is_hiring.value: null` when there is not enough signal. An agent must say “unknown”, not “not hiring”. See [Provenance and confidence](/concepts/provenance-and-confidence/). ## Errors Tools return the same error codes as the REST API, with the same fields. The ones agents hit most: | Code | What the agent should do | | ---------------------- | -------------------------------------------------------------------- | | `insufficient_credits` | Stop. Tell the user `credits_needed` and the `upgrade_url`. | | `plan_required` | Tell the user the `required_plan`. Do not retry. | | `unknown_filter` | Fix the field named in `param`. The message suggests the right name. | | `rate_limited` | Wait `Retry-After` seconds, then retry once. | Full list: [Errors](/platform/errors/). ## Related * [Agent quickstart](/agents/agent-quickstart/) * [llms.txt and Markdown](/agents/llms-txt/) * [Credits and billing](/concepts/credits-and-billing/) # Credits and billing > What exactly costs a credit, and how do I avoid a surprise bill? **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 | 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](/api/) 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 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](/concepts/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. Compare with per-provider billing Per TheirStack docs, re-fetching the same job is billed again, and the docs suggest filtering on `discovered_at_gte` to avoid it. Per JobsPipe docs, one credit buys a job for the rest of the calendar month. BetterJobs charges a job you already paid for `0` credits, and the ledger shows each free re-read as `already_paid`. ## 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 ```bash 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 }' ``` * Python ```python import os import 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"]) ``` * TypeScript ```ts 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): ```json { "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](/agents/mcp-server/) call the same estimate through the `estimate_search` tool. ## 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. ```json { "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` Every search response says what it cost: ```json "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. Partial results bill only what you got If a provider fails or misses `timeout_ms`, you still get `200` with `metadata.status: partial`. You pay for the jobs returned, nothing for the provider that failed. ## 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 ```bash curl "https://api.betterjobs.cc/v1/billing/ledger?job_id=job_01JC8X4M2Q7RV3T9KD5W6YH0AB" \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python import os import 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"]) ``` * TypeScript ```ts 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): ```json { "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 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](/platform/idempotency/). ## 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`. | ```json { "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`](/platform/errors/#insufficient_credits) and [Async searches](/platform/async-searches/). `GET /v1/account` returns `credits_remaining` and `credits_reset_at`, so you can check before a big run. ## Plans | 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](/concepts/waterfall/#plan-availability) for what each plan can route to. The same plan data is machine-readable at [`/.well-known/pricing.json`](/.well-known/pricing.json). ## Estimate your monthly cost Enter how many unique jobs you expect per month. Duplicates and empty searches stay free whatever you enter. ## Related * [The waterfall](/concepts/waterfall/#budget-caps): `max_credits` and `timeout_ms` * [Canonical jobs](/concepts/canonical-jobs/): why one opening is one credit * [Get the billing ledger](/api/operations/getledger/) * [Errors](/platform/errors/#insufficient_credits) # Canonical jobs > What is a canonical job, and how are duplicates from different providers merged? A **canonical job** is one real opening at one company. BetterJobs builds it from every provider record that describes that opening. You get one row per opening, not one row per provider. ## Posting vs job A **posting** is one listing somewhere: an ATS page, a job board ad, an aggregator copy. One opening often has many postings. The employer lists it on its careers page. Boards copy it. Two months later it is re-listed with a fresh date. A **job** is the opening behind those postings. BetterJobs returns jobs. Each job lists the postings it was built from in `sources[]`: ```json { "id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "title": "Head of Revenue Operations", "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "repost_count": 0, "sources": [ { "provider": "betterjobs", "provider_job_id": "bj_idx_5521907", "fields": ["title", "description", "apply_url", "location", "employment_type", "posted_at"] }, { "provider": "theirstack", "provider_job_id": "ts_88213377", "fields": ["salary", "seniority"] }, { "provider": "techmap", "provider_job_id": "tm_3f9a2c71", "fields": ["job_family"] } ] } ``` Illustrative record, trimmed. The full version is in [Search jobs](/api/operations/searchjobs/). Three providers saw this opening. You pay for one job. ## How duplicates are found Records are compared on the signals that identify an opening: * **Company.** Resolved to one canonical company (`company.id`, `company.domain`). * **Title.** Normalized, so “Head of RevOps” and “Head of Revenue Operations” can match. * **Location.** City, region and country. * **Posting URLs.** The origin URL and `apply_url`, when sources report them. * **Provider ids.** A provider’s own `provider_job_id` keeps its record attached to the same job across requests. When records match, they merge. Each canonical field takes its value from the source best placed to supply it. `sources[].fields` tells you which source supplied which field. Why providers disagree on what a duplicate is Providers dedup differently on their own. Per Reqbeat docs, it keeps one row per company + title + country. Per Coresignal docs, its Base API returns one row per source with an `isDuplicate` flag. Per Techmap’s field reference, records carry `isDuplicate` too. BetterJobs applies one dedup across all of them, so the same opening from two providers is one job, not two. ## Reposts When an employer re-lists the same opening, it stays the same canonical job. `repost_count` goes up. `id` does not change. * A watch delivers `job.reposted`, not `job.opened`. Do not re-trigger outbound on it. See [Events](/data/events/). * `job.reposted` is free. Only `job.opened` costs a credit. * A high `repost_count` is a signal worth reading: the role may be hard to fill, or the listing may be evergreen. ## Ids are stable `id` (`job_...`) is stable across sources and requests. The same opening returns the same id from tomorrow’s search, from a different strategy, and from `GET /v1/jobs/{id}`. Use it as your primary key: * Upsert on `id` in your database or CRM. See [Sync patterns](/guides/sync-patterns/). * A job you already paid for is free on every later read. `metadata.jobs_already_paid` counts them. See [Credits and billing](/concepts/credits-and-billing/). * `company.id` (`cmp_...`) works the same way for companies. Do not key on `sources[].provider_job_id` or a posting URL. Those belong to one posting, and a job can gain or lose postings over time. ## What merging costs Nothing. `metadata.duplicates_merged` counts provider records folded into canonical jobs. They are free. ## Related * [Provenance and confidence](/concepts/provenance-and-confidence/): reading `sources[]` and `p_real` * [Freshness and lifecycle](/concepts/freshness-and-lifecycle/): `first_seen_at`, `status`, `closed_reason` * [Field dictionary](/data/field-dictionary/#field-job-repost_count) # Freshness and lifecycle > How fresh is a job, and how do I know when it opened, reposted or closed? Every job carries its own timestamps and a lifecycle status. You do not have to trust a freshness claim. You can read it off each record. ## The timestamps | Field | Set by | Meaning | Can be `null` | | ------------------------- | ------------ | ----------------------------------------------------- | --------------------------------------- | | `posted_at` | The employer | Date the employer posted the job, if known. | Yes. Many postings do not state it. | | `first_seen_at` | Any source | Earliest time any source saw the job. | No | | `last_seen_at` | Any source | Latest time any source saw the job. | No | | `last_verified_at` | Origin check | Latest time the job was confirmed live at its origin. | Yes. `null` = never verified at origin. | | `sources[].first_seen_at` | One provider | When this provider first saw the job. | No | | `sources[].last_seen_at` | One provider | When this provider last saw the job. | No | An illustrative job from the spec: ```json { "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 } ``` The canonical `first_seen_at` is the earliest of the `sources[].first_seen_at` values. The canonical `last_seen_at` is the latest of the `sources[].last_seen_at` values. So adding sources can only make a job look earlier-found and more recently seen, never staler. ## Which timestamp to use | You want | Use | | -------------------------------- | ------------------------------------------------------------------ | | “New jobs since my last sync” | `first_seen_at`. It is always set, and a repost does not reset it. | | “How old is the opening?” | `posted_at` when set, else `first_seen_at`. | | “Is it still up?” | `status`, then `last_verified_at` for proof from the origin. | | “How stale is this record?” | Now minus `last_seen_at`. | | “Which provider found it first?” | The `sources[]` entry with the earliest `first_seen_at`. | posted\_within\_days filters on first\_seen, not posted\_at `filters.posted_within_days` keeps jobs **first seen** within that many days. A job the employer dated three weeks ago but that BetterJobs first saw yesterday matches `posted_within_days: 7`. If you need the employer’s date, filter `posted_at` yourself after the response. ## Lifecycle A job is `open` or `closed`. When it closes, `closed_reason` says why. | `status` | `closed_reason` | Meaning | | -------- | --------------- | -------------------------------------------------- | | `open` | `null` | Live. `closed_reason` is always `null` while open. | | `closed` | `filled` | The role was filled. | | `closed` | `expired` | The listing ran out. | | `closed` | `removed` | The listing was taken down. | | `closed` | `unknown` | It is gone, the reason is not known. | A re-listed job is not a new job. It keeps its `id` and `repost_count` goes up. See [Canonical jobs](/concepts/canonical-jobs/#reposts). Searches return only open jobs by default. Send `filters.include_closed: true` to get closed ones too, for example when you backfill history or reconcile a CRM. * curl ```bash 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": { "company_domain_or": ["acme-robotics.example"], "posted_within_days": 30, "include_closed": true }, "waterfall": { "max_credits": 100 }, "limit": 100 }' ``` * Python ```python import os import 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": { "company_domain_or": ["acme-robotics.example"], "posted_within_days": 30, "include_closed": True, }, "waterfall": {"max_credits": 100}, "limit": 100, }, ) resp.raise_for_status() for job in resp.json()["data"]: print(job["id"], job["status"], job["closed_reason"], job["last_seen_at"]) ``` * TypeScript ```ts 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: { company_domain_or: ['acme-robotics.example'], posted_within_days: 30, include_closed: true, }, waterfall: { max_credits: 100 }, limit: 100, }), }); if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`); const { data } = await resp.json(); for (const job of data) console.log(job.id, job.status, job.closed_reason, job.last_seen_at); ``` ## Lifecycle events Instead of polling, watch a company or a saved search. Each change arrives as a typed event: | Event | What happened | What to do | data | Cost | | -------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------ | -------- | | `job.opened` | A new canonical job appeared for a watched company or saved search. | Trigger outbound. This is the only event that should start a sequence. | job (full Job) | 1 credit | | `job.reposted` | The same job was re-listed. repost\_count went up. | Do not re-trigger outbound. Update your copy of the job only. | job (id, title, company, status, repost\_count) | Free | | `job.closed` | The job is no longer live. closed\_reason says why: filled, expired, removed or unknown. | Stop sequences tied to this job. Mark it closed in your CRM. | job (id, title, company, status, closed\_reason) | Free | | `job.updated` | A field on an open job changed, for example salary or location. | Upsert the job by id. No outbound action. | job (full Job) | Free | Only `job.opened` should start outbound. See [Events](/data/events/) and [Webhooks](/platform/webhooks/). ## How fresh are the sources? BetterJobs is only as fresh as the sources that see a job first. Each partner provider publishes its own refresh claims. They are theirs, not measured by BetterJobs: | Source | Refresh, as published | Per | | ------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------ | | [Reqbeat](/providers/reqbeat/) | Refresh: Corpus refreshed every 3 hours | per Reqbeat docs | | [SignalsAPI](/providers/signalsapi/) | Recheck: Sources rechecked every 15 minutes | per SignalsAPI docs | | [TheirStack](/providers/theirstack/) | Discovery: 73% of jobs discovered the same day, 91% by the end of the next day | per TheirStack docs | | [JobsPipe](/providers/jobspipe/) | Freshness: Under 6h on Builder, under 1h on Scale | per JobsPipe docs | | [Coresignal](/providers/coresignal/) | Recheck: Active postings rechecked within 24h | per Coresignal docs | | [Techmap](/providers/techmap/) | Feeds: Daily country feeds via AWS Data Exchange | per Techmap (jobdatafeeds.com) | The [BetterJobs index](/providers/betterjobs-index/) refresh rate will be published at GA. The `freshest_first` strategy asks the fastest-refreshing sources first. See [the waterfall](/concepts/waterfall/#strategies). Measured freshness at GA BetterJobs does not yet publish measured freshness numbers for the merged result. They will be published at GA, with the method. Until then, measure it on your own queries: compare `first_seen_at` with `posted_at`, and now with `last_seen_at`, across the jobs you get back. ## Related * [Canonical jobs](/concepts/canonical-jobs/): reposts and stable ids * [Sync patterns](/guides/sync-patterns/): backfill, daily upsert, closing stale jobs * [Detect hiring changes](/guides/detect-hiring-changes/) * [Field dictionary](/data/field-dictionary/#field-job-last_verified_at) # Licensing > Can I display or resell the jobs I get from BetterJobs? Every job carries a `license` object with two flags. Read them before a job leaves your team. ```json "license": { "display": true, "resale": false } ``` | Flag | `true` means | | ----------------- | ------------------------------------------------ | | `license.display` | You may show this job to your end users. | | `license.resale` | You may resell or redistribute this job as data. | Both are always present and always `true` or `false`. Never `null`. ## In plain English | `display` | `resale` | You may | You may not | | --------- | -------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `true` | `true` | Show it in your product and pass the data on to others. | | | `true` | `false` | Show it in your product, for example on a job board, in an app, or in an alert to your users. | Sell or hand over the data itself, for example as a file, a feed or an API. | | `false` | `true` | Pass the data on as data. | Show it to end users in your product. | | `false` | `false` | | Show it to end users, or resell it. | What you may do inside your own team, such as research or enriching your own CRM, is set by your agreement with BetterJobs, not by these flags. ## Common cases * **Sales prospecting in your own CRM.** Internal use. Your agreement covers it; the flags matter once jobs reach people outside your team. * **A job board or job alerts for your users.** Keep only jobs with `license.display: true`. * **An agency sending shortlists to clients.** Your clients are end users. Keep only `license.display: true`. * **Selling a dataset, a feed or an API built on these jobs.** Keep only `license.resale: true`. * **Training data or a public dump.** That is redistribution. Keep only `license.resale: true`, and check your agreement. Check per job, not per search Flags are set on each job. One page of results can mix jobs with different flags. Filter on every job, every time, including jobs that arrive in webhook events. ## Filter before you publish There is no search filter for license, so filter the response. This keeps only jobs you may show: * curl ```bash 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": { "max_credits": 100 } }' \ | jq '.data | map(select(.license.display))' ``` * Python ```python import os import 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": {"max_credits": 100}, }, ) resp.raise_for_status() showable = [job for job in resp.json()["data"] if job["license"]["display"]] ``` * TypeScript ```ts 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: { max_credits: 100 }, }), }); if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`); const { data } = await resp.json(); const showable = data.filter((job: { license: { display: boolean } }) => job.license.display); ``` Jobs you filter out are still charged, because they were returned. If most of your results are not usable for your case, tell us: the mix depends on the sources a search routes to. ## Simplest licensing: `own_only` The `own_only` strategy uses only the [BetterJobs index](/providers/betterjobs-index/), with no partner providers. One source means one set of terms. It is the strategy to start with when licensing matters more than coverage. See [the waterfall](/concepts/waterfall/#strategies). Partner providers have their own terms with BetterJobs. The `license` flags on each job are where those terms reach you. You do not need to read six provider contracts. ## This is not legal advice These flags summarize what BetterJobs allows. Your agreement with BetterJobs is what binds you. If you plan to resell or publish at scale, read it or ask us before you ship. ## Related * [Provenance and confidence](/concepts/provenance-and-confidence/): `sources[]` shows which providers a job came from * [Field dictionary](/data/field-dictionary/#field-job-license-display) * [When BetterJobs is the wrong tool](/resources/when-not-to-use/) # Provenance and confidence > Where did each field come from, and how much should I trust it? Every job tells you which providers saw it, what each one contributed and when. On top of that, BetterJobs gives you two confidence signals: `p_real` on jobs and `is_hiring` on companies. This page explains how to read all of them, and what `null` means. ## `sources[]`: who said what Each [canonical job](/concepts/canonical-jobs/) carries a `sources[]` array. One entry per provider that saw the opening: ```json "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"] } ] ``` Illustrative record from the spec. Read it like this: | Key | Answers | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `provider` | Which source. `betterjobs` is the [BetterJobs index](/providers/betterjobs-index/). | | `provider_job_id` | The provider’s own id for its posting. Useful for support with that provider. Not a primary key. | | `url` | The posting as this provider saw it. Two sources can point to different URLs for the same job. | | `first_seen_at`, `last_seen_at` | When this provider first and last saw the job. See [Freshness and lifecycle](/concepts/freshness-and-lifecycle/). | | `fields` | Which canonical fields this source supplied. | So in the example, `salary` and `seniority` came from TheirStack, `job_family` from Techmap, and the rest from the BetterJobs index. An empty fields list still counts `fields: []` means the source saw the job and corroborated it, but contributed no field to the canonical record. It still counts as corroboration. ## Declared vs inferred Some values are stated by the employer. Others are estimated by a source. The response tells you which where it matters: * `salary.origin` is `declared` (stated in the posting) or `inferred` (estimated by a source). Do not show an inferred range as the employer’s offer. * `seniority` and `job_family` are classified from the title and text. The [field dictionary](/data/field-dictionary/) marks each field as `raw`, `normalized` or `inferred`. ## `p_real`: is this a real open job? `p_real` is a probability from `0` to `1` that the job is a real open req. It comes from cross-source corroboration: independent sources seeing the same opening push it up. How to use it: * **Sort or threshold** before outbound. Pick a cutoff that fits your cost of a wrong contact. * **Combine with `consensus`.** The [`consensus` strategy](/concepts/waterfall/#strategies) drops jobs seen by fewer than `min_sources` sources. `p_real` grades the jobs that remain. * **Compare with a single provider.** Per JobsPipe docs, JobsPipe publishes a `ghost_score` per job. `p_real` points the other way: higher means more likely real. p\_real is a probability, not a verdict A job with `p_real: 0.6` is not fake. It is less corroborated. A brand-new job seen by one source can start lower and rise as other sources pick it up. The calibration method and measured accuracy will be published at GA. ## `is_hiring`: yes, no, or unknown Company profiles (`GET /v1/companies/{domain}`) answer “is this company hiring?” with three values, not two: | `is_hiring.value` | Meaning | What to do | | ----------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------ | | `true` | Open jobs, corroborated. | Hiring-based plays are on. | | `false` | Evidence the company stopped hiring, for example all its jobs closed. | Pause hiring-based plays. | | `null` | Unknown. Not enough signal. | Do not treat as `false`. Check again later or try another channel. | Each value comes with `confidence` (0 to 1) and a plain-English `basis`: ```json { "value": true, "confidence": 0.96, "basis": "42 open jobs corroborated by 4 sources in the last 30 days" } ``` ```json { "value": null, "confidence": 0.0, "basis": "No careers page or ATS found for this domain" } ``` Both illustrative, from the spec. Northwind Traders (`northwind.example`) gets `null` because no careers page or ATS was found. That says nothing about whether it hires. Never treat null as false If you write `if not profile["is_hiring"]["value"]`, a company you know nothing about lands in the “not hiring” bucket. Check for `null` explicitly. Per Reqbeat docs, Reqbeat draws the same line: `coverage_status: no_ats_signal` means unknown, not “not hiring”. * curl ```bash curl https://api.betterjobs.cc/v1/companies/acme-robotics.example \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python import os import requests resp = requests.get( "https://api.betterjobs.cc/v1/companies/acme-robotics.example", headers={ "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", }, ) resp.raise_for_status() hiring = resp.json()["is_hiring"] if hiring["value"] is None: print("unknown:", hiring["basis"]) elif hiring["value"]: print("hiring", hiring["confidence"]) else: print("not hiring", hiring["confidence"]) ``` * TypeScript ```ts const resp = await fetch('https://api.betterjobs.cc/v1/companies/acme-robotics.example', { headers: { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, 'BetterJobs-Version': '2026-10-01', }, }); if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`); const { is_hiring } = await resp.json(); if (is_hiring.value === null) console.log('unknown:', is_hiring.basis); else if (is_hiring.value) console.log('hiring', is_hiring.confidence); else console.log('not hiring', is_hiring.confidence); ``` A watch fires `company.hiring_stopped` only when `value` changes to `false`. A change to `null` never fires it. See [Detect hiring changes](/guides/detect-hiring-changes/). ## Null semantics One rule covers jobs and company profiles: | You see | It means | | ------------- | ------------------------------------------------------------------------------------------------------- | | `null` | Unknown. No source reported it. | | `[]` | Verified none. For example `top_job_families: []` = no open jobs found. | | Field omitted | Not part of this payload, for example the lifecycle-only job in `job.closed` and `job.reposted` events. | `false` is a real answer, never a stand-in for unknown. `location.remote: null` means nobody said; `location.remote: false` means on-site or hybrid. The [field dictionary](/data/field-dictionary/) lists what `null` means for each field. ## How complete is a result? Every search response reports fill rates for the jobs it returned in `metadata.field_coverage`: the fraction (0 to 1) of returned jobs with a non-null value, per field. ```json "field_coverage": { "salary": 0.41, "seniority": 0.97, "location.remote": 0.88, "description": 0.99 } ``` Illustrative numbers from the spec. Read your own from each response: they depend on the query, the strategy and the providers on your plan. Measured fill rates per provider will be published at GA. ## License travels with the job Every job also carries `license.display` and `license.resale`. They tell you what you may do with the data. See [Licensing](/concepts/licensing/). ## Related * [Canonical jobs](/concepts/canonical-jobs/): how `sources[]` is built * [Field dictionary](/data/field-dictionary/#field-job-p_real): `p_real` and every other field * [Company profiles](/data/companies/) * [Get a company hiring profile](/api/operations/getcompany/) # The waterfall > How does one request fan out to seven sources and come back as one list? You send one search. BetterJobs routes it to the BetterJobs index and the partner providers your plan enables. It merges what comes back into canonical jobs and returns one list. You pay 1 credit per unique job, not per provider asked. Diagram: one search request goes to 7 sources: BetterJobs index (contributed fields), Reqbeat (duplicate, merged), SignalsAPI (no match), TheirStack (contributed fields), JobsPipe (duplicate, merged), Coresignal (no match), Techmap (contributed fields). Results are merged and deduplicated into one canonical job whose sources array lists betterjobs, theirstack, techmap. Illustrative: one request, seven sources, one canonical job. Duplicates are merged and free. ## What happens on one request 1. **Validate.** Filters are checked against the [filter grammar](/platform/filters/). An unknown field returns `400 unknown_filter`. It is never silently ignored. 2. **Plan.** The `waterfall.strategy` picks which sources to ask and in what order. Only sources enabled on your plan are eligible. 3. **Fan out.** Each source gets the query translated into its own grammar and field names. You never see six APIs. 4. **Merge.** Records that describe the same opening collapse into one [canonical job](/concepts/canonical-jobs/). Each source that saw it is listed in `sources[]`, with the fields it contributed. 5. **Bill.** You pay for unique jobs you have not paid for before. Duplicates, already-paid jobs and empty pages are free. See [Credits and billing](/concepts/credits-and-billing/). 6. **Report.** `metadata` tells you what happened: which providers were tried, which hit, which failed, how many duplicates were merged and what you were charged. ## The sources Seven sources can answer a search. The BetterJobs index is on every plan. The six partners are added by plan. | Source | Slug | What it sells | | ------------------------------------------------ | ------------ | ---------------------------------------------------------------------------------------------- | | [BetterJobs index](/providers/betterjobs-index/) | `betterjobs` | Our own job sources: an owned crawl of employer career pages and ATSs. | | [Reqbeat](/providers/reqbeat/) | `reqbeat` | Deduplicated, normalized job postings and hiring events from ATSs, job boards and aggregators. | | [SignalsAPI](/providers/signalsapi/) | `signalsapi` | Recruiter-focused hiring signals plus the hiring owner's verified work email. | | [TheirStack](/providers/theirstack/) | `theirstack` | Global job postings, technographics inferred from job text, buying intent and firmographics. | | [JobsPipe](/providers/jobspipe/) | `jobspipe` | Normalized job postings from 30+ sources with 12 months of history. | | [Coresignal](/providers/coresignal/) | `coresignal` | Large historical job-posting dataset plus company and employee records. | | [Techmap](/providers/techmap/) | `techmap` | High-volume job feeds from ATSs, job boards and public employment offices (jobdatafeeds.com). | `GET /v1/providers` returns every source, whether your plan enables it, and its live status. See [Providers](/providers/) for each source’s facts and [List providers](/api/operations/listproviders/) for the endpoint. ## Strategies Set `waterfall.strategy` on the request. The default is `cheapest_first`. | Strategy | What it does | Use when | | ---------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `cheapest_first` | Starts with the BetterJobs index and adds providers only until the page is full. | Default. Good for most searches and for keeping cost low. | | `freshest_first` | Tries the sources with the fastest refresh first. | You care about jobs posted in the last hours more than total coverage. | | `max_coverage` | Queries every provider enabled on your plan. | Market sizing, backfills, or any time a missed job costs more than a credit. | | `consensus` | Returns only jobs seen by at least min\_sources sources (default 2). | You need high confidence the job is real, for example before outbound. | | `own_only` | Uses the BetterJobs index only. No partner providers. | Free and Starter plans, or when you need the simplest licensing. | You can also pin the sources yourself with `waterfall.providers`, for example `["betterjobs", "theirstack"]`. Every slug must be enabled on your plan, or the request returns `403 plan_required`. For a decision guide, see [Choose a strategy](/guides/choose-a-strategy/). consensus filters, it does not add `consensus` drops every job seen by fewer than `waterfall.min_sources` sources (default `2`, range `2` to `7`). It returns fewer jobs, each corroborated. It needs at least `min_sources` sources enabled on your plan. ## Budget caps Two fields bound every request. Set both in production. | Field | Range | What it does | | ----------------------- | ---------------------------------- | ---------------------------------------------------------------------------- | | `waterfall.max_credits` | `0` or more | Hard cap on credits this request may charge. Results stop at the cap. | | `waterfall.timeout_ms` | `1000` to `30000`, default `10000` | Time budget. Providers that miss it are dropped and the result is `partial`. | A dropped or failing provider never fails the request. You get `200` with `metadata.status: partial`, the provider listed in `metadata.providers.failed` with code `provider_timeout` or `provider_error`, and a bill for returned jobs only. * curl ```bash 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", "Head of Revenue Operations"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7 }, "waterfall": { "strategy": "max_coverage", "max_credits": 100, "timeout_ms": 10000 }, "limit": 25 }' ``` * Python ```python import os import 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", "Head of Revenue Operations"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7, }, "waterfall": {"strategy": "max_coverage", "max_credits": 100, "timeout_ms": 10000}, "limit": 25, }, ) resp.raise_for_status() meta = resp.json()["metadata"] print(meta["status"], meta["providers"]["hit"], meta["providers"]["failed"]) ``` * TypeScript ```ts 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', 'Head of Revenue Operations'], country_code_or: ['DE', 'AT', 'CH'], posted_within_days: 7, }, waterfall: { strategy: 'max_coverage', max_credits: 100, timeout_ms: 10000 }, limit: 25, }), }); if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`); const { metadata } = await resp.json(); console.log(metadata.status, metadata.providers.hit, metadata.providers.failed); ``` ## Reading the trace Every response carries a `metadata.providers` block. This is the illustrative example from the spec: ```json "providers": { "tried": ["betterjobs", "techmap", "theirstack", "coresignal"], "hit": ["betterjobs", "techmap", "theirstack"], "failed": [{ "provider": "coresignal", "code": "provider_timeout" }], "contributions": { "betterjobs": 1, "techmap": 0, "theirstack": 0 } } ``` * `tried`: sources the strategy asked. * `hit`: sources that returned at least one match. * `failed`: sources that errored or missed `timeout_ms`. * `contributions`: unique jobs each source contributed first. A source with `0` may still have corroborated jobs and added fields. Check `sources[]` on each job. `metadata.duplicates_merged` counts provider records folded into jobs you already have in the response. They are free. ## Why this is not a contact waterfall Contact-enrichment waterfalls ask provider A for an email, then B if A has none, and stop at the first valid answer. One person has one right answer. A job search has no single right answer. It is a set. Each provider sees a different slice of the market, and the same opening often shows up in several of them with different fields filled. So BetterJobs does not stop at the first hit: * It keeps asking until the page is full (`cheapest_first`) or until every source has answered (`max_coverage`). * It merges overlapping records instead of discarding them. A second source can add `salary` or `seniority` the first one lacked. * Overlap raises confidence. A job seen by several independent sources gets a higher `p_real`. See [Provenance and confidence](/concepts/provenance-and-confidence/). ## Plan availability | Plan | Sources you can route to | | ---------------------- | -------------------------------------------- | | Free, Starter | BetterJobs index only. Use `own_only`. | | Growth | BetterJobs index + 2 partner providers | | Pro, Scale, Enterprise | BetterJobs index + all six partner providers | Prices and credits are in the [plan table](/concepts/credits-and-billing/#plans). `GET /v1/providers` shows exactly which sources your key can use today. ## Related * [Choose a strategy](/guides/choose-a-strategy/) * [Canonical jobs](/concepts/canonical-jobs/) * [Errors and partial results](/platform/errors/#provider_timeout) * [Search jobs API reference](/api/operations/searchjobs/) # Companies > What does a company hiring profile contain, and how is is_hiring decided? Cost: 1 credit / profile ## Overview A company hiring profile answers one question: **is this company hiring right now, and in which direction?** You look a company up by its domain. The profile rolls up every open canonical job BetterJobs knows for that company, across all sources: * `open_jobs_count`: open canonical jobs right now. * `is_hiring`: a three-state answer (`true`, `false`, `null`) with a `confidence` and a plain-English `basis`. * `hiring_pulse`: the 30-day trend (`up`, `flat`, `down`) and the change in open jobs. * `top_job_families`: where the hiring is. * `sources[]`: how many open jobs each provider sees, and when it last saw the company. The same `company` object (`id`, `name`, `domain`) is embedded in every job and event, so you can join profiles to jobs on `company.id`. ### How is\_hiring is decided `is_hiring` is derived from the company’s canonical jobs and how many sources corroborate them. The decision is never a bare boolean. Every answer comes with: * `value`: `true`, `false` or `null`. * `confidence`: 0 to 1. * `basis`: the reason in plain English, for example `"42 open jobs corroborated by 4 sources in the last 30 days"`. The three values mean different things: | `is_hiring.value` | Meaning | Example `basis` (illustrative) | | ----------------- | ------------------------------------------------ | ------------------------------------------------------------- | | `true` | Open jobs exist and sources corroborate them. | `42 open jobs corroborated by 4 sources in the last 30 days` | | `false` | Sources see the company and it has no open jobs. | `All 6 open jobs closed in the last 14 days across 3 sources` | | `null` | Unknown. Not enough signal to decide. | `No careers page or ATS found for this domain` | Never treat null as false `null` means BetterJobs could not find enough signal: no careers page, no ATS, no source coverage. The company may be hiring heavily through channels no source sees. Do not put `null` companies on a “not hiring” list. Read `basis` and `confidence` before you act on either value. `company.hiring_stopped` fires only when `value` changes to `false`. A change to `null` never fires it. See [Events](/data/events/). Read [Provenance and confidence](/concepts/provenance-and-confidence/) for how corroboration feeds confidence. ## Field dictionary | Field | Type | Description | null / \[] means | Derivation | Example | | ----------------------------------- | --------------------------------------- | --------------------------------------------- | --------------------------------------- | ---------- | ---------------------------------------------------------------------------------- | | `company` | `Company` | Company reference: `id`, `name`, `domain`. | Never null | normalized | `{"id":"cmp_4Rk7TzP1aQ","name":"Acme Robotics","domain":"acme-robotics.example"}` | | `open_jobs_count` | `integer` | Open canonical jobs right now. | Never null | normalized | `42` | | `is_hiring.value` | `boolean \| null` | Whether the company is hiring. | Unknown. Never treat `null` as `false`. | inferred | `true` | | `is_hiring.confidence` | `number (0-1)` | Confidence in `is_hiring.value`. | Never null | inferred | `0.96` | | `is_hiring.basis` | `string` | Plain-English reason for the value. | Never null | inferred | `"42 open jobs corroborated by 4 sources in the last 30 days"` | | `hiring_pulse.direction` | `up \| flat \| down` | Direction of open jobs over the last 30 days. | Never null | inferred | `"up"` | | `hiring_pulse.open_jobs_30d_change` | `integer` | Change in open jobs over the last 30 days. | Never null | normalized | `9` | | `top_job_families` | `{job_family, open_jobs}[]` | Job families with the most open jobs. | `[]` = no open jobs found. | inferred | `[{"job_family":"engineering","open_jobs":18}]` | | `sources` | `{provider, open_jobs, last_seen_at}[]` | Open jobs per provider for this company. | `[]` = no source sees this company. | raw | `[{"provider":"theirstack","open_jobs":31,"last_seen_at":"2026-10-11T04:02:00Z"}]` | The fields of the embedded `company` object (`company.id`, `company.name`, `company.domain`) are described with the job fields in the [Field dictionary](/data/field-dictionary/). ## Record sample The three profiles in [`companies.json`](/samples/companies.json) cover the three states of `is_hiring`. Illustrative data: companies and domains are fictional. * Hiring (true) companies.json — record 1 (illustrative) ```json { "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "open_jobs_count": 42, "is_hiring": { "value": true, "confidence": 0.96, "basis": "42 open jobs corroborated by 4 sources in the last 30 days" }, "hiring_pulse": { "direction": "up", "open_jobs_30d_change": 9 }, "top_job_families": [ { "job_family": "engineering", "open_jobs": 18 }, { "job_family": "sales", "open_jobs": 11 }, { "job_family": "operations", "open_jobs": 6 } ], "sources": [ { "provider": "betterjobs", "open_jobs": 38, "last_seen_at": "2026-10-11T06:10:00Z" }, { "provider": "theirstack", "open_jobs": 31, "last_seen_at": "2026-10-11T04:02:00Z" }, { "provider": "techmap", "open_jobs": 27, "last_seen_at": "2026-10-10T23:40:00Z" }, { "provider": "coresignal", "open_jobs": 25, "last_seen_at": "2026-10-10T18:15:00Z" } ] } ``` * Not hiring (false) companies.json — record 2 (illustrative) ```json { "company": { "id": "cmp_7Jd4FgH1sA", "name": "Globex Analytics", "domain": "globex.example" }, "open_jobs_count": 0, "is_hiring": { "value": false, "confidence": 0.91, "basis": "All 6 open jobs closed in the last 14 days across 3 sources" }, "hiring_pulse": { "direction": "down", "open_jobs_30d_change": -6 }, "top_job_families": [], "sources": [ { "provider": "betterjobs", "open_jobs": 0, "last_seen_at": "2026-10-11T05:00:00Z" }, { "provider": "jobspipe", "open_jobs": 0, "last_seen_at": "2026-10-11T03:30:00Z" }, { "provider": "theirstack", "open_jobs": 0, "last_seen_at": "2026-10-10T20:00:00Z" } ] } ``` * Unknown (null) companies.json — record 3 (illustrative) ```json { "company": { "id": "cmp_9Hs2VbN6eW", "name": "Northwind Traders", "domain": "northwind.example" }, "open_jobs_count": 0, "is_hiring": { "value": null, "confidence": 0.0, "basis": "No careers page or ATS found for this domain" }, "hiring_pulse": { "direction": "flat", "open_jobs_30d_change": 0 }, "top_job_families": [], "sources": [] } ``` Compare records 2 and 3. Both have `open_jobs_count: 0`. Record 2 is `false` because three sources see the company and all its jobs closed. Record 3 is `null` because no source sees the company at all (`sources: []`). ## Coverage BetterJobs does not publish a static count of covered companies during the v1 preview. It is published at GA. Coverage for one company is visible in its profile: * `sources[]` lists every provider that sees the company, with its own `open_jobs` and `last_seen_at`. `[]` means no source sees this company. * `is_hiring.value: null` with a `basis` such as `No careers page or ATS found for this domain` marks a coverage gap, not a hiring answer. * `open_jobs_count` counts canonical jobs. It is usually lower than the sum of `sources[].open_jobs`, because the same job seen by four providers counts once. Your plan decides which providers can contribute. `GET /v1/providers` shows what your plan enables. ## Freshness * `sources[].last_seen_at` shows when each provider last saw the company. * `hiring_pulse` covers the last 30 days. * The underlying jobs carry `first_seen_at`, `last_seen_at` and `last_verified_at`. Fetch them with `company_domain_or` (below) when you need job-level timing. To be told when a company changes state instead of polling, create a company watch. It delivers `company.hiring_started`, `company.hiring_stopped` and job events. See [Detect hiring changes](/guides/detect-hiring-changes/). ## API * curl ```bash curl -s https://api.betterjobs.cc/v1/companies/acme-robotics.example \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python import os import requests resp = requests.get( "https://api.betterjobs.cc/v1/companies/acme-robotics.example", headers={ "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", }, timeout=30, ) resp.raise_for_status() profile = resp.json() hiring = profile["is_hiring"]["value"] if hiring is None: print("Unknown:", profile["is_hiring"]["basis"]) # not the same as False else: print("Hiring" if hiring else "Not hiring", profile["is_hiring"]["confidence"]) ``` * TypeScript ```ts const resp = await fetch("https://api.betterjobs.cc/v1/companies/acme-robotics.example", { headers: { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, "BetterJobs-Version": "2026-10-01", }, }); if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`); const profile = await resp.json(); const { value, confidence, basis } = profile.is_hiring; if (value === null) console.log("Unknown:", basis); // not the same as false else console.log(value ? "Hiring" : "Not hiring", confidence); ``` | Operation | Endpoint | Cost | | ---------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------- | | [Get a company hiring profile](/api/operations/getcompany/) | `GET /v1/companies/{domain}` | Cost: 1 credit / profile | | [Create a watch](/api/operations/createwatch/) (`type: company`) | `POST /v1/watches` | Cost: FreeEach job.opened event a watch delivers costs 1 credit; other events are free. | Pass the domain without scheme or path: `acme-robotics.example`, not `https://www.acme-robotics.example/`. ## Bulk There is no batch profile endpoint. For many companies, pick the pattern that fits: * **Their open jobs**: one search with `company_domain_or` set to a list of domains. You pay per unique job, not per company. See [Find companies hiring](/guides/find-companies-hiring/). * **Ongoing monitoring**: one company watch per domain. Changes arrive as [events](/data/events/). * **Many profiles**: call `GET /v1/companies/{domain}` per domain, within your [rate limit](/platform/rate-limits/). Each call costs 1 credit, so cache profiles instead of re-fetching them. # Events > Which events can I receive, and what should my integration do with each one? Cost: 1 credit / job.opened deliveredFree if you already paid for that job. Every other event type, retries and replays are free. ## Overview An event tells you that something changed: a job opened, closed or was re-listed, or a company started or stopped hiring. Events come from two places: * **Watches.** Watch a company domain (`type: company`) or a saved search (`type: search`) with `POST /v1/watches`. * **Async searches.** `search.completed` fires when a search you started with `POST /v1/searches` finishes. Each event is delivered to your `webhook_url` and also kept in the feed at `GET /v1/events`. ## Event catalog Each event type has one meaning and one recommended action. The **What to do** column is the part to get right. | Event | What happened | What to do | data | Cost | | ------------------------ | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------ | -------- | | `job.opened` | A new canonical job appeared for a watched company or saved search. | Trigger outbound. This is the only event that should start a sequence. | job (full Job) | 1 credit | | `job.reposted` | The same job was re-listed. repost\_count went up. | Do not re-trigger outbound. Update your copy of the job only. | job (id, title, company, status, repost\_count) | Free | | `job.closed` | The job is no longer live. closed\_reason says why: filled, expired, removed or unknown. | Stop sequences tied to this job. Mark it closed in your CRM. | job (id, title, company, status, closed\_reason) | Free | | `job.updated` | A field on an open job changed, for example salary or location. | Upsert the job by id. No outbound action. | job (full Job) | Free | | `company.hiring_started` | is\_hiring.value for a watched company changed to true. | Good moment for account-level outreach. | company, is\_hiring | Free | | `company.hiring_stopped` | is\_hiring.value for a watched company changed to false. A change to null (unknown) never fires this event. | Pause hiring-based plays for this account. | company, is\_hiring | Free | | `search.completed` | An async search finished with status completed, partial or failed. | Read results with GET /v1/searches/{id}. Branch on status. | search (id, status, jobs\_found) | Free | Only job.opened starts outbound A re-listed job fires `job.reposted`, not `job.opened`. Treat it as an update. If you start a sequence on every event, you will email the same company again each time the employer refreshes an old posting. Unknown is not stopped `company.hiring_stopped` fires only when `is_hiring.value` changes to `false`. If a company becomes unknown (`null`), no event fires. See [Companies](/data/companies/#how-is_hiring-is-decided). ## Envelope Every event has the same envelope. `data` carries the objects relevant to `type`. | Field | Type | Meaning | | -------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------- | | `id` | `string` | Event id (`evt_...`). Use it to dedupe and to replay. | | `type` | `EventType` | One of the types in the catalog above. | | `created_at` | `date-time` | When the event was created. | | `watch_id` | `string \| null` | Watch that produced the event. `null` for `search.completed`. | | `data.job` | `object` | Full `Job` for `job.opened` and `job.updated`. Id, title, company, status and lifecycle fields otherwise. | | `data.company`, `data.is_hiring` | `object` | For `company.hiring_started` and `company.hiring_stopped`. | | `data.search` | `object` | `id`, `status`, `jobs_found` for `search.completed`. | A field missing from `data.job` is not part of that payload. It is not `null` and not unknown. Fetch the full job with `GET /v1/jobs/{id}` if you need it; re-reading a job you already paid for is free. ## Samples Illustrative events from the [webhook reference](/api/webhooks/webhookevent/). Companies and domains are fictional. * job.closed ```json { "id": "evt_1Kp6MnB3vC", "type": "job.closed", "created_at": "2026-10-11T10:00:00Z", "watch_id": "wat_6Np3QyR8tU", "data": { "job": { "id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "title": "Head of Revenue Operations", "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "status": "closed", "closed_reason": "filled" } } } ``` * job.reposted ```json { "id": "evt_3Fh8JkL2pQ", "type": "job.reposted", "created_at": "2026-10-11T07:30:00Z", "watch_id": "wat_6Np3QyR8tU", "data": { "job": { "id": "job_01JC2B7Y9MZQ4W8E1R6T3N5K0D", "title": "Senior Robotics Engineer", "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "status": "open", "repost_count": 2 } } } ``` * company.hiring\_stopped ```json { "id": "evt_5Gt2HyU7iO", "type": "company.hiring_stopped", "created_at": "2026-10-11T10:30:00Z", "watch_id": "wat_2Hb7KsM4pE", "data": { "company": { "id": "cmp_7Jd4FgH1sA", "name": "Globex Analytics", "domain": "globex.example" }, "is_hiring": { "value": false, "confidence": 0.91, "basis": "All 6 open jobs closed in the last 14 days across 3 sources" } } } ``` * search.completed ```json { "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 } } } ``` A `job.opened` event carries the full job. Its shape is the record on [Jobs](/data/jobs/#record-sample). ## Handle events Route on `type`, dedupe on `id`, upsert jobs on `data.job.id`. * curl ```bash # Read the event feed (free). Pass next_cursor back as since to resume. curl -s "https://api.betterjobs.cc/v1/events?limit=100" \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python def handle(event: dict, seen: set[str]) -> None: if event["id"] in seen: # retries and replays reuse the same id return seen.add(event["id"]) match event["type"]: case "job.opened": start_outbound(event["data"]["job"]) # the only event that starts a sequence case "job.updated": upsert_job(event["data"]["job"]) # full Job; update only, never re-trigger outbound case "job.reposted": job = event["data"]["job"] # partial payload: update repost_count only set_repost_count(job["id"], job["repost_count"]) case "job.closed": stop_sequences(event["data"]["job"]["id"]) case "company.hiring_started" | "company.hiring_stopped": update_account(event["data"]["company"], event["data"]["is_hiring"]) case "search.completed": fetch_results(event["data"]["search"]["id"]) # branch on search status ``` * TypeScript ```ts function handle(event: { id: string; type: string; data: any }, seen: Set): void { if (seen.has(event.id)) return; // retries and replays reuse the same id seen.add(event.id); switch (event.type) { case "job.opened": startOutbound(event.data.job); // the only event that starts a sequence break; case "job.updated": upsertJob(event.data.job); // full Job; update only, never re-trigger outbound break; case "job.reposted": setRepostCount(event.data.job.id, event.data.job.repost_count); // partial payload break; case "job.closed": stopSequences(event.data.job.id); break; case "company.hiring_started": case "company.hiring_stopped": updateAccount(event.data.company, event.data.is_hiring); break; case "search.completed": fetchResults(event.data.search.id); // branch on search status break; } } ``` ## Delivery * BetterJobs POSTs one event per request to your `webhook_url`, signed with the `BetterJobs-Signature` header. Verify it before you trust the body. * Return any `2xx` within 10 seconds. * Failed deliveries retry 1m, 5m, 30m, 2h, 6h, 12h, 24h after the first failed attempt, over 24 hours. * Replay any event with `POST /v1/webhooks/replay`. Replays and retries never charge again. * Missed something? Read `GET /v1/events` from your last cursor. It is free. Signature verification, retries and local testing are covered in [Webhooks](/platform/webhooks/). If your plan does not include watches, `POST /v1/watches` returns `403 plan_required` with `required_plan`. See [Errors](/platform/errors/#plan_required). ## Coming from a provider’s events Some providers publish their own event types. Their names map onto BetterJobs events like this: | Provider event (per provider docs) | BetterJobs event | | ---------------------------------- | ----------------------------------------------------------------------- | | Reqbeat `opened` | `job.opened` | | Reqbeat `reposted` | `job.reposted` | | Reqbeat `closed` | `job.closed` | | Reqbeat `reobserved` | No event. `last_seen_at` moves forward. | | TheirStack `job.new` | `job.opened` | | TheirStack `job.closed` | `job.closed` | | TheirStack `company.new` | No direct equivalent. Use a company watch and `company.hiring_started`. | ## API | Operation | Endpoint | Cost | | -------------------------------------------------- | -------------------------- | ------------------------------------------------------------ | | [Create a watch](/api/operations/createwatch/) | `POST /v1/watches` | Cost: FreeEach job.opened the watch delivers costs 1 credit. | | [List watches](/api/operations/listwatches/) | `GET /v1/watches` | Cost: Free | | [Delete a watch](/api/operations/deletewatch/) | `DELETE /v1/watches/{id}` | Cost: Free | | [List events](/api/operations/listevents/) | `GET /v1/events` | Cost: Free | | [Replay a webhook](/api/operations/replaywebhook/) | `POST /v1/webhooks/replay` | Cost: Free | | [Event delivery](/api/webhooks/webhookevent/) | Your `webhook_url` | Cost: 1 credit / job.opened | Recipe: [Detect hiring changes](/guides/detect-hiring-changes/). # Field dictionary > What does every field mean, how is it derived, and what do providers call it? Every field BetterJobs returns, in one table. Field names are dotted paths as they appear in the API response: `salary.min` is `min` inside `salary`, and `sources[].url` is `url` inside each item of `sources`. The [OpenAPI spec](/openapi.yaml) is the source of truth; this page is built from the same field list. ## All fields Filter by name, for example `salary` or `seen`. Each row has a stable anchor you can link to, such as [`#field-job-last_verified_at`](#field-job-last_verified_at) or [`#field-company-is_hiring-value`](#field-company-is_hiring-value). | Field | Type | Description | null / \[] means | Derivation | Example | | ----------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------- | | `id` | `string` | Canonical job id (`job_...`). Stable across sources and requests. | Never null | normalized | `"job_01JC8X4M2Q7RV3T9KD5W6YH0AB"` | | `title` | `string` | Job title as posted. | Never null | raw | `"Head of Revenue Operations"` | | `company.id` | `string` | Canonical company id (`cmp_...`). | Never null | normalized | `"cmp_4Rk7TzP1aQ"` | | `company.name` | `string` | Company name. | Never null | normalized | `"Acme Robotics"` | | `company.domain` | `string \| null` | Primary web domain of the company. | Unknown. No source reported it. | normalized | `"acme-robotics.example"` | | `location.city` | `string \| null` | City. | Unknown. No source reported it. | normalized | `"Berlin"` | | `location.region` | `string \| null` | State, province or region. | Unknown. No source reported it. | normalized | `"Berlin"` | | `location.country_code` | `string \| null` | ISO 3166-1 alpha-2 country code. | Unknown. No source reported it. | normalized | `"DE"` | | `location.remote` | `boolean \| null` | `true` remote, `false` on-site or hybrid. | Unknown. Not the same as `false`. | normalized | `false` | | `employment_type` | `full_time \| part_time \| contract \| internship \| temporary \| null` | Employment type. | Unknown. No source reported it. | normalized | `"full_time"` | | `seniority` | `intern \| junior \| mid \| senior \| lead \| director \| vp \| c_level \| null` | Seniority level. | Unknown. No source reported it. | inferred | `"lead"` | | `job_family` | `string \| null` | Job family, e.g. `engineering`, `sales`, `operations`. | Unknown. No source reported it. | inferred | `"operations"` | | `salary` | `Salary \| null` | Pay range. See the `salary.*` fields. | Unknown. No source reported pay. | normalized | `{"min":110000,"max":135000,"currency":"EUR","period":"year","origin":"declared"}` | | `salary.min` | `number \| null` | Lower bound in `salary.currency` per `salary.period`. | Unknown lower bound. | normalized | `110000` | | `salary.max` | `number \| null` | Upper bound in `salary.currency` per `salary.period`. | Unknown upper bound. | normalized | `135000` | | `salary.currency` | `string` | ISO 4217 currency code. | Never null | normalized | `"EUR"` | | `salary.period` | `year \| month \| hour` | Pay period. | Never null | normalized | `"year"` | | `salary.origin` | `declared \| inferred` | `declared` = stated in the posting. `inferred` = estimated by a source. | Never null | normalized | `"declared"` | | `description` | `string \| null` | Plain-text job description. | Unknown. No source reported it. | raw | `"Acme Robotics is hiring a Head of Revenue Operations..."` | | `apply_url` | `string \| null` | Where a candidate applies. | Unknown. No source reported it. | raw | `"https://jobs.acme-robotics.example/revops-lead/apply"` | | `posted_at` | `date-time \| null` | Date the employer posted the job. | Unknown. Use `first_seen_at` instead. | raw | `"2026-10-08T00:00:00Z"` | | `first_seen_at` | `date-time` | Earliest time any source saw the job. | Never null | normalized | `"2026-10-08T06:40:00Z"` | | `last_seen_at` | `date-time` | Latest time any source saw the job. | Never null | normalized | `"2026-10-11T06:10:00Z"` | | `last_verified_at` | `date-time \| null` | Latest time the job was confirmed live at its origin. | Never verified at origin. | normalized | `"2026-10-11T06:10:00Z"` | | `status` | `open \| closed` | Lifecycle status. | Never null | normalized | `"open"` | | `closed_reason` | `filled \| expired \| removed \| unknown \| null` | Why the job closed. | The job is open. | normalized | `null` | | `repost_count` | `integer` | Times the same job was re-listed. | Never null | normalized | `0` | | `p_real` | `number (0-1)` | Probability the job is a real open req, from cross-source corroboration. | Never null | inferred | `0.94` | | `sources` | `Source[]` | Every provider that saw this job, with what it contributed. | Never null | raw | `[{"provider":"theirstack",...}]` | | `sources[].provider` | `ProviderSlug` | Provider slug. | Never null | raw | `"theirstack"` | | `sources[].provider_job_id` | `string` | The provider's own id for this posting. | Never null | raw | `"ts_88213377"` | | `sources[].url` | `string \| null` | Posting URL as this provider saw it. | The provider did not report a URL. | raw | `"https://jobs.acme-robotics.example/revops-lead"` | | `sources[].first_seen_at` | `date-time` | When this provider first saw the job. | Never null | raw | `"2026-10-08T07:12:00Z"` | | `sources[].last_seen_at` | `date-time` | When this provider last saw the job. | Never null | raw | `"2026-10-11T04:02:00Z"` | | `sources[].fields` | `string[]` | Job fields this source contributed to the canonical record. | `[]` = the source corroborated the job but contributed no field. | raw | `["salary","seniority"]` | | `license.display` | `boolean` | You may show this job to your end users. | Never null | normalized | `true` | | `license.resale` | `boolean` | You may resell or redistribute this job as data. | Never null | normalized | `false` | | `company` | `Company` | Company reference: `id`, `name`, `domain`. | Never null | normalized | `{"id":"cmp_4Rk7TzP1aQ","name":"Acme Robotics","domain":"acme-robotics.example"}` | | `open_jobs_count` | `integer` | Open canonical jobs right now. | Never null | normalized | `42` | | `is_hiring.value` | `boolean \| null` | Whether the company is hiring. | Unknown. Never treat `null` as `false`. | inferred | `true` | | `is_hiring.confidence` | `number (0-1)` | Confidence in `is_hiring.value`. | Never null | inferred | `0.96` | | `is_hiring.basis` | `string` | Plain-English reason for the value. | Never null | inferred | `"42 open jobs corroborated by 4 sources in the last 30 days"` | | `hiring_pulse.direction` | `up \| flat \| down` | Direction of open jobs over the last 30 days. | Never null | inferred | `"up"` | | `hiring_pulse.open_jobs_30d_change` | `integer` | Change in open jobs over the last 30 days. | Never null | normalized | `9` | | `top_job_families` | `{job_family, open_jobs}[]` | Job families with the most open jobs. | `[]` = no open jobs found. | inferred | `[{"job_family":"engineering","open_jobs":18}]` | | `sources` | `{provider, open_jobs, last_seen_at}[]` | Open jobs per provider for this company. | `[]` = no source sees this company. | raw | `[{"provider":"theirstack","open_jobs":31,"last_seen_at":"2026-10-11T04:02:00Z"}]` | For the same fields grouped with a record sample and coverage notes, see [Jobs](/data/jobs/) and [Companies](/data/companies/). Enum values are listed in [Taxonomies](/data/taxonomies/). ## Derivation The **Derivation** column tells you how much BetterJobs changed the value before returning it. | Derivation | Meaning | Trust it as | | ------------ | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | `raw` | Passed through from a source as posted. | What the posting says. | | `normalized` | Mapped to one format: ISO codes, BetterJobs enums, merged timestamps, canonical ids. | The posting’s data in a consistent shape. | | `inferred` | Estimated from other data, for example `seniority` from the title or `p_real` from cross-source corroboration. | A best estimate. Check it before you rely on it for hard filtering. | `salary.origin` adds a second layer for pay. `declared` means the posting stated it. `inferred` means a source estimated it. ## Null semantics The rule is the same for every field: | You see | It means | | ------------- | ------------------------------------------------------------------------------------- | | `null` | Unknown. No source reported it. | | `[]` | Verified none. For example `top_job_families: []` means no open jobs found. | | Field missing | Not part of this payload, for example the lifecycle-only job in a `job.closed` event. | Fields marked **Never null** in the table always carry a value. Three nulls that bite * `location.remote: null` is unknown, not on-site. Only `false` means on-site or hybrid. * `is_hiring.value: null` is unknown, not “not hiring”. See [Companies](/data/companies/#how-is_hiring-is-decided). * `posted_at: null` means the employer’s posting date is unknown. Use `first_seen_at`, which is never null. How often a field is non-null in your results is reported live in `metadata.field_coverage`. See [Coverage](/data/jobs/#coverage). ## Provider field names Each partner provider names the same data differently. This table maps BetterJobs fields to the names each provider uses in its own documentation. Only fields with at least one confident equivalent are shown. | BetterJobs | Reqbeat | SignalsAPI | TheirStack | JobsPipe | Coresignal | Techmap | | ------------------------- | ------- | ------------- | ----------------------- | --------------- | ---------- | -------------- | | `title` | — | — | `job_title` | `job_title` | — | — | | `company.domain` | — | — | `company_domain` | — | — | — | | `location.remote` | — | — | `remote` | — | — | — | | `employment_type` | — | — | — | — | — | `contractType` | | `seniority` | — | — | `seniority` | `seniority` | — | — | | `salary` | — | — | `salary_string` | `salary_usd` | — | — | | `salary.min` | — | — | `min_annual_salary_usd` | — | — | — | | `posted_at` | — | — | `date_posted` | `date_posted` | — | — | | `first_seen_at` | — | — | `discovered_at` | `discovered_at` | — | — | | `last_seen_at` | — | — | — | `last_seen_at` | — | — | | `last_verified_at` | — | — | — | `verified_at` | — | — | | `closed_reason` | — | — | — | `closed_reason` | — | — | | `sources[].url` | — | — | `url` | `url` | — | — | | `sources[].first_seen_at` | — | — | `discovered_at` | `discovered_at` | — | — | | `sources[].last_seen_at` | — | `observed_at` | — | `last_seen_at` | — | — | Notes on the mapping: * **One field, different units.** TheirStack `min_annual_salary_usd` and JobsPipe `salary_usd` are in US dollars. BetterJobs keeps the posting’s currency in `salary.currency` and its period in `salary.period`. * **Inverse scores.** JobsPipe publishes a `ghost_score`, a measure of how likely a posting is a ghost job. BetterJobs `p_real` runs the opposite way: higher = more likely a real open req. Do not copy thresholds across. * **Per-source vs canonical timestamps.** A provider’s `discovered_at` maps to `sources[].first_seen_at` for that provider. The top-level `first_seen_at` is the earliest across all sources. * **No equivalent (—)** means we found no confident one-to-one match in the provider’s published names. It does not mean the provider lacks the data. Moving from one provider? The migration guides translate queries as well as fields: [TheirStack](/guides/migrate-from-theirstack/), [JobsPipe](/guides/migrate-from-jobspipe/), [Coresignal](/guides/migrate-from-coresignal/), [Techmap](/guides/migrate-from-techmap/). # Jobs > What is in a job record, and what do null and empty values mean? Cost: 1 credit / unique jobDuplicates, jobs you already paid for, empty pages and dry runs are free. ## Overview A job is one real opening. BetterJobs calls it a **canonical job**. Several providers often list the same opening. BetterJobs merges those listings into one record with one stable `id` (`job_...`). Each provider that saw the opening appears in `sources[]`, with the fields it contributed. You pay for the canonical job once, not once per provider. Every job carries three kinds of data: * **What the job is**: `title`, `company`, `location`, `employment_type`, `seniority`, `job_family`, `salary`, `description`, `apply_url`. * **Where it is in its life**: `posted_at`, `first_seen_at`, `last_seen_at`, `last_verified_at`, `status`, `closed_reason`, `repost_count`. * **How far to trust it**: `p_real`, `sources[]`, `license`. Read [Canonical jobs](/concepts/canonical-jobs/) for how merging works and [Provenance and confidence](/concepts/provenance-and-confidence/) for `sources[]` and `p_real`. ### Null, empty and missing Three states mean three different things. Do not collapse them. | You see | It means | Example | | ------------- | ------------------------------- | -------------------------------------------------------------------------------------- | | `null` | Unknown. No source reported it. | `"salary": null` means no source reported pay. It does not mean the job is unpaid. | | `[]` | Verified none. | `"fields": []` on a source means it corroborated the job but contributed no field. | | Field missing | Not part of this payload. | `job.closed` events carry only `id`, `title`, `company`, `status` and `closed_reason`. | null is not false `location.remote: null` means remote status is unknown. It is not the same as `false` (on-site or hybrid). Filter with `remote: true` or `remote: false` only when you want to drop the unknowns. The per-field meaning of `null` is in the **null / \[] means** column below. ## Field dictionary Every field on a job, with its type, how it is derived and what `null` means. Each row has an anchor, for example [`salary.min`](#field-job-salary-min). The [Field dictionary](/data/field-dictionary/) adds the company fields and the names each provider uses. | Field | Type | Description | null / \[] means | Derivation | Example | | --------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------- | | `id` | `string` | Canonical job id (`job_...`). Stable across sources and requests. | Never null | normalized | `"job_01JC8X4M2Q7RV3T9KD5W6YH0AB"` | | `title` | `string` | Job title as posted. | Never null | raw | `"Head of Revenue Operations"` | | `company.id` | `string` | Canonical company id (`cmp_...`). | Never null | normalized | `"cmp_4Rk7TzP1aQ"` | | `company.name` | `string` | Company name. | Never null | normalized | `"Acme Robotics"` | | `company.domain` | `string \| null` | Primary web domain of the company. | Unknown. No source reported it. | normalized | `"acme-robotics.example"` | | `location.city` | `string \| null` | City. | Unknown. No source reported it. | normalized | `"Berlin"` | | `location.region` | `string \| null` | State, province or region. | Unknown. No source reported it. | normalized | `"Berlin"` | | `location.country_code` | `string \| null` | ISO 3166-1 alpha-2 country code. | Unknown. No source reported it. | normalized | `"DE"` | | `location.remote` | `boolean \| null` | `true` remote, `false` on-site or hybrid. | Unknown. Not the same as `false`. | normalized | `false` | | `employment_type` | `full_time \| part_time \| contract \| internship \| temporary \| null` | Employment type. | Unknown. No source reported it. | normalized | `"full_time"` | | `seniority` | `intern \| junior \| mid \| senior \| lead \| director \| vp \| c_level \| null` | Seniority level. | Unknown. No source reported it. | inferred | `"lead"` | | `job_family` | `string \| null` | Job family, e.g. `engineering`, `sales`, `operations`. | Unknown. No source reported it. | inferred | `"operations"` | | `salary` | `Salary \| null` | Pay range. See the `salary.*` fields. | Unknown. No source reported pay. | normalized | `{"min":110000,"max":135000,"currency":"EUR","period":"year","origin":"declared"}` | | `salary.min` | `number \| null` | Lower bound in `salary.currency` per `salary.period`. | Unknown lower bound. | normalized | `110000` | | `salary.max` | `number \| null` | Upper bound in `salary.currency` per `salary.period`. | Unknown upper bound. | normalized | `135000` | | `salary.currency` | `string` | ISO 4217 currency code. | Never null | normalized | `"EUR"` | | `salary.period` | `year \| month \| hour` | Pay period. | Never null | normalized | `"year"` | | `salary.origin` | `declared \| inferred` | `declared` = stated in the posting. `inferred` = estimated by a source. | Never null | normalized | `"declared"` | | `description` | `string \| null` | Plain-text job description. | Unknown. No source reported it. | raw | `"Acme Robotics is hiring a Head of Revenue Operations..."` | | `apply_url` | `string \| null` | Where a candidate applies. | Unknown. No source reported it. | raw | `"https://jobs.acme-robotics.example/revops-lead/apply"` | | `posted_at` | `date-time \| null` | Date the employer posted the job. | Unknown. Use `first_seen_at` instead. | raw | `"2026-10-08T00:00:00Z"` | | `first_seen_at` | `date-time` | Earliest time any source saw the job. | Never null | normalized | `"2026-10-08T06:40:00Z"` | | `last_seen_at` | `date-time` | Latest time any source saw the job. | Never null | normalized | `"2026-10-11T06:10:00Z"` | | `last_verified_at` | `date-time \| null` | Latest time the job was confirmed live at its origin. | Never verified at origin. | normalized | `"2026-10-11T06:10:00Z"` | | `status` | `open \| closed` | Lifecycle status. | Never null | normalized | `"open"` | | `closed_reason` | `filled \| expired \| removed \| unknown \| null` | Why the job closed. | The job is open. | normalized | `null` | | `repost_count` | `integer` | Times the same job was re-listed. | Never null | normalized | `0` | | `p_real` | `number (0-1)` | Probability the job is a real open req, from cross-source corroboration. | Never null | inferred | `0.94` | | `sources` | `Source[]` | Every provider that saw this job, with what it contributed. | Never null | raw | `[{"provider":"theirstack",...}]` | | `sources[].provider` | `ProviderSlug` | Provider slug. | Never null | raw | `"theirstack"` | | `sources[].provider_job_id` | `string` | The provider's own id for this posting. | Never null | raw | `"ts_88213377"` | | `sources[].url` | `string \| null` | Posting URL as this provider saw it. | The provider did not report a URL. | raw | `"https://jobs.acme-robotics.example/revops-lead"` | | `sources[].first_seen_at` | `date-time` | When this provider first saw the job. | Never null | raw | `"2026-10-08T07:12:00Z"` | | `sources[].last_seen_at` | `date-time` | When this provider last saw the job. | Never null | raw | `"2026-10-11T04:02:00Z"` | | `sources[].fields` | `string[]` | Job fields this source contributed to the canonical record. | `[]` = the source corroborated the job but contributed no field. | raw | `["salary","seniority"]` | | `license.display` | `boolean` | You may show this job to your end users. | Never null | normalized | `true` | | `license.resale` | `boolean` | You may resell or redistribute this job as data. | Never null | normalized | `false` | Derivation tells you how much BetterJobs touched the value: * **raw**: passed through from a source as posted. * **normalized**: mapped to one format or enum (ISO codes, our enums, merged timestamps). * **inferred**: estimated from other data, for example `seniority` from the title. Enum values are listed in [Taxonomies](/data/taxonomies/). ## Record sample The first record of [`jobs.json`](/samples/jobs.json). Illustrative data: the company and domain are fictional. jobs.json — record 1 (illustrative) ```json { "id": "job_01JCSAMPLE01X4M2Q7RV3T9KD5W", "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": "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://jobs.acme-robotics.example/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 } } ``` How to read it: the BetterJobs index found the posting first and supplied the core fields. TheirStack added pay and seniority. Techmap added the job family. Three listings, one job, one credit. The full set of 10 sample jobs includes closed jobs, `null` salaries and unknown remote status. See [Sample data](/data/samples/). ## Coverage Coverage is the share of jobs that have a value for a field. BetterJobs does not publish static coverage percentages during the v1 preview. Per-field figures across the whole index are published at GA. What you get today is coverage measured on **your own results**. Every search response includes `metadata.field_coverage`, the fraction (0-1) of returned jobs with a non-null value, per field. metadata.field\_coverage (illustrative) ```json { "salary": 0.41, "seniority": 0.97, "location.remote": 0.88, "description": 0.99 } ``` How it is computed: for each field, the number of returned jobs where the field is not `null`, divided by the number of returned jobs. Merging raises coverage, because a field missing from one provider’s listing can come from another. `sources[].fields` shows which provider filled which field. Try it against the keyless sandbox. It returns fixed illustrative data and charges nothing. * curl ```bash curl -s 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}' \ | jq '.metadata.field_coverage' ``` * Python ```python 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, }, timeout=30, ) resp.raise_for_status() print(resp.json()["metadata"]["field_coverage"]) ``` * TypeScript ```ts 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(); console.log(body.metadata.field_coverage); ``` Filters change coverage `salary_min_gte` drops every job whose `salary` is `null`. Your `field_coverage.salary` becomes 1.0, but you lose the jobs that do not state pay. Use it only when jobs with unknown pay are useless to you. To see which providers fed a result, read `metadata.providers.hit` and `metadata.providers.contributions` (unique jobs each provider contributed first). Coverage also depends on your plan: `GET /v1/providers` shows which sources your plan enables. Provider-level claims are on each [provider page](/providers/). ## Freshness Every job carries its own timestamps. Use them instead of trusting a global freshness claim. | Field | Answers | | --------------------------------------------------- | ------------------------------------------------------------------------------------- | | `posted_at` | When did the employer post it? `null` when no source knows. Use `first_seen_at` then. | | `first_seen_at` | When did any source first see it? `posted_within_days` filters on this. | | `last_seen_at` | When did any source last see it? | | `last_verified_at` | When was it last confirmed live at its origin? `null` = never verified. | | `sources[].first_seen_at`, `sources[].last_seen_at` | The same, per provider. | Merged freshness metrics (for example, the share of jobs found on their posting day) are published at GA. Until then, measure it on your own data: compare `first_seen_at` with `posted_at` on jobs where both are set. Each provider’s self-published refresh rate is on its [provider page](/providers/). Jobs move from `open` to `closed`, and `repost_count` goes up when the same job is re-listed. Read [Freshness and lifecycle](/concepts/freshness-and-lifecycle/) for the full lifecycle, and [Events](/data/events/) to be told when it changes. ## API | Operation | Endpoint | Cost | | ---------------------------------------------------- | ------------------------------ | ---------------------------------------------------------- | | [Search jobs](/api/operations/searchjobs/) | `POST /v1/jobs/search` | Cost: 1 credit / unique job | | [Get a job](/api/operations/getjob/) | `GET /v1/jobs/{id}` | Cost: 1 credit / jobFree if you already paid for this job. | | [Sandbox search](/api/operations/sandboxsearchjobs/) | `POST /v1/sandbox/jobs/search` | Cost: Free | Search returns up to 100 jobs per page. Page with `next_cursor` (see [Pagination](/platform/pagination/)). Filters are listed in [Filters](/platform/filters/). Re-reading a job you already paid for is free; `GET /v1/billing/ledger` proves it. ## Bulk For more than one page, use an **async search**. `POST /v1/searches` collects up to 10,000 jobs and returns `202` with a search `id`. Poll `GET /v1/searches/{id}` or pass `webhook_url` to receive `search.completed`. Reading results is free; jobs are charged as the search collects them. Branch on status A `200` from `GET /v1/searches/{id}` can carry `queued`, `running`, `completed`, `partial`, `failed` or `on_hold`. `on_hold` means you ran out of credits; the search resumes after a top-up. Details: [Async searches](/platform/async-searches/), [Create a search](/api/operations/createsearch/), [Get a search](/api/operations/getsearch/). For daily upserts and backfills, follow [Sync patterns](/guides/sync-patterns/). **CSV.** The API returns JSON. CSV export is a plan feature, listed from the Growth plan (see [Credits and billing](/concepts/credits-and-billing/)). [`jobs.csv`](/samples/jobs.csv) shows a flattened layout: nested fields become columns such as `salary_min`, and source slugs are pipe-joined in `source_providers`. # Sample data > Where can I download illustrative sample records without signing up? Download real-shaped records before you write any code. No key, no signup. Every sample record validates against its schema in the OpenAPI spec. Illustrative data Sample records are made up. Companies are fictional and use `.example` domains such as `acme-robotics.example` and `northwind.example`. Use the files to build and test parsers, not to measure coverage. ## Files | File | Format | Contents | Schema | | ------------------------------------------- | ---------------- | ----------------------------------------------------- | ------------------------------------ | | [`jobs.json`](/samples/jobs.json) | JSON array | 10 canonical jobs | [`Job`](/data/jobs/) | | [`jobs.csv`](/samples/jobs.csv) | CSV, header row | The same 10 jobs, flattened | [`Job`](/data/jobs/), flattened | | [`companies.json`](/samples/companies.json) | JSON array | 3 company hiring profiles | [`CompanyProfile`](/data/companies/) | | [`openapi.yaml`](/openapi.yaml) | OpenAPI 3.1 YAML | The full API spec: every endpoint, schema and example | — | [jobs.json](/samples/jobs.json) [jobs.csv](/samples/jobs.csv) [companies.json](/samples/companies.json) [openapi.yaml](/openapi.yaml) ## What the samples cover The records are chosen to exercise the cases your code must handle, not just the happy path. **`jobs.json`** * Open and closed jobs. Closed jobs carry `closed_reason` (`filled`, `expired`); open jobs have `closed_reason: null`. * Jobs with `salary: null` (no source reported pay) next to jobs with `salary.origin` `declared` and `inferred`. * `location.remote` as `true`, `false` and `null` (unknown). * `posted_at: null` and `last_verified_at: null` on some jobs. * Re-listed jobs with `repost_count` above 0. * Several values of `seniority`, `employment_type` and `job_family`. See [Taxonomies](/data/taxonomies/). * `sources[]` with one to three providers per job, each listing the `fields` it contributed. **`companies.json`** has one profile per state of `is_hiring.value`: `true` (Acme Robotics), `false` (Globex Analytics) and `null` (Northwind Traders, no careers page or ATS found). See [Companies](/data/companies/#record-sample). ## CSV layout `jobs.csv` holds one row per canonical job. Nested objects are flattened into columns: | Column | From | | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `company_id`, `company_name`, `company_domain` | `company.*` | | `city`, `region`, `country_code`, `remote` | `location.*` | | `salary_min`, `salary_max`, `salary_currency`, `salary_period`, `salary_origin` | `salary.*` | | `source_providers` | `sources[].provider`, joined with `\|` (for example `betterjobs\|theirstack\|techmap`) | | `license_display`, `license_resale` | `license.*` | All other columns keep their JSON names. An empty cell means `null`. CSV loses detail CSV keeps only the provider slugs from `sources[]`. Each source’s `provider_job_id`, `url`, timestamps and `fields` are only in JSON. It also cannot tell `null` from an empty string. Use JSON when provenance matters. ## Load a sample * curl ```bash curl -sO https://docs.betterjobs.cc/samples/jobs.json curl -sO https://docs.betterjobs.cc/samples/jobs.csv curl -sO https://docs.betterjobs.cc/samples/companies.json curl -sO https://docs.betterjobs.cc/openapi.yaml ``` * Python ```python import requests jobs = requests.get("https://docs.betterjobs.cc/samples/jobs.json", timeout=30).json() for job in jobs: remote = job["location"]["remote"] # True, False or None (unknown) pay = job["salary"] # None = no source reported pay print(job["id"], job["status"], remote, pay and pay["min"]) ``` * TypeScript ```ts const jobs: any[] = await (await fetch("https://docs.betterjobs.cc/samples/jobs.json")).json(); for (const job of jobs) { const remote = job.location.remote; // true, false or null (unknown) const pay = job.salary; // null = no source reported pay console.log(job.id, job.status, remote, pay?.min ?? null); } ``` To generate types instead of using `any`, point an OpenAPI generator at [`/openapi.yaml`](/openapi.yaml). See [OpenAPI and SDKs](/platform/openapi-and-sdks/). ## Live samples The keyless sandbox, `POST /v1/sandbox/jobs/search`, returns fixed illustrative data in the exact `SearchResponse` shape, including `metadata`. It charges nothing and calls no provider. Start at the [Quickstart](/getting-started/quickstart/) or the [sandbox reference](/api/operations/sandboxsearchjobs/). Every example in the [API reference](/api/) also validates against its schema, so you can use them as test fixtures. # Taxonomies > Which values can seniority, employment type and job family take? Fields with a fixed set of values use the enums below. The value lists come from the [OpenAPI spec](/openapi.yaml): this page renders them from shared data that a build check keeps in sync with the spec. Values are lowercase `snake_case`. Pass them to filters exactly as written. Every enum field on a job can also be `null`, which means unknown. See [Null semantics](/data/field-dictionary/#null-semantics). ## Seniority `seniority` is **inferred**, mostly from the title. Filter with `seniority_or`. | seniority | Meaning | | ---------- | ------------------------------------------------------------------------------------------------ | | `intern` | Internship or working-student role. | | `junior` | Entry level or early career. | | `mid` | Experienced individual contributor. | | `senior` | Senior individual contributor. | | `lead` | Leads a team or a function, or a staff-level individual contributor. "Head of" titles land here. | | `director` | Director of a department or function. | | `vp` | Vice president. | | `c_level` | C-suite executive, for example CEO, CTO or CFO. | Tip Titles are messy. A “Head of Revenue Operations” at a 40-person startup and at a 10,000-person enterprise both map to `lead`. When the exact level matters, filter on `title_or` as well. ## Employment type `employment_type` is **normalized** from the posting. Filter with `employment_type_or`. | employment\_type | Meaning | | ---------------- | -------------------------------------------- | | `full_time` | Permanent full-time role. | | `part_time` | Permanent part-time role. | | `contract` | Fixed-term contract or freelance engagement. | | `internship` | Internship. | | `temporary` | Temporary or seasonal role. | Techmap calls this field `contractType` (per its reference). See the [Field dictionary](/data/field-dictionary/#provider-field-names). ## Job family `job_family` is **inferred** from the title and description. Filter with `job_family_or`. In the v1 preview, `job_family` is a string, not a fixed enum. Values are lowercase `snake_case`. The values in the [sample data](/data/samples/) are: | Value | Example titles (illustrative) | | ------------------ | ---------------------------------------------------------------- | | `engineering` | Senior Robotics Engineer, Data Engineer, Staff Platform Engineer | | `sales` | Account Executive, VP of Sales | | `operations` | Head of Revenue Operations, Warehouse Operations Intern | | `marketing` | Product Marketing Manager | | `data` | Clinical Data Analyst | | `customer_success` | Customer Success Manager | Not a closed list yet Other values can appear. Do not reject a job because its `job_family` is not in this table. To see which families a company hires for, read `top_job_families` on its [company profile](/data/companies/). Providers use their own taxonomies. JobsPipe, for example, maps jobs to ISCO-08, ISIC and ESCO (per JobsPipe docs). BetterJobs returns its own `job_family` so one filter works across every source. ## Status and closed reason `status` is `open` or `closed`. When a job closes, `closed_reason` says why. While the job is open, `closed_reason` is `null`. | status | Meaning | | -------- | ----------------------------------------------------------------------------------- | | `open` | The job is live. closed\_reason is null. | | `closed` | The job is no longer live. Hidden from search unless you set include\_closed: true. | | closed\_reason | Meaning | | -------------- | ----------------------------------------------------------- | | `filled` | A source reports the position was filled. | | `expired` | The posting reached its end date without a fill signal. | | `removed` | The posting was taken down at its origin before it expired. | | `unknown` | The job stopped appearing and no source gave a reason. | A closed job fires a `job.closed` event for watches that cover it. See [Events](/data/events/) and [Freshness and lifecycle](/concepts/freshness-and-lifecycle/). ## Waterfall strategy `waterfall.strategy` decides which providers a search queries, and in what order. The default is `cheapest_first`. | strategy | What it does | Use when | | ---------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `cheapest_first` | Starts with the BetterJobs index and adds providers only until the page is full. | Default. Good for most searches and for keeping cost low. | | `freshest_first` | Tries the sources with the fastest refresh first. | You care about jobs posted in the last hours more than total coverage. | | `max_coverage` | Queries every provider enabled on your plan. | Market sizing, backfills, or any time a missed job costs more than a credit. | | `consensus` | Returns only jobs seen by at least min\_sources sources (default 2). | You need high confidence the job is real, for example before outbound. | | `own_only` | Uses the BetterJobs index only. No partner providers. | Free and Starter plans, or when you need the simplest licensing. | Pick one with [Choose a strategy](/guides/choose-a-strategy/). How the waterfall runs is in [Waterfall](/concepts/waterfall/). ## Other enums | Field | Values | Where | | ------------------------ | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `salary.period` | `year`, `month`, `hour` | Job | | `salary.origin` | `declared`, `inferred` | Job. `declared` = stated in the posting, `inferred` = estimated by a source. | | `hiring_pulse.direction` | `up`, `flat`, `down` | Company profile | | `sources[].provider` | `betterjobs`, `reqbeat`, `signalsapi`, `theirstack`, `jobspipe`, `coresignal`, `techmap` | Job, company profile. `betterjobs` is the BetterJobs index. | | `status` (search) | `queued`, `running`, `completed`, `partial`, `failed`, `on_hold` | Async search. See [Async searches](/platform/async-searches/). | | `status` (provider) | `operational`, `degraded`, `down` | `GET /v1/providers`. See [Provider status](/resources/provider-status/). | # Choose a strategy > Which waterfall strategy should my request use, and when should I pin providers? `waterfall.strategy` decides which sources a request asks and when it stops. The default, `cheapest_first`, is right for most searches. This page tells you when it is not. What each strategy does is defined on [The waterfall](/concepts/waterfall/#strategies). ## Decide in five questions Stop at the first “yes”. 1. **Are you on Free or Starter?** Use `own_only`. These plans route to the BetterJobs index only, so `cheapest_first`, `freshest_first`, `max_coverage` and `own_only` ask the same single source, and `consensus` cannot run (it needs at least 2 sources). `own_only` says so explicitly. 2. **Do you need every matching job?** Backfills, daily sync, market sizing, territory counts. Use `max_coverage`. It asks every provider your plan enables. A missed job costs you more than a credit. 3. **Will a person or a sequence act on each job?** Outbound, recruiter alerts, CRM tasks. Use `consensus` with `min_sources: 2`. You get fewer jobs, each seen by at least two independent sources. 4. **Do the last few hours matter more than completeness?** Alerting on fresh postings, “posted today” feeds. Use `freshest_first`. It asks the fastest-refreshing sources first. 5. **None of the above?** Keep `cheapest_first`. It starts with the BetterJobs index and adds providers only until the page is full. ## By job to be done | You want to | Strategy | Settings | Why | | ------------------------------------------ | -------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | Look up who is hiring for a role this week | `cheapest_first` | `max_credits` | Fills the page at the lowest cost. See [Find companies hiring](/guides/find-companies-hiring/). | | Build or refresh a local copy | `max_coverage` | `max_credits`, async for more than 100 jobs | Same sources every run, so gaps do not appear between runs. See [Sync patterns](/guides/sync-patterns/). | | Size a market or a territory | `max_coverage` | `dry_run: true` first | The free estimate gives `expected_unique_jobs_range` before you spend. | | Trigger outbound on a job | `consensus` | `min_sources: 2`, filter on `p_real` | Corroborated jobs only. Fewer wasted touches on stale or ghost postings. | | Alert on brand-new postings | `freshest_first` | short `posted_within_days`, `timeout_ms` | Fast sources answer first. Pair with a [watch](/guides/detect-hiring-changes/) for push delivery. | | Show jobs to your own end users | `own_only` or pinned `providers` | check `license.display` | Simplest licensing. See [Licensing](/concepts/licensing/). | | Keep using a provider you already trust | any | `providers: [...]` | Pin the sources. See [Pin providers](#pin-providers). | ## Compare strategies for free Run the same filters with `dry_run: true` once per strategy. Nothing is fetched or charged. Compare `providers_planned` and `credits_range`. * curl ```bash for strategy in cheapest_first max_coverage consensus; do curl -s 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": "'"$strategy"'" }, "limit": 100, "dry_run": true }' echo done ``` * 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", } FILTERS = {"title_or": ["Head of RevOps"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7} for strategy in ["cheapest_first", "max_coverage", "consensus"]: resp = requests.post( f"{API}/jobs/search", headers=HEADERS, json={"filters": FILTERS, "waterfall": {"strategy": strategy}, "limit": 100, "dry_run": True}, timeout=30, ) resp.raise_for_status() est = resp.json()["estimate"] print(strategy, est["providers_planned"], est["credits_range"]) ``` * 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', }; const filters = { title_or: ['Head of RevOps'], country_code_or: ['DE', 'AT', 'CH'], posted_within_days: 7 }; for (const strategy of ['cheapest_first', 'max_coverage', 'consensus']) { const res = await fetch(`${API}/jobs/search`, { method: 'POST', headers, body: JSON.stringify({ filters, waterfall: { strategy }, limit: 100, dry_run: true }), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const { estimate } = await res.json(); console.log(strategy, estimate.providers_planned, estimate.credits_range); } ``` One estimate (illustrative): ```json { "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 } } } ``` After a real request, `metadata.providers.contributions` shows how many unique jobs each source added first, and `metadata.field_coverage` shows how complete each field was. Those two numbers, on your own queries, are the best guide to which strategy pays off. ## Pin providers `waterfall.providers` overrides the strategy’s choice of sources. Use it to keep a provider you already know, or to leave one out. ```json { "filters": { "title_or": ["Head of RevOps"], "country_code_or": ["DE", "AT", "CH"] }, "waterfall": { "strategy": "max_coverage", "providers": ["betterjobs", "theirstack", "techmap"] } } ``` Every slug must be enabled on your plan. `GET /v1/providers` shows `enabled_on_your_plan` per source, and `GET /v1/account` lists `providers_enabled`. Asking for a source outside your plan returns `403` [`plan_required`](/platform/errors/#plan_required) with `required_plan`. ## Build one Pick a strategy and copy the request. ## Budget knobs These work with every strategy. | Field | What it does | Set it when | | ----------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------- | | `waterfall.max_credits` | Hard cap on credits for this request. Results stop at the cap. | Always, in production. | | `waterfall.timeout_ms` | Time budget, `1000` to `30000`, default `10000`. Slow providers are dropped and the result is `partial`. | A user is waiting on the response. | | `waterfall.min_sources` | Sources that must agree for `consensus`, `2` to `7`, default `2`. | Raising it trades volume for certainty. | | `dry_run` | Free estimate, nothing fetched. | Before any large or new query. | ## Pitfalls max\_coverage means your plan's coverage `max_coverage` asks every provider **your plan enables**. On Growth that is the BetterJobs index plus two partner providers; Pro and above include all six. Check `metadata.providers.tried` to see who was asked. consensus drops jobs, it does not find more A job seen by one source only is left out, even if it is real. Use `consensus` to act on jobs, not to count them. It also needs at least `min_sources` sources enabled on your plan. * **`cheapest_first` is not for sync.** It stops when the page is full, so the set of sources can change between runs. Use `max_coverage` for copies you keep. * **`freshest_first` changes order, not data.** It asks fast-refreshing sources first. Judge freshness on each job with `first_seen_at`, `last_seen_at` and `last_verified_at`. See [Freshness and lifecycle](/concepts/freshness-and-lifecycle/). * **Strategies cost the same per job.** Every source costs 1 credit per unique job. A strategy changes how many jobs you get, not the price of one. * **Partial is not failure.** Any strategy can return `metadata.status: partial` when a provider times out. You pay only for returned jobs. ## Next * [The waterfall](/concepts/waterfall/): how fan-out, merge and billing work. * [Provenance and confidence](/concepts/provenance-and-confidence/): `p_real` and `sources[]`. * [Providers](/providers/): what each source sells. # Detect hiring changes > How do I get told when a company opens, reposts or closes a job, or starts or stops hiring? Cost: 1 credit / job.opened deliveredCreating a watch, reading /events, every other event type, retries and replays are free. **Goal:** get a webhook when Acme Robotics opens or closes a job, or when any company posts a new Head of RevOps job in Germany, Austria or Switzerland. Start outreach on new jobs only, never on reposts. You create a **watch**. BetterJobs sends each matching event to your `webhook_url` and keeps a copy in `GET /v1/events`. ## Steps 1. **Create a watch.** Use `type: company` with a `domain`, or `type: search` with a `filters` object. List only the event types you act on in `events`. 2. **Verify every delivery.** Check the `BetterJobs-Signature` header before you trust the body. 3. **Deduplicate on event `id`.** A retry or a replay sends the same event again. 4. **Branch on `type`.** Start outbound on `job.opened` only. Update your records on the others. 5. **Answer fast.** Return any `2xx` within 10 seconds. Queue slow work. ## The events | Event | What happened | What to do | data | Cost | | ------------------------ | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------ | -------- | | `job.opened` | A new canonical job appeared for a watched company or saved search. | Trigger outbound. This is the only event that should start a sequence. | job (full Job) | 1 credit | | `job.reposted` | The same job was re-listed. repost\_count went up. | Do not re-trigger outbound. Update your copy of the job only. | job (id, title, company, status, repost\_count) | Free | | `job.closed` | The job is no longer live. closed\_reason says why: filled, expired, removed or unknown. | Stop sequences tied to this job. Mark it closed in your CRM. | job (id, title, company, status, closed\_reason) | Free | | `job.updated` | A field on an open job changed, for example salary or location. | Upsert the job by id. No outbound action. | job (full Job) | Free | | `company.hiring_started` | is\_hiring.value for a watched company changed to true. | Good moment for account-level outreach. | company, is\_hiring | Free | | `company.hiring_stopped` | is\_hiring.value for a watched company changed to false. A change to null (unknown) never fires this event. | Pause hiring-based plays for this account. | company, is\_hiring | Free | The full catalog, `search.completed` included, is on [Events](/data/events/). job.reposted is not a new job When an employer re-lists the same opening, BetterJobs keeps the same canonical job `id`, raises `repost_count` and sends `job.reposted`. It does not send `job.opened` again. If you start a sequence on every event, the same contact gets the same email each time the job is re-listed. Start outbound on `job.opened` only. ## Create a watch Two kinds. A company watch follows one domain. A search watch follows a saved `filters` object, using the same [filter grammar](/platform/filters/) as search. * curl ```bash # Watch one company curl https://api.betterjobs.cc/v1/watches \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3c1d7a52-8e4f-4b19-a6d0-2f9e5b7c4a10" \ -d '{ "type": "company", "domain": "acme-robotics.example", "webhook_url": "https://hooks.northwind.example/betterjobs", "events": ["job.opened", "job.closed", "company.hiring_started", "company.hiring_stopped"] }' # Watch a saved search curl https://api.betterjobs.cc/v1/watches \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 9e2b4f61-0c7a-4d38-b5e1-6a8f3c2d9b04" \ -d '{ "type": "search", "filters": { "title_or": ["Head of RevOps", "Head of Revenue Operations"], "country_code_or": ["DE", "AT", "CH"] }, "webhook_url": "https://hooks.northwind.example/betterjobs", "events": ["job.opened", "job.reposted", "job.closed"] }' ``` * Python ```python import os import uuid import requests API = "https://api.betterjobs.cc/v1" HEADERS = { "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", } WEBHOOK_URL = "https://hooks.northwind.example/betterjobs" def create_watch(body: dict) -> dict: resp = requests.post( f"{API}/watches", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json=body, timeout=30, ) resp.raise_for_status() return resp.json() company_watch = create_watch({ "type": "company", "domain": "acme-robotics.example", "webhook_url": WEBHOOK_URL, "events": ["job.opened", "job.closed", "company.hiring_started", "company.hiring_stopped"], }) search_watch = create_watch({ "type": "search", "filters": { "title_or": ["Head of RevOps", "Head of Revenue Operations"], "country_code_or": ["DE", "AT", "CH"], }, "webhook_url": WEBHOOK_URL, "events": ["job.opened", "job.reposted", "job.closed"], }) print(company_watch["id"], search_watch["id"]) ``` * 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', }; const webhookUrl = 'https://hooks.northwind.example/betterjobs'; async function createWatch(body: object) { const res = await fetch(`${API}/watches`, { method: 'POST', headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res.json(); } const companyWatch = await createWatch({ type: 'company', domain: 'acme-robotics.example', webhook_url: webhookUrl, events: ['job.opened', 'job.closed', 'company.hiring_started', 'company.hiring_stopped'], }); const searchWatch = await createWatch({ type: 'search', filters: { title_or: ['Head of RevOps', 'Head of Revenue Operations'], country_code_or: ['DE', 'AT', 'CH'] }, webhook_url: webhookUrl, events: ['job.opened', 'job.reposted', 'job.closed'], }); console.log(companyWatch.id, searchWatch.id); ``` Response, `201 Created` (illustrative): ```json { "id": "wat_6Np3QyR8tU", "type": "company", "domain": "acme-robotics.example", "filters": null, "webhook_url": "https://hooks.northwind.example/betterjobs", "events": ["job.opened", "job.closed", "company.hiring_started", "company.hiring_stopped"], "status": "active", "created_at": "2026-10-11T08:00:00Z" } ``` Omit `events` to receive all job and company events. List watches with `GET /v1/watches` and remove one with `DELETE /v1/watches/{id}`. Both are free. ## Handle deliveries BetterJobs POSTs one event per request. The `BetterJobs-Signature` header looks like `t=,v1=`. Compute HMAC-SHA256 over `.` with your endpoint secret and compare it to `v1` in constant time. Use the raw bytes, not re-serialized JSON. * curl ```bash # Your handler was down and you fixed it? Re-send one event (free). curl https://api.betterjobs.cc/v1/webhooks/replay \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 5a7c9e1b-3d2f-4a6b-8c0e-1f3a5b7d9c2e" \ -d '{ "event_id": "evt_7Wq1ZxC4vB" }' # Or read what you missed from the event feed (free). curl "https://api.betterjobs.cc/v1/events?limit=100" \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python import hashlib import hmac import os import time from flask import Flask, abort, request app = Flask(__name__) SECRET = os.environ["BETTERJOBS_WEBHOOK_SECRET"].encode() TOLERANCE_S = 300 # reject deliveries signed more than 5 minutes from your clock seen_event_ids: set[str] = set() # use a unique column in your database in production def verify(header: str, body: bytes) -> bool: parts = dict(item.split("=", 1) for item in header.split(",") if "=" in item) t, v1 = parts.get("t", ""), parts.get("v1") if not t.isdigit() or v1 is None or abs(time.time() - int(t)) > TOLERANCE_S: return False expected = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) @app.post("/betterjobs") def betterjobs_webhook(): if not verify(request.headers.get("BetterJobs-Signature", ""), request.get_data()): abort(400) event = request.get_json() if event["id"] in seen_event_ids: return "", 200 # retry or replay: already handled seen_event_ids.add(event["id"]) job = event["data"].get("job") match event["type"]: case "job.opened": upsert_job(job) start_outreach(job) # the only event that starts a sequence case "job.reposted": update_repost_count(job["id"], job["repost_count"]) case "job.closed": mark_closed(job["id"], job["closed_reason"]) stop_outreach(job["id"]) case "job.updated": upsert_job(job) case "company.hiring_started" | "company.hiring_stopped": set_account_hiring(event["data"]["company"], event["data"]["is_hiring"]) return "", 200 ``` * TypeScript ```ts import { createHmac, timingSafeEqual } from 'node:crypto'; import { createServer } from 'node:http'; const secret = process.env.BETTERJOBS_WEBHOOK_SECRET; if (!secret) throw new Error('BETTERJOBS_WEBHOOK_SECRET is not set'); const seenEventIds = new Set(); // use a unique column in your database in production const TOLERANCE_S = 300; // reject deliveries signed more than 5 minutes from your clock function verify(header: string, body: Buffer): boolean { const parts: Record = Object.fromEntries( header.split(',').filter((item) => item.includes('=')).map((item) => { const i = item.indexOf('='); return [item.slice(0, i), item.slice(i + 1)]; }), ); if (!/^\d+$/.test(parts.t ?? '') || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - Number(parts.t)) > TOLERANCE_S) return false; const expected = createHmac('sha256', secret!).update(`${parts.t}.`).update(body).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); return a.length === b.length && timingSafeEqual(a, b); } createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const body = Buffer.concat(chunks); if (!verify(String(req.headers['betterjobs-signature']), body)) { res.writeHead(400).end(); return; } const event = JSON.parse(body.toString('utf8')); if (!seenEventIds.has(event.id)) { seenEventIds.add(event.id); const job = event.data.job; switch (event.type) { case 'job.opened': upsertJob(job); startOutreach(job); // the only event that starts a sequence break; case 'job.reposted': updateRepostCount(job.id, job.repost_count); break; case 'job.closed': markClosed(job.id, job.closed_reason); stopOutreach(job.id); break; case 'job.updated': upsertJob(job); break; case 'company.hiring_started': case 'company.hiring_stopped': setAccountHiring(event.data.company, event.data.is_hiring); break; } } res.writeHead(200).end(); }); }).listen(3000); ``` `upsert_job`, `start_outreach` and the other handlers are yours: your database, CRM or sequencer. ## Example deliveries `job.opened` carries the full job (illustrative, trimmed): ```json { "id": "evt_7Wq1ZxC4vB", "type": "job.opened", "created_at": "2026-10-11T06:15:00Z", "watch_id": "wat_6Np3QyR8tU", "data": { "job": { "id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "title": "Head of Revenue Operations", "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "status": "open", "repost_count": 0, "p_real": 0.94 } } } ``` `job.reposted` and `job.closed` carry the job id, title, company, status and lifecycle fields only: ```json { "id": "evt_3Fh8JkL2pQ", "type": "job.reposted", "created_at": "2026-10-11T07:30:00Z", "watch_id": "wat_6Np3QyR8tU", "data": { "job": { "id": "job_01JC2B7Y9MZQ4W8E1R6T3N5K0D", "title": "Senior Robotics Engineer", "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "status": "open", "repost_count": 2 } } } ``` ```json { "id": "evt_1Kp6MnB3vC", "type": "job.closed", "created_at": "2026-10-11T10:00:00Z", "watch_id": "wat_6Np3QyR8tU", "data": { "job": { "id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "title": "Head of Revenue Operations", "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "status": "closed", "closed_reason": "filled" } } } ``` ## Credit cost | Action | Cost | | ---------------------------------------------------------------- | ------------------------------------------------ | | `POST /v1/watches`, `GET /v1/watches`, `DELETE /v1/watches/{id}` | Free | | `job.opened` delivered by a watch | 1 credit (free if you already paid for that job) | | Every other event type | Free | | Retries and `POST /v1/webhooks/replay` | Free | | `GET /v1/events` | Free | A watch on a company that opens 12 jobs in a month costs up to 12 credits that month (jobs you already paid for are free). If you only need to keep your records current, leave `job.opened` out of `events`: the remaining lifecycle events are free. See [Credits and billing](/concepts/credits-and-billing/). ## Pitfalls hiring\_stopped never fires on unknown `company.hiring_stopped` fires only when `is_hiring.value` changes to `false`. A change to `null` (unknown) sends nothing. Do not read silence as “stopped hiring”, and never treat `null` as `false`. See [Provenance and confidence](/concepts/provenance-and-confidence/). Webhooks are a plan feature Webhooks are listed on the Pro plan and above in the [plan table](/concepts/credits-and-billing/). When a request needs a feature your plan lacks, the API returns `403` [`plan_required`](/platform/errors/#plan_required) with `required_plan` and `upgrade_url`. * **Deliveries can arrive twice.** Failed deliveries retry for 24 hours (1m, 5m, 30m, 2h, 6h, 12h and 24h after the first failed attempt). A replay sends the same event `id`. Store event ids with a unique constraint. * **Retries arrive late.** A retried `job.opened` can land after a later `job.updated` for the same job. Upsert on job `id` and keep the newer `last_seen_at`. * **Slow handlers cause retries.** If you call a CRM or LLM, put the event on a queue and return `2xx` first. * **Closed jobs stay in your data.** On `job.closed`, set `status` and `closed_reason`. Do not delete the job: it is your hiring history. * **Missed events are not lost.** Read `GET /v1/events` with `since` to catch up. See [Sync patterns](/guides/sync-patterns/#recover-from-an-outage). ## Next * [Webhooks](/platform/webhooks/): signature, retries and replay in detail. * [Sync patterns](/guides/sync-patterns/): keep a full local copy using searches plus free lifecycle events. * API reference: [Create a watch](/api/operations/createwatch/), [List events](/api/operations/listevents/), [Replay a webhook](/api/operations/replaywebhook/), [Event delivery](/api/webhooks/webhookevent/). # Find companies hiring > How do I find companies hiring for a role in a region? Cost: 1 credit / unique jobDuplicates, already-paid jobs, empty pages and dry runs are free. **Goal:** a list of companies hiring a Head of RevOps in Germany, Austria or Switzerland in the last 7 days, with the matching jobs under each company. You search jobs, then group them by `company.domain`. One request covers every provider on your plan. ## Steps 1. **Estimate for free.** Send the search with `dry_run: true`. You get `expected_unique_jobs_range` and `credits_range`. Nothing is fetched or charged. 2. **Search with a cap.** Send the same search without `dry_run`. Set `waterfall.max_credits` so one page cannot charge more than you expect, and stop paging once the summed `metadata.credits_charged` reaches your run budget. 3. **Page through results.** Pass `next_cursor` back as `cursor` until it is `null`. Each page holds up to 100 jobs. 4. **Group by company.** Key each job on `company.domain`. Fall back to `company.id` when the domain is `null`. 5. **Optional: check each company.** Call `GET /v1/companies/{domain}` for `is_hiring` and `hiring_pulse`. That is 1 credit per profile. ## Estimate first * curl ```bash 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", "Head of Revenue Operations"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7 }, "waterfall": { "strategy": "cheapest_first" }, "limit": 100, "dry_run": true }' ``` * 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", } FILTERS = { "title_or": ["Head of RevOps", "Head of Revenue Operations"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7, } resp = requests.post( f"{API}/jobs/search", headers=HEADERS, json={"filters": FILTERS, "waterfall": {"strategy": "cheapest_first"}, "limit": 100, "dry_run": True}, ) resp.raise_for_status() print(resp.json()["estimate"]) # free ``` * 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', }; const filters = { title_or: ['Head of RevOps', 'Head of Revenue Operations'], country_code_or: ['DE', 'AT', 'CH'], posted_within_days: 7, }; const res = await fetch(`${API}/jobs/search`, { method: 'POST', headers, body: JSON.stringify({ filters, waterfall: { strategy: 'cheapest_first' }, limit: 100, dry_run: true }), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); console.log((await res.json()).estimate); // free ``` The estimate looks like this (illustrative): ```json { "request_id": "req_3Kd8PwZ1uY", "estimate": { "expected_unique_jobs_range": { "min": 40, "max": 75 }, "providers_planned": ["betterjobs", "theirstack", "techmap"], "credits_range": { "min": 40, "max": 75 } } } ``` ## Search, page and group * curl ```bash # First page. Repeat with "cursor": "" until next_cursor is null. 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" \ -H "Idempotency-Key: 7b8f2c4e-1a3d-4f5b-9c6e-0d2a4b6c8e1f" \ -d '{ "filters": { "title_or": ["Head of RevOps", "Head of Revenue Operations"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7 }, "waterfall": { "strategy": "cheapest_first", "max_credits": 100 }, "limit": 100 }' ``` Group the saved pages with `jq`: ```bash jq -s '[.[].data[]] | group_by(.company.domain // .company.id) | map({company: .[0].company, jobs: map({id, title, location})})' page-*.json ``` * Python ```python import uuid from collections import defaultdict def search_all(filters: dict, budget: int) -> list[dict]: """Stops when the run has spent `budget` credits. max_credits caps each page only.""" jobs, cursor, spent = [], None, 0 while spent < budget: body = { "filters": filters, "waterfall": {"strategy": "cheapest_first", "max_credits": min(100, budget - spent)}, "limit": 100, } if cursor: body["cursor"] = cursor resp = requests.post( f"{API}/jobs/search", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json=body, ) resp.raise_for_status() page = resp.json() if page["metadata"]["status"] == "partial": print("partial page, failed providers:", page["metadata"]["providers"]["failed"]) jobs += page["data"] spent += page["metadata"]["credits_charged"] cursor = page["next_cursor"] if cursor is None: break return jobs companies: dict[str, dict] = defaultdict(lambda: {"company": None, "jobs": []}) for job in search_all(FILTERS, budget=100): key = job["company"]["domain"] or job["company"]["id"] companies[key]["company"] = job["company"] companies[key]["jobs"].append({"id": job["id"], "title": job["title"], "p_real": job["p_real"]}) for key, entry in sorted(companies.items(), key=lambda kv: -len(kv[1]["jobs"])): print(entry["company"]["name"], key, len(entry["jobs"])) ``` * TypeScript ```ts type Job = { id: string; title: string; p_real: number; company: { id: string; name: string; domain: string | null }; }; async function searchAll(budget: number): Promise { // Stops when the run has spent `budget` credits. max_credits caps each page only. const jobs: Job[] = []; let cursor: string | null = null; let spent = 0; do { const res = await fetch(`${API}/jobs/search`, { method: 'POST', headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify({ filters, waterfall: { strategy: 'cheapest_first', max_credits: Math.min(100, budget - spent) }, limit: 100, ...(cursor ? { cursor } : {}), }), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const page = await res.json(); if (page.metadata.status === 'partial') console.warn('partial page', page.metadata.providers.failed); jobs.push(...page.data); spent += page.metadata.credits_charged; cursor = page.next_cursor; } while (cursor && spent < budget); return jobs; } const companies = new Map[] }>(); for (const job of await searchAll(100)) { const key = job.company.domain ?? job.company.id; const entry = companies.get(key) ?? { company: job.company, jobs: [] }; entry.jobs.push({ id: job.id, title: job.title, p_real: job.p_real }); companies.set(key, entry); } console.log([...companies.values()].sort((a, b) => b.jobs.length - a.jobs.length)); ``` ## Example response excerpt One canonical job from the search (illustrative, trimmed). Three sources saw it; you pay for it once. ```json { "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 }, "seniority": "lead", "salary": { "min": 110000, "max": 135000, "currency": "EUR", "period": "year", "origin": "declared" }, "status": "open", "p_real": 0.94, "sources": [ { "provider": "betterjobs", "provider_job_id": "bj_idx_5521907", "fields": ["title", "description", "apply_url", "location", "employment_type", "posted_at"] }, { "provider": "theirstack", "provider_job_id": "ts_88213377", "fields": ["salary", "seniority"] }, { "provider": "techmap", "provider_job_id": "tm_3f9a2c71", "fields": ["job_family"] } ] } ], "next_cursor": "cur_8fJ2kQ", "metadata": { "status": "complete", "credits_charged": 1, "jobs_already_paid": 0, "duplicates_merged": 2, "providers": { "tried": ["betterjobs", "techmap", "theirstack"], "hit": ["betterjobs", "techmap", "theirstack"], "failed": [] } } } ``` After grouping (illustrative): ```json [ { "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "jobs": [{ "id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "title": "Head of Revenue Operations", "p_real": 0.94 }] } ] ``` ## Check a company (optional) A search tells you who posted matching jobs. A company profile tells you whether the company is hiring overall and in which direction. * curl ```bash curl https://api.betterjobs.cc/v1/companies/acme-robotics.example \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python profile = requests.get(f"{API}/companies/acme-robotics.example", headers=HEADERS) profile.raise_for_status() p = profile.json() print(p["is_hiring"]["value"], p["hiring_pulse"]["direction"], p["open_jobs_count"]) ``` * TypeScript ```ts const profile = await fetch(`${API}/companies/acme-robotics.example`, { headers }); if (!profile.ok) throw new Error(`${profile.status} ${await profile.text()}`); const p = await profile.json(); console.log(p.is_hiring.value, p.hiring_pulse.direction, p.open_jobs_count); ``` ```json { "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "open_jobs_count": 42, "is_hiring": { "value": true, "confidence": 0.96, "basis": "42 open jobs corroborated by 4 sources in the last 30 days" }, "hiring_pulse": { "direction": "up", "open_jobs_30d_change": 9 } } ``` ## Credit cost | Call | Cost | | ------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `POST /v1/jobs/search` with `dry_run: true` | Free | | `POST /v1/jobs/search` | 1 credit per unique job returned. Duplicates, jobs you already paid for and empty pages are free. | | `GET /v1/companies/{domain}` | 1 credit per profile | A company with 30 matching jobs costs 30 credits through search. If you already have an account list and only need yes or no, `GET /v1/companies/{domain}` at 1 credit each is cheaper. See [Credits and billing](/concepts/credits-and-billing/). ## Pitfalls Group on domain, not on name Company names differ across sources (“Acme Robotics”, “Acme Robotics GmbH”). Group on `company.domain`. When it is `null`, use `company.id`; never drop the job. null is not false `is_hiring.value: null` means unknown, not “not hiring”. Do not remove an account from a list because of a `null`. See [Provenance and confidence](/concepts/provenance-and-confidence/). * **`posted_within_days` counts from first seen.** It matches jobs first seen within that many days. A job posted earlier but discovered late still matches. Check `posted_at` if you need the employer’s date. * **Partial pages are normal.** A provider timeout gives `200` with `metadata.status: partial`. You pay only for returned jobs. Re-run later to fill gaps; jobs you already paid for come back free. * **`max_credits` stops results.** When the cap is hit, the page stops early. Raise the cap or narrow the filters. * **Before outbound, prefer corroborated jobs.** Use `p_real`, or the `consensus` strategy. See [Choose a strategy](/guides/choose-a-strategy/). * **Unknown filter fields fail.** A typo returns `400 unknown_filter` with the field in `param`. See [Filters](/platform/filters/). ## Next * [Detect hiring changes](/guides/detect-hiring-changes/): get told when these companies open or close jobs. * [Pagination](/platform/pagination/) and [Companies](/data/companies/). * API reference: [Search jobs](/api/operations/searchjobs/), [Get a company](/api/operations/getcompany/). # Migrate from Coresignal > How do I translate my Coresignal queries and fields to BetterJobs? Coresignal splits job retrieval in two: a search that finds matches, then a collect call per job. Its Base API returns one row per source and flags duplicates. BetterJobs does all of it in one request: search, fetch, merge and dedup, billed per unique job. Coresignal is also one of the six providers behind BetterJobs, so its postings can still reach you through the waterfall. ### [Coresignal](/providers/coresignal/) `coresignal` Large historical job-posting dataset plus company and employee records. * Records Job postings, company records, employee records * Postings 475M+ job postings, 70M+ active * History Since August 2020 * Recheck Active postings rechecked within 24h * Credits Search free; collect 1 credit per job, 20 per company or employee record Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per Coresignal docs. ## What changes, in one table | Topic | Coresignal (per Coresignal docs) | BetterJobs | | ---------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | Flow | Search (free), then collect each job | One `POST /v1/jobs/search` returns full jobs | | Free count before you pay | Search is free | `dry_run: true` returns a free estimate | | Job price | 1 credit per job collected | 1 credit per unique job returned | | Duplicates | One row per source with an `isDuplicate` flag; you filter | One canonical job per opening; every source in `sources[]`; duplicates free | | Query language | Elasticsearch DSL or flat filters | One flat `filters` object with suffix grammar. No DSL. See [Filters](/platform/filters/) | | History | Since August 2020 | `posted_within_days` up to 365, `include_closed: true` for closed jobs | | Large pulls | Bulk JSONL, Parquet or CSV to S3, GCS, Azure or Snowflake | Async searches, up to 10,000 jobs each. No bulk file delivery in v1 preview | | Company and employee records | 20 credits each | `GET /v1/companies/{domain}`: 1 credit, hiring profile only. No employee records | Not a replacement for Coresignal's datasets If you rely on Coresignal’s multi-year history, employee records or bulk file delivery to a warehouse, BetterJobs v1 preview does not cover that. Keep Coresignal for those jobs. See [When not to use BetterJobs](/resources/when-not-to-use/). ## Translate a request Before: search, collect each hit, drop duplicate rows. `coresignal_search` and `coresignal_collect` stand for your existing wrappers around Coresignal’s endpoints. ```python # Before (Coresignal): 1 search + N collect calls + your own dedup ids = coresignal_search(query) # free per Coresignal docs rows = [coresignal_collect(job_id) for job_id in ids] # 1 credit per job collected jobs = [r for r in rows if not r["isDuplicate"]] # one row per source, so filter ``` After: one request returns merged, deduplicated jobs. Start with a free estimate, then fetch with a cap. * curl ```bash # Free estimate (replaces the free search step) 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": ["Data Engineer", "Analytics Engineer"], "country_code_or": ["DE", "NL"], "posted_within_days": 30 }, "waterfall": { "strategy": "max_coverage" }, "limit": 100, "dry_run": true }' # Fetch (replaces search + collect + dedup). Drop dry_run, add a cap. 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": ["Data Engineer", "Analytics Engineer"], "country_code_or": ["DE", "NL"], "posted_within_days": 30 }, "waterfall": { "strategy": "max_coverage", "max_credits": 100 }, "limit": 100 }' ``` * 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", } BODY = { "filters": { "title_or": ["Data Engineer", "Analytics Engineer"], "country_code_or": ["DE", "NL"], "posted_within_days": 30, }, "waterfall": {"strategy": "max_coverage"}, "limit": 100, } estimate = requests.post(f"{API}/jobs/search", headers=HEADERS, json={**BODY, "dry_run": True}, timeout=30) estimate.raise_for_status() print(estimate.json()["estimate"]["credits_range"]) # free body = {**BODY, "waterfall": {**BODY["waterfall"], "max_credits": 100}} resp = requests.post(f"{API}/jobs/search", headers=HEADERS, json=body, timeout=30) resp.raise_for_status() jobs = resp.json()["data"] # already merged and deduplicated ``` * 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', }; const body = { filters: { title_or: ['Data Engineer', 'Analytics Engineer'], country_code_or: ['DE', 'NL'], posted_within_days: 30, }, waterfall: { strategy: 'max_coverage' }, limit: 100, }; async function search(payload: object) { const res = await fetch(`${API}/jobs/search`, { method: 'POST', headers, body: JSON.stringify(payload) }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res.json(); } const { estimate } = await search({ ...body, dry_run: true }); // free console.log(estimate.credits_range); const { data: jobs } = await search({ ...body, waterfall: { ...body.waterfall, max_credits: 100 } }); // merged, deduplicated ``` For pulls above 100 jobs, use `POST /v1/searches` (up to 10,000 jobs) and read the result pages for free. See [Sync patterns](/guides/sync-patterns/#initial-backfill). ### From Elasticsearch DSL to filters BetterJobs has no query DSL. Translate the clauses you use into the flat filters below; anything else, filter client-side. | What your Coresignal query does | BetterJobs filter | | ------------------------------------------- | ------------------------------------------------------------- | | Match any of several titles | `title_or` | | Exclude titles | `title_not` | | Restrict to countries | `country_code_or` (ISO 3166-1 alpha-2) | | Restrict to companies | `company_domain_or` | | Restrict by recency | `posted_within_days` (1 to 365, counted from `first_seen_at`) | | Restrict by seniority or employment type | `seniority_or`, `employment_type_or` | | Include expired or closed postings | `include_closed: true` | | Nested boolean logic, scoring, aggregations | No equivalent. Run several searches or post-filter | Different filters combine with AND; values inside one `_or` list combine with OR. ## Map the fields We do not publish one-to-one field names for Coresignal yet: none are confirmed against Coresignal’s current schema. Map by meaning using the [field dictionary](/data/field-dictionary/), which lists every BetterJobs field with its type, derivation and what `null` means. The fields you will use most: | You need | BetterJobs field | | ------------------- | ---------------------------------------------------------------------------------- | | Stable job key | `id` (canonical, `job_...`) | | The source’s own id | `sources[].provider_job_id` where `sources[].provider` is `coresignal` | | Duplicate handling | Not needed: duplicates are merged. `sources[]` lists every source that saw the job | | Still live? | `status`, `closed_reason`, `last_verified_at` | | When it appeared | `posted_at` (employer date, may be `null`), `first_seen_at` (earliest sighting) | ## Billing differences 1. **No collect step.** Coresignal charges 1 credit per job collected, per its docs. BetterJobs charges 1 credit per unique job returned by the search itself. 2. **Duplicates are free.** With one row per source, the same opening can arrive more than once. BetterJobs merges them into one canonical job and charges once. `metadata.duplicates_merged` shows how many records were folded. 3. **Re-reads are free.** A job you already paid for returns free in any later search or `GET /v1/jobs/{id}`. The ledger shows it as `already_paid`. 4. **Company data costs less and covers less.** Coresignal company records cost 20 credits each, per its docs. A BetterJobs company profile costs 1 credit and covers hiring only. Coresignal is a partner provider: Growth includes two partner providers, Pro and above include all six. `GET /v1/providers` shows whether your plan enables `coresignal`. On Scale you can bring your own provider keys. Plans are on [Credits and billing](/concepts/credits-and-billing/). ## What changes in your code 1. **Delete the collect loop.** Search returns full jobs. No per-id fetch. 2. **Delete the dedup filter.** No `isDuplicate` check. Key rows on the canonical `id`. 3. **Rewrite DSL queries as `filters`.** Use the table above. Unknown filter names return `400 unknown_filter`. 4. **Replace bulk deliveries with async searches.** One async search per slice (for example per country), up to 10,000 jobs each, upserted on `id`. 5. **Read provenance from `sources[]`.** Each entry has `provider`, `provider_job_id`, `url`, `first_seen_at`, `last_seen_at` and the `fields` it contributed. ## Pitfalls null is not false `location.remote: null` and `is_hiring.value: null` mean unknown. Do not map them to `false` when you port code that expected booleans. * **`posted_within_days` caps at 365.** For older history, keep Coresignal or your existing archive. * **Partial results are billed only for what returns.** A provider timeout gives `200` with `metadata.status: partial`. Re-run later; already-paid jobs are free. * **Set `max_credits`.** Coresignal’s free search let you look before paying. Do the same with `dry_run`, then cap the real request. ## Next * [Coresignal provider page](/providers/coresignal/) * [Canonical jobs](/concepts/canonical-jobs/): how merge and dedup work. * [Async searches](/platform/async-searches/) and [Sync patterns](/guides/sync-patterns/) # Migrate from JobsPipe > How do I translate my JobsPipe queries and fields to BetterJobs? JobsPipe and BetterJobs look alike from the outside: both search jobs with `POST /v1/jobs/search` and bill per job. The differences are inside: BetterJobs fans the search out to JobsPipe and up to six other sources, merges the results into canonical jobs, and never bills the same job twice. JobsPipe is one of the six providers behind BetterJobs, so its postings can still reach you through the waterfall. ### [JobsPipe](/providers/jobspipe/) `jobspipe` Normalized job postings from 30+ sources with 12 months of history. * Sources 30+ sources incl. Greenhouse, Lever, Ashby, Workday, Indeed, LinkedIn * History 12 months * Freshness Under 6h on Builder, under 1h on Scale * Credits 1 credit per job; one credit buys a job for the rest of the calendar month Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per JobsPipe docs. ## What changes, in one table | Topic | JobsPipe (per JobsPipe docs) | BetterJobs | | --------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Search endpoint | `POST /v1/jobs/search` | `POST /v1/jobs/search` on `https://api.betterjobs.cc` | | Sources | 30+ sources incl. Greenhouse, Lever, Ashby, Workday, Indeed, LinkedIn | BetterJobs index plus up to six providers, JobsPipe included, merged | | Job price | 1 credit per job | 1 credit per unique job | | Paying again | One credit buys a job for the rest of the calendar month | A job you already paid for returns free; the ledger shows `already_paid` | | Ghost postings | `ghost_score` | `p_real`: probability the job is real, from cross-source corroboration | | Taxonomies | ISCO-08, ISIC, ESCO | `job_family` strings and a fixed `seniority` enum. No ISCO, ISIC or ESCO codes in v1 preview | | History | 12 months | `posted_within_days` up to 365, `include_closed: true` for closed jobs | | Freshness | Under 6h on Builder, under 1h on Scale | Published at GA. Each job carries `first_seen_at`, `last_seen_at`, `last_verified_at` | ## Translate a request The path is the same. Change the base URL, the auth header and the body: BetterJobs puts every filter inside one `filters` object and routing next to it in `waterfall`. ```text Before: POST /v1/jobs/search + your JobsPipe filters and key After: POST https://api.betterjobs.cc/v1/jobs/search Authorization: Bearer bj_live_... BetterJobs-Version: 2026-10-01 { "filters": { ... }, "waterfall": { ... }, "limit": 100 } ``` * curl ```bash 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": ["Senior Data Engineer", "Staff Data Engineer"], "country_code_or": ["NL", "DE"], "seniority_or": ["senior", "lead"], "remote": true, "posted_within_days": 14 }, "waterfall": { "strategy": "cheapest_first", "max_credits": 100 }, "limit": 100 }' ``` * Python ```python import os import 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": ["Senior Data Engineer", "Staff Data Engineer"], "country_code_or": ["NL", "DE"], "seniority_or": ["senior", "lead"], "remote": True, "posted_within_days": 14, }, "waterfall": {"strategy": "cheapest_first", "max_credits": 100}, "limit": 100, }, timeout=30, ) resp.raise_for_status() page = resp.json() jobs, meta = page["data"], page["metadata"] print(meta["credits_charged"], "charged,", meta["jobs_already_paid"], "already paid (free)") ``` * TypeScript ```ts const res = 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: ['Senior Data Engineer', 'Staff Data Engineer'], country_code_or: ['NL', 'DE'], seniority_or: ['senior', 'lead'], remote: true, posted_within_days: 14, }, waterfall: { strategy: 'cheapest_first', max_credits: 100 }, limit: 100, }), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const { data: jobs, metadata } = await res.json(); console.log(metadata.credits_charged, 'charged,', metadata.jobs_already_paid, 'already paid (free)'); ``` Rewrite each JobsPipe filter you use as one of the BetterJobs filters on [Filters](/platform/filters/). A filter name BetterJobs does not know returns `400 unknown_filter` with the name in `error.param`, so a missed rename fails on the first call instead of returning a wider result. Every BetterJobs search response states its cost in `metadata.credits_charged`, plus the `X-Credits-Charged` and `X-Credits-Remaining` headers. ## Map the fields Field names in JobsPipe responses and their BetterJobs equivalents. Rows show only fields with a confident one-to-one match. | BetterJobs | JobsPipe | | ------------------------- | --------------- | | `title` | `job_title` | | `seniority` | `seniority` | | `salary` | `salary_usd` | | `posted_at` | `date_posted` | | `first_seen_at` | `discovered_at` | | `last_seen_at` | `last_seen_at` | | `last_verified_at` | `verified_at` | | `closed_reason` | `closed_reason` | | `sources[].url` | `url` | | `sources[].first_seen_at` | `discovered_at` | | `sources[].last_seen_at` | `last_seen_at` | Every BetterJobs field, with type and null meaning, is in the [field dictionary](/data/field-dictionary/). ghost\_score and p\_real point in opposite directions A high `ghost_score` flags a likely ghost posting. A high `p_real` means the job is likely real. The scales are computed differently, so `1 - ghost_score` is not `p_real`. Re-tune your thresholds on your own data. salary\_usd is not salary BetterJobs `salary` is an object: `min`, `max`, `currency`, `period` and `origin` (`declared` or `inferred`). Amounts stay in the posting’s currency. Convert to USD yourself if your code expects `salary_usd`. ## Billing differences 1. **Charged once, not once a month.** Per JobsPipe docs, one credit buys a job for the rest of the calendar month. BetterJobs charges 1 credit the first time a job reaches you; later reads of that job in searches or `GET /v1/jobs/{id}` are free. 2. **Duplicates across sources are free.** If JobsPipe and another source report the same opening, you pay for one canonical job. `metadata.duplicates_merged` counts the folded records. 3. **Empty pages and estimates are free.** `dry_run: true` returns `expected_unique_jobs_range` and `credits_range` without fetching. 4. **Proof of charge.** `GET /v1/billing/ledger?job_id=...` lists the original charge and every free re-read. JobsPipe is a partner provider: Growth includes two partner providers, Pro and above include all six. `GET /v1/providers` shows whether your plan enables `jobspipe`. On Scale you can bring your own provider keys. Plans are on [Credits and billing](/concepts/credits-and-billing/). ## What changes in your code 1. **Base URL, key and version header.** See [Authentication](/getting-started/authentication/). 2. **Wrap filters.** Move them into `filters` and rename them to BetterJobs names. 3. **Rename response fields.** Use the table above, or the adapter below at the edge of your code. 4. **Replace taxonomy codes.** Map your ISCO-08 or ESCO lists to `job_family_or` and `seniority_or` values. See [Taxonomies](/data/taxonomies/). 5. **Drop month-boundary logic.** Code that avoided re-fetching across months to save credits is no longer needed. 6. **Read provenance.** `sources[]` shows which sources saw each job; the JobsPipe record appears with `provider: "jobspipe"`. If downstream code reads JobsPipe names, adapt each job once: * curl ```bash curl -s 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": ["Senior Data Engineer"], "posted_within_days": 14}, "limit": 100}' \ | jq '[.data[] | { job_title: .title, date_posted: .posted_at, discovered_at: .first_seen_at, last_seen_at: .last_seen_at, verified_at: .last_verified_at, seniority: .seniority, closed_reason: .closed_reason, url: ([.sources[].url | select(. != null)] | first), p_real: .p_real }]' ``` * Python ```python def to_jobspipe_names(job: dict) -> dict: """Rename BetterJobs fields to the JobsPipe names your code already reads.""" return { "job_title": job["title"], "date_posted": job["posted_at"], # may be null: use discovered_at then "discovered_at": job["first_seen_at"], "last_seen_at": job["last_seen_at"], "verified_at": job["last_verified_at"], "seniority": job["seniority"], "closed_reason": job["closed_reason"], # filled | expired | removed | unknown | None "url": next((s["url"] for s in job["sources"] if s["url"]), None), "p_real": job["p_real"], # not ghost_score: re-tune thresholds # No salary_usd: use job["salary"] (currency, period, origin). } rows = [to_jobspipe_names(job) for job in jobs] ``` * TypeScript ```ts type Job = { title: string; posted_at: string | null; first_seen_at: string; last_seen_at: string; last_verified_at: string | null; seniority: string | null; closed_reason: 'filled' | 'expired' | 'removed' | 'unknown' | null; sources: { url: string | null }[]; p_real: number; }; /** Rename BetterJobs fields to the JobsPipe names your code already reads. */ function toJobsPipeNames(job: Job) { return { job_title: job.title, date_posted: job.posted_at, // may be null: use discovered_at then discovered_at: job.first_seen_at, last_seen_at: job.last_seen_at, verified_at: job.last_verified_at, seniority: job.seniority, closed_reason: job.closed_reason, url: job.sources.find((s) => s.url)?.url ?? null, p_real: job.p_real, // not ghost_score: re-tune thresholds // No salary_usd: use job.salary (currency, period, origin). }; } const rows = jobs.map(toJobsPipeNames); ``` ## Pitfalls * **Same path, different host.** Change the base URL and the key together. A key BetterJobs does not recognize returns `401` [`unauthorized`](/platform/errors/#unauthorized). * **`closed_reason` values are a fixed enum.** BetterJobs uses `filled`, `expired`, `removed`, `unknown`, or `null` while open. Map JobsPipe values you stored before. * **Seniority values differ.** Check your `seniority` filters against [Taxonomies](/data/taxonomies/) before copying them. * **`null` is unknown.** `last_verified_at: null` means never verified at origin, not “dead”. ## Next * [JobsPipe provider page](/providers/jobspipe/) * [Provenance and confidence](/concepts/provenance-and-confidence/): `p_real` and `sources[]`. * [Sync patterns](/guides/sync-patterns/) # Migrate from Techmap > How do I translate my Techmap queries and fields to BetterJobs? Techmap (jobdatafeeds.com) sells high-volume job feeds: an API priced per thousand jobs, daily country files and a `/count` endpoint to estimate cost. BetterJobs replaces the feed-and-dedup work with one search that merges Techmap and up to six other sources into canonical jobs. Techmap is one of the six providers behind BetterJobs, so its postings can still reach you through the waterfall. ### [Techmap](/providers/techmap/) `techmap` High-volume job feeds from ATSs, job boards and public employment offices (jobdatafeeds.com). * Sources 200+ sources incl. 120 ATSs and 28 public employment offices * New postings About 8M per month * History 451M+ postings since 2020 * Countries Claims 250 countries (about 125 with more than 100 jobs per month) * Feeds Daily country feeds via AWS Data Exchange * API $1 per 1k jobs; /count endpoint estimates cost Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per Techmap (jobdatafeeds.com). [Provider docs ↗](https://jobdatafeeds.com) ## What changes, in one table | Topic | Techmap (per Techmap) | BetterJobs | | ----------------------- | --------------------------------------------------- | --------------------------------------------------------------------------- | | Delivery | API, plus daily country feeds via AWS Data Exchange | API: sync search (100 jobs per page) and async search (up to 10,000 jobs) | | Estimate before you pay | `/count` endpoint | `dry_run: true`, free | | Price | $1 per 1,000 jobs via the API | 1 credit per unique job; dollar value depends on your plan (below) | | Duplicates | `isDuplicate` flag on records | Merged into one canonical job; every source in `sources[]`; duplicates free | | Formats | json, csv, rss, parquet | JSON API. CSV on Growth and above. No parquet or RSS in v1 preview | | Raw posting markup | `jsonLD` (schema.org `JobPosting`) | Normalized fields only. No JSON-LD passthrough | | History | 451M+ postings since 2020 | `posted_within_days` up to 365, `include_closed: true` for closed jobs | Honest price check Per Techmap, its API costs $1 per 1,000 jobs. A BetterJobs credit costs more: Growth $4.90, Pro $3.32, Scale $2.40 per 1,000 credits. You pay the difference for merge and dedup across every source your plan enables (Growth: the BetterJobs index plus 2 partner providers; Pro and above: all six), cross-source `p_real`, lifecycle events and one bill. If you only need Techmap’s raw feed at volume, Techmap direct is cheaper. On Scale you can bring your own provider keys. ## Translate a request Before: pull a feed or API page, estimate with `/count`, drop flagged duplicates, normalize. `techmap_count` and `techmap_fetch` stand for your existing wrappers. ```python # Before (Techmap): estimate, fetch, then dedup and normalize yourself cost = techmap_count(query) # /count estimates cost, per Techmap rows = techmap_fetch(query) # $1 per 1k jobs, per Techmap rows = [r for r in rows if not r["isDuplicate"]] jobs = [normalize(r["jsonLD"]) for r in rows] # your own mapping from schema.org JobPosting ``` After: estimate free, then fetch merged canonical jobs with a credit cap. * curl ```bash # Free estimate (replaces /count) 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": ["Warehouse Manager", "Logistics Manager"], "country_code_or": ["DE"], "employment_type_or": ["full_time"], "posted_within_days": 1 }, "waterfall": { "strategy": "max_coverage" }, "limit": 100, "dry_run": true }' # Fetch merged jobs (replaces fetch + isDuplicate filter + normalize) 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": ["Warehouse Manager", "Logistics Manager"], "country_code_or": ["DE"], "employment_type_or": ["full_time"], "posted_within_days": 1 }, "waterfall": { "strategy": "max_coverage", "max_credits": 100 }, "limit": 100 }' ``` * 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", } BODY = { "filters": { "title_or": ["Warehouse Manager", "Logistics Manager"], "country_code_or": ["DE"], "employment_type_or": ["full_time"], "posted_within_days": 1, }, "waterfall": {"strategy": "max_coverage"}, "limit": 100, } estimate = requests.post(f"{API}/jobs/search", headers=HEADERS, json={**BODY, "dry_run": True}, timeout=30) estimate.raise_for_status() print(estimate.json()["estimate"]) # free, replaces /count body = {**BODY, "waterfall": {**BODY["waterfall"], "max_credits": 100}} resp = requests.post(f"{API}/jobs/search", headers=HEADERS, json=body, timeout=30) resp.raise_for_status() jobs = resp.json()["data"] # merged, deduplicated, normalized ``` * 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', }; const body = { filters: { title_or: ['Warehouse Manager', 'Logistics Manager'], country_code_or: ['DE'], employment_type_or: ['full_time'], posted_within_days: 1, }, waterfall: { strategy: 'max_coverage' }, limit: 100, }; async function search(payload: object) { const res = await fetch(`${API}/jobs/search`, { method: 'POST', headers, body: JSON.stringify(payload) }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res.json(); } console.log((await search({ ...body, dry_run: true })).estimate); // free, replaces /count const { data: jobs } = await search({ ...body, waterfall: { ...body.waterfall, max_credits: 100 } }); ``` ### Replacing the daily country feed A daily country file becomes a daily search per country with one day of overlap: ```json { "filters": { "country_code_or": ["DE"], "posted_within_days": 2 }, "waterfall": { "strategy": "max_coverage", "max_credits": 10000 }, "limit": 10000 } ``` Send it to `POST /v1/searches` (async, up to 10,000 jobs), read the pages for free and upsert on `id`. Jobs from the overlap day that you already paid for are free. Add a search watch with `job.closed` to mark expired jobs, also free. The full recipe is on [Sync patterns](/guides/sync-patterns/). Size it before you schedule it A whole country per day can exceed 10,000 jobs. Send the same `filters` and `waterfall` to `POST /v1/jobs/search` with `"limit": 100` and `"dry_run": true` first (async searches have no `dry_run`). `expected_unique_jobs_range` counts every matching job, not one page. If its `max` is above 10,000, split by `job_family_or` or by title groups. ## Map the fields Field names in Techmap records and their BetterJobs equivalents. Rows show only fields with a confident one-to-one match. | BetterJobs | Techmap | | ----------------- | -------------- | | `employment_type` | `contractType` | Other Techmap fields, per its reference, and where that information lives in BetterJobs: | Techmap field | BetterJobs | Note | | ------------- | ------------------------ | ----------------------------------------------------------------------------------------- | | `isDuplicate` | none needed | Duplicates are merged. `sources[]` lists every source that saw the job | | `jsonLD` | the canonical job fields | Normalized, not passed through. See the [field dictionary](/data/field-dictionary/) | | `dateActive` | — | No confident equivalent. Compare with `first_seen_at`, `last_seen_at`, `last_verified_at` | | `workPlace` | — | No confident equivalent. Compare with `location.*` and `location.remote` | ## Billing differences 1. **Per unique job, not per row.** Techmap prices by jobs delivered ($1 per 1,000 via the API, per Techmap). BetterJobs charges 1 credit per unique canonical job. Records from other sources that describe the same opening are merged for free. 2. **Re-reads are free.** A job you already paid for returns free in later searches. Overlapping daily windows cost nothing extra. 3. **Estimates are free in both.** `/count` on Techmap, `dry_run` on BetterJobs. 4. **Proof of charge.** `GET /v1/billing/ledger?job_id=...` lists each charge and each free re-read. Techmap is a partner provider: Growth includes two partner providers, Pro and above include all six. `GET /v1/providers` shows whether your plan enables `techmap`. Plans are on [Credits and billing](/concepts/credits-and-billing/). ## What changes in your code 1. **Replace file ingestion with API pages.** Async search plus cursor paging replaces downloading daily files. 2. **Delete the dedup step.** No `isDuplicate` filter. Key rows on the canonical `id`. 3. **Delete your JSON-LD mapping.** Read normalized fields such as `title`, `location`, `employment_type`, `salary` directly. 4. **Map `contractType` to `employment_type`.** Values are `full_time`, `part_time`, `contract`, `internship`, `temporary` or `null`. See [Taxonomies](/data/taxonomies/). 5. **Handle closures with events.** Use `job.closed` from a watch instead of diffing daily files. See [Detect hiring changes](/guides/detect-hiring-changes/). ## Pitfalls Country count is not coverage Per Techmap, it claims 250 countries, about 125 with more than 100 jobs per month. BetterJobs coverage per country is not published during the preview. Measure on your own queries with `metadata.providers.contributions` and `metadata.field_coverage`. * **`posted_within_days` counts from `first_seen_at`.** It is the earliest sighting across all sources. * **No parquet in v1 preview.** Write the JSON pages to parquet yourself if your warehouse needs it. * **`null` is unknown.** `employment_type: null` means no source said, not “other”. ## Next * [Techmap provider page](/providers/techmap/) * [Sync patterns](/guides/sync-patterns/) and [Async searches](/platform/async-searches/) * [Choose a strategy](/guides/choose-a-strategy/) # Migrate from TheirStack > How do I translate my TheirStack queries and fields to BetterJobs? TheirStack and BetterJobs share a filter style: field names with suffixes such as `_or` and `_not`. Most queries translate line by line. The bigger changes are billing (re-reads are free) and the response shape (one canonical job with `sources[]`). TheirStack is also one of the six providers behind BetterJobs. You can keep it in the mix and add the others with the same request. ### [TheirStack](/providers/theirstack/) `theirstack` Global job postings, technographics inferred from job text, buying intent and firmographics. * Coverage Global job postings; no person data * Discovery 73% of jobs discovered the same day, 91% by the end of the next day * Credits 1 API credit per job, 3 per company * Re-fetch Re-fetching the same job is billed again (filter discovered\_at\_gte) Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per TheirStack docs. ## What changes, in one table | Topic | TheirStack (per TheirStack docs) | BetterJobs | | --------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | Sources | TheirStack’s own job corpus | BetterJobs index plus up to six providers, TheirStack included, merged | | Filter grammar | `_or`, `_not`, `_gte` / `_lte`, `_max_age_days` | `_or`, `_not`, `_gte` on `salary_min_gte`, plus `posted_within_days`. See [Filters](/platform/filters/) | | Job price | 1 API credit per job | 1 credit per unique job | | Fetching the same job again | Billed again; filter on `discovered_at_gte` to avoid it | Free. Jobs you already paid for return at no charge | | Company data | 3 credits per company; technographics and firmographics | `GET /v1/companies/{domain}`: 1 credit, hiring profile only (`is_hiring`, `hiring_pulse`) | | Webhooks | `job.new`, `job.closed`, `company.new` | `job.opened`, `job.reposted`, `job.closed`, `job.updated`, `company.hiring_started`, `company.hiring_stopped`. See [Events](/data/events/) | | Unknown filter names | — | `400 unknown_filter`, never ignored | BetterJobs has no technographics TheirStack infers technographics and buying intent from job text, per its docs. BetterJobs v1 preview returns jobs and hiring profiles only. If you use TheirStack for tech-stack data, keep that part of your integration. See [When not to use BetterJobs](/resources/when-not-to-use/). ## Translate a request Your TheirStack job search body (illustrative; built from the field names and suffix grammar in TheirStack docs, so check exact names against your code): ```json { "job_title_or": ["Head of RevOps", "Head of Revenue Operations"], "job_title_not": ["Intern"], "company_domain_or": ["acme-robotics.example", "northwind.example"], "remote": true, "discovered_at_gte": "2026-10-04T00:00:00Z", "limit": 25 } ``` The same search on BetterJobs: * curl ```bash 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", "Head of Revenue Operations"], "title_not": ["Intern"], "company_domain_or": ["acme-robotics.example", "northwind.example"], "remote": true, "posted_within_days": 7 }, "waterfall": { "strategy": "cheapest_first", "max_credits": 25 }, "limit": 25 }' ``` * Python ```python import os import 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", "Head of Revenue Operations"], "title_not": ["Intern"], "company_domain_or": ["acme-robotics.example", "northwind.example"], "remote": True, "posted_within_days": 7, }, "waterfall": {"strategy": "cheapest_first", "max_credits": 25}, "limit": 25, }, timeout=30, ) resp.raise_for_status() page = resp.json() jobs, meta = page["data"], page["metadata"] ``` * TypeScript ```ts const res = 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', 'Head of Revenue Operations'], title_not: ['Intern'], company_domain_or: ['acme-robotics.example', 'northwind.example'], remote: true, posted_within_days: 7, }, waterfall: { strategy: 'cheapest_first', max_credits: 25 }, limit: 25, }), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const { data: jobs, metadata } = await res.json(); ``` ### Filter by filter | TheirStack pattern | BetterJobs filter | Note | | ------------------------------------------ | -------------------- | ----------------------------------------------------------------------------------- | | `job_title` + `_or` | `title_or` | Sending `job_title_or` returns `400 unknown_filter` with a “Did you mean” hint. | | `job_title` + `_not` | `title_not` | | | `company_domain` + `_or` | `company_domain_or` | | | `seniority` + `_or` | `seniority_or` | BetterJobs values: see [Taxonomies](/data/taxonomies/). | | `remote` | `remote` | `null` or omitted = any. `false` = on-site or hybrid only. | | `min_annual_salary_usd` + `_gte` | `salary_min_gte` | Compared in the job’s own currency, yearly. Not converted to USD. | | `discovered_at_gte`, `..._max_age_days` | `posted_within_days` | Relative days, counted from `first_seen_at`. No absolute date filter in v1 preview. | | `_lte` on any field | none | Filter client-side for now. | | filters on technographics or firmographics | none | Not in BetterJobs. | Everything goes inside a `filters` object. Routing, budget and paging sit next to it: `waterfall`, `limit`, `cursor`, `dry_run`. ## Map the fields Field names in TheirStack responses and their BetterJobs equivalents. Rows show only fields with a confident one-to-one match. | BetterJobs | TheirStack | | ------------------------- | ----------------------- | | `title` | `job_title` | | `company.domain` | `company_domain` | | `location.remote` | `remote` | | `seniority` | `seniority` | | `salary` | `salary_string` | | `salary.min` | `min_annual_salary_usd` | | `posted_at` | `date_posted` | | `first_seen_at` | `discovered_at` | | `sources[].url` | `url` | | `sources[].first_seen_at` | `discovered_at` | Every BetterJobs field, with type and null meaning, is in the [field dictionary](/data/field-dictionary/). ### Response shape TheirStack returns one record per job it found. BetterJobs returns one **canonical job** per real opening, merged from every source that saw it: ```json { "id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "title": "Head of Revenue Operations", "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "posted_at": "2026-10-08T00:00:00Z", "first_seen_at": "2026-10-08T06:40:00Z", "salary": { "min": 110000, "max": 135000, "currency": "EUR", "period": "year", "origin": "declared" }, "sources": [ { "provider": "betterjobs", "provider_job_id": "bj_idx_5521907", "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", "fields": ["salary", "seniority"] } ] } ``` Illustrative, trimmed. When TheirStack saw the job, its record is listed in `sources[]` with `provider: "theirstack"` and the fields it contributed. ## Billing differences 1. **Re-reads are free.** Per TheirStack docs, fetching the same job again is billed again, which is why you filter on `discovered_at_gte`. On BetterJobs, a job you already paid for comes back free and is counted in `metadata.jobs_already_paid`. Overlapping windows cost nothing. 2. **Duplicates across providers are free.** If TheirStack and another source report the same opening, you pay 1 credit for the canonical job. `metadata.duplicates_merged` counts the folded records. 3. **Company lookups are a different product.** TheirStack charges 3 credits per company (per its docs) for company data. BetterJobs charges 1 credit per hiring profile: open jobs, `is_hiring`, `hiring_pulse`, top job families. No firmographics. 4. **Proof of charge.** `GET /v1/billing/ledger?job_id=...` shows when each job was charged and every free re-read. TheirStack is a partner provider: Growth includes two partner providers, Pro and above include all six. To keep TheirStack in your results, check `GET /v1/providers` for `enabled_on_your_plan`. On Scale you can bring your own provider keys. Plans are on [Credits and billing](/concepts/credits-and-billing/). ## What changes in your code 1. **Base URL, auth and version header.** `https://api.betterjobs.cc/v1`, `Authorization: Bearer bj_live_...`, `BetterJobs-Version: 2026-10-01`. See [Authentication](/getting-started/authentication/). 2. **Wrap filters.** Move filters into `filters` and rename them with the table above. 3. **Drop the re-fetch guard.** Remove `discovered_at_gte` bookkeeping that only existed to avoid paying twice. Use `posted_within_days` with a day of overlap. See [Sync patterns](/guides/sync-patterns/). 4. **Key on the canonical `id`.** Store `job_...` ids. Keep the TheirStack id from `sources[].provider_job_id` only if you need to join old rows. 5. **Rename webhook handlers.** `job.new` becomes `job.opened`, `job.closed` stays `job.closed`. Add a no-op for `job.reposted`: it must not start outreach. `company.new` has no equivalent. See [Detect hiring changes](/guides/detect-hiring-changes/). 6. **Handle partial results.** A provider timeout returns `200` with `metadata.status: partial`. Log `metadata.providers.failed`; do not treat it as an error. If downstream code expects TheirStack field names, adapt each job at the edge: * curl ```bash curl -s 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"], "posted_within_days": 7}, "limit": 25}' \ | jq '[.data[] | { job_title: .title, company_domain: .company.domain, date_posted: .posted_at, discovered_at: .first_seen_at, remote: .location.remote, seniority: .seniority, url: ([.sources[].url | select(. != null)] | first) }]' ``` * Python ```python def to_theirstack_names(job: dict) -> dict: """Rename BetterJobs fields to the TheirStack names your code already reads.""" return { "job_title": job["title"], "company_domain": job["company"]["domain"], "date_posted": job["posted_at"], # may be null: use discovered_at then "discovered_at": job["first_seen_at"], "remote": job["location"]["remote"], # null = unknown, not False "seniority": job["seniority"], "url": next((s["url"] for s in job["sources"] if s["url"]), None), # No min_annual_salary_usd: BetterJobs salary is in salary.currency per salary.period. } rows = [to_theirstack_names(job) for job in jobs] ``` * TypeScript ```ts type Job = { title: string; company: { domain: string | null }; posted_at: string | null; first_seen_at: string; location: { remote: boolean | null }; seniority: string | null; sources: { url: string | null }[]; }; /** Rename BetterJobs fields to the TheirStack names your code already reads. */ function toTheirStackNames(job: Job) { return { job_title: job.title, company_domain: job.company.domain, date_posted: job.posted_at, // may be null: use discovered_at then discovered_at: job.first_seen_at, remote: job.location.remote, // null = unknown, not false seniority: job.seniority, url: job.sources.find((s) => s.url)?.url ?? null, // No min_annual_salary_usd: BetterJobs salary is in salary.currency per salary.period. }; } const rows = jobs.map(toTheirStackNames); ``` ## Pitfalls Salary is not converted to USD `min_annual_salary_usd` is an annual USD figure. BetterJobs `salary.min` is in `salary.currency` per `salary.period`, and `salary.origin` says whether it was `declared` or `inferred`. Convert yourself if you need USD. * **`posted_within_days` uses first sighting.** It filters on `first_seen_at`, the closest match to TheirStack’s `discovered_at`, not `date_posted`. * **`null` is unknown.** `location.remote: null` means no source said. It is not `false`. * **Typos fail loudly.** Unknown filter names return `400 unknown_filter` instead of a wider, more expensive result. * **Enum values differ.** Check `seniority_or` values against [Taxonomies](/data/taxonomies/) before you copy them over. ## Next * [TheirStack provider page](/providers/theirstack/) * [Filters](/platform/filters/) and [field dictionary](/data/field-dictionary/) * [Find companies hiring](/guides/find-companies-hiring/) # Sync patterns > How do I backfill, keep a daily copy in sync, and recover from outages? Cost: 1 credit / unique jobJobs you already paid for come back free, so overlapping windows cost nothing extra. **Goal:** a local table of every Data Engineer and Analytics Engineer job in five EU countries, filled once, kept current every day, and repaired after downtime. Four patterns, one key: the canonical job `id`. It is stable across sources and requests, so every pattern ends in an upsert on `id`. | Pattern | Call | When | | ---------------------------------------------------- | ------------------------------------------------------ | ---------------------------------- | | [Initial backfill](#initial-backfill) | `POST /v1/searches` (async) | Once, up to 10,000 jobs per search | | [Daily incremental](#daily-incremental) | `POST /v1/jobs/search` with `posted_within_days` | Every day | | [Lifecycle updates](#handle-closed-and-updated-jobs) | Watch with `job.closed`, `job.updated`, `job.reposted` | Continuous, free | | [Outage recovery](#recover-from-an-outage) | `GET /v1/events?since=` | After your side was down | ## Your table Store the full job as JSON plus the columns you query. Guard the upsert so older data never overwrites newer data. ```sql CREATE TABLE jobs ( id text PRIMARY KEY, -- canonical job id, job_... status text NOT NULL, -- open | closed closed_reason text, -- filled | expired | removed | unknown | NULL last_seen_at timestamptz NOT NULL, doc jsonb NOT NULL -- the full Job object ); CREATE TABLE sync_state (key text PRIMARY KEY, value text NOT NULL); -- Upsert one job from a search page or a job.opened / job.updated event INSERT INTO jobs (id, status, closed_reason, last_seen_at, doc) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, closed_reason = EXCLUDED.closed_reason, last_seen_at = EXCLUDED.last_seen_at, doc = EXCLUDED.doc WHERE jobs.last_seen_at <= EXCLUDED.last_seen_at; ``` ## Initial backfill 1. **Estimate.** Send the filters to `POST /v1/jobs/search` with `dry_run: true`. Free. If `expected_unique_jobs_range.max` is above 10,000, split the backfill (see below). 2. **Create an async search.** `POST /v1/searches` with `limit` up to 10,000, `waterfall.max_credits` as a hard cap, and an `Idempotency-Key`. You get `202` and a `srch_...` id. 3. **Wait for it.** Pass `webhook_url` to receive `search.completed`, or poll `GET /v1/searches/{id}`. Branch on `status`, not on the HTTP code. 4. **Read every page.** Once `status` is `completed` or `partial`, `data` holds the first page. Pass `next_cursor` as `cursor` until it is `null`. Reading is free. 5. **Upsert each job on `id`.** * curl ```bash # 1. Create the search (charged as jobs are collected) curl https://api.betterjobs.cc/v1/searches \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 0f6e2a94-7b3c-4d81-9e5a-c2b8d4f61a37" \ -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" }' # 2. Check status / read pages (free). Add &cursor= for the next page. curl "https://api.betterjobs.cc/v1/searches/srch_2Vd9KqL4mN?limit=100" \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python import os import time import uuid import requests API = "https://api.betterjobs.cc/v1" HEADERS = { "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", } FILTERS = { "title_or": ["Data Engineer", "Analytics Engineer"], "country_code_or": ["DE", "FR", "NL", "ES", "PL"], } def backfill(days: int, max_credits: int) -> None: created = requests.post( f"{API}/searches", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json={ "filters": {**FILTERS, "posted_within_days": days}, "waterfall": {"strategy": "max_coverage", "max_credits": max_credits}, "limit": max_credits, }, timeout=30, ) created.raise_for_status() search_id = created.json()["id"] while True: page = get_search(search_id) if page["status"] in ("completed", "partial"): break if page["status"] == "failed": raise RuntimeError(f"search {search_id} failed") if page["status"] == "on_hold": print("Out of credits. Top up and the search resumes.") time.sleep(15) while True: for job in page["data"]: upsert_job(job) # the SQL upsert above if page["next_cursor"] is None: return page = get_search(search_id, page["next_cursor"]) def get_search(search_id: str, cursor: str | None = None) -> dict: params = {"limit": 100, **({"cursor": cursor} if cursor else {})} resp = requests.get(f"{API}/searches/{search_id}", headers=HEADERS, params=params, timeout=30) resp.raise_for_status() return resp.json() backfill(days=30, max_credits=5000) ``` * 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', }; const filters = { title_or: ['Data Engineer', 'Analytics Engineer'], country_code_or: ['DE', 'FR', 'NL', 'ES', 'PL'], }; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function getSearch(id: string, cursor?: string) { const qs = new URLSearchParams({ limit: '100', ...(cursor ? { cursor } : {}) }); const res = await fetch(`${API}/searches/${id}?${qs}`, { headers }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res.json(); } async function backfill(days: number, maxCredits: number) { const res = await fetch(`${API}/searches`, { method: 'POST', headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify({ filters: { ...filters, posted_within_days: days }, waterfall: { strategy: 'max_coverage', max_credits: maxCredits }, limit: maxCredits, }), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const { id } = await res.json(); let page = await getSearch(id); while (!['completed', 'partial'].includes(page.status)) { if (page.status === 'failed') throw new Error(`search ${id} failed`); if (page.status === 'on_hold') console.warn('Out of credits. Top up and the search resumes.'); await sleep(15_000); page = await getSearch(id); } for (;;) { for (const job of page.data) await upsertJob(job); // the SQL upsert above if (!page.next_cursor) return; page = await getSearch(id, page.next_cursor); } } await backfill(30, 5000); ``` A finished search looks like this (illustrative, trimmed): ```json { "id": "srch_2Vd9KqL4mN", "status": "completed", "jobs_found": 3184, "data": [{ "id": "job_01JC9F2K7NQ3XW5R8T1Y6M4H0C", "title": "Senior Data Engineer", "status": "open" }], "next_cursor": "cur_Lp0sR3", "metadata": { "status": "complete", "credits_charged": 3012, "jobs_already_paid": 172, "duplicates_merged": 1907 } } ``` `jobs_already_paid` jobs were free: you had paid for them before. `duplicates_merged` provider records were folded into canonical jobs, also free. More than 10,000 jobs One async search returns up to 10,000 jobs. Split larger backfills by a filter that partitions the set, for example one search per `country_code_or` value or per `job_family_or` value. Overlap between slices is safe: a job you already paid for comes back free, and the upsert on `id` keeps one row. ## Daily incremental Once a day, search jobs first seen in the last **2** days and upsert them. The extra day is overlap: a late or failed run still catches everything. Overlap is free: jobs you already paid for come back at no charge and are counted in `metadata.jobs_already_paid`. * curl ```bash 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" \ -H "Idempotency-Key: daily-2026-10-11-page-1" \ -d '{ "filters": { "title_or": ["Data Engineer", "Analytics Engineer"], "country_code_or": ["DE", "FR", "NL", "ES", "PL"], "posted_within_days": 2 }, "waterfall": { "strategy": "max_coverage", "max_credits": 100 }, "limit": 100 }' ``` * Python ```python from datetime import date def daily_sync(budget: int = 500) -> None: """Stops when the run has spent `budget` credits. max_credits caps each page only.""" cursor, page_no, spent = None, 1, 0 while spent < budget: body = { "filters": {**FILTERS, "posted_within_days": 2}, "waterfall": {"strategy": "max_coverage", "max_credits": min(100, budget - spent)}, "limit": 100, **({"cursor": cursor} if cursor else {}), } # Same key on a retry of the same page = never charged twice key = f"daily-{date.today().isoformat()}-page-{page_no}" resp = requests.post( f"{API}/jobs/search", headers={**HEADERS, "Idempotency-Key": key}, json=body, timeout=30 ) resp.raise_for_status() page = resp.json() for job in page["data"]: upsert_job(job) meta = page["metadata"] spent += meta["credits_charged"] print(meta["status"], meta["credits_charged"], "charged,", meta["jobs_already_paid"], "already paid") cursor, page_no = page["next_cursor"], page_no + 1 if cursor is None: return print(f"Run budget of {budget} credits reached. Resume tomorrow or raise the budget.") ``` * TypeScript ```ts async function dailySync(budget = 500) { // Stops when the run has spent `budget` credits. max_credits caps each page only. let cursor: string | null = null; let pageNo = 1; let spent = 0; do { // Same key on a retry of the same page = never charged twice const key = `daily-${new Date().toISOString().slice(0, 10)}-page-${pageNo}`; const res = await fetch(`${API}/jobs/search`, { method: 'POST', headers: { ...headers, 'Idempotency-Key': key }, body: JSON.stringify({ filters: { ...filters, posted_within_days: 2 }, waterfall: { strategy: 'max_coverage', max_credits: Math.min(100, budget - spent) }, limit: 100, ...(cursor ? { cursor } : {}), }), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const page = await res.json(); for (const job of page.data) await upsertJob(job); const { status, credits_charged, jobs_already_paid } = page.metadata; spent += credits_charged; console.log(status, credits_charged, 'charged,', jobs_already_paid, 'already paid'); cursor = page.next_cursor; pageNo += 1; } while (cursor && spent < budget); if (cursor) console.warn(`Run budget of ${budget} credits reached. Resume tomorrow or raise the budget.`); } ``` Use the same strategy as the backfill `cheapest_first` stops once a page is full, so it is the wrong choice for a complete copy. Use `max_coverage` for backfill and daily runs alike, so both see the same sources. See [Choose a strategy](/guides/choose-a-strategy/). If a daily window returns more jobs than you want to page through synchronously, run it as an async search instead, with the same filters and `posted_within_days: 2`. ## Handle closed and updated jobs A daily search finds new jobs. It does not tell you when an old job closes. Create a search watch with the same filters and only the free lifecycle events: * curl ```bash curl https://api.betterjobs.cc/v1/watches \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6b0d8f2a-4c1e-4a73-9d5b-e7f9a1c3b508" \ -d '{ "type": "search", "filters": { "title_or": ["Data Engineer", "Analytics Engineer"], "country_code_or": ["DE", "FR", "NL", "ES", "PL"] }, "webhook_url": "https://hooks.northwind.example/betterjobs", "events": ["job.closed", "job.updated", "job.reposted"] }' ``` * Python ```python def apply_event(event: dict) -> None: """Apply one event. Safe to call twice with the same event.""" job = event["data"].get("job") match event["type"]: case "job.updated" | "job.opened": upsert_job(job) # full Job case "job.closed": mark_closed(job["id"], job["closed_reason"]) # keep the row, set status and closed_reason case "job.reposted": set_repost_count(job["id"], job["repost_count"]) resp = requests.post( f"{API}/watches", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json={ "type": "search", "filters": FILTERS, "webhook_url": "https://hooks.northwind.example/betterjobs", "events": ["job.closed", "job.updated", "job.reposted"], }, timeout=30, ) resp.raise_for_status() ``` * TypeScript ```ts async function applyEvent(event: { type: string; data: { job?: any } }) { // Safe to call twice with the same event. const job = event.data.job; switch (event.type) { case 'job.updated': case 'job.opened': return upsertJob(job); // full Job case 'job.closed': return markClosed(job.id, job.closed_reason); // keep the row, set status and closed_reason case 'job.reposted': return setRepostCount(job.id, job.repost_count); } } const res = await fetch(`${API}/watches`, { method: 'POST', headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify({ type: 'search', filters, webhook_url: 'https://hooks.northwind.example/betterjobs', events: ['job.closed', 'job.updated', 'job.reposted'], }), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); ``` ```sql -- job.closed carries a partial job: update the columns, do not replace doc UPDATE jobs SET status = 'closed', closed_reason = $2, doc = doc || jsonb_build_object('status', 'closed', 'closed_reason', $2) WHERE id = $1; ``` Leaving `job.opened` out keeps the watch free: new jobs already arrive through the daily search. Webhook handling (signature check, event-id dedup) is on [Detect hiring changes](/guides/detect-hiring-changes/#handle-deliveries). No watch? Re-check stale jobs Without a watch, re-check open jobs whose `last_seen_at` is old with `GET /v1/jobs/{id}`. It is free for jobs you already paid for and returns the current `status` and `closed_reason`. ## Recover from an outage Every event is also kept in `GET /v1/events`, oldest first. Store the last `next_cursor` you processed. After downtime, pass it as `since` and read until the feed is empty. * curl ```bash curl "https://api.betterjobs.cc/v1/events?since=cur_E5vB7n&limit=100" \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python def catch_up() -> None: since = load_state("events_cursor") # None on first run = oldest retained event while True: params = {"limit": 100, **({"since": since} if since else {})} resp = requests.get(f"{API}/events", headers=HEADERS, params=params, timeout=30) resp.raise_for_status() page = resp.json() for event in page["data"]: if not event_seen(event["id"]): # same id store as the webhook handler apply_event(event) mark_event_seen(event["id"]) if page["next_cursor"]: since = page["next_cursor"] save_state("events_cursor", since) # save after the page is applied if not page["data"] or page["next_cursor"] is None: return ``` * TypeScript ```ts async function catchUp() { let since: string | null = await loadState('events_cursor'); // null = oldest retained event for (;;) { const qs = new URLSearchParams({ limit: '100', ...(since ? { since } : {}) }); const res = await fetch(`${API}/events?${qs}`, { headers }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const page = await res.json(); for (const event of page.data) { if (await eventSeen(event.id)) continue; // same id store as the webhook handler await applyEvent(event); await markEventSeen(event.id); } if (page.next_cursor) { since = page.next_cursor; await saveState('events_cursor', since); // save after the page is applied } if (page.data.length === 0 || !page.next_cursor) return; } } ``` ```json { "data": [ { "id": "evt_3Fh8JkL2pQ", "type": "job.reposted", "created_at": "2026-10-11T07:30:00Z", "watch_id": "wat_6Np3QyR8tU", "data": { "job": { "id": "job_01JC2B7Y9MZQ4W8E1R6T3N5K0D", "status": "open", "repost_count": 2 } } } ], "next_cursor": "cur_E5vB7n" } ``` Then run the daily incremental once. Jobs that opened during the outage come back; jobs you already have are free. Three kinds of outage, three fixes: | What was down | What you lost | Fix | | --------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | Your webhook endpoint | Deliveries after the 24-hour retry window | `GET /v1/events?since=`, or `POST /v1/webhooks/replay` per event | | Your daily job | One or more daily runs | Re-run the daily search with `posted_within_days` covering the gap | | An upstream provider | Jobs only that provider had | Responses say `metadata.status: partial` and list it in `metadata.providers.failed`. Re-run later; already-paid jobs are free | ## Credit cost | Step | Cost | | ------------------------------------------------------ | ---------------------------------------------------------------------------- | | `dry_run` estimate | Free | | Backfill (`POST /v1/searches`) | 1 credit per unique job collected. Duplicates and already-paid jobs are free | | Reading search pages (`GET /v1/searches/{id}`) | Free | | Daily incremental | 1 credit per new unique job. The overlap day is free | | Watch with `job.closed`, `job.updated`, `job.reposted` | Free | | `GET /v1/events`, replays | Free | | `GET /v1/jobs/{id}` on a job you paid for | Free | Prove what you paid for with `GET /v1/billing/ledger?job_id=job_...`: re-reads show `credits: 0` and `reason: already_paid`. See [Credits and billing](/concepts/credits-and-billing/). ## Pitfalls Branch on status, not on HTTP 200 `GET /v1/searches/{id}` returns `200` while the search is `queued`, `running` or `on_hold`. Only `completed` and `partial` carry results. `on_hold` means out of credits; the search resumes after a top-up. See [Async searches](/platform/async-searches/). * **`posted_within_days` counts from `first_seen_at`.** A job the employer posted weeks ago but a source found yesterday is in today’s window. That is what you want for sync. * **Never key on a provider id.** `sources[].provider_job_id` differs per provider and a job can gain sources over time. Key on the canonical `id`. * **Do not delete closed jobs.** Keep the row with `status: closed` and `closed_reason`. Searches skip closed jobs unless you send `include_closed: true`. * **Reuse the Idempotency-Key on retries.** A retried `POST` with the same key and body returns the first response and never charges twice. A new key per page per day, as above, is enough. See [Idempotency](/platform/idempotency/). * **Cap every scheduled run.** `max_credits` caps one request, so for a paged sync run it caps each page only. Cap the run by summing `metadata.credits_charged`, as `daily_sync` does. An async search is one request, so its `max_credits` caps the whole search. ## Next * [Detect hiring changes](/guides/detect-hiring-changes/): webhook verification and event handling. * [Pagination](/platform/pagination/), [Async searches](/platform/async-searches/), [Freshness and lifecycle](/concepts/freshness-and-lifecycle/). * API reference: [Create an async search](/api/operations/createsearch/), [Get an async search](/api/operations/getsearch/), [List events](/api/operations/listevents/). # Clay > How do I call BetterJobs from a Clay HTTP API column? BetterJobs has no native Clay app. You call it from Clay’s generic **HTTP API** enrichment column. Each row sends one request. The response fields you pick become new columns. This page has two recipes: * **Is this company hiring?** One company profile per row. Fixed cost: 1 credit per row. * **Which matching jobs does this company have open?** A job search per row, capped at a few jobs. Up to `limit` credits per row, and 0 when nothing matches. Before you start You need a live API key (`bj_live_...`). See [Authentication](/getting-started/authentication/). Check what your plan includes on [Credits and billing](/concepts/credits-and-billing/): partner providers start on Growth. ## Recipe 1: is this company hiring? Use this when your table has a company domain column and you want a yes / no / unknown answer per row. 1. In your Clay table, add a column and choose **HTTP API** as the enrichment. 2. Set the request: | Setting | Value | | --------------------------- | --------------------------------------------------- | | Method | `GET` | | Endpoint | `https://api.betterjobs.cc/v1/companies/{{Domain}}` | | Header `Authorization` | `Bearer bj_live_...` | | Header `BetterJobs-Version` | `2026-10-01` | Replace `{{Domain}}` with your domain column. In Clay you insert a column reference by typing `/` in the field. Send the bare domain (`acme-robotics.example`), with no `https://` and no path. 3. Run the column on two or three rows first. Open a cell to see the full JSON response. 4. Pick the response paths to add as columns: | Response path | Column suggestion | | -------------------------------- | -------------------------------------------- | | `is_hiring.value` | Hiring? (`true`, `false` or empty = unknown) | | `is_hiring.confidence` | Hiring confidence (0-1) | | `is_hiring.basis` | Why | | `open_jobs_count` | Open jobs | | `hiring_pulse.direction` | Trend (`up`, `flat`, `down`) | | `top_job_families[0].job_family` | Top job family | 5. Run the rest of the table. Empty does not mean 'not hiring' `is_hiring.value` is `null` when BetterJobs has no signal for the domain, for example no careers page or ATS found. Clay shows `null` as an empty cell. Do not filter empty cells into a “not hiring” segment. Only `false` means not hiring. See [Provenance and confidence](/concepts/provenance-and-confidence/). Re-running this column charges again A company profile costs 1 credit per request. Unlike jobs, profiles have no “already paid” rule. Re-running the column on the same rows charges again. Turn off auto-run on this column if your table refreshes often. ## Recipe 2: open jobs at this company Use this when you want the actual job (title, apply link, date) to personalize outreach. 1. Add an **HTTP API** column. 2. Set the request: | Setting | Value | | --------------------------- | ------------------------------------------ | | Method | `POST` | | Endpoint | `https://api.betterjobs.cc/v1/jobs/search` | | Header `Authorization` | `Bearer bj_live_...` | | Header `BetterJobs-Version` | `2026-10-01` | | Header `Content-Type` | `application/json` | 3. Paste this body and replace `{{Domain}}` with your domain column: ```json { "filters": { "company_domain_or": ["{{Domain}}"], "title_or": ["Head of RevOps", "Revenue Operations"], "posted_within_days": 30 }, "waterfall": { "strategy": "cheapest_first", "max_credits": 3 }, "limit": 3 } ``` `limit: 3` returns at most three jobs. `max_credits: 3` is a hard cap on what one row can cost. Keep them equal. 4. Pick the response paths to add as columns: | Response path | Column suggestion | | -------------------------- | --------------------------------------------- | | `data[0].title` | Job title | | `data[0].apply_url` | Apply link | | `data[0].posted_at` | Posted (empty = unknown, use `first_seen_at`) | | `data[0].first_seen_at` | First seen | | `data[0].p_real` | Real-job probability (0-1) | | `data[0].id` | BetterJobs job id | | `metadata.credits_charged` | Credits this row cost | | `metadata.status` | `complete` or `partial` | Store `data[0].id`. It is stable, and fetching that job again later is free. 5. Run a few rows, check `metadata.credits_charged`, then run the table. A row with no matching jobs returns `data: []` and costs 0 credits. Re-running the column returns jobs you already paid for at no charge (`metadata.jobs_already_paid` counts them). See [Credits and billing](/concepts/credits-and-billing/). Only want confident jobs before outbound? Set `"strategy": "consensus"`. BetterJobs then returns only jobs seen by at least two sources. Fewer rows match, and the ones that do are corroborated. See [Choose a strategy](/guides/choose-a-strategy/). ## Build the body without typing JSON Set the filters below and open the **Clay HTTP column** tab. It shows the method, endpoint, headers and body. Copy the body into Clay, then swap fixed values for `{{Column}}` references. Every filter name must match the [filter list](/platform/filters/). A misspelled name returns `400 unknown_filter`. It is never ignored. Clay shows the error message in the cell, and `error.param` names the bad field. ## Test the request outside Clay If a cell shows an error, run the same request from a terminal. It removes Clay from the picture. * curl ```bash 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": { "company_domain_or": ["acme-robotics.example"], "title_or": ["Head of RevOps", "Revenue Operations"], "posted_within_days": 30 }, "waterfall": { "strategy": "cheapest_first", "max_credits": 3 }, "limit": 3 }' ``` * Python ```python import os import 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": { "company_domain_or": ["acme-robotics.example"], "title_or": ["Head of RevOps", "Revenue Operations"], "posted_within_days": 30, }, "waterfall": {"strategy": "cheapest_first", "max_credits": 3}, "limit": 3, }, timeout=30, ) resp.raise_for_status() print(resp.json()["metadata"]["credits_charged"], "credits charged") ``` * TypeScript ```ts const res = 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: { company_domain_or: ["acme-robotics.example"], title_or: ["Head of RevOps", "Revenue Operations"], posted_within_days: 30, }, waterfall: { strategy: "cheapest_first", max_credits: 3 }, limit: 3, }), }); if (!res.ok) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`); const { metadata } = await res.json(); console.log(metadata.credits_charged, "credits charged"); ``` `acme-robotics.example` is a fictional domain. Use one from your table. ## Cost per 1,000 rows | Recipe | Credits per row | Worst case per 1,000 rows | | ---------------------- | ------------------------------- | ------------------------- | | Company profile | 1, always | 1,000 credits | | Job search, `limit: 3` | 0 to 3 (0 when nothing matches) | 3,000 credits | The worst case assumes every row returns `limit` new jobs. Rows with no match, duplicates merged across providers and jobs you already paid for are free, so real spend is usually lower. Enter your own numbers below. “Unique jobs” is rows × jobs returned per row. ## Rate limits in Clay Clay can send many rows at once. If cells fail with `429 rate_limited`, lower the column’s request rate in Clay and re-run the failed rows. Your limit per window is in `GET /v1/account` under `rate_limit`. See [Rate limits](/platform/rate-limits/). ## Related * [Find companies hiring](/guides/find-companies-hiring/) for the same searches in code. * [Field dictionary](/data/field-dictionary/) for every response path you can map. * [Troubleshooting](/resources/troubleshooting/) for empty cells, `402` and `partial` results. # Google Sheets > How do I pull BetterJobs results into a Google Sheet? BetterJobs has no Sheets add-on. You add a small Apps Script function to your sheet. It calls the API with `UrlFetchApp` and returns a table, so you can type this in a cell: ```text =BETTERJOBS_SEARCH("Head of RevOps, Head of Revenue Operations", "DE, AT, CH", 7, 25) ``` The result spills into the cells below and to the right: one header row, then one row per canonical job. Before you start You need a live API key (`bj_live_...`). See [Authentication](/getting-started/authentication/). ## Set it up 1. In your sheet, open **Extensions → Apps Script**. 2. Open **Project Settings** (the gear icon). Under **Script properties**, add a property named `BETTERJOBS_API_KEY` with your key as the value. The key stays out of the code and out of the sheet. 3. Back in the editor, replace the contents of `Code.gs` with the script below and save. 4. In any cell, type `=BETTERJOBS_SEARCH(...)` as shown above. ## The script ```js const BETTERJOBS_URL = 'https://api.betterjobs.cc/v1/jobs/search'; const BETTERJOBS_VERSION = '2026-10-01'; /** * Searches BetterJobs and returns one row per canonical job. * * @param {string} titles Comma-separated title keywords, e.g. "Head of RevOps, Head of Revenue Operations". * @param {string} countries Comma-separated ISO country codes, e.g. "DE, AT, CH". * @param {number} days Jobs first seen within this many days (1-365). * @param {number} limit Jobs to return (1-100). Also the credit cap for this cell. * @return {Array>} Header row plus one row per job. * @customfunction */ function BETTERJOBS_SEARCH(titles, countries, days, limit) { const key = PropertiesService.getScriptProperties().getProperty('BETTERJOBS_API_KEY'); if (!key) throw new Error('Add the BETTERJOBS_API_KEY script property first.'); if (!titles || !countries || !days || !limit) { throw new Error('Usage: =BETTERJOBS_SEARCH(titles, countries, days, limit)'); } const list = (value) => String(value).split(',').map((s) => s.trim()).filter((s) => s); const body = { filters: { title_or: list(titles), country_code_or: list(countries).map((c) => c.toUpperCase()), posted_within_days: Number(days), }, waterfall: { strategy: 'cheapest_first', max_credits: Number(limit) }, limit: Number(limit), }; const res = UrlFetchApp.fetch(BETTERJOBS_URL, { method: 'post', contentType: 'application/json', headers: { Authorization: 'Bearer ' + key, 'BetterJobs-Version': BETTERJOBS_VERSION }, payload: JSON.stringify(body), muteHttpExceptions: true, }); const json = JSON.parse(res.getContentText()); if (res.getResponseCode() !== 200) { throw new Error(json.error.code + ': ' + json.error.message + ' (' + json.error.request_id + ')'); } const header = ['title', 'company', 'domain', 'country', 'seniority', 'posted_at', 'first_seen_at', 'apply_url', 'sources', 'p_real', 'id']; const rows = json.data.map((job) => [ job.title, job.company.name, job.company.domain ?? '', job.location.country_code ?? '', job.seniority ?? '', job.posted_at ?? '', job.first_seen_at, job.apply_url ?? '', job.sources.map((s) => s.provider).join('|'), job.p_real, job.id, ]); return [header, ...rows]; } ``` Empty cells mean unknown (`null` in the API), not “none”. `sources` lists every provider that saw the job, joined with `|`, the same format as the [sample CSV](/data/samples/). Each column is described in the [field dictionary](/data/field-dictionary/). If the request fails, the cell shows `#ERROR!`. Hover it to read the error code and message, for example `insufficient_credits: ...`. The request id in brackets is what support needs. Error codes are listed on [Errors](/platform/errors/). ## Control what it costs Each cell is a live API call. Read this before you copy the formula down a column. Sheets re-runs custom functions Sheets runs the function again when its arguments change, and it can run it again when the file is reopened. Each run is a new request: * Jobs you already paid for come back free (`metadata.jobs_already_paid`). * Jobs that appeared since the last run are new unique jobs, and cost 1 credit each. `limit` doubles as `max_credits`, so one cell never costs more than `limit` credits per run. To freeze a result, copy the range and use **Paste special → Values only**, then delete the formula. * A search with no matches returns only the header row and costs 0 credits. * Duplicates merged across providers are free. You pay once per unique job. * To preview cost first, send the same body with `"dry_run": true`. The response has `estimate.credits_range` and costs nothing. See [Credits and billing](/concepts/credits-and-billing/). ## Try it without a key Change `BETTERJOBS_URL` to `https://api.betterjobs.cc/v1/sandbox/jobs/search` and remove the key check and the `Authorization` header. The sandbox returns fixed illustrative data and charges nothing. It is good for laying out the sheet before you spend credits. Switch back to the live URL when you are done. ## Limits * One cell returns at most 100 jobs, the maximum page size. For more, use an [async search](/platform/async-searches/) from code and import the result as CSV. * Apps Script stops a custom function after 30 seconds. The API’s default `timeout_ms` is 10 seconds, so a search finishes well within that. Slow providers are dropped and the result is `partial`. * Many cells recalculating at once can hit your rate limit (`429 rate_limited`). Keep the number of live formulas small. See [Rate limits](/platform/rate-limits/). ## Related * [Find companies hiring](/guides/find-companies-hiring/) for the filters behind this formula. * [Filters](/platform/filters/) for `seniority_or`, `remote` and the rest. Add them to `body.filters` in the script. * [Troubleshooting](/resources/troubleshooting/) if the sheet stays empty. # Make > How do I call BetterJobs and receive its webhooks in Make? BetterJobs has no native Make app. You use three built-in modules: * **HTTP → Make a request** to call the API. * **Webhooks → Custom webhook** to receive events. * **Tools** and a **filter** to check the event signature. Before you start You need a live API key (`bj_live_...`). See [Authentication](/getting-started/authentication/). Webhook deliveries are listed on the Pro plan and above. Check yours on [Credits and billing](/concepts/credits-and-billing/). ## Search jobs with the HTTP module 1. Add **HTTP → Make a request**. 2. Set: | Setting | Value | | --------------------------- | ------------------------------------------ | | URL | `https://api.betterjobs.cc/v1/jobs/search` | | Method | `POST` | | Header `Authorization` | `Bearer bj_live_...` | | Header `BetterJobs-Version` | `2026-10-01` | | Body type | Raw | | Content type | JSON (`application/json`) | | Parse response | Yes | 3. Paste the request content: ```json { "filters": { "title_or": ["Head of RevOps", "Head of Revenue Operations"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7 }, "waterfall": { "strategy": "cheapest_first", "max_credits": 100 }, "limit": 25 } ``` To use a value from an earlier module, place the cursor inside the quotes and pick the item from the mapping panel, for example a company domain inside `company_domain_or`. 4. Add an **Iterator** after it and map `data`. Each bundle is now one canonical job. Map fields such as `title`, `company.domain`, `apply_url` and `id` into the next module. Store `id`. Fetching the same job later is free. All fields are in the [field dictionary](/data/field-dictionary/). Keep the key out of shared blueprints A header typed into the module is saved in the scenario blueprint. If you export or share blueprints, store the key in a Make connection or custom variable instead, and map it into the header. ### Errors and rate limits * Add a **Break** error handler to the HTTP module for `429` and `500`. It retries the bundle later. A `500` is safe to retry with the same `Idempotency-Key` header: you are never charged twice. See [Idempotency](/platform/idempotency/). * `200` with `metadata.status` = `partial` is a success. One provider failed or timed out, and you pay only for the jobs returned. See [Provider status](/resources/provider-status/). * A misspelled filter returns `400 unknown_filter`. Read `error.param` in the module output. See [Errors](/platform/errors/). ## Large searches: async and polling One request returns at most 100 jobs. For backfills up to 10,000 jobs, use an async search. Make scenarios should not wait in a loop, so split the work in two scenarios. **Scenario A: start the search.** 1. **HTTP → Make a request**: `POST https://api.betterjobs.cc/v1/searches` with the same headers. The body takes `filters`, `waterfall` and `limit` (up to 10,000). Add an `Idempotency-Key` header so a retried run does not start a second search. 2. Save the returned `id` (`srch_...`) in a **Data store** record with a `done` flag set to false. **Scenario B: poll and collect.** Schedule it every few minutes. 1. **Data store → Search records** where `done` is false. 2. **HTTP → Make a request**: `GET https://api.betterjobs.cc/v1/searches/{id}` with the stored id. 3. Add a **Router** on `status`: * `queued` or `running`: do nothing. The next run checks again. * `on_hold`: you ran out of credits. The search resumes after a top-up. * `completed` or `partial`: read results, then set `done` to true. * `failed`: set `done` to true and alert someone. 4. To read results, page with `GET /v1/searches/{id}?limit=100&cursor=...`. Use a **Repeater** with *Repeats* set to `ceil(jobs_found / 100)`. Keep `next_cursor` in a **Set variable** with lifetime *One execution*, and pass it as `cursor` on the next repeat. Branch on `status`, never on the HTTP code. `GET /v1/searches/{id}` returns `200` for every state. Reading results is free: jobs are charged when the search collects them. See [Async searches](/platform/async-searches/). On Pro or above? Skip the polling scenario. Set `webhook_url` on `POST /v1/searches` to a Make custom webhook. You receive `search.completed` when the search ends. ## Receive webhooks BetterJobs POSTs one event per request to your endpoint and signs it with the `BetterJobs-Signature` header: ```text BetterJobs-Signature: t=,v1=."> ``` You compute the same HMAC with your endpoint secret and compare it to `v1`. ### 1. Create the custom webhook 1. Add **Webhooks → Custom webhook** as the first module and create a new hook. 2. Open **Advanced settings**. Turn on **Get request headers** and **JSON pass-through**. Pass-through keeps the raw body as one text value. The signature covers those exact bytes. 3. Copy the webhook URL. Use it as `webhook_url` in `POST /v1/watches` or `POST /v1/searches`. 4. Click **Redetermine data structure**, then send a test event, for example with `POST /v1/webhooks/replay` on an existing event id. Make responds `200` as soon as the webhook accepts the request, which meets the 10-second deadline. ### 2. Check the signature Add **Tools → Set multiple variables** after the webhook: | Variable | Value | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sig` | The `BetterJobs-Signature` value from the headers array. Use `map()` on the headers with key `value`, filtered by `name`, then `first()`. Check the exact header name in the webhook output. | | `t` | `sig` split on `,`, first part, with `t=` removed | | `v1` | `sig` split on `,`, last part, with `v1=` removed | | `expected` | `sha256()` of the text `t` + `.` + the raw body value, encoding `hex`, key = your endpoint secret | Then set a **filter** on the link to the next module: `expected` *Equal to* `v1`. Bundles that fail the filter stop there. Limits of a filter A filter is not a constant-time comparison, and it does not check that `t` is recent. For high-value flows, also reject events whose `t` is more than 5 minutes old, and store the secret in a custom variable rather than typing it into the module. ### 3. Parse and route Add **JSON → Parse JSON** on the raw body, then a **Router** on `type`: * `job.opened`: start outbound. It is the only event that should. * `job.reposted`: update your copy. Never re-trigger outbound. * `job.closed`: stop sequences for that job. * `search.completed`: fetch results with `GET /v1/searches/{id}`. What each event means and costs is in the [event catalog](/data/events/). ### Failed deliveries If a delivery does not get a `2xx` within 10 seconds, BetterJobs retries for 24 hours with backoff. After that, or after you fix a broken scenario, replay any event with `POST /v1/webhooks/replay`. Replays are free. See [Webhooks](/platform/webhooks/). ## Related * [Detect hiring changes](/guides/detect-hiring-changes/) for watches end to end. * [Pagination](/platform/pagination/) for how cursors work. * [Troubleshooting](/resources/troubleshooting/) for signature mismatches and `402`. # n8n > How do I call BetterJobs and receive its webhooks in n8n? BetterJobs has no native n8n node. You use two built-in nodes: * **HTTP Request** to call the API. * **Webhook** plus a **Code** node to receive events and check their signature. Before you start You need a live API key (`bj_live_...`). See [Authentication](/getting-started/authentication/). Webhook deliveries are listed on the Pro plan and above. Check yours on [Credits and billing](/concepts/credits-and-billing/). ## Store the API key as a credential 1. In n8n, create a credential of type **Header Auth**. 2. Set **Name** to `Authorization`. 3. Set **Value** to `Bearer bj_live_...` with your key. 4. Save it as `BetterJobs`. Every HTTP Request node below uses it. Keeping the key in a credential keeps it out of exported workflow JSON. ## Search jobs with the HTTP Request node 1. Add an **HTTP Request** node. 2. Set: | Setting | Value | | -------------- | ------------------------------------------------------- | | Method | `POST` | | URL | `https://api.betterjobs.cc/v1/jobs/search` | | Authentication | Generic Credential Type → Header Auth → `BetterJobs` | | Send Headers | On. Name `BetterJobs-Version`, value `2026-10-01` | | Send Body | On. Body Content Type `JSON`, Specify Body `Using JSON` | 3. Paste the body: ```json { "filters": { "title_or": ["Head of RevOps", "Head of Revenue Operations"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7 }, "waterfall": { "strategy": "cheapest_first", "max_credits": 100 }, "limit": 25 } ``` To use a value from an earlier node, switch the field to expression mode and insert it, for example `"company_domain_or": ["{{ $json.domain }}"]`. 4. Add a **Split Out** node after it. Set **Field To Split Out** to `data`. You now get one n8n item per canonical job. Each item has the [Job fields](/data/field-dictionary/): `id`, `title`, `company.domain`, `apply_url`, `sources` and so on. Store `id`. Fetching the same job later is free. ### Page through results One request returns at most 100 jobs. For more, turn on pagination in the HTTP Request node under **Options → Pagination**: | Setting | Value | | ------------------------ | ------------------------------------------- | | Pagination Mode | Update a Parameter in Each Request | | Type | Body | | Name | `cursor` | | Value | `{{ $response.body.next_cursor }}` | | Pagination Complete When | Other | | Complete Expression | `{{ $response.body.next_cursor === null }}` | Also set **Max Pages** so a broad filter cannot page forever. `waterfall.max_credits` caps each request, not the whole run. See [Pagination](/platform/pagination/). More than a few hundred jobs? Use an async search instead of paging. Send `POST /v1/searches` with `webhook_url` set to an n8n Webhook node URL. When the search ends you receive `search.completed`, then read results with `GET /v1/searches/{id}`. Without webhooks, poll that endpoint with a **Wait** node in a loop and branch on `status`. See [Async searches](/platform/async-searches/). ### Handle errors and rate limits * Under the node’s **Settings**, turn on **Retry On Fail** for `429` and `500`. A `500` is safe to retry: send the same `Idempotency-Key` header and you are never charged twice. See [Idempotency](/platform/idempotency/). * If you call BetterJobs once per input item, set **Options → Batching** so items are sent in small batches with an interval. That keeps you under your rate limit. See [Rate limits](/platform/rate-limits/). * `200` with `metadata.status: "partial"` is not an error. One provider failed or timed out. You still get the other results and pay only for those. See [Provider status](/resources/provider-status/). ## Receive webhooks BetterJobs POSTs one event per request to your endpoint. It signs each delivery with the `BetterJobs-Signature` header. Check the signature before you act on the event. ### 1. Add the Webhook node 1. Add a **Webhook** node. Set **HTTP Method** to `POST` and pick a path, for example `betterjobs`. 2. Set **Respond** to `Immediately`. BetterJobs needs a `2xx` within 10 seconds. A slow workflow would otherwise cause retries. 3. Under **Options**, turn on **Raw Body**. The signature covers the exact bytes BetterJobs sent. Parsed and re-serialized JSON will not match. 4. Copy the **Production URL**. Use it as `webhook_url` when you create a watch (`POST /v1/watches`) or an async search. ### 2. Check the signature in a Code node Add a **Code** node after the Webhook node. Set **Mode** to `Run Once for All Items` and **Language** to JavaScript. ```js // Verifies BetterJobs-Signature: t=,v1=."> const crypto = require('crypto'); const secret = $env.BETTERJOBS_WEBHOOK_SECRET; if (!secret) throw new Error('BETTERJOBS_WEBHOOK_SECRET is not set'); const TOLERANCE_SECONDS = 300; const out = []; for (let i = 0; i < $input.all().length; i++) { const item = $input.all()[i]; const header = item.json.headers['betterjobs-signature']; if (!header) continue; const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))); const raw = (await this.helpers.getBinaryDataBuffer(i, 'data')).toString('utf8'); const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${raw}`).digest('hex'); const a = Buffer.from(expected, 'hex'); const b = Buffer.from(parts.v1 ?? '', 'hex'); const signatureOk = a.length === b.length && crypto.timingSafeEqual(a, b); const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= TOLERANCE_SECONDS; if (signatureOk && fresh) out.push({ json: JSON.parse(raw) }); } return out; ``` The node passes on only events with a valid signature, as parsed JSON. Invalid deliveries are dropped. Check the Webhook node’s output once to confirm the binary property is named `data`. If not, change the second argument of `getBinaryDataBuffer`. Self-hosted n8n The Code node blocks Node built-ins by default. Start n8n with `NODE_FUNCTION_ALLOW_BUILTIN=crypto`. Set `BETTERJOBS_WEBHOOK_SECRET` as an environment variable, and make sure `$env` access is not blocked by `N8N_BLOCK_ENV_ACCESS_IN_NODE`. Do not paste the secret into the code. The 300-second tolerance on `t` rejects old deliveries replayed by someone else. Full details are on [Webhooks](/platform/webhooks/). ### 3. Route by event type Add a **Switch** node on `{{ $json.type }}`. These three types matter most for outbound: | Event | What happened | What to do | data | Cost | | -------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------ | -------- | | `job.opened` | A new canonical job appeared for a watched company or saved search. | Trigger outbound. This is the only event that should start a sequence. | job (full Job) | 1 credit | | `job.reposted` | The same job was re-listed. repost\_count went up. | Do not re-trigger outbound. Update your copy of the job only. | job (id, title, company, status, repost\_count) | Free | | `job.closed` | The job is no longer live. closed\_reason says why: filled, expired, removed or unknown. | Stop sequences tied to this job. Mark it closed in your CRM. | job (id, title, company, status, closed\_reason) | Free | `job.opened` is the only event that should start a sequence. Never re-trigger on `job.reposted`. All event types are in the [event catalog](/data/events/). ### Failed deliveries If your workflow is off or errors before it responds, BetterJobs retries with backoff for 24 hours. After you fix it, re-send any event with `POST /v1/webhooks/replay`. Replays are free. You can also read every event from `GET /v1/events` as a fallback. ## Related * [Detect hiring changes](/guides/detect-hiring-changes/) for watches and events end to end. * [Sync patterns](/guides/sync-patterns/) for daily upserts by job `id`. * [Troubleshooting](/resources/troubleshooting/) for signature mismatches and empty results. # Zapier > How do I call BetterJobs and receive its webhooks in Zapier? BetterJobs has no native Zapier app. You use two built-in apps: * **Webhooks by Zapier** to call the API and to catch events. * **Code by Zapier** to check the event signature. Before you start You need a live API key (`bj_live_...`). See [Authentication](/getting-started/authentication/). Webhook deliveries are listed on the Pro plan and above. Check yours on [Credits and billing](/concepts/credits-and-billing/). ## Search jobs with a Custom Request 1. Add an action step: **Webhooks by Zapier → Custom Request**. 2. Set: | Field | Value | | ------- | ---------------------------------------------------------------------------------------------------------------- | | Method | `POST` | | URL | `https://api.betterjobs.cc/v1/jobs/search` | | Headers | `Authorization` = `Bearer bj_live_...`, `BetterJobs-Version` = `2026-10-01`, `Content-Type` = `application/json` | 3. Paste the **Data** field: ```json { "filters": { "company_domain_or": ["acme-robotics.example"], "title_or": ["Head of RevOps", "Revenue Operations"], "posted_within_days": 30 }, "waterfall": { "strategy": "cheapest_first", "max_credits": 5 }, "limit": 5 } ``` Replace `acme-robotics.example` (a fictional domain) with a field mapped from the trigger, for example a company domain from your CRM. 4. Test the step. Zapier parses the JSON response into fields. Zapier flattens the `data` array: each job field becomes a list, such as all titles joined. For one job per row, keep `limit` small and use the first value. For one action per job, add **Looping by Zapier** on the job `id` list. Map `metadata.credits_charged` into a log column so you can see what each run cost. A run with no matching jobs returns `data: []` and costs 0 credits. Zaps re-run Replays and Zap retries send the request again. Jobs you already paid for are free on a repeat, so search requests are safe. `GET /v1/companies/{domain}` is not: each profile request costs 1 credit, every time. See [Credits and billing](/concepts/credits-and-billing/). Zapier is a poor fit for paging through hundreds of jobs. For large pulls, use an [async search](/platform/async-searches/) with `webhook_url` set to a Zapier catch hook, or use [n8n](/integrations/n8n/) or code. ## Receive webhooks BetterJobs POSTs one event per request and signs it with the `BetterJobs-Signature` header (`t=,v1=`). `v1` is the HMAC-SHA256 of `.` with your endpoint secret. Check it before the Zap acts. ### 1. Catch the raw hook 1. Create a Zap with the trigger **Webhooks by Zapier → Catch Raw Hook**. Use *Raw*, not the plain *Catch Hook*: the signature covers the exact body bytes, and the plain trigger parses them first. 2. Copy the webhook URL. Use it as `webhook_url` in `POST /v1/watches` or `POST /v1/searches`. 3. Send a test event. `POST /v1/webhooks/replay` re-sends an existing event to its endpoint. 4. In the test data, find the raw body field and the `BetterJobs-Signature` header field. Zapier responds `200` as soon as it catches the request, which meets BetterJobs’ 10-second deadline. ### 2. Verify in a Code step Add **Code by Zapier → Run Python** (or **Run JavaScript**). Set the **Input Data**: | Key | Value | | ----------- | -------------------------------------------------------- | | `raw_body` | The raw body field from the trigger | | `signature` | The `BetterJobs-Signature` header field from the trigger | | `secret` | Your endpoint secret | The secret lives in the step’s input field, not in the code. * Python ```python import hashlib import hmac import json import time TOLERANCE_SECONDS = 300 parts = dict(p.split("=", 1) for p in input_data["signature"].split(",")) signed = f'{parts["t"]}.{input_data["raw_body"]}'.encode() expected = hmac.new(input_data["secret"].encode(), signed, hashlib.sha256).hexdigest() fresh = abs(time.time() - int(parts["t"])) <= TOLERANCE_SECONDS valid = fresh and hmac.compare_digest(expected, parts["v1"]) event = json.loads(input_data["raw_body"]) if valid else {} output = { "valid": valid, "type": event.get("type"), "event_id": event.get("id"), "job_id": event.get("data", {}).get("job", {}).get("id"), } ``` * JavaScript ```js const crypto = require('crypto'); const TOLERANCE_SECONDS = 300; const parts = Object.fromEntries(inputData.signature.split(',').map((p) => p.split('='))); const expected = crypto .createHmac('sha256', inputData.secret) .update(`${parts.t}.${inputData.raw_body}`) .digest('hex'); const a = Buffer.from(expected, 'hex'); const b = Buffer.from(parts.v1 ?? '', 'hex'); const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= TOLERANCE_SECONDS; const valid = fresh && a.length === b.length && crypto.timingSafeEqual(a, b); const event = valid ? JSON.parse(inputData.raw_body) : {}; output = { valid, type: event.type ?? null, event_id: event.id ?? null, job_id: event.data?.job?.id ?? null, }; ``` ### 3. Filter and route 1. Add **Filter by Zapier**: only continue if `valid` is true. 2. Add **Paths by Zapier** on `type`: * `job.opened`: start outbound. It is the only event that should. * `job.reposted`: update the record. Never re-trigger outbound. * `job.closed`: stop sequences tied to `job_id`. Return more fields from the Code step if a later step needs them. The full payload of each event type is in the [event catalog](/data/events/). ### Failed deliveries If a delivery does not get a `2xx` within 10 seconds, BetterJobs retries for 24 hours with backoff. After that, replay any event with `POST /v1/webhooks/replay`. Replays are free. See [Webhooks](/platform/webhooks/). ## Related * [Detect hiring changes](/guides/detect-hiring-changes/) for watches end to end. * [Clay](/integrations/clay/) for per-row enrichment in a table. * [Troubleshooting](/resources/troubleshooting/) for signature mismatches and empty results. # Async searches > How do I run a search for up to 10,000 jobs and collect the results? 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. 1. `POST /v1/searches` with your `filters`. You get `202` and a search `id` (`srch_...`). 2. Wait. Poll `GET /v1/searches/{id}`, or pass `webhook_url` and receive `search.completed`. 3. Read the results page by page with `GET /v1/searches/{id}?cursor=...`. Reading is free. ## Start a search The body takes the same `filters` and `waterfall` as [`POST /v1/jobs/search`](/platform/filters/). `limit` is the total number of unique jobs to collect: `1` to `10000`, default `1000`. * curl ```bash 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" }' ``` * Python ```python import os import uuid import 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_..." ``` * TypeScript ```ts 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`. Send an Idempotency-Key If the create call times out and you retry without a key, you can start the same search twice. Jobs are never charged twice, but you wait for two searches. Reuse one `Idempotency-Key` across retries of the same create. See [Idempotency](/platform/idempotency/). ## 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. Branch on status, not on the HTTP code `GET /v1/searches/{id}` returns `200` for every state, including `failed` and `on_hold`. A `200` does not mean the search worked. Read `status` and branch on it. Non-2xx codes only mean the read itself failed, for example `404 not_found` for an unknown id or `429 rate_limited`. ## Poll with a deadline Polling is free, but it counts against your [rate limit](/platform/rate-limits/). 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 ```bash curl https://api.betterjobs.cc/v1/searches/srch_2Vd9KqL4mN \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python 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) ``` * TypeScript ```ts 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 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. ```json { "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](/platform/webhooks/#verify-the-signature). 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 * 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_credits` caps the whole search. Set it to the most you are willing to spend. * If credits run out, the search moves to `on_hold` instead 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](/concepts/credits-and-billing/). ## Related * [Pagination](/platform/pagination/) * [Webhooks](/platform/webhooks/) * [Errors and partial results](/platform/errors/#partial-results) * [Create an async search API reference](/api/operations/createsearch/) and [Get an async search](/api/operations/getsearch/) # Errors > What does each error code mean, and should I retry? BetterJobs uses HTTP status codes for request-level failures and one JSON error shape for all of them. Provider failures are different: they never fail your request. You get `200` with partial results and pay only for what was returned. ## The error body Every error response has the same shape: ```json { "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" } } ``` | Field | Always present | What it is | | ---------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | Yes | Broad class: `invalid_request_error`, `authentication_error`, `billing_error`, `permission_error`, `not_found_error`, `conflict_error`, `rate_limit_error` or `api_error`. | | `code` | Yes | The specific error. Branch on this. | | `message` | Yes | Human-readable explanation. It can change. Never parse it. | | `doc_url` | Yes | Link to this code’s row below. | | `request_id` | Yes | Same as the `X-Request-Id` header. Quote it to support. | | `param` | On `invalid_request`, `unknown_filter` | The offending field, for example `filters.job_title_or`. | | `credits_needed` | On `insufficient_credits` | Credits the request needs. | | `required_plan` | On `plan_required` | The plan that includes the feature or provider. | | `upgrade_url` | On `insufficient_credits`, `plan_required` | Where to top up or upgrade. | Branch on code, not on message `error.code` is stable within an [API version](/platform/versioning/). `error.message` is for humans and may be reworded at any time. ## Error codes Each row has an anchor. `doc_url` in an error body links straight to it. | Code | HTTP | Cause | Fix | Retry | Extra fields | | ----------------------------------------------- | ----- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | ------------------------------- | | [`invalid_request`](#invalid_request) | 400 | The body or a parameter is malformed or out of range. | Read `error.param` and `error.message`, then fix the request. | after fix | `param` | | [`unknown_filter`](#unknown_filter) | 400 | A field in `filters` does not exist. Unknown fields are rejected, never ignored. | Check the name against the filter list. `error.param` names the bad field. | after fix | `param` | | [`unauthorized`](#unauthorized) | 401 | Missing, malformed or revoked API key. | Send `Authorization: Bearer bj_live_...` (or `bj_test_...`). | after fix | — | | [`insufficient_credits`](#insufficient_credits) | 402 | Not enough credits left for this request. | Top up or upgrade at `error.upgrade_url`, or lower `waterfall.max_credits`. | after top-up | `credits_needed`, `upgrade_url` | | [`plan_required`](#plan_required) | 403 | Your plan does not include this feature or provider. | Upgrade to `error.required_plan`, or remove the provider from `waterfall.providers`. | no | `required_plan`, `upgrade_url` | | [`not_found`](#not_found) | 404 | The id or domain does not exist. | Check the id prefix (`job_`, `srch_`, `wat_`, `evt_`) and value. | no | — | | [`idempotency_conflict`](#idempotency_conflict) | 409 | The same `Idempotency-Key` was reused with a different body. | Use a new key for a new request. | after fix | — | | [`rate_limited`](#rate_limited) | 429 | Too many requests in the current window. | Wait `Retry-After` seconds. Watch `RateLimit-Remaining`. | after Retry-After | — | | [`provider_timeout`](#provider_timeout) | 200\* | A provider missed `waterfall.timeout_ms`. | Not an HTTP error. You get `200` with `metadata.status: partial` and the provider in `metadata.providers.failed`. You pay only for returned jobs. | yes | — | | [`provider_error`](#provider_error) | 200\* | A provider returned an error. | Not an HTTP error. You get `200` with `metadata.status: partial` and the provider in `metadata.providers.failed`. You pay only for returned jobs. | yes | — | | [`internal_error`](#internal_error) | 500 | Something failed on our side. | Retry with the same `Idempotency-Key`. You will not be charged twice. | yes | — | \* Not an HTTP error. Reported inside a 200 response as `metadata.status: partial`. ## Partial results A slow or failing provider never turns your request into an error. BetterJobs drops it, returns what the other sources found, and says so in `metadata`: ```json { "data": ["..."], "next_cursor": null, "metadata": { "request_id": "req_9Qa4NvB2sE", "status": "partial", "credits_charged": 1, "providers": { "tried": ["betterjobs", "techmap", "theirstack", "coresignal"], "hit": ["betterjobs", "techmap", "theirstack"], "failed": [{ "provider": "coresignal", "code": "provider_timeout" }], "contributions": { "betterjobs": 1, "techmap": 0, "theirstack": 0 } } } } ``` Illustrative and shortened from the spec example. * `metadata.status` is `complete` or `partial`. The HTTP status is `200` either way. * `metadata.providers.failed` lists each dropped provider with a code: `provider_timeout` (it missed `waterfall.timeout_ms`) or `provider_error` (it returned an error). * You are billed only for jobs in the response. A failed provider costs nothing. What to do with a partial result depends on the job: * **Good enough.** Use it. `metadata.providers.hit` shows which sources did answer. * **Need full coverage.** Retry the same request later. Jobs you already paid for come back free, so a retry costs only the new jobs the missing provider adds. * **Timeouts again and again.** Raise `waterfall.timeout_ms` (up to `30000`), or check the provider’s live status with `GET /v1/providers`. See [Provider status](/resources/provider-status/). Async searches report the same thing as `status: partial` on the search. See [Async searches](/platform/async-searches/#status). ## Should I retry? | Situation | Retry? | How | | ------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------- | | `429 rate_limited` | Yes | Wait `Retry-After` seconds, then retry. See [Rate limits](/platform/rate-limits/). | | `500 internal_error` | Yes | Back off, then retry with the **same** `Idempotency-Key`. You are not charged twice. | | Network error or timeout, no response | Yes | Retry with the same `Idempotency-Key`. Without one, you may create a second async search or watch. | | `metadata.status: partial` | Optional | Retry later if you need the missing provider. Already-paid jobs are free. | | `402 insufficient_credits` | After top-up | Top up at `error.upgrade_url`, or lower `waterfall.max_credits`. | | `400`, `401`, `409` | After a fix | The same request fails the same way. Fix it first. | | `403 plan_required`, `404 not_found` | No | Change the request or the plan. Retrying does not help. | `GET` and `DELETE` requests are safe to retry as they are. For `POST`, send an `Idempotency-Key` so a retry can never double up. See [Idempotency](/platform/idempotency/). The [backoff helper on the rate limits page](/platform/rate-limits/#retry-with-backoff) implements this table for `429` and `5xx`. ## Handle errors in code * curl ```bash # -i prints the status line and headers, including X-Request-Id 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": { "job_title_or": ["Head of RevOps"] } }' # HTTP/2 400 ... "code": "unknown_filter", "param": "filters.job_title_or" ``` * Python ```python import os import requests class BetterJobsError(Exception): def __init__(self, status: int, error: dict): super().__init__(f"{status} {error['code']}: {error['message']} (request {error['request_id']})") self.status = status self.code = error["code"] self.error = error def call(method: str, path: str, **kwargs) -> dict: resp = requests.request( method, f"https://api.betterjobs.cc/v1{path}", headers={ "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", }, timeout=60, **kwargs, ) if resp.status_code >= 400: raise BetterJobsError(resp.status_code, resp.json()["error"]) return resp.json() try: result = call("POST", "/jobs/search", json={"filters": {"title_or": ["Head of RevOps"]}}) except BetterJobsError as e: if e.code == "insufficient_credits": print("Top up:", e.error["upgrade_url"], "needed:", e.error["credits_needed"]) raise else: if result["metadata"]["status"] == "partial": print("Missing providers:", result["metadata"]["providers"]["failed"]) ``` * TypeScript ```ts export class BetterJobsError extends Error { constructor( readonly status: number, readonly error: { code: string; message: string; request_id: string; [k: string]: unknown }, ) { super(`${status} ${error.code}: ${error.message} (request ${error.request_id})`); } } export async function call(method: string, path: string, body?: unknown) { const res = await fetch(`https://api.betterjobs.cc/v1${path}`, { method, headers: { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, "BetterJobs-Version": "2026-10-01", "Content-Type": "application/json", }, body: body === undefined ? undefined : JSON.stringify(body), }); if (res.status >= 400) throw new BetterJobsError(res.status, (await res.json()).error); return res.json(); } try { const result = await call("POST", "/jobs/search", { filters: { title_or: ["Head of RevOps"] } }); if (result.metadata.status === "partial") console.warn("Missing providers:", result.metadata.providers.failed); } catch (e) { if (e instanceof BetterJobsError && e.error.code === "insufficient_credits") { console.error("Top up:", e.error.upgrade_url, "needed:", e.error.credits_needed); } throw e; } ``` Errors are free A request that returns an error returns no jobs, so it charges no credits. `X-Credits-Charged` appears only on billable responses. ## Related * [Troubleshooting](/resources/troubleshooting/) * [Filters](/platform/filters/#unknown-filters-are-rejected) * [Credits and billing](/concepts/credits-and-billing/) # Filters > Which filters can I send, and how does the suffix grammar work? Every search takes one `filters` object. The same object works in `POST /v1/jobs/search`, `POST /v1/searches` and in search watches (`POST /v1/watches` with `type: search`). BetterJobs translates it into each provider’s own query language, so you learn one grammar instead of six. ## Build a query Change the fields and copy the request. The output updates as you type. ## Grammar A filter name is a field name plus an optional suffix. The suffix says how to compare. | Suffix | Meaning | Value type | Example | | ------ | ------------------------------------------------- | ------------------ | --------------------------------------- | | `_or` | Match any value in the list | array | `"country_code_or": ["DE", "AT", "CH"]` | | `_not` | Exclude any value in the list | array | `"title_not": ["Intern"]` | | `_gte` | Greater than or equal to | number | `"salary_min_gte": 90000` | | none | Exact value or special rule (see the table below) | boolean or integer | `"remote": true` | Two rules hold for every request: * **Different filters combine with AND.** A job must pass every filter you send. * **Values inside one `_or` list combine with OR.** `title_or: ["Head of RevOps", "Head of Revenue Operations"]` matches either title. ## All filters | Filter | Type | What it does | | -------------------- | ------------------------ | ------------------------------------------------------------------------------------ | | `title_or` | string\[] | Match any of these title keywords. | | `title_not` | string\[] | Exclude titles containing any of these. | | `country_code_or` | string\[] | ISO 3166-1 alpha-2 codes, for example `DE`. | | `posted_within_days` | integer, 1 to 365 | Jobs first seen within this many days. | | `seniority_or` | Seniority\[] | Any of `intern`, `junior`, `mid`, `senior`, `lead`, `director`, `vp`, `c_level`. | | `employment_type_or` | EmploymentType\[] | Any of `full_time`, `part_time`, `contract`, `internship`, `temporary`. | | `remote` | boolean or null | `true` remote only, `false` non-remote only, `null` or omitted = any. | | `salary_min_gte` | number | Keep jobs whose `salary.min` is at least this value (yearly, in the job’s currency). | | `company_domain_or` | string\[] | Company domains, for example `acme-robotics.example`. | | `job_family_or` | string\[] | Job families, for example `operations`, `sales`. | | `include_closed` | boolean, default `false` | Include jobs with `status: closed`. | Enum values for `seniority_or` and `employment_type_or` are listed on [Taxonomies](/data/taxonomies/). What each job field means is on the [field dictionary](/data/field-dictionary/). posted\_within\_days uses first\_seen\_at `posted_within_days` filters on when any source **first saw** the job (`first_seen_at`), not on `posted_at`. Many postings have no employer post date, so `posted_at` can be `null`. Filtering on first sighting keeps those jobs in your results. salary\_min\_gte drops jobs without pay When you set `salary_min_gte`, jobs whose `salary` is `null` are excluded. Pay is unknown for those jobs, not low. Leave the filter out if you want them. remote: false is not 'any' `remote: false` keeps only on-site and hybrid jobs. To accept any work mode, omit `remote` or send `null`. ## Examples The landing-page query: Head of RevOps in Germany, Austria and Switzerland, first seen in the last 7 days. * curl ```bash 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", "Head of Revenue Operations"], "title_not": ["Intern"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7 }, "limit": 25 }' ``` * Python ```python import os import 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", "Head of Revenue Operations"], "title_not": ["Intern"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7, }, "limit": 25, }, timeout=30, ) resp.raise_for_status() jobs = resp.json()["data"] ``` * TypeScript ```ts const res = 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", "Head of Revenue Operations"], title_not: ["Intern"], country_code_or: ["DE", "AT", "CH"], posted_within_days: 7, }, limit: 25, }), }); if (!res.ok) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`); const { data: jobs } = await res.json(); ``` More `filters` objects you can drop into the same request: ```json { "seniority_or": ["senior", "lead"], "remote": true, "salary_min_gte": 90000 } ``` Senior or lead remote roles that state a minimum of at least 90,000 per year. ```json { "company_domain_or": ["acme-robotics.example", "northwind.example"], "job_family_or": ["sales"] } ``` Sales jobs at two named companies. Pair it with a [watch](/data/events/) to hear about new ones. ```json { "company_domain_or": ["acme-robotics.example"], "include_closed": true, "posted_within_days": 90 } ``` Every job, open or closed, a company listed in the last 90 days. Useful to measure hiring history. ## Unknown filters are rejected A filter name that does not exist returns `400` with code `unknown_filter`. BetterJobs never silently ignores a filter, because an ignored filter returns a wider, more expensive result than you asked for. `error.param` names the bad field: ```json { "error": { "type": "invalid_request_error", "code": "unknown_filter", "message": "Unknown filter field 'job_title_or'. Did you mean 'title_or'?", "param": "filters.job_title_or", "doc_url": "https://docs.betterjobs.cc/platform/errors/#unknown_filter", "request_id": "req_4Bn8CxV2zA" } } ``` A rejected request is never charged. See [`unknown_filter`](/platform/errors/#unknown_filter) in the error catalog. Coming from TheirStack? Per TheirStack docs, TheirStack uses a similar suffix grammar (`_or`, `_not`, `_gte`/`_lte`, `_max_age_days`) but with its own field names, such as `job_title`. BetterJobs uses `title_or`, not `job_title_or`, and `posted_within_days` in place of a max-age suffix. The [migration guide](/guides/migrate-from-theirstack/) maps each filter. ## Check the cost before you fetch Add `"dry_run": true` to any `POST /v1/jobs/search` body. You get a free estimate (`expected_unique_jobs_range`, `providers_planned`, `credits_range`) and nothing is fetched or charged. Set `waterfall.max_credits` to cap what a real request can spend. See [Credits and billing](/concepts/credits-and-billing/). ## Related * [Pagination](/platform/pagination/): read more than one page of results. * [Async searches](/platform/async-searches/): run filters over up to 10,000 jobs. * [Choose a strategy](/guides/choose-a-strategy/): decide which providers a filter runs against. # Idempotency > How do I retry a POST safely without being charged twice? Networks fail. A request can time out after BetterJobs already did the work. Send an `Idempotency-Key` header on every `POST`, and reuse it when you retry. The retry returns the first response instead of doing the work again. ## How it works | You send | You get | | --------------------------------- | ------------------------------------------------------------------------------- | | A new key | The request runs normally. | | The same key and the same body | The **first** response, replayed. Nothing runs again. Nothing is charged again. | | The same key and a different body | `409 idempotency_conflict`. Nothing runs. | The key is any string up to 255 characters. A UUID v4 is the simplest choice. ## Which requests take it | Endpoint | Without a key, a retry could… | | -------------------------- | -------------------------------------------------------------------------------------------------- | | `POST /v1/jobs/search` | Run the search again. Jobs you already paid for are free, so the main cost is time and rate limit. | | `POST /v1/searches` | Start a **second** async search. | | `POST /v1/watches` | Create a **second** watch, which then delivers every event twice. | | `POST /v1/webhooks/replay` | Queue the same event twice. | `GET` and `DELETE` are already safe to repeat. They take no key. If a retried `DELETE /v1/watches/{id}` returns `404 not_found`, the first attempt worked. You are never charged twice for a job anyway BetterJobs bills each unique job once. A repeated search returns jobs you already paid for at no cost, and `GET /v1/billing/ledger` shows them with `reason: already_paid`. The key protects you from the other side effects: duplicate async searches, duplicate watches, wasted time. See [Credits and billing](/concepts/credits-and-billing/). ## Generate once, reuse on retry Create the key **before** the first attempt, outside your retry loop. A key generated inside the loop is a new key on every attempt and protects nothing. * curl ```bash # Pick one key per logical request. Reuse it verbatim on retry. curl https://api.betterjobs.cc/v1/watches \ --retry 5 \ -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 '{ "type": "company", "domain": "acme-robotics.example", "webhook_url": "https://hooks.northwind.example/betterjobs", "events": ["job.opened", "job.closed", "company.hiring_started", "company.hiring_stopped"] }' ``` * Python ```python import os import uuid API = "https://api.betterjobs.cc/v1" HEADERS = { "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", } key = str(uuid.uuid4()) # once, before any attempt watch = send( # send() retries 429/5xx with the same headers: see Rate limits "POST", f"{API}/watches", headers={**HEADERS, "Idempotency-Key": key}, json={ "type": "company", "domain": "acme-robotics.example", "webhook_url": "https://hooks.northwind.example/betterjobs", "events": ["job.opened", "job.closed", "company.hiring_started", "company.hiring_stopped"], }, ).json() ``` * TypeScript ```ts const API = "https://api.betterjobs.cc/v1"; const key = crypto.randomUUID(); // once, before any attempt // send() retries 429/5xx with the same init: see Rate limits const res = await send(`${API}/watches`, { method: "POST", headers: { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, "BetterJobs-Version": "2026-10-01", "Content-Type": "application/json", "Idempotency-Key": key, }, body: JSON.stringify({ type: "company", domain: "acme-robotics.example", webhook_url: "https://hooks.northwind.example/betterjobs", events: ["job.opened", "job.closed", "company.hiring_started", "company.hiring_stopped"], }), }); const watch = await res.json(); ``` `send()` is the retry helper from [Rate limits](/platform/rate-limits/#retry-with-backoff). ## Choosing keys * **One key per logical operation.** “Create the watch for acme-robotics.example” is one operation, however many attempts it takes. * **Derive it when the operation already has an id.** If a job in your own queue triggers the request, a key such as `watch-acme-robotics.example-` survives a process restart. A random key held in memory does not. * **New body, new key.** Each page of a paged search has a different `cursor`, so each page needs its own key. Reusing a key with a changed body returns `409 idempotency_conflict`. * **No secrets in keys.** Keys can appear in logs. Do not put API keys, emails or other personal data in them. 409 idempotency\_conflict means a bug in your key logic You sent a key you had already used, with a different body. Do not retry with the same key. Find out why two different requests got the same key, then send the new request with a new key. See [`idempotency_conflict`](/platform/errors/#idempotency_conflict). ## Related * [Rate limits](/platform/rate-limits/) * [Errors](/platform/errors/#should-i-retry) * [Async searches](/platform/async-searches/) # OpenAPI and SDKs > Where is the OpenAPI spec, and how do I generate a client from it? One OpenAPI 3.1 file describes the whole BetterJobs API: every path, field, enum, error code and credit cost. The [API reference](/api/) on this site is generated from it. You can generate a typed client from the same file, or import it into Postman. ## Download the spec The spec is served at **[/openapi.yaml](/openapi.yaml)**: ```bash curl -O https://docs.betterjobs.cc/openapi.yaml ``` It is the source of truth. When a page on this site and the spec disagree, the spec wins. Please report the mismatch. Every operation and the webhook carry an `x-credit-cost` extension that says what the call costs: ```yaml x-credit-cost: credits: 1 per: unique_job free: [duplicates, already_paid_jobs, empty_pages, dry_run] note: 1 credit per unique job returned that you have not already paid for. ``` Agents and internal tools can read it to estimate spend before calling. See [Credits and billing](/concepts/credits-and-billing/). ## Generate a client The preview has no hand-written SDK packages. Generate a client from the spec instead. It stays in sync because you regenerate it from the same file whenever the [version](/platform/versioning/) changes. ### TypeScript [openapi-typescript](https://openapi-ts.dev/) turns the spec into types. [openapi-fetch](https://openapi-ts.dev/openapi-fetch/) is a small typed `fetch` wrapper that uses them. ```bash npm install openapi-fetch npx openapi-typescript https://docs.betterjobs.cc/openapi.yaml -o src/betterjobs.d.ts ``` ```ts import createClient from "openapi-fetch"; import type { paths } from "./betterjobs"; const betterjobs = createClient({ baseUrl: "https://api.betterjobs.cc/v1", headers: { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, "BetterJobs-Version": "2026-10-01", }, }); const { data, error } = await betterjobs.POST("/jobs/search", { body: { filters: { title_or: ["Head of RevOps"], country_code_or: ["DE", "AT", "CH"], posted_within_days: 7 }, limit: 25, }, }); if (error) throw new Error(`${error.error.code}: ${error.error.message}`); // The 200 body is a search result, or an estimate when dry_run is true. if ("estimate" in data) throw new Error("expected results, got an estimate"); console.log(data.metadata.credits_charged, data.data.map((job) => job.title)); ``` Paths, bodies and responses are typed. A filter typo such as `job_title_or` fails at compile time, before the API would return `400 unknown_filter`. ### Python [OpenAPI Generator](https://openapi-generator.tech/) has a `python` generator. Operation names come from `operationId` and API classes from tags, so `searchJobs` under the `Jobs` tag becomes `JobsApi.search_jobs`. ```bash npx @openapitools/openapi-generator-cli generate \ -i https://docs.betterjobs.cc/openapi.yaml \ -g python \ -o betterjobs-client \ --package-name betterjobs_client pip install ./betterjobs-client ``` ```python import os import betterjobs_client from betterjobs_client.models import SearchFilters, SearchRequest config = betterjobs_client.Configuration( host="https://api.betterjobs.cc/v1", access_token=os.environ["BETTERJOBS_API_KEY"], ) with betterjobs_client.ApiClient(config) as client: client.set_default_header("BetterJobs-Version", "2026-10-01") jobs_api = betterjobs_client.JobsApi(client) result = jobs_api.search_jobs( SearchRequest( filters=SearchFilters(title_or=["Head of RevOps"], country_code_or=["DE", "AT", "CH"], posted_within_days=7), limit=25, ) ) ``` Check the generator's OpenAPI 3.1 support The spec uses OpenAPI 3.1 features such as `type: [string, 'null']` for nullable fields. Generators differ in how fully they support 3.1. Generate, then check that nullable fields come out as optional types (`str | None`, `string | null`) and that models accept unknown properties. New fields can appear within a version. See [Versioning](/platform/versioning/#what-can-change-within-a-version). ### Other languages The same spec works with any OpenAPI 3.1 tool. Run `npx @openapitools/openapi-generator-cli list` to see every generator, for example `go`, `java`, `ruby` or `csharp`. ## Import into Postman 1. In Postman, click **Import**. 2. Paste `https://docs.betterjobs.cc/openapi.yaml` and confirm. Postman builds a collection with one request per operation. 3. On the collection, open **Authorization**, choose **Bearer Token**, and paste your API key. 4. Add a `BetterJobs-Version: 2026-10-01` header to the requests you use. 5. Check that the collection’s base URL is `https://api.betterjobs.cc/v1`. To try requests without a key, call the [sandbox endpoint](/api/operations/sandboxsearchjobs/) `POST /v1/sandbox/jobs/search`. It returns fixed illustrative data and charges nothing. Insomnia, Bruno and most other API clients import the same URL. ## Related * [API reference](/api/) * [Versioning](/platform/versioning/) * [llms.txt and Markdown pages](/agents/llms-txt/) for feeding these docs to an agent # Pagination > How do I page through results with next_cursor? 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, maxPages = 20): Promise { 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](/concepts/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. 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](/platform/idempotency/). ## 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](/platform/async-searches/) 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](/platform/webhooks/#recover-missed-events) and [Sync patterns](/guides/sync-patterns/). ## Related * [Async searches](/platform/async-searches/) * [Filters](/platform/filters/) * [Search jobs API reference](/api/operations/searchjobs/) # Rate limits > How many requests can I send, and what do I do on a 429? Each account can send a fixed number of requests per time window. Responses tell you where you stand, so you can slow down before you hit the limit. If you do hit it, you get `429 rate_limited` and a `Retry-After` header that says how long to wait. ## Find your limit `GET /v1/account` returns your limit as `rate_limit`. It is free. ```json { "plan": "growth", "credits_remaining": 9841, "credits_reset_at": "2026-11-01T00:00:00Z", "providers_enabled": ["betterjobs", "theirstack", "techmap"], "rate_limit": { "limit": 60, "window_seconds": 60 } } ``` Illustrative. `limit` is requests per window and `window_seconds` is the window length. Read your own values from the endpoint rather than hard-coding them. ## Headers | Header | On | What it tells you | | --------------------- | ---------- | --------------------------------------- | | `RateLimit-Limit` | Responses | Requests allowed in the current window. | | `RateLimit-Remaining` | Responses | Requests left in the current window. | | `RateLimit-Reset` | Responses | Seconds until the window resets. | | `Retry-After` | `429` only | Seconds to wait before you retry. | The names follow the IETF draft `RateLimit` header fields, without an `X-` prefix. `RateLimit-Reset` is a number of seconds from now, not a Unix timestamp. A `429` body uses the standard [error shape](/platform/errors/#the-error-body): ```json { "error": { "type": "rate_limit_error", "code": "rate_limited", "message": "Rate limit exceeded. Retry after 12 seconds.", "doc_url": "https://docs.betterjobs.cc/platform/errors/#rate_limited", "request_id": "req_0Ig1JqH5sT" } } ``` A `429` returns no jobs, so it charges nothing. ## Retry with backoff Wrap every call in one helper: * On `429`, wait exactly `Retry-After` seconds. * On `5xx` or a network error, wait with exponential backoff plus jitter, capped at 30 seconds. * Stop after a few attempts and raise. * For `POST`, pass the same `Idempotency-Key` on every attempt, so a retry can never double up. See [Idempotency](/platform/idempotency/). - curl ```bash # curl waits for Retry-After on 429 and backs off on 5xx. curl https://api.betterjobs.cc/v1/jobs/search \ --retry 5 --retry-max-time 120 \ -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": ["Head of RevOps"], "country_code_or": ["DE", "AT", "CH"] } }' ``` - Python ```python import random import time import requests MAX_ATTEMPTS = 5 def send(method: str, url: str, **kwargs) -> requests.Response: """Send a request, retrying 429, 5xx and network errors. Reuses kwargs (and Idempotency-Key) on every attempt.""" for attempt in range(MAX_ATTEMPTS): last = attempt == MAX_ATTEMPTS - 1 try: resp = requests.request(method, url, timeout=60, **kwargs) except (requests.ConnectionError, requests.Timeout): if last: raise time.sleep(min(2**attempt, 30) + random.random()) continue if resp.status_code == 429 and not last: time.sleep(int(resp.headers["Retry-After"])) elif resp.status_code >= 500 and not last: time.sleep(min(2**attempt, 30) + random.random()) else: resp.raise_for_status() return resp raise AssertionError("unreachable") ``` - TypeScript ```ts const MAX_ATTEMPTS = 5; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const backoffMs = (attempt: number) => (Math.min(2 ** attempt, 30) + Math.random()) * 1000; /** Send a request, retrying 429, 5xx and network errors. Reuses init (and Idempotency-Key) on every attempt. */ export async function send(url: string, init: RequestInit): Promise { for (let attempt = 0; ; attempt++) { const last = attempt === MAX_ATTEMPTS - 1; let res: Response; try { res = await fetch(url, init); } catch (err) { if (last) throw err; await sleep(backoffMs(attempt)); continue; } if (res.status === 429 && !last) { await sleep(Number(res.headers.get("Retry-After")) * 1000); } else if (res.status >= 500 && !last) { await sleep(backoffMs(attempt)); } else { if (!res.ok) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`); return res; } } } ``` Do not retry other 4xx codes `400`, `401`, `402`, `403`, `404` and `409` fail the same way every time until you change something. Retrying them only burns your rate limit. The [errors page](/platform/errors/#should-i-retry) says what to do for each. ## Stay under the limit Backoff handles the occasional `429`. For steady workloads, avoid them: * **Pace on `RateLimit-Remaining`.** When it reaches `0`, wait `RateLimit-Reset` seconds before the next call. * **Ask for bigger pages.** `limit: 100` on `POST /v1/jobs/search` needs a quarter of the requests that the default `25` does. * **Use async searches for bulk work.** One `POST /v1/searches` collects up to 10,000 jobs. See [Async searches](/platform/async-searches/). * **Prefer webhooks to polling.** A watch or `webhook_url` pushes events to you. Polling `GET /v1/searches/{id}` or `GET /v1/events` in a tight loop spends requests on “nothing yet”. * **Share one limiter.** Parallel workers on one account share one budget. Put one rate limiter in front of all of them. ## Related * [Errors](/platform/errors/) * [Idempotency](/platform/idempotency/) * [Get your account API reference](/api/operations/getaccount/) # Versioning > How does date-pinned versioning work, and what can change within a version? The BetterJobs API is versioned by date. You pin a version with the `BetterJobs-Version` header. Within a version, changes are additive only, so code that works today keeps working. The current version is `2026-10-01`. ## Send the header Send `BetterJobs-Version` on every request. * curl ```bash curl -i https://api.betterjobs.cc/v1/account \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python import os import requests session = requests.Session() session.headers.update({ "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", # pin it in one place }) resp = session.get("https://api.betterjobs.cc/v1/account", timeout=30) ``` * TypeScript ```ts // Pin it in one place and reuse these headers everywhere. export const BETTERJOBS_HEADERS = { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, "BetterJobs-Version": "2026-10-01", }; const res = await fetch("https://api.betterjobs.cc/v1/account", { headers: BETTERJOBS_HEADERS }); ``` If you leave the header out, the request is served with your account’s pinned version. `POST /v1/jobs/search` responses echo the version that served them in a `BetterJobs-Version` response header. Always send it explicitly Relying on the account default means a change to your account’s pinned version changes what your code receives. An explicit header in your code makes the version visible in code review and keeps every environment on the same contract. ## What can change within a version Within one version, BetterJobs only **adds**. Additive changes include: * New endpoints. * New optional request fields and filters. * New fields in response objects. Anything that could break working code needs a new dated version. That covers removing or renaming a field, changing a field’s type or meaning, making an optional field required, and changing a default. Write tolerant clients Additive changes are safe only if your code tolerates them. Ignore response fields you do not recognize. Do not fail validation on extra keys. If you generate a client from the [OpenAPI spec](/platform/openapi-and-sdks/), make sure its models do not reject unknown properties. ## v1 preview The API is a **v1 preview**, not generally available. Endpoints and fields may still change before GA. Changes are listed in the [changelog](/resources/changelog/). The deprecation policy for versions after GA, including how long an old version keeps working, will be published at GA. ## Upgrading to a new version When a new version ships: 1. Read its entry in the [changelog](/resources/changelog/). 2. Update your code for the listed changes. 3. Change the `BetterJobs-Version` value in the one place you pinned it. 4. Test against the [sandbox](/getting-started/quickstart/) or a `bj_test_` key, then deploy. Because the version is a header, you can move one service at a time. ## Related * [Changelog](/resources/changelog/) * [OpenAPI and SDKs](/platform/openapi-and-sdks/) * [Authentication](/getting-started/authentication/) # Webhooks > How do I receive, verify and replay webhook deliveries? BetterJobs POSTs events to a URL you own. Two things send them: [watches](/data/events/) (a company domain or a saved search) and [async searches](/platform/async-searches/) created with `webhook_url`. Your endpoint verifies the signature, stores the event, and returns `2xx` fast. ## The event envelope Every delivery is one JSON `Event` per request. The same envelope is used for every type. ```json { "id": "evt_7Wq1ZxC4vB", "type": "job.opened", "created_at": "2026-10-11T06:15:00Z", "watch_id": "wat_6Np3QyR8tU", "data": { "job": { "id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "title": "Head of Revenue Operations", "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" }, "status": "open" } } } ``` Illustrative and shortened. A real `job.opened` carries the full `Job`. | Field | What it is | | ------------ | ---------------------------------------------------------------------------------------------------- | | `id` | Event id (`evt_...`). The same on every retry and replay. Use it to dedupe. | | `type` | One of the [event types](/data/events/). | | `created_at` | When the event happened. | | `watch_id` | The watch that produced it. `null` for `search.completed`. | | `data` | `job` for job events, `company` and `is_hiring` for company events, `search` for `search.completed`. | The [event catalog](/data/events/) lists each type, what it means, what your code should do and what it costs. Act on job.opened, not job.reposted `job.opened` is the only event that should start an outbound sequence. `job.reposted` means the same job was re-listed. Update your copy and do nothing else, or you will contact the same account twice for one opening. ## Verify the signature Every delivery carries a `BetterJobs-Signature` header: ```text BetterJobs-Signature: t=1760173200,v1=5f2b7c1e9a4d3f6b8c0e2a4d6f8b0c2e4a6d8f0b2c4e6a8d0f2b4c6e8a0d2f4b ``` * `t` is a Unix timestamp in seconds. * `v1` is the hex HMAC-SHA256 of the string `.`, keyed with your endpoint secret. To verify: 1. Read the **raw** request body as bytes. Do not parse and re-serialize the JSON first. Any change in whitespace or key order breaks the signature. 2. Compute HMAC-SHA256 over `t`, a literal `.`, and the raw body. 3. Compare your result to `v1` with a **constant-time** compare. A plain `==` leaks timing information. 4. Reject the delivery if `t` is too far from your clock. We suggest 5 minutes. This stops an attacker from replaying a captured request later. * Python ```python import hashlib import hmac import json import os import time from fastapi import FastAPI, Request, Response SECRET = os.environ["BETTERJOBS_WEBHOOK_SECRET"] TOLERANCE_S = 300 app = FastAPI() def verify_signature(raw_body: bytes, header: str, secret: str) -> bool: pairs = [part.split("=", 1) for part in header.split(",") if "=" in part] t = next((v for k, v in pairs if k == "t"), None) signatures = [v for k, v in pairs if k == "v1"] if t is None or not t.isdigit() or not signatures: return False if abs(time.time() - int(t)) > TOLERANCE_S: return False expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, sig) for sig in signatures) @app.post("/betterjobs/webhook") async def betterjobs_webhook(request: Request) -> Response: raw = await request.body() if not verify_signature(raw, request.headers.get("BetterJobs-Signature", ""), SECRET): return Response(status_code=400) event = json.loads(raw) if not already_processed(event["id"]): # your database enqueue(event) # your queue; do the work later return Response(status_code=200) ``` * TypeScript ```ts import { createHmac, timingSafeEqual } from "node:crypto"; import express from "express"; const SECRET = process.env.BETTERJOBS_WEBHOOK_SECRET; if (!SECRET) throw new Error("BETTERJOBS_WEBHOOK_SECRET is not set"); const TOLERANCE_S = 300; export function verifySignature(rawBody: Buffer, header: string, secret: string): boolean { const pairs = header.split(",").map((part) => { const i = part.indexOf("="); return [part.slice(0, i), part.slice(i + 1)] as const; }); const t = pairs.find(([k]) => k === "t")?.[1]; const signatures = pairs.filter(([k]) => k === "v1").map(([, v]) => v); if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_S) return false; const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest(); return signatures.some((sig) => { const given = Buffer.from(sig, "hex"); return given.length === expected.length && timingSafeEqual(given, expected); }); } const app = express(); // express.raw keeps the body as a Buffer, exactly as signed. app.post("/betterjobs/webhook", express.raw({ type: "application/json" }), async (req, res) => { if (!verifySignature(req.body, req.get("BetterJobs-Signature") ?? "", SECRET)) { return res.sendStatus(400); } const event = JSON.parse(req.body.toString("utf8")); if (!(await alreadyProcessed(event.id))) await enqueue(event); // your database and queue res.sendStatus(200); }); ``` Raw body or nothing Most frameworks parse JSON before your handler runs. Verify against the bytes that arrived, not against a re-encoded object. In FastAPI use `await request.body()`. In Express mount `express.raw()` on the webhook route, before any global `express.json()`. Keep the secret in an environment variable or secret store. Never commit it, and never log the header. ### Test your verifier Sign a body yourself and send it to your local endpoint. This checks your parsing and compare logic without waiting for a real event. ```python import hashlib import hmac import os import time body = b'{"id":"evt_test_0001","type":"job.opened","created_at":"2026-10-11T06:15:00Z","watch_id":null,"data":{}}' t = str(int(time.time())) v1 = hmac.new(os.environ["BETTERJOBS_WEBHOOK_SECRET"].encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest() print(f"BetterJobs-Signature: t={t},v1={v1}") ``` ## Respond fast Return any `2xx` within **10 seconds**. Anything else counts as a failed delivery: a non-2xx status, a timeout, or a connection error. Do the real work after you respond. Write the event to a queue or table, return `200`, and process it in a worker. A slow CRM call inside the handler turns into timeouts and retries. ## Retries A failed delivery is retried with backoff for 24 hours. The retries happen this long after the first failed attempt: 1. 1m 2. 5m 3. 30m 4. 2h 5. 6h 6. 12h 7. 24h Retries are free, including retries of `job.opened`. You are charged once per event, not once per attempt. After the last retry, BetterJobs stops sending. The event is still in the [event feed](#recover-missed-events), and you can [replay](#replay-an-event) it. ## Make handlers idempotent You will see the same event more than once: after a retry, after a replay, or when your endpoint timed out after it had already done the work. Design for it. * **Dedupe on `id`.** Store each processed event `id`. If it is already stored, return `200` and stop. * **Upsert jobs by `data.job.id`.** The canonical job id is stable across sources and events. Write with upsert, never blind insert. * **Key side effects on the job, not the event.** Before you start a sequence for `job.opened`, check that you have not already started one for that `data.job.id`. * **Handle any order.** Do not assume `job.opened` arrives before `job.updated` or `job.closed` for the same job. Compare `created_at` and keep the newest state. ## Replay an event Fixed a broken endpoint? Re-send any event with `POST /v1/webhooks/replay`. It goes to the event’s original webhook endpoint and returns `202` with `status: queued`. Replays are free and never charge again. * curl ```bash curl https://api.betterjobs.cc/v1/webhooks/replay \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" \ -H "Content-Type: application/json" \ -d '{ "event_id": "evt_7Wq1ZxC4vB" }' ``` * Python ```python import os import requests resp = requests.post( "https://api.betterjobs.cc/v1/webhooks/replay", headers={ "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", }, json={"event_id": "evt_7Wq1ZxC4vB"}, timeout=30, ) resp.raise_for_status() print(resp.json()) # {"event_id": "evt_7Wq1ZxC4vB", "status": "queued"} ``` * TypeScript ```ts const res = await fetch("https://api.betterjobs.cc/v1/webhooks/replay", { method: "POST", headers: { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, "BetterJobs-Version": "2026-10-01", "Content-Type": "application/json", }, body: JSON.stringify({ event_id: "evt_7Wq1ZxC4vB" }), }); if (res.status !== 202) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`); console.log(await res.json()); // { event_id: "evt_7Wq1ZxC4vB", status: "queued" } ``` ## Recover missed events Webhooks are the fast path. `GET /v1/events` is the record. Every event from your watches and async searches appears there, oldest first, whether or not it was delivered. To recover after an outage, page through the feed from your last saved position. Store the `next_cursor` of the last page you processed and pass it as `since` next time. Your dedupe on `id` makes overlap harmless. Reading the feed is free. See [Pagination](/platform/pagination/#the-event-feed-uses-since) and [Sync patterns](/guides/sync-patterns/). ## What it costs * `job.opened`: 1 credit per event delivered, free if you already paid for that job. * Every other event type: free. * Retries and replays: free. * Creating a watch and reading `GET /v1/events`: free. ## Related * [Event catalog](/data/events/) * [Detect hiring changes](/guides/detect-hiring-changes/) * [Event delivery reference](/api/webhooks/webhookevent/) and [Replay a webhook delivery](/api/operations/replaywebhook/) # Providers > Which sources does BetterJobs query, and what does each one bring? A **provider** is one upstream source of job data. BetterJobs can route a search to seven of them: its own index and six partner providers. You never call a provider yourself. You send one request, BetterJobs asks the providers your plan enables, merges what they return and bills you 1 credit per unique job. One signup, one key, one invoice. Not a separate signup, key and invoice for every provider. ## The sources ### [BetterJobs index](/providers/betterjobs-index/) `betterjobs` Our own job sources: an owned crawl of employer career pages and ATSs. Every plan. Facts BetterJobs. ### [Reqbeat](/providers/reqbeat/) `reqbeat` Deduplicated, normalized job postings and hiring events from ATSs, job boards and aggregators. Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per Reqbeat docs. ### [SignalsAPI](/providers/signalsapi/) `signalsapi` Recruiter-focused hiring signals plus the hiring owner's verified work email. Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per SignalsAPI docs. ### [TheirStack](/providers/theirstack/) `theirstack` Global job postings, technographics inferred from job text, buying intent and firmographics. Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per TheirStack docs. ### [JobsPipe](/providers/jobspipe/) `jobspipe` Normalized job postings from 30+ sources with 12 months of history. Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per JobsPipe docs. ### [Coresignal](/providers/coresignal/) `coresignal` Large historical job-posting dataset plus company and employee records. Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per Coresignal docs. ### [Techmap](/providers/techmap/) `techmap` High-volume job feeds from ATSs, job boards and public employment offices (jobdatafeeds.com). Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per Techmap (jobdatafeeds.com). [Provider docs ↗](https://jobdatafeeds.com) Each card links to the provider’s page. Provider facts there are the provider’s own published claims, attributed to them. They are not BetterJobs measurements. ### At a glance | Source | Slug | Refresh (as published) | Cheapest plan | | ------------------------------------------------ | ------------ | -------------------------------------------------------------------------------------------------------- | ---------------------- | | [BetterJobs index](/providers/betterjobs-index/) | `betterjobs` | Published at GA. Each job carries first\_seen\_at, last\_seen\_at and last\_verified\_at. *(BetterJobs)* | [Free](#plan-free) | | [Reqbeat](/providers/reqbeat/) | `reqbeat` | Corpus refreshed every 3 hours *(per Reqbeat docs)* | [Growth](#plan-growth) | | [SignalsAPI](/providers/signalsapi/) | `signalsapi` | Sources rechecked every 15 minutes *(per SignalsAPI docs)* | [Growth](#plan-growth) | | [TheirStack](/providers/theirstack/) | `theirstack` | 73% of jobs discovered the same day, 91% by the end of the next day *(per TheirStack docs)* | [Growth](#plan-growth) | | [JobsPipe](/providers/jobspipe/) | `jobspipe` | Under 6h on Builder, under 1h on Scale *(per JobsPipe docs)* | [Growth](#plan-growth) | | [Coresignal](/providers/coresignal/) | `coresignal` | Active postings rechecked within 24h *(per Coresignal docs)* | [Growth](#plan-growth) | | [Techmap](/providers/techmap/) | `techmap` | Daily country feeds via AWS Data Exchange *(per Techmap (jobdatafeeds.com))* | [Growth](#plan-growth) | ## Which plan includes which provider * **Free and Starter:** the [BetterJobs index](/providers/betterjobs-index/) only. No partner providers. * **Growth:** the BetterJobs index plus 2 partner providers. * **Pro, Scale and Enterprise:** the BetterJobs index plus all six partner providers. | 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 | — | Your key’s exact set is live data, not a table on this page. `GET /v1/providers` returns every source with `enabled_on_your_plan` and its live `status`. `GET /v1/account` returns the same set as `providers_enabled`. * curl ```bash curl https://api.betterjobs.cc/v1/providers \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python import os import requests resp = requests.get( "https://api.betterjobs.cc/v1/providers", headers={ "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", }, ) resp.raise_for_status() for p in resp.json()["data"]: print(p["slug"], p["enabled_on_your_plan"], p["status"]) ``` * TypeScript ```ts const resp = await fetch('https://api.betterjobs.cc/v1/providers', { 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 p of data) console.log(p.slug, p.enabled_on_your_plan, p.status); ``` The call is free. Illustrative response, seen from a Growth plan: ```json { "data": [ { "slug": "betterjobs", "name": "BetterJobs index", "enabled_on_your_plan": true, "status": "operational", "last_checked_at": "2026-10-11T09:00:00Z" }, { "slug": "theirstack", "name": "TheirStack", "enabled_on_your_plan": true, "status": "operational", "last_checked_at": "2026-10-11T09:00:00Z" }, { "slug": "coresignal", "name": "Coresignal", "enabled_on_your_plan": false, "status": "degraded", "last_checked_at": "2026-10-11T09:00:00Z" } ] } ``` Trimmed to three rows. The full example is in [List providers](/api/operations/listproviders/). ## How routing works You do not pick providers per request unless you want to. The `waterfall.strategy` on the request decides which sources are asked and in what order. The default is `cheapest_first`: it starts with the BetterJobs index and adds partner providers only until the page is full. `max_coverage` asks every provider on your plan. `own_only` asks the BetterJobs index only. To pin sources yourself, list them in `waterfall.providers`: ```json "waterfall": { "providers": ["betterjobs", "theirstack"], "max_credits": 100 } ``` Every slug must be enabled on your plan. If one is not, the request returns `403 plan_required` with `required_plan` and `upgrade_url`. See [plan\_required](/platform/errors/#plan_required). Each source gets the query translated into its own grammar and field names. Records that describe the same opening merge into one [canonical job](/concepts/canonical-jobs/). `sources[]` on each job lists every provider that saw it and the fields it contributed. The full flow is in [The waterfall](/concepts/waterfall/). For picking a strategy, see [Choose a strategy](/guides/choose-a-strategy/). ## When a provider fails A failing or slow provider never fails your request. You still get `200`. The response tells you what happened: * `metadata.status` is `partial` instead of `complete`. * `metadata.providers.failed` lists each provider that dropped out, with code `provider_timeout` (it missed `waterfall.timeout_ms`) or `provider_error`. * You are billed only for jobs actually returned. A provider that failed costs nothing. ```json "metadata": { "status": "partial", "credits_charged": 1, "providers": { "tried": ["betterjobs", "techmap", "theirstack", "coresignal"], "hit": ["betterjobs", "techmap", "theirstack"], "failed": [{ "provider": "coresignal", "code": "provider_timeout" }], "contributions": { "betterjobs": 1, "techmap": 0, "theirstack": 0 } } } ``` Illustrative, from the spec’s partial example. Branch on metadata.status, not on the HTTP code A partial result is a `200`. If coverage matters for the run, such as a backfill or market count, check `metadata.status` and retry later when it is `partial`. Jobs you already got are free on the retry: they count in `metadata.jobs_already_paid`. Async searches report the same way: `GET /v1/searches/{id}` finishes with `status: partial` when a provider failed. See [Errors and partial results](/platform/errors/#provider_timeout). ## Is a provider up right now? Check `status` in `GET /v1/providers`: `operational`, `degraded` or `down`, with `last_checked_at`. How to use it, and what we publish about provider health, is on [Provider status](/resources/provider-status/). Coverage and fill-rate numbers The API is a v1 preview. We do not publish measured coverage, fill rates or freshness per provider yet. They are published at GA. Until then, every search reports its own numbers live: `metadata.providers.contributions` (unique jobs each provider added first) and `metadata.field_coverage` (share of returned jobs with each field filled). ## Related * [The waterfall](/concepts/waterfall/) * [Provenance and confidence](/concepts/provenance-and-confidence/): reading `sources[]` * [Field dictionary](/data/field-dictionary/): provider field names next to ours * [Credits and billing](/concepts/credits-and-billing/) # BetterJobs index > What is the BetterJobs index, and why is it on every plan? The BetterJobs index is our own job sources: an owned crawl of employer career pages and ATSs. It is the one source on every plan, including Free. BetterJobs index `betterjobs` Our own job sources: an owned crawl of employer career pages and ATSs. * Sources Employer career pages and ATSs crawled by BetterJobs * Volume Published at GA. Live counts per request in metadata.providers.contributions. * Freshness Published at GA. Each job carries first\_seen\_at, last\_seen\_at and last\_verified\_at. * Cost 1 credit per unique job returned, same as every source Every plan. Facts BetterJobs. ## What it is BetterJobs crawls employer career pages and the ATSs behind them. Jobs found there go into the index. It is not a partner provider. No third party sits between the employer’s page and your result. Because we run it ourselves: * It is on **every plan**. Free (1,000 credits to test, no card) and Starter search the index only. * `cheapest_first`, the default strategy, asks it **first**. Partner providers are added only when the index cannot fill the page. * `own_only` restricts a search to it. Use this on Free and Starter, or when you want the simplest licensing. ## What it contributes to a BetterJobs job The index reads the employer’s own posting, so it tends to supply the fields that live on that page. In the spec’s illustrative example, the index contributed these fields to a canonical job: ```json { "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"] } ``` Illustrative. On real jobs, `sources[].fields` tells you exactly what the index supplied. See [Provenance and confidence](/concepts/provenance-and-confidence/). ## Refresh We do not publish a refresh number for the index yet. It is published at GA. Every job carries its own timestamps instead: * `sources[].first_seen_at` and `sources[].last_seen_at` for the index’s entry: when our crawl first and last saw the posting. * `last_verified_at` on the job: the latest time the job was confirmed live at its origin. See [Freshness and lifecycle](/concepts/freshness-and-lifecycle/). ## Volume Published at GA. Each search reports what the index added: `metadata.providers.contributions.betterjobs` counts the unique jobs it contributed first. ## Search the index only * curl ```bash 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", "Head of Revenue Operations"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7 }, "waterfall": { "strategy": "own_only", "max_credits": 50 }, "limit": 25 }' ``` * Python ```python import os import 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", "Head of Revenue Operations"], "country_code_or": ["DE", "AT", "CH"], "posted_within_days": 7, }, "waterfall": {"strategy": "own_only", "max_credits": 50}, "limit": 25, }, ) resp.raise_for_status() body = resp.json() print(len(body["data"]), body["metadata"]["credits_charged"]) ``` * TypeScript ```ts 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', 'Head of Revenue Operations'], country_code_or: ['DE', 'AT', 'CH'], posted_within_days: 7, }, waterfall: { strategy: 'own_only', max_credits: 50 }, limit: 25, }), }); if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`); const body = await resp.json(); console.log(body.data.length, body.metadata.credits_charged); ``` ## Cost Same as every source: 1 credit per unique job returned. Duplicates and empty searches are free. A job the index and a partner both found is one job and one credit. See [Credits and billing](/concepts/credits-and-billing/). Free and Starter: only the index On Free and Starter, sending a partner slug in `waterfall.providers` returns `403 plan_required`. Growth adds 2 partner providers; Pro and above add all six. See [Providers](/providers/#which-plan-includes-which-provider). ## Provider docs None. This is our own source. Everything about it is on this site. ## Related * [The waterfall](/concepts/waterfall/) * [Choose a strategy](/guides/choose-a-strategy/) * [Canonical jobs](/concepts/canonical-jobs/) # Coresignal > What does Coresignal contribute, and when does BetterJobs route to it? Coresignal sells a large historical job-posting dataset, plus company and employee records. BetterJobs uses it as one of six partner providers, for job postings. Coresignal `coresignal` Large historical job-posting dataset plus company and employee records. * Records Job postings, company records, employee records * Postings 475M+ job postings, 70M+ active * History Since August 2020 * Recheck Active postings rechecked within 24h * Credits Search free; collect 1 credit per job, 20 per company or employee record Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per Coresignal docs. ## What it is strong at All facts in this section are per Coresignal docs. * **Scale.** 475M+ job postings, 70M+ of them active. * **History.** Postings since August 2020. * **Recheck.** Active postings are rechecked within 24 hours. * **Bulk delivery.** JSONL, Parquet or CSV to S3, GCS, Azure or Snowflake. * **Query power.** Elasticsearch DSL and flat filters. ## What it contributes to a BetterJobs job Coresignal job postings join the same merge as every other source. We do not list one-to-one field-name equivalents for Coresignal in the [field dictionary](/data/field-dictionary/) yet, so check each job directly: the `coresignal` entry in `sources[]` lists the `fields` it supplied. In the spec’s illustrative async-search example, Coresignal supplied `job_family` to a job JobsPipe found first. Coresignal’s company and employee records are not part of BetterJobs results. BetterJobs returns jobs and its own [company hiring profiles](/data/companies/). ## Refresh Per Coresignal docs, active postings are rechecked within 24 hours. On each BetterJobs job, the `coresignal` entry in `sources[]` carries `first_seen_at` and `last_seen_at` for that source. ## Notes and quirks we normalize ### isDuplicate rows become one job Per Coresignal docs, its Base API returns one row per source, with an `isDuplicate` flag on the copies. If you use Coresignal directly, you filter those rows yourself. BetterJobs does this for you. Every row that describes the same opening merges into one [canonical job](/concepts/canonical-jobs/), together with matching records from the other providers. Merged copies are free and counted in `metadata.duplicates_merged`. You pay for the job once. ### Search is free there; estimates are free here Per Coresignal docs, search is free and collecting costs 1 credit per job and 20 per company or employee record. BetterJobs has one step, not two. To see the cost before you fetch, send the same request with `dry_run: true`. It returns `expected_unique_jobs_range`, `providers_planned` and `credits_range`, and costs nothing. ```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 } ``` See [Search jobs](/api/operations/searchjobs/). ### No Elasticsearch DSL BetterJobs takes one flat filter grammar with suffix operators (`title_or`, `country_code_or`, `salary_min_gte`). It does not accept Elasticsearch DSL. Unknown fields return `400 unknown_filter`. See [Filters](/platform/filters/) and [Migrate from Coresignal](/guides/migrate-from-coresignal/). ### Bulk files vs async searches Coresignal ships bulk files to your storage. BetterJobs has no bulk file delivery in v1. For large pulls, use an [async search](/platform/async-searches/) of up to 10,000 jobs, with `webhook_url` for `search.completed`. Partial results If Coresignal is slow, it is dropped at `waterfall.timeout_ms` and the result is `partial`, with `coresignal` in `metadata.providers.failed`. The spec’s partial example shows exactly this case. You pay only for returned jobs. See [Providers](/providers/#when-a-provider-fails). ## When BetterJobs routes to it Only when Coresignal is enabled on your plan. Then: * `max_coverage` asks it on every search. * `cheapest_first` asks it only if the BetterJobs index and earlier sources did not fill the page. * `waterfall.providers: ["betterjobs", "coresignal"]` pins it. Check `metadata.providers.tried` and `metadata.providers.hit` to see whether it was asked and matched. ## Plans that include it Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. See the [plan table](/providers/#which-plan-includes-which-provider). ## Provider docs We do not link to Coresignal’s docs from this site. Everything you need to use Coresignal data through BetterJobs is on this page and in [Migrate from Coresignal](/guides/migrate-from-coresignal/). ## Related * [Migrate from Coresignal](/guides/migrate-from-coresignal/) * [All providers](/providers/) * [Canonical jobs](/concepts/canonical-jobs/) # JobsPipe > What does JobsPipe contribute, and when does BetterJobs route to it? JobsPipe sells normalized job postings from 30+ sources with 12 months of history. BetterJobs uses it as one of six partner providers. JobsPipe `jobspipe` Normalized job postings from 30+ sources with 12 months of history. * Sources 30+ sources incl. Greenhouse, Lever, Ashby, Workday, Indeed, LinkedIn * History 12 months * Freshness Under 6h on Builder, under 1h on Scale * Credits 1 credit per job; one credit buys a job for the rest of the calendar month Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per JobsPipe docs. ## What it is strong at All facts in this section are per JobsPipe docs. * **Major ATSs and boards in one schema.** 30+ sources, including Greenhouse, Lever, Ashby, Workday, Indeed and LinkedIn. * **History.** 12 months of postings. * **Lifecycle timestamps.** `discovered_at`, `last_seen_at`, `verified_at` and `closed_reason` per job. * **Ghost detection.** A `ghost_score` per job. * **Standard taxonomies.** ISCO-08, ISIC and ESCO. ## What it contributes to a BetterJobs job JobsPipe’s field names next to ours: | BetterJobs | JobsPipe | | ------------------------- | --------------- | | `title` | `job_title` | | `seniority` | `seniority` | | `salary` | `salary_usd` | | `posted_at` | `date_posted` | | `first_seen_at` | `discovered_at` | | `last_seen_at` | `last_seen_at` | | `last_verified_at` | `verified_at` | | `closed_reason` | `closed_reason` | | `sources[].url` | `url` | | `sources[].first_seen_at` | `discovered_at` | | `sources[].last_seen_at` | `last_seen_at` | What JobsPipe filled on a given job is in that job’s `sources[]` entry for `jobspipe`, under `fields`. In the spec’s illustrative async-search example, JobsPipe supplied title, description, apply URL, location, seniority and employment type. ## Refresh Per JobsPipe docs, freshness is under 6 hours on its Builder plan and under 1 hour on its Scale plan. On each BetterJobs job, the `jobspipe` entry in `sources[]` carries `first_seen_at` and `last_seen_at` for that source. JobsPipe’s `verified_at` maps to our `last_verified_at`. Builder and Scale are JobsPipe's plan names The freshness tiers above refer to JobsPipe’s own plans. BetterJobs also has a plan called Scale. It is unrelated. Measured BetterJobs freshness is published at GA; until then, read `first_seen_at` and `last_verified_at` on each job. ## Notes and quirks we normalize ### ghost\_score becomes p\_real JobsPipe scores how likely a posting is a ghost. BetterJobs returns the opposite question, `p_real`: the probability (0 to 1) that the job is a real open req. `p_real` comes from corroboration across every source that saw the job, not from JobsPipe alone. Read it as roughly the inverse of `ghost_score`, not as a rescaled copy. See [`p_real`](/data/field-dictionary/#field-job-p_real) and [Provenance and confidence](/concepts/provenance-and-confidence/). ### Paying once for a job Per JobsPipe docs, 1 credit buys a job for the rest of the calendar month. BetterJobs bills 1 credit per unique job you have not already paid for. A job you already paid for is free on later reads and counts in `metadata.jobs_already_paid`. `GET /v1/billing/ledger?job_id=...` proves it. See [Credits and billing](/concepts/credits-and-billing/). ### Different search request JobsPipe searches with `POST /v1/jobs/search`. BetterJobs uses the same method and path on its own host, `https://api.betterjobs.cc/v1/jobs/search`, but a different body: filters go under `filters` with suffix operators like `title_or`. Unknown filter fields return `400 unknown_filter`. The translation is in [Migrate from JobsPipe](/guides/migrate-from-jobspipe/). ### Salary currency JobsPipe reports `salary_usd`. BetterJobs keeps pay in the job’s own currency: `salary.currency` (ISO 4217) with `min`, `max` and `period`. Convert yourself if you need USD. ### Taxonomies JobsPipe classifies with ISCO-08, ISIC and ESCO. BetterJobs returns its own `job_family` and `seniority` values. See [Taxonomies](/data/taxonomies/). ## When BetterJobs routes to it Only when JobsPipe is enabled on your plan. Then: * `max_coverage` asks it on every search. * `cheapest_first` asks it only if the BetterJobs index and earlier sources did not fill the page. * `waterfall.providers: ["betterjobs", "jobspipe"]` pins it. Check `metadata.providers.tried` and `metadata.providers.hit` to see whether it was asked and matched. ## Plans that include it Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. See the [plan table](/providers/#which-plan-includes-which-provider). ## Provider docs We do not link to JobsPipe’s docs from this site. Everything you need to use JobsPipe data through BetterJobs is on this page and in [Migrate from JobsPipe](/guides/migrate-from-jobspipe/). ## Related * [Migrate from JobsPipe](/guides/migrate-from-jobspipe/) * [All providers](/providers/) * [Freshness and lifecycle](/concepts/freshness-and-lifecycle/) # Reqbeat > What does Reqbeat contribute, and when does BetterJobs route to it? Reqbeat sells deduplicated, normalized job postings and hiring events. BetterJobs uses it as one of six partner providers. Reqbeat `reqbeat` Deduplicated, normalized job postings and hiring events from ATSs, job boards and aggregators. * Sources ATSs, job boards and aggregators; agencies and aggregators excluded by default * Dedup One row per company + title + country * Refresh Corpus refreshed every 3 hours Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per Reqbeat docs. ## What it is strong at All facts in this section are per Reqbeat docs. * **Clean, normalized postings.** Postings come from ATSs, job boards and aggregators. Reqbeat dedups and normalizes them: seniority, job family, skills, remote type, and declared plus inferred pay. * **Less noise by default.** Agencies and aggregators are excluded by default. * **Hiring events.** Postings move through `opened`, `reobserved`, `reposted` and `closed`. * **Unknown kept separate from “no”.** A `coverage_status` of `no_ats_signal` means Reqbeat does not know, not that the company is not hiring. * **No person PII.** Postings and companies only. ## What it contributes to a BetterJobs job Reqbeat’s normalized attributes line up with these BetterJobs fields: | Reqbeat attribute (per Reqbeat docs) | BetterJobs field | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | Seniority | [`seniority`](/data/field-dictionary/#field-job-seniority) | | Job family | [`job_family`](/data/field-dictionary/#field-job-job_family) | | Remote type | [`location.remote`](/data/field-dictionary/#field-job-location-remote) | | Declared and inferred pay | [`salary`](/data/field-dictionary/#field-job-salary), with [`salary.origin`](/data/field-dictionary/#field-job-salary-origin) `declared` or `inferred` | What Reqbeat actually filled on a given job is in that job’s `sources[]` entry for `reqbeat`, under `fields`. Our v1 job schema has no skills field, so Reqbeat’s skills tags are not returned. ## Refresh Per Reqbeat docs, its corpus is refreshed every 3 hours. On each BetterJobs job, the `reqbeat` entry in `sources[]` carries `first_seen_at` and `last_seen_at`: when Reqbeat first and last saw that posting. ## Notes and quirks we normalize ### Unknown is not “not hiring” Per Reqbeat docs, `no_ats_signal` means unknown. BetterJobs keeps that meaning. On a [company profile](/data/companies/), `is_hiring.value` is `true`, `false` or `null`, and `null` means unknown. Never treat null as false A company with no detected careers page or ATS returns `is_hiring.value: null`, not `false`. If you suppress outreach on `false`, do not also suppress it on `null`. See [Provenance and confidence](/concepts/provenance-and-confidence/). ### Two dedup passes, one result Per Reqbeat docs, it keeps one row per company + title + country. BetterJobs then runs its own dedup across all seven sources, so a Reqbeat row and a TheirStack record for the same opening become one [canonical job](/concepts/canonical-jobs/). The merged record is free: it counts in `metadata.duplicates_merged`. ### Event names Reqbeat’s event names differ from ours. The closest BetterJobs equivalents: | Reqbeat event | BetterJobs | | ------------- | ----------------------------------------------------------------------------------------------- | | `opened` | [`job.opened`](/data/events/#event-job-opened) | | `reposted` | [`job.reposted`](/data/events/#event-job-reposted). `repost_count` goes up; the job `id` stays. | | `closed` | [`job.closed`](/data/events/#event-job-closed), with `closed_reason` | | `reobserved` | No event. `last_seen_at` moves forward. | ### MCP tools Per Reqbeat docs, it runs its own MCP server with about 15 tools, including `is_hiring`, `hiring_pulse` and `get_changes`. The BetterJobs [MCP server](/agents/mcp-server/) is a separate server with its own tools. Its `is_hiring` tool answers from the merged company profile (`GET /v1/companies/{domain}`), built from every source on your plan, not from Reqbeat alone. ## When BetterJobs routes to it Only when Reqbeat is enabled on your plan. Then: * `max_coverage` asks it on every search. * `cheapest_first` asks it only if the BetterJobs index and earlier sources did not fill the page. * `waterfall.providers: ["betterjobs", "reqbeat"]` pins it. Check `metadata.providers.tried` and `metadata.providers.hit` to see whether it was asked and matched. ## Plans that include it Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. See the [plan table](/providers/#which-plan-includes-which-provider). ## Provider docs We do not link to Reqbeat’s docs from this site. Everything you need to use Reqbeat data through BetterJobs is on this page and in the [field dictionary](/data/field-dictionary/). ## Related * [All providers](/providers/) * [Detect hiring changes](/guides/detect-hiring-changes/) * [Events](/data/events/) # SignalsAPI > What does SignalsAPI contribute, and when does BetterJobs route to it? SignalsAPI sells recruiter-focused hiring signals. BetterJobs uses it as one of six partner providers, for the job signals it sees. SignalsAPI `signalsapi` Recruiter-focused hiring signals plus the hiring owner's verified work email. * Sources 290+ sources: job boards, LinkedIn, funding databases, news, government filings * Signals About 210k signals per 30 days * Markets 506 markets in 7 regions * Recheck Sources rechecked every 15 minutes Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per SignalsAPI docs. ## What it is strong at All facts in this section are per SignalsAPI docs. * **Wide signal net.** 290+ sources: job boards, LinkedIn, funding databases, news and government filings. * **Fast recheck.** Sources are rechecked every 15 minutes. * **Broad market reach.** About 210k signals per 30 days across 506 markets in 7 regions. * **Provenance on every value.** Each value comes in an envelope: `value`, `source_board`, `observed_at`, `confidence`. * **Hiring owner contact.** Signals can carry the hiring owner’s verified work email. ## What it contributes to a BetterJobs job SignalsAPI feeds job signals into the same merge as every other source. Where its field names have a confident BetterJobs equivalent: | BetterJobs | SignalsAPI | | ------------------------ | ------------- | | `sources[].last_seen_at` | `observed_at` | What it filled on a given job is in that job’s `sources[]` entry for `signalsapi`, under `fields`. No hiring-owner email in v1 results The BetterJobs v1 job schema has no contact or email field. You do not get the hiring owner’s email through BetterJobs, even when SignalsAPI contributed to the job. See the [Job schema](/data/jobs/). ## Refresh Per SignalsAPI docs, its sources are rechecked every 15 minutes. On each BetterJobs job, the `signalsapi` entry in `sources[]` carries `first_seen_at` and `last_seen_at` for that source. ## Notes and quirks we normalize ### Provenance envelope becomes sources\[] SignalsAPI wraps each value in `{value, source_board, observed_at, confidence}`. BetterJobs records provenance per source instead of per value. Each job has a `sources[]` array: one entry per provider, with `url`, `first_seen_at`, `last_seen_at` and the `fields` it contributed. SignalsAPI’s `observed_at` maps to `sources[].last_seen_at`. Cross-source confidence on the whole job is `p_real` (0 to 1). It comes from corroboration across sources, not from one provider’s `confidence`. See [Provenance and confidence](/concepts/provenance-and-confidence/). ### Signals, not only postings Per SignalsAPI docs, some of its 290+ sources are funding databases, news and filings, not job boards. BetterJobs returns jobs. Only signals that resolve to a job opening become, or merge into, a canonical job. ## When BetterJobs routes to it Only when SignalsAPI is enabled on your plan. Then: * `max_coverage` asks it on every search. * `cheapest_first` asks it only if the BetterJobs index and earlier sources did not fill the page. * `waterfall.providers: ["betterjobs", "signalsapi"]` pins it. Check `metadata.providers.tried` and `metadata.providers.hit` to see whether it was asked and matched. ## Plans that include it Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. See the [plan table](/providers/#which-plan-includes-which-provider). ## Provider docs We do not link to SignalsAPI’s docs from this site. Everything you need to use SignalsAPI data through BetterJobs is on this page and in the [field dictionary](/data/field-dictionary/). ## Related * [All providers](/providers/) * [Provenance and confidence](/concepts/provenance-and-confidence/) * [Freshness and lifecycle](/concepts/freshness-and-lifecycle/) # Techmap > What does Techmap contribute, and when does BetterJobs route to it? Techmap (jobdatafeeds.com) sells high-volume job feeds from ATSs, job boards and public employment offices. BetterJobs uses it as one of six partner providers. Techmap `techmap` High-volume job feeds from ATSs, job boards and public employment offices (jobdatafeeds.com). * Sources 200+ sources incl. 120 ATSs and 28 public employment offices * New postings About 8M per month * History 451M+ postings since 2020 * Countries Claims 250 countries (about 125 with more than 100 jobs per month) * Feeds Daily country feeds via AWS Data Exchange * API $1 per 1k jobs; /count endpoint estimates cost Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per Techmap (jobdatafeeds.com). ## What it is strong at All facts in this section are per Techmap (jobdatafeeds.com). * **Volume.** About 8M new postings per month, and 451M+ postings since 2020. * **Source mix.** 200+ sources, including 120 ATSs and 28 public employment offices. * **Country reach.** It claims 250 countries, about 125 of them with more than 100 jobs per month. * **Cheap bulk.** API at $1 per 1k jobs, with a `/count` endpoint to estimate cost first. * **Formats.** json, csv, rss and parquet, plus daily country feeds via AWS Data Exchange. ## What it contributes to a BetterJobs job Techmap’s field names next to ours, where there is a confident equivalent: | BetterJobs | Techmap | | ----------------- | -------------- | | `employment_type` | `contractType` | What Techmap filled on a given job is in that job’s `sources[]` entry for `techmap`, under `fields`. In the spec’s illustrative example, Techmap supplied `job_family` to a job the BetterJobs index found first. ## Refresh Per Techmap, its bulk product is daily country feeds via AWS Data Exchange. On each BetterJobs job, the `techmap` entry in `sources[]` carries `first_seen_at` and `last_seen_at` for that source. ## Notes and quirks we normalize ### Thin countries Per Techmap, it claims 250 countries but about 125 have more than 100 jobs per month. In the rest, expect few or no Techmap hits. That is not an error. A search there returns what the other sources found, and an empty search is free. Check what Techmap added on each search: `metadata.providers.hit` says whether it matched, and `metadata.providers.contributions.techmap` counts unique jobs it contributed first. We do not publish per-country coverage numbers yet; they are published at GA. ### isDuplicate rows become one job Per Techmap’s field reference, records carry an `isDuplicate` flag. BetterJobs merges every record for the same opening, from Techmap and from the other providers, into one [canonical job](/concepts/canonical-jobs/). Merged copies are free and counted in `metadata.duplicates_merged`. ### /count becomes dry\_run Techmap’s `/count` endpoint estimates cost before you fetch. The BetterJobs equivalent is `dry_run: true` on [Search jobs](/api/operations/searchjobs/). It is free and returns `expected_unique_jobs_range`, `providers_planned` and `credits_range`. ### Fields we do not pass through Per Techmap’s field reference, records include `dateActive`, `jsonLD` (schema.org JobPosting) and `workPlace`. BetterJobs does not return them as-is. Every job uses the same BetterJobs timestamps instead (`posted_at`, `first_seen_at`, `last_seen_at`, `last_verified_at`), whichever source saw it. `contractType` maps to `employment_type`. See the [field dictionary](/data/field-dictionary/). Different billing unit Per Techmap, its own API lists $1 per 1k jobs. A BetterJobs credit is a different unit: 1 credit per unique merged job, priced by plan. Duplicates and empty searches are free. See [Credits and billing](/concepts/credits-and-billing/). The query translation is in [Migrate from Techmap](/guides/migrate-from-techmap/). ## When BetterJobs routes to it Only when Techmap is enabled on your plan. Then: * `max_coverage` asks it on every search. * `cheapest_first` asks it only if the BetterJobs index and earlier sources did not fill the page. * `waterfall.providers: ["betterjobs", "techmap"]` pins it. Check `metadata.providers.tried` and `metadata.providers.hit` to see whether it was asked and matched. ## Plans that include it Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. See the [plan table](/providers/#which-plan-includes-which-provider). ## Provider docs [Techmap (jobdatafeeds.com) ↗](https://jobdatafeeds.com) ## Related * [Migrate from Techmap](/guides/migrate-from-techmap/) * [All providers](/providers/) * [Canonical jobs](/concepts/canonical-jobs/) # TheirStack > What does TheirStack contribute, and when does BetterJobs route to it? TheirStack sells global job postings with technographics, buying intent and firmographics on top. BetterJobs uses it as one of six partner providers. TheirStack `theirstack` Global job postings, technographics inferred from job text, buying intent and firmographics. * Coverage Global job postings; no person data * Discovery 73% of jobs discovered the same day, 91% by the end of the next day * Credits 1 API credit per job, 3 per company * Re-fetch Re-fetching the same job is billed again (filter discovered\_at\_gte) Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. Facts per TheirStack docs. ## What it is strong at All facts in this section are per TheirStack docs. * **Global postings.** Job postings worldwide. No person data. * **Technographics.** The tech a company uses, inferred from the text of its job posts. * **Buying intent and firmographics** at company level. * **Fast discovery.** 73% of jobs are discovered the same day and 91% by the end of the next day. * **Webhooks.** `job.new`, `job.closed` and `company.new`. ## What it contributes to a BetterJobs job TheirStack’s field names next to ours: | BetterJobs | TheirStack | | ------------------------- | ----------------------- | | `title` | `job_title` | | `company.domain` | `company_domain` | | `location.remote` | `remote` | | `seniority` | `seniority` | | `salary` | `salary_string` | | `salary.min` | `min_annual_salary_usd` | | `posted_at` | `date_posted` | | `first_seen_at` | `discovered_at` | | `sources[].url` | `url` | | `sources[].first_seen_at` | `discovered_at` | What TheirStack filled on a given job is in that job’s `sources[]` entry for `theirstack`, under `fields`. In the spec’s illustrative example it supplied `salary` and `seniority` to a job the BetterJobs index found first. Technographics, buying intent and firmographics are not part of the v1 job schema. You get the job, not TheirStack’s company enrichment. ## Refresh Per TheirStack docs, 73% of jobs are discovered the same day and 91% by the end of the next day. Their `discovered_at` maps to `sources[].first_seen_at` for the `theirstack` entry, and feeds the job’s `first_seen_at` when TheirStack saw it first. ## Notes and quirks we normalize ### Re-fetching is billed again there, not here Per TheirStack docs, it charges 1 API credit per job and 3 per company, and re-fetching the same job is billed again. Their advice is to filter on `discovered_at_gte` so you only pull new jobs. Through BetterJobs you do not need that workaround. You pay 1 credit for a unique job once. A job you already paid for is free on every later search or `GET /v1/jobs/{id}`. It counts in `metadata.jobs_already_paid`, and `GET /v1/billing/ledger?job_id=...` shows the charge and the free re-reads. See [Credits and billing](/concepts/credits-and-billing/). A BetterJobs [company profile](/data/companies/) costs 1 credit. ### Same suffix grammar, different field names TheirStack filters use suffixes: `_or`, `_not`, `_gte` / `_lte`, `_max_age_days`. BetterJobs uses the same suffix style, so most queries translate directly. The field names differ. | You write for TheirStack | You write for BetterJobs | | ------------------------ | ------------------------ | | `job_title_or` | `title_or` | | `job_title_not` | `title_not` | | `company_domain_or` | `company_domain_or` | | `*_max_age_days` | `posted_within_days` | Old names fail loudly BetterJobs rejects unknown filter fields. Sending `job_title_or` returns `400 unknown_filter` with `"Did you mean 'title_or'?"` and `param: filters.job_title_or`. It is never silently ignored. See [unknown\_filter](/platform/errors/#unknown_filter) and the full list in [Filters](/platform/filters/). The full translation, with a worked query, is in [Migrate from TheirStack](/guides/migrate-from-theirstack/). ### Salary in many shapes TheirStack reports pay as `salary_string` and as `min_annual_salary_usd`. BetterJobs returns one `salary` object: `min`, `max`, `currency`, `period` and `origin` (`declared` or `inferred`). See [`salary`](/data/field-dictionary/#field-job-salary). ### Webhooks TheirStack’s `job.new` and `job.closed` correspond to BetterJobs [`job.opened`](/data/events/#event-job-opened) and [`job.closed`](/data/events/#event-job-closed). BetterJobs events come from the merged job, not from one provider. See [Webhooks](/platform/webhooks/). ## When BetterJobs routes to it Only when TheirStack is enabled on your plan. Then: * `max_coverage` asks it on every search. * `cheapest_first` asks it only if the BetterJobs index and earlier sources did not fill the page. * `waterfall.providers: ["betterjobs", "theirstack"]` pins it. Check `metadata.providers.tried` and `metadata.providers.hit` to see whether it was asked and matched. ## Plans that include it Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables. See the [plan table](/providers/#which-plan-includes-which-provider). ## Provider docs We do not link to TheirStack’s docs from this site. Everything you need to use TheirStack data through BetterJobs is on this page and in [Migrate from TheirStack](/guides/migrate-from-theirstack/). ## Related * [Migrate from TheirStack](/guides/migrate-from-theirstack/) * [All providers](/providers/) * [Field dictionary](/data/field-dictionary/) # Changelog > What changed in the API and the docs, and when? Newest first. Each entry names the API version it applies to. You pin a version with the `BetterJobs-Version` header. See [Versioning](/platform/versioning/). ## 2026-10-01: v1 preview **API version `2026-10-01`.** First public version. It is a preview: endpoints and fields may change before general availability. Preview Changes inside a version are additive only, such as new endpoints or new optional fields. Breaking changes ship as a new dated version, listed here. Build your integration to ignore fields it does not know. ### Endpoints Base URL `https://api.betterjobs.cc/v1`. | Area | Endpoints | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Jobs | [`POST /jobs/search`](/api/operations/searchjobs/) (sync, up to 100 per page, `dry_run` estimate), [`GET /jobs/{id}`](/api/operations/getjob/) | | Async searches | [`POST /searches`](/api/operations/createsearch/) (up to 10,000 jobs), [`GET /searches/{id}`](/api/operations/getsearch/) | | Companies | [`GET /companies/{domain}`](/api/operations/getcompany/) with `is_hiring` and `hiring_pulse` | | Watches and events | [`POST /watches`](/api/operations/createwatch/), [`GET /watches`](/api/operations/listwatches/), [`DELETE /watches/{id}`](/api/operations/deletewatch/), [`GET /events`](/api/operations/listevents/), [`POST /webhooks/replay`](/api/operations/replaywebhook/) | | Account and billing | [`GET /account`](/api/operations/getaccount/), [`GET /billing/ledger`](/api/operations/getledger/) | | Providers | [`GET /providers`](/api/operations/listproviders/) with live status | | Sandbox | [`POST /sandbox/jobs/search`](/api/operations/sandboxsearchjobs/), no key, fixed illustrative data | ### Behavior * **Waterfall** across six partner providers (Reqbeat, SignalsAPI, TheirStack, JobsPipe, Coresignal, Techmap) and the BetterJobs index. Strategies: `cheapest_first` (default), `freshest_first`, `max_coverage`, `consensus`, `own_only`. Caps: `waterfall.max_credits`, `waterfall.timeout_ms`. See [Waterfall](/concepts/waterfall/). * **Canonical jobs** with `sources[]` provenance, `p_real`, lifecycle fields and `license`. See [Canonical jobs](/concepts/canonical-jobs/). * **Billing**: 1 credit per unique job returned that you have not already paid for. Duplicates, already-paid jobs, empty pages and dry runs are free. Every charge is in the ledger. See [Credits and billing](/concepts/credits-and-billing/). * **Partial results**: a failed or slow provider gives `200` with `metadata.status: partial`, never an error. * **Webhooks**: [event types](/data/events/) `job.opened`, `job.reposted`, `job.closed`, `job.updated`, `company.hiring_started`, `company.hiring_stopped`, `search.completed`. Signed with `BetterJobs-Signature`, retried for 24 hours, replayable. * **Platform**: `Idempotency-Key` on every authenticated `POST`, `RateLimit-*` headers, `X-Request-Id` on every response, `X-Credits-Charged` and `X-Credits-Remaining` on billable responses. * **Errors** with stable codes and a `doc_url`. See [Errors](/platform/errors/). ### Docs and machine-readable files * OpenAPI 3.1 spec at [`/openapi.yaml`](/openapi.yaml). It is the source of truth for every path, field and error code. * Every page is available as Markdown: append `.md` to its URL. * `llms.txt`, `llms-full.txt` and `llms-small.txt` for agents. See [llms.txt](/agents/llms-txt/). * MCP server for agents. See [MCP server](/agents/mcp-server/). ## Not published yet Measured coverage, fill rates, freshness and uptime figures are published at GA. Until then, each response reports what it actually got: `metadata.field_coverage` and `metadata.providers`. # Glossary > What do canonical job, p_real, waterfall and other BetterJobs terms mean? Short definitions, A to Z. Each links to the page that explains it in full. Names in `code` are exact API field or value names. ### Already paid A job you were charged for before. Returning it again, from any endpoint, costs 0 credits. Counted in `metadata.jobs_already_paid` and shown in the ledger with `reason: already_paid`. See [Credits and billing](/concepts/credits-and-billing/). ### Async search A search for up to 10,000 jobs that runs in the background. Start it with `POST /v1/searches`, then poll `GET /v1/searches/{id}` or receive `search.completed`. Branch on its `status`: `queued`, `running`, `completed`, `partial`, `failed` or `on_hold`. See [Async searches](/platform/async-searches/). ### BetterJobs index BetterJobs’ own job sources: an owned crawl of employer career pages and ATSs. Slug `betterjobs`. Available on every plan, and searched first by `cheapest_first`. See [BetterJobs index](/providers/betterjobs-index/). ### Canonical job One real opening, merged from every source that saw it. It has one stable `id` (`job_...`) no matter how many providers listed it or how often it was reposted. See [Canonical jobs](/concepts/canonical-jobs/). ### Consensus A waterfall strategy that returns only jobs seen by at least `min_sources` sources (default 2). Fewer jobs, more confidence. See [Choose a strategy](/guides/choose-a-strategy/). ### Credit The billing unit. 1 credit = 1 unique job returned that you have not already paid for. Company profiles cost 1 credit each, and each `job.opened` a watch delivers costs 1 credit unless you already paid for that job. See [Credits and billing](/concepts/credits-and-billing/). ### Cursor An opaque string that points to the next page. Pass `next_cursor` back as `cursor`. `next_cursor: null` means the last page. On `GET /v1/events` you pass it as `since`. See [Pagination](/platform/pagination/). ### Dry run A free estimate. Send `"dry_run": true` on `POST /v1/jobs/search` and get `expected_unique_jobs_range`, `providers_planned` and `credits_range` without fetching any jobs. ### Duplicate A provider record that describes a job already in the results. BetterJobs merges it into the canonical job and adds it to `sources[]`. Duplicates are free. Counted in `metadata.duplicates_merged`. ### Event A typed message about a change, such as `job.opened` or `company.hiring_stopped`. Delivered by webhook and readable from `GET /v1/events`. See [Events](/data/events/). ### Field coverage `metadata.field_coverage`: for each field, the fraction (0-1) of returned jobs that have a non-null value. It is measured on your actual results, per request. See [Field dictionary](/data/field-dictionary/). ### First seen, last seen, last verified Three timestamps on every job. `first_seen_at`: earliest time any source saw it. `last_seen_at`: latest time any source saw it. `last_verified_at`: latest time it was confirmed live at its origin. `posted_at` is the employer’s own date, often unknown. See [Freshness and lifecycle](/concepts/freshness-and-lifecycle/). ### Hiring pulse `hiring_pulse` on a company profile: `direction` (`up`, `flat`, `down`) and `open_jobs_30d_change`. See [Companies](/data/companies/). ### Idempotency key The `Idempotency-Key` header on a `POST`. A retry with the same key and body returns the first response and never charges twice. The same key with a different body returns `409 idempotency_conflict`. See [Idempotency](/platform/idempotency/). ### is\_hiring A company’s hiring answer: `value` (`true`, `false` or `null`), `confidence` (0-1) and a plain-English `basis`. `null` means unknown. Never treat `null` as `false`. See [field reference](/data/field-dictionary/#field-company-is_hiring-value). ### Ledger `GET /v1/billing/ledger`: one entry per job per charge or free re-read, with the `request_id` that caused it. Your proof of what you paid for. See [Credits and billing](/concepts/credits-and-billing/). ### License `license` on every job. `display`: you may show the job to your end users. `resale`: you may resell or redistribute it as data. See [Licensing](/concepts/licensing/). ### max\_credits `waterfall.max_credits`: a hard cap on what one request may charge. Results stop at the cap. ### on\_hold An async search `status` that means you ran out of credits. The search resumes after a top-up. ### p\_real The probability (0-1) that a job is a real open req, from how many independent sources corroborate it. See [Provenance and confidence](/concepts/provenance-and-confidence/) and the [field reference](/data/field-dictionary/#field-job-p_real). ### Partial `metadata.status: partial`: at least one provider failed or missed `timeout_ms`. You still get `200`, the other sources’ jobs, and you pay only for those. The failures are in `metadata.providers.failed`. See [Provider status](/resources/provider-status/). ### Partner provider One of the six third-party sources BetterJobs routes to: Reqbeat, SignalsAPI, TheirStack, JobsPipe, Coresignal and Techmap. Growth includes two, Pro and above include all six. See [Providers](/providers/). ### Provenance Where each part of a job came from. `sources[]` lists every provider that saw the job, its own id and URL, when it saw it, and which `fields` it contributed. See [Provenance and confidence](/concepts/provenance-and-confidence/). ### Provider Any source BetterJobs can route to: the six partner providers plus the BetterJobs index. Identified by its slug. `GET /v1/providers` lists them with live status. ### Repost The same job listed again. BetterJobs keeps the same canonical `id` and raises `repost_count`. A watch sends `job.reposted`, which should never re-trigger outbound. ### Sandbox `POST /v1/sandbox/jobs/search`: same request and response shape as the live search, no key, fixed illustrative data, nothing charged. ### Strategy `waterfall.strategy`: how BetterJobs picks and orders providers. One of `cheapest_first` (default), `freshest_first`, `max_coverage`, `consensus`, `own_only`. See [Choose a strategy](/guides/choose-a-strategy/). ### timeout\_ms `waterfall.timeout_ms`: the time budget for one request, 1,000 to 30,000 ms (default 10,000). Providers slower than this are dropped and the result is `partial`. ### Unique job A canonical job counted once, however many providers returned it. What a credit buys. ### Version The date in the `BetterJobs-Version` header, currently `2026-10-01`. Changes inside a version are additive only. See [Versioning](/platform/versioning/). ### Watch A standing subscription to a company domain (`type: company`) or a saved filter (`type: search`). Matching events go to its `webhook_url`. Free to create. See [Detect hiring changes](/guides/detect-hiring-changes/). ### Waterfall How BetterJobs runs one search across many providers: it asks them according to the strategy, merges what they return into canonical jobs, and removes duplicates. See [Waterfall](/concepts/waterfall/). ### Webhook signature The `BetterJobs-Signature` header on every delivery: `t=,v1=`. `v1` is the HMAC-SHA256 of `.` with your endpoint secret. See [Webhooks](/platform/webhooks/). # Provider status > How do I check whether each upstream provider is working right now? BetterJobs depends on six partner providers and its own index. When one is slow or down, your results can be thinner. `GET /v1/providers` tells you the live state of each source, and every search response tells you which sources actually answered. ## Check status `GET /v1/providers` is free. It lists every source BetterJobs can route to, whether your plan includes it, and its status. * curl ```bash curl https://api.betterjobs.cc/v1/providers \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python import os import requests resp = requests.get( "https://api.betterjobs.cc/v1/providers", headers={ "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", }, timeout=30, ) resp.raise_for_status() for p in resp.json()["data"]: print(p["slug"], p["status"], "enabled" if p["enabled_on_your_plan"] else "not on plan") ``` * TypeScript ```ts const res = await fetch("https://api.betterjobs.cc/v1/providers", { headers: { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, "BetterJobs-Version": "2026-10-01", }, }); if (!res.ok) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`); const { data } = await res.json(); for (const p of data) { console.log(p.slug, p.status, p.enabled_on_your_plan ? "enabled" : "not on plan"); } ``` Illustrative response, seen from a Growth plan: ```json { "data": [ { "slug": "betterjobs", "name": "BetterJobs index", "enabled_on_your_plan": true, "status": "operational", "last_checked_at": "2026-10-11T09:00:00Z" }, { "slug": "reqbeat", "name": "Reqbeat", "enabled_on_your_plan": false, "status": "operational", "last_checked_at": "2026-10-11T09:00:00Z" }, { "slug": "signalsapi", "name": "SignalsAPI", "enabled_on_your_plan": false, "status": "operational", "last_checked_at": "2026-10-11T09:00:00Z" }, { "slug": "theirstack", "name": "TheirStack", "enabled_on_your_plan": true, "status": "operational", "last_checked_at": "2026-10-11T09:00:00Z" }, { "slug": "jobspipe", "name": "JobsPipe", "enabled_on_your_plan": false, "status": "operational", "last_checked_at": "2026-10-11T09:00:00Z" }, { "slug": "coresignal", "name": "Coresignal", "enabled_on_your_plan": false, "status": "degraded", "last_checked_at": "2026-10-11T09:00:00Z" }, { "slug": "techmap", "name": "Techmap", "enabled_on_your_plan": true, "status": "operational", "last_checked_at": "2026-10-11T09:00:00Z" } ] } ``` ## Fields | Field | Type | Meaning | | ---------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `slug` | `betterjobs` \| `reqbeat` \| `signalsapi` \| `theirstack` \| `jobspipe` \| `coresignal` \| `techmap` | Source id. The same slug appears in `sources[].provider`, `waterfall.providers` and `metadata.providers`. `betterjobs` is the BetterJobs index. | | `name` | string | Display name. | | `enabled_on_your_plan` | boolean | Whether your plan can route to this source. `GET /v1/account` lists the same set as `providers_enabled`. | | `status` | `operational` \| `degraded` \| `down` | Live state of the source. | | `last_checked_at` | date-time | When this status was last checked. | What each status means for you: | `status` | What to expect | | ------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `operational` | Normal. | | `degraded` | The source is answering badly, for example slowly or with errors. It is more likely to show up in `metadata.providers.failed`. | | `down` | The source is not answering. Expect it in `metadata.providers.failed` on every request that tries it. | Sending a provider that is not enabled on your plan in `waterfall.providers` returns `403 plan_required`, with `error.required_plan` and `error.upgrade_url`. See [Errors](/platform/errors/#plan_required). ## How degradation affects your results A failing provider never fails your request. BetterJobs waits up to `waterfall.timeout_ms` (default 10,000 ms), drops any provider that errors or misses it, and returns what the others found. You get `200` with `metadata.status: "partial"`. The failed provider is in `metadata.providers.failed`: ```json { "metadata": { "status": "partial", "credits_charged": 1, "providers": { "tried": ["betterjobs", "techmap", "theirstack", "coresignal"], "hit": ["betterjobs", "techmap", "theirstack"], "failed": [{ "provider": "coresignal", "code": "provider_timeout" }], "contributions": { "betterjobs": 1, "techmap": 0, "theirstack": 0 } } } } ``` * `code` is `provider_timeout` (missed `timeout_ms`) or `provider_error` (the provider returned an error). * You pay only for jobs returned. A provider that failed costs nothing. * Jobs only that provider would have found are missing from this page. * An async search that ends this way has `status: "partial"`. `metadata.providers` is the receipt for each request: `tried` (asked), `hit` (returned at least one match), `failed`, and `contributions` (unique jobs each source added first). When a partial result is not good enough * **Retry later.** A repeat is cheap: jobs you already paid for come back free. * **Give slow providers more time.** Raise `waterfall.timeout_ms`, up to 30,000. * **Route around it.** Set `waterfall.providers` to the list without the failing source. * **Branch in code.** Treat `metadata.status: "partial"` as “complete, but maybe not exhaustive”. For backfills, re-run partial pages once the provider is `operational` again. ## What this page does not show * **Uptime history and latency percentiles.** Not published during the preview. They are published at GA. Each response reports its own `latency_ms` in `metadata`. * **Per-provider coverage and freshness.** These differ by source. What each provider says about itself is on its card under [Providers](/providers/), attributed to that provider. For your own queries, read `metadata.field_coverage` and `metadata.providers.contributions`. ## Related * [Waterfall](/concepts/waterfall/) for how providers are tried. * [Choose a strategy](/guides/choose-a-strategy/) for picking a strategy and providers. * [Troubleshooting](/resources/troubleshooting/) for partial and empty results. # Troubleshooting > Something looks wrong. What should I check first? Find your symptom below. Each one lists what to check, in order. Every response carries an `X-Request-Id` header. Keep it: it is what support needs. ## I got no results `data` is `[]`. This costs 0 credits. Check, in order: 1. **Which sources were tried.** Read `metadata.providers.tried`. On Free and Starter, or with `strategy: own_only`, only `betterjobs` (the BetterJobs index) is searched. Partner providers start on Growth. See [Credits and billing](/concepts/credits-and-billing/). 2. **The time window.** `posted_within_days` counts from when a job was first seen. A window of `1` or `2` days is narrow. Try `30`. 3. **Title keywords.** `title_or` matches title keywords. Add common variants: `["Head of RevOps", "Head of Revenue Operations", "RevOps Lead"]`. Check that `title_not` is not excluding them. 4. **Country codes.** `country_code_or` takes ISO 3166-1 alpha-2 codes: `GB`, not `UK`. 5. **Filters that drop unknowns.** `salary_min_gte` excludes every job whose `salary` is `null`. Many postings do not state pay, so this filter removes a lot. `remote: true` keeps remote jobs only. 6. **The strategy.** `consensus` returns only jobs seen by at least `min_sources` sources (default 2). Switch to `cheapest_first` or `max_coverage` to see single-source jobs. 7. **Closed jobs.** Closed jobs are excluded unless you set `include_closed: true`. 8. **The status.** If `metadata.status` is `partial`, a provider failed. See [below](#i-got-a-partial-result). Before running a wide search, send it with `"dry_run": true`. The free estimate returns `expected_unique_jobs_range` and `providers_planned`. A range of `0` to `0` means the filters are too tight. Sandbox data is fixed `POST /v1/sandbox/jobs/search` returns the same illustrative jobs whatever you send. It cannot tell you whether your filters are right. Use a live key for that. ## I got a partial result `metadata.status` is `partial`, or an async search ended with `status: partial`. This is not an error. * One or more providers failed or missed `waterfall.timeout_ms`. They are listed in `metadata.providers.failed` with `provider_timeout` or `provider_error`. * You got everything the other sources found, and you paid only for that. * Check `GET /v1/providers` for the source’s `status`. To recover: retry later (jobs you already paid for are free), raise `timeout_ms` up to 30,000, or leave the failing source out of `waterfall.providers`. See [Provider status](/resources/provider-status/). ## 400 unknown\_filter or invalid\_request * `unknown_filter`: a field in `filters` does not exist. BetterJobs rejects unknown fields instead of ignoring them, so a typo never silently widens your search. `error.param` names the field, and the message suggests a fix. A common cause is pasting another provider’s filter names, for example `job_title_or` instead of `title_or`. See [Filters](/platform/filters/). * `invalid_request`: a value is out of range or malformed, for example `limit` above 100 on `POST /v1/jobs/search`. Read `error.param` and `error.message`. ## 401 unauthorized Send the header exactly as `Authorization: Bearer bj_live_...`. Check for a missing `Bearer `, a trailing space or newline from copy-paste, or a revoked key. Sandbox keys start with `bj_test_`. See [Authentication](/getting-started/authentication/). ## 402 insufficient\_credits You do not have enough credits for this request. * The body has `error.credits_needed` and `error.upgrade_url`. * `GET /v1/account` shows `credits_remaining` and `credits_reset_at`. * Lower `waterfall.max_credits` or `limit` so the request fits what you have left. * An async search does not fail when credits run out. It moves to `status: on_hold` and resumes after a top-up. See [Errors](/platform/errors/#insufficient_credits). ## 403 plan\_required You asked for a provider or feature your plan does not include, often by listing a provider in `waterfall.providers`. `error.required_plan` names the plan you need. Remove the provider, or check `enabled_on_your_plan` in `GET /v1/providers` first. ## 429 rate\_limited You sent too many requests in the current window. * Wait the number of seconds in the `Retry-After` header, then retry. * Watch `RateLimit-Remaining` and `RateLimit-Reset` on every response and slow down before you hit `0`. * No-code tools often send one request per row at full speed. Throttle the step (Clay request rate, n8n batching, Make scheduling). - curl ```bash # -i prints the response headers, including RateLimit-* and Retry-After curl -i https://api.betterjobs.cc/v1/account \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` - Python ```python import os import time import requests def post_with_retry(url: str, body: dict) -> dict: headers = { "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", } while True: resp = requests.post(url, headers=headers, json=body, timeout=30) if resp.status_code != 429: resp.raise_for_status() return resp.json() time.sleep(int(resp.headers["Retry-After"])) ``` - TypeScript ```ts async function postWithRetry(url: string, body: unknown): Promise { while (true) { const res = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, "BetterJobs-Version": "2026-10-01", "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (res.status !== 429) { if (!res.ok) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`); return res.json(); } await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After")) * 1000)); } } ``` See [Rate limits](/platform/rate-limits/). ## Webhook signature does not match Your computed HMAC differs from `v1` in `BetterJobs-Signature`. The cause is almost always the input, not the algorithm. Check: 1. **Raw body.** Sign the exact bytes you received. If your framework or tool parsed the JSON and you re-serialize it, key order and whitespace change and the HMAC breaks. Use the raw body option: n8n *Raw Body*, Make *JSON pass-through*, Zapier *Catch Raw Hook*, Express `express.raw()`. 2. **Signed string.** It is `.`: the `t` value from the header, a dot, then the body. Not the body alone. 3. **Header parsing.** Split the header on `,`, then each part on the first `=`. `t` is unix seconds. `v1` is lowercase hex. 4. **Secret.** Use the secret of the endpoint that received the event. A different endpoint has a different secret. 5. **Encoding.** Compare hex to hex. Do not base64 the digest. 6. **Clock.** If you reject old timestamps, check your server clock. A 5-minute tolerance is common. Once your check is fixed, replay missed events with `POST /v1/webhooks/replay`. Replays are free and never charge again. See [Webhooks](/platform/webhooks/). ## I think I was charged twice BetterJobs charges a job once. Re-reading a job you already paid for is free, whichever endpoint returns it. To check a specific job, read the ledger: * curl ```bash curl "https://api.betterjobs.cc/v1/billing/ledger?job_id=job_01JC8X4M2Q7RV3T9KD5W6YH0AB" \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01" ``` * Python ```python import os import requests resp = requests.get( "https://api.betterjobs.cc/v1/billing/ledger", params={"job_id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB"}, headers={ "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}", "BetterJobs-Version": "2026-10-01", }, timeout=30, ) resp.raise_for_status() for e in resp.json()["data"]: print(e["created_at"], e["operation"], e["reason"], e["credits"]) ``` * TypeScript ```ts const url = new URL("https://api.betterjobs.cc/v1/billing/ledger"); url.searchParams.set("job_id", "job_01JC8X4M2Q7RV3T9KD5W6YH0AB"); const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`, "BetterJobs-Version": "2026-10-01", }, }); if (!res.ok) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`); const { data } = await res.json(); for (const e of data) console.log(e.created_at, e.operation, e.reason, e.credits); ``` Illustrative result: one charge, then two free re-reads. ```json { "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 } ``` Then check the usual causes of a real second charge: * **Two different jobs.** Two postings that look alike (same title, different city or team) are two canonical jobs with two `id` values. Compare the ids. * **Company profiles.** `GET /v1/companies/{domain}` costs 1 credit per request, every time. There is no already-paid rule for profiles. * **Retried POSTs.** Send an `Idempotency-Key` on every `POST`. A retry with the same key and body returns the first response and never charges twice. See [Idempotency](/platform/idempotency/). Each response’s `X-Credits-Charged` header and `metadata.credits_charged` show what that request cost. If the ledger still shows two `charged` entries for one `job_id`, contact support with both `request_id` values. ## Related * [Errors](/platform/errors/) for every error code. * [Credits and billing](/concepts/credits-and-billing/) for what is and is not charged. * [Glossary](/resources/glossary/) for terms used here. # When not to use BetterJobs > When is BetterJobs the wrong tool for the job? BetterJobs does one thing: it finds job postings across many providers and returns each real opening once. If your problem is something else, another tool will serve you better. Here are the cases we know about. ## You need contact data for people BetterJobs returns jobs and company hiring profiles. A [canonical job](/data/jobs/) has no person fields: no hiring manager name, email or phone. **Use instead:** a contact-data waterfall. Feed it the company domain from `company.domain` and the role you want to reach. Use BetterJobs to find *which* companies to contact and *why now*, and the contact tool to find *who*. Note Some partner providers sell person data of their own. Per SignalsAPI docs, SignalsAPI returns the hiring owner’s verified work email. BetterJobs does not pass person data through. ## You need many years of job history BetterJobs is built for current and recent hiring. `posted_within_days` goes up to 365. Of the partner providers, the longest history any of them claims starts in 2020: per Coresignal docs, postings since August 2020, and per Techmap, 451M+ postings since 2020. Nobody in the waterfall covers ten years. **Use instead:** a labor-market data vendor that sells long historical series. If 2020 onward is enough and you need it in bulk, buy the dataset from Coresignal or Techmap directly (see below). ## You already pay for the one provider you need If one provider’s coverage is enough for you and you already have a contract, BetterJobs adds a layer between you and data you already own. The value of BetterJobs is the merge: more sources, one schema, duplicates removed, one bill. With one source, there is nothing to merge. **Use instead:** that provider’s API directly. Our [migration guides](/guides/migrate-from-theirstack/) map field names both ways, so you can compare later. Want both? On the Scale plan you can bring your own provider keys. BetterJobs then uses your existing contract inside the waterfall. See [Credits and billing](/concepts/credits-and-billing/). ## You need full datasets delivered to your warehouse BetterJobs is an API. An async search returns up to 10,000 jobs per search, paged at 100. It does not drop files into your bucket. **Use instead:** a bulk feed from a provider. Per Coresignal docs, Coresignal delivers JSONL, Parquet or CSV to S3, GCS, Azure or Snowflake. Per Techmap, Techmap offers daily country feeds via AWS Data Exchange. ## You need technographics or firmographics BetterJobs answers “who is hiring for what”. It does not return a company’s tech stack, size, funding or revenue. **Use instead:** a company-data provider. Per TheirStack docs, TheirStack sells technographics inferred from job text plus firmographics. Join on the company domain. ## You need to resell job data Each job carries a `license`. `license.resale: false` means you may not resell or redistribute that job as data. Check it before you build a product on top. See [Licensing](/concepts/licensing/). ## You need GA guarantees today The API is a **v1 preview**. Endpoints and fields may change before general availability. Uptime, coverage and freshness figures are published at GA. If you need a contractual SLA now, wait for GA or talk to us about Enterprise terms. ## Still a fit? If you need current job postings from many sources, merged, with one key and one bill, start with the [Quickstart](/getting-started/quickstart/). The keyless sandbox costs nothing.