Skip to content
API v1 preview — endpoints and fields may change before general availability.

Jobs

What is in a job record, and what do null and empty values mean?

View .md
Cost: 1 credit / unique jobDuplicates, jobs you already paid for, empty pages and dry runs are free.

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.

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.

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.

FieldTypeDescriptionnull / [] meansDerivationExample
idstringCanonical job id (job_...). Stable across sources and requests.Never nullnormalized"job_01JC8X4M2Q7RV3T9KD5W6YH0AB"
titlestringJob title as posted.Never nullraw"Head of Revenue Operations"
company.idstringCanonical company id (cmp_...).Never nullnormalized"cmp_4Rk7TzP1aQ"
company.namestringCompany name.Never nullnormalized"Acme Robotics"
company.domainstring | nullPrimary web domain of the company.Unknown. No source reported it.normalized"acme-robotics.example"
location.citystring | nullCity.Unknown. No source reported it.normalized"Berlin"
location.regionstring | nullState, province or region.Unknown. No source reported it.normalized"Berlin"
location.country_codestring | nullISO 3166-1 alpha-2 country code.Unknown. No source reported it.normalized"DE"
location.remoteboolean | nulltrue remote, false on-site or hybrid.Unknown. Not the same as false.normalizedfalse
employment_typefull_time | part_time | contract | internship | temporary | nullEmployment type.Unknown. No source reported it.normalized"full_time"
seniorityintern | junior | mid | senior | lead | director | vp | c_level | nullSeniority level.Unknown. No source reported it.inferred"lead"
job_familystring | nullJob family, e.g. engineering, sales, operations.Unknown. No source reported it.inferred"operations"
salarySalary | nullPay range. See the salary.* fields.Unknown. No source reported pay.normalized{"min":110000,"max":135000,"currency":"EUR","period":"year","origin":"declared"}
salary.minnumber | nullLower bound in salary.currency per salary.period.Unknown lower bound.normalized110000
salary.maxnumber | nullUpper bound in salary.currency per salary.period.Unknown upper bound.normalized135000
salary.currencystringISO 4217 currency code.Never nullnormalized"EUR"
salary.periodyear | month | hourPay period.Never nullnormalized"year"
salary.origindeclared | inferreddeclared = stated in the posting. inferred = estimated by a source.Never nullnormalized"declared"
descriptionstring | nullPlain-text job description.Unknown. No source reported it.raw"Acme Robotics is hiring a Head of Revenue Operations..."
apply_urlstring | nullWhere a candidate applies.Unknown. No source reported it.raw"https://jobs.acme-robotics.example/revops-lead/apply"
posted_atdate-time | nullDate the employer posted the job.Unknown. Use first_seen_at instead.raw"2026-10-08T00:00:00Z"
first_seen_atdate-timeEarliest time any source saw the job.Never nullnormalized"2026-10-08T06:40:00Z"
last_seen_atdate-timeLatest time any source saw the job.Never nullnormalized"2026-10-11T06:10:00Z"
last_verified_atdate-time | nullLatest time the job was confirmed live at its origin.Never verified at origin.normalized"2026-10-11T06:10:00Z"
statusopen | closedLifecycle status.Never nullnormalized"open"
closed_reasonfilled | expired | removed | unknown | nullWhy the job closed.The job is open.normalizednull
repost_countintegerTimes the same job was re-listed.Never nullnormalized0
p_realnumber (0-1)Probability the job is a real open req, from cross-source corroboration.Never nullinferred0.94
sourcesSource[]Every provider that saw this job, with what it contributed.Never nullraw[{"provider":"theirstack",...}]
sources[].providerProviderSlugProvider slug.Never nullraw"theirstack"
sources[].provider_job_idstringThe provider's own id for this posting.Never nullraw"ts_88213377"
sources[].urlstring | nullPosting URL as this provider saw it.The provider did not report a URL.raw"https://jobs.acme-robotics.example/revops-lead"
sources[].first_seen_atdate-timeWhen this provider first saw the job.Never nullraw"2026-10-08T07:12:00Z"
sources[].last_seen_atdate-timeWhen this provider last saw the job.Never nullraw"2026-10-11T04:02:00Z"
sources[].fieldsstring[]Job fields this source contributed to the canonical record.[] = the source corroborated the job but contributed no field.raw["salary","seniority"]
license.displaybooleanYou may show this job to your end users.Never nullnormalizedtrue
license.resalebooleanYou may resell or redistribute this job as data.Never nullnormalizedfalse

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.

The first record of jobs.json. Illustrative data: the company and domain are fictional.

jobs.json — record 1 (illustrative)
{
"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 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)
{
"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.

Terminal window
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'

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.

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.