Job Opportunities API

Check the data. Then trust it.

ledger

Provenance

Every field on every row says whether the source published it, whether we inferred it, or whether it is absent. This is the most distinctive thing in the product.

Last verified 2026-08-22 · Assumes: That you have seen a job row. If not, read the quickstart first. · Markdown copy

Most data products give you a field and leave you to guess where it came from. A remote: true might be a checkbox the employer ticked or a model's reading of the phrase “flexible working”. Those are different facts, and merging them is how a dataset becomes unusable for anything that matters.

1field_sources — per-field provenance

Every job row carries a field_sources object marking each field published (the source said so), inferred (we derived it) or absent (there is none).

In depthWhy it exists, what it is not, what people get wrong

Three values, and the third is as important as the other two. absent is not the same as false and it is not the same as a guess: it says the source carried nothing here, so anything you assert about this field is yours, not ours.

published
The value was carried by the source. It is a quotation.
inferred
We produced it. It may well be right; it is not a quotation, and you should treat it as our reading rather than the employer's statement.
absent
There is no value. A different failure from a guess, and reported as such.

Two fields are always inferred, by construction: category and seniority are read off the job title by a classifier and are never a field an employer filled in. category additionally carries category_confidence, and no category is published at all below 0.6 — so that number is either null or at least 0.6.

One field is subtler. salary is published when the source stated it in a structured field, and inferred when a real figure was quoted in the advert text and we read it out and normalised it — we chose the number and the period, so it is our reading. It is never an estimate: modelled salaries are refused by the projection itself and cannot reach this API by any route.

Exact contractTypes, defaults, ranges, errors, edge cases

Per-field provenance. published means the value was carried by the source. inferred means WE produced it — it may be right, it is not a quotation. absent means no value, which is a different failure from a guess and is reported as such.

category and seniority are ALWAYS inferred: both are read off the job title by a classifier, never from a field the employer filled in.

salary is published when the source stated it in a field (salary_source: structured), and inferred when we read it out of the advert text (salary_source: parsed_description). It is never an estimate: the projection refuses to emit Erioun's AI salary predictions at all, so a modelled figure cannot reach this API by any route.

FieldTypeAlways presentMeaning
categorystringpublishedinferredabsentyes
descriptionstringpublishedinferredabsentyes
employment_typestringpublishedinferredabsentyes
locationstringpublishedinferredabsentyes
posted_atstringpublishedinferredabsentyes
remotestringpublishedinferredabsentyes
salarystringpublishedinferredabsentyes
senioritystringpublishedinferredabsentyes
source_typestringpublishedinferredabsentyesinferred when the value is the provider's default class; published when the row itself was classified upstream. TODAY THIS IS ALWAYS inferred: the row-level column is null on every live listing, so the provider's class is the only path taken. published is reserved for when per-row classification lands. Do not build a filter that expects it to appear.
One row, with its provenance block
$ curl -s 'https://api.jobopportunitiesapi.org/public/jobs?limit=1' \
  | jq '.data[0] | {title, company, remote, remote_inferred, category, category_confidence, field_sources}'
{
  "data": [
    {
      "id": "fec3c1c4-aafd-4835-b9d1-d0eaf1f69852",
      "slug": "specialty-representative-florida-state-university-united-states-field-worker-fec3c1c4",
      "title": "Specialty Representative - Florida State University - United States Field Worker",
      "company": "amgen",
      "company_slug": "amgen",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/amgen.png",
      "category_confidence": null,
      "country": "US",
      "city": "Jacksonville",
      "location": "US - Florida - Jacksonville",
      "remote_inferred": false,
      "posted_at": "2026-09-12T01:02:09Z",
      "first_seen_at": "2026-09-12T01:05:21Z",
      "last_verified_at": "2026-09-12T01:50:04Z",
      "status": "live",
      "closed_at": null,
      "closed_reason": null,
      "apply_url": "https://amgen.wd1.myworkdayjobs.com/careers/job/US---Florida---Jacksonville/Specialty-Representative---Florida-State-University---United-States-Field-Worker_R-255772",
      "source": "workday",
      "source_type": "ats",
      "provider_type": "employer_ats",
      "has_description": false,
      "field_sources": {
        "remote": "absent",
        "employment_type": "absent",
        "category": "absent",
        "seniority": "absent",
        "salary": "absent",
        "location": "inferred",
        "posted_at": "published",
        "description": "absent",
… 9 more lines
Read `field_sources` first, then the fields. A `remote` value with `field_sources.remote: "inferred"` is our reading of the location text, not the employer's statement — and `remote_inferred: true` says the same thing at the top level, always present, never omitted when false. Real response, fetched from /public/jobs?limit=1 when this page was built (12 September 2026, 02:15 UTC). Run the command yourself and you will get today's rows, not these.

2require_fields — the honest subset

Ask for only the rows where every named field was published by the source. This is the answer to “only a small share of your rows have a salary”.

In depthWhy it exists, what it is not, what people get wrong

The complaint is real and the answer is not a denial. A minority of rows carry a salary the employer stated. require_fields=salary returns that minority — and every row in it carries a figure a human at the employer actually wrote, with no estimate anywhere in the response. A smaller, clean set is worth more than a large set you have to filter yourself and cannot audit.

It composes with everything else. require_fields=salary,location&country=DE is a perfectly reasonable query, and it is the shape of question the parameter exists for. It also adds a completeness block to the response, which tells you how many live rows carry each required field across the whole ledger — so you can see whether an empty page means your filters were narrow or the data is thin.

Exact contractTypes, defaults, ranges, errors, edge cases
require_fieldsstringcomma-separated

Comma-separated. Returns only rows where EVERY named field is published in field_sources — a value the source carried, never one we derived. The response then also contains a completeness object saying how many live rows carry each of them. This is the answer to "only 6.3% of your rows have a salary". They do — and require_fields=salary returns 229,376 rows of which 100% carry a figure an employer actually wrote, with no estimate anywhere in the response. Check the per-country split at /public/coverage/countries before you spend a record. category and seniority are refused with 422: both are read off the job title by our classifier, so they are inferred by construction and no row can ever satisfy them. An empty page would look like a coverage problem; the error says what it is. source_type is accepted and currently matches nothing — the per-row classification exists in the schema and no live row carries one yet. The completeness block reports that as a count rather than leaving you to infer it from an empty page.

1 parameter, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug — please say so with the thumbs-down below.

The completeness block is present only when you passed require_fields. published_in_ledger is per field and ledger-wide: the number matching your other filters as well is at most the smallest of them. It is deliberately not an exact count of your query, because running that aggregate per request is how an endpoint becomes a timeout.

The completeness block, from a real response
{
  "required": ["salary"],
  "published_in_ledger": { "salary": 307345 },
  "labels": { "salary": "Salary stated by the employer" },
  "generated_at": "2026-08-22T19:53:52Z",
  "note": "Every row in `data` carries all of `required` as \"published\" in field_sources …",
  "by_country": "https://api.jobopportunitiesapi.org/public/coverage/countries"
}
German roles where the employer stated the salary — and nothing else
curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs?require_fields=salary&country=DE&limit=5' \
  | jq '{completeness, rows: [.data[] | {title, company, salary_min, salary_max, salary_currency, salary_source}]}'

3Why require_fields=category is an error

category and seniority are inferred by construction, so no row can ever satisfy them. The API says so with a 422 instead of returning an empty page.

In depthWhy it exists, what it is not, what people get wrong

This reads like a bug and is a feature, which is exactly why it needs explaining rather than merely documenting. require_fields means “only rows where the source published this”. category and seniority are read off the job title by our classifier — there is no employer field behind them — so the honest answer to require_fields=category is “zero rows, and zero rows forever”.

An empty 200 would be indistinguishable from a coverage problem. A developer would reasonably conclude the ledger has no categorised roles, which is false: about 95% of live rows carry a category. The 422 says what is actually happening and points at the filter you wanted, which is ?category= plus reading category_confidence.

Exact contractTypes, defaults, ranges, errors, edge cases
The refusal, and what it tells you to do instead
$ curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs?require_fields=category'
{
  "error": "field_never_published",
  "message": "category is read off the job title by our classifier, so it is always
               inferred. Filter it with ?category= and read category_confidence.",
  "docs": "https://jobopportunitiesapi.org/api"
}
Captured 2026-08-22. HTTP 422.

One related case behaves differently and is worth knowing. require_fields=source_type is accepted and currently matches nothing, because the per-row classification exists in the schema and no live row carries one yet. That is a “not yet”, not a “never”, so it is not an error — and the completeness block reports the zero as a count rather than leaving you to infer it from an empty page.

4Salary, specifically

Published when the source stated it in a field, inferred when we read a real figure out of the advert text. Never a model's estimate, under any parameter.

In depthWhy it exists, what it is not, what people get wrong

Salary is the field most people want and the field most sources omit, which makes it the field most tempting to fabricate. This API does not. There is a salary estimator elsewhere in the group that produces modelled figures for internal use, and the projection that builds this ledger wraps every salary column in a condition that refuses to emit an estimated one. It is not a policy someone has to remember; it is a CASE WHEN in the query that builds the data.

salary_source: structured
The source published it in a field. field_sources.salary is published.
salary_source: parsed_description
A real figure quoted in the advert body, which we read out and normalised. We chose the number and the period, so field_sources.salary is inferred.
No salary_source
There is no salary. field_sources.salary is absent.

The has_salary parameter respects this distinction rather than papering over it. has_salary=true (and its alias structured) returns only source-published figures — the meaning it has always had, kept so that adding parsed salaries did not silently change the results of a query someone already shipped. has_salary=any additionally includes the parsed ones.

Exact contractTypes, defaults, ranges, errors, edge cases
has_salarystring
truestructuredany

true (and structured) returns only rows whose salary the SOURCE published — the meaning this parameter has always had, kept so that adding derived salaries does not change the results of a query you already ship. any also includes figures we read out of the advert text (salary_source: parsed_description, reported as inferred). AI estimates are never published under any value.

min_salarynumber

Lower bound on salary_min_annual_eur. Selects ONLY rows with structured salary we could normalise — 2.0% of the ledger — so this is a narrow filter by nature, not a broken one.

max_salarynumber

Upper bound on salary_min_annual_eur. Same 2.0% caveat as min_salary.

3 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug — please say so with the thumbs-down below.

salary_min_annual_eur is a derived comparison field: salary_min converted to an annual EUR figure so that a row quoting USD per hour and one quoting GBP per year can be compared. It is null whenever the period or the currency is unrecognised — never guessed, because reading an hourly rate as an annual salary is wrong by a factor of about 2080. min_salary and max_salary filter on it, which is why they select only the small share of rows with a normalisable figure.

Conversion uses the indicative rates published on /v1/meta/freshness, not a settlement rate. If you are doing anything financial with the number, take the raw salary_min, salary_currency and salary_period and convert them yourself.

This page was rendered 12 September 2026, 02:15 UTC. Every figure on it comes from the endpoint named beside it, and every published request is re-sent against the live API before this site is allowed to build. If something here is wrong, the thumbs-down above reaches a person.