Overview
Section titled “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 for how merging works and Provenance and confidence for sources[] and p_real.
Null, empty and missing
Section titled “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. |
The per-field meaning of null is in the null / [] means column below.
Field dictionary
Section titled “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. The 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 |
No field matches.
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
seniorityfrom the title. Enum values are listed in Taxonomies.
Record sample
Section titled “Record sample”The first record of jobs.json. Illustrative data: the company and domain are fictional.
{ "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.
Coverage
Section titled “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.
{ "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 -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'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"])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);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.
Freshness
Section titled “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.
Jobs move from open to closed, and repost_count goes up when the same job is re-listed. Read Freshness and lifecycle for the full lifecycle, and Events to be told when it changes.
| Operation | Endpoint | Cost |
|---|---|---|
| Search jobs | POST /v1/jobs/search |
Cost: 1 credit / unique job |
| Get a job | GET /v1/jobs/{id} |
Cost: 1 credit / jobFree if you already paid for this job. |
| Sandbox search | POST /v1/sandbox/jobs/search |
Cost: Free |
Search returns up to 100 jobs per page. Page with next_cursor (see Pagination). Filters are listed in Filters. Re-reading a job you already paid for is free; GET /v1/billing/ledger proves it.
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.
Details: Async searches, Create a search, Get a search. For daily upserts and backfills, follow Sync patterns.
CSV. The API returns JSON. CSV export is a plan feature, listed from the Growth plan (see Credits and billing). jobs.csv shows a flattened layout: nested fields become columns such as salary_min, and source slugs are pipe-joined in source_providers.