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.
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.
llm_inferred- Used by
field_sources.countryonly. A language model read the location text and named the country with no second signal. Low trust:country_confidenceislow. unknown- Used by
field_sources.countryonly. The row has a country, but nothing records where it came from, so the payload says that instead of dressing a guess as a fact.
Where a country comes from. field_sources.country is published when the recorded origin says the source's own countries array carried it (before 26 Oct 2026 this followed the source's own location text with no marker against it), inferred when a rule of ours (a gazetteer or a state code) or a repair set it, or when the gazetteer and a language model agreed (country_confidence: high, measured precision 99.5%), llm_inferred when a language model alone read it from the location text (country_confidence: low, measured precision about 95%, from a hand-checked sample of 120 rows and an independent model check of 400), unknown when the job has a country but nothing records where it came from, and absent when the job has no country. country_confidence appears only on rows whose country we filled. To leave the low-trust rows out, add country_confidence=high to a list request; country_confidence=low returns only those rows. Any other value is a 422 and bills no records.
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, but 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 AI salary predictions
at all, so a modelled figure cannot reach this API by any route.
region, expires_at, description_length, source_requisition_id,
employer_website, salary_max, salary_currency and salary_period
were added with db/144, which moved the fields parsed out of the source's
own page into a side table (joa.listing_extract). The payload coalesces
the listing column with the extracted one, preferring whatever the row
already carried, and these keys say whether a value is there: a value
that came from the source is published, a missing one is absent.
Every key on this object is ALWAYS present on every job (the vocabulary is the value, not the presence), so a client can bind the whole shape.
| Field | Type | Always present | Meaning |
|---|---|---|---|
agency | stringpublishedinferredabsent | yes | |
category | stringpublishedinferredabsent | yes | |
compensation_type | stringinferredabsent | no | Always inferred when the compensation_type key is present on
the job object (db/370): a rule's reading of the description,
never a structured field a source publishes. absent means the
rule found no commission-only claim. |
contract_category | stringpublishedinferredabsent | yes | |
country | stringpublishedinferredllm_inferredunknownabsent | yes | How the job's country was obtained. published: the recorded origin says the
source's own countries array carried it (before 26 Oct 2026 this keyed off the
source's own location text with no marker against it). inferred: a rule of ours
(gazetteer, state code) or a repair set it, or the gazetteer and a language model
agreeing (country_confidence: "high", measured precision 99.5%).
llm_inferred: LOW TRUST. A language model read the location text and named the
country with no second signal; country_confidence is "low" and the measured
precision of this class is about 95% (hand-checked sample of 120 rows; 98% on an
independent model check of 400 rows), against about 99% for the other classes.
Filter on field_sources.country in your own code to keep only the higher-trust
values. unknown: the row has a country but nothing records where it came from,
the honest label for a legacy value. absent: the job has no country. |
description | stringpublishedinferredabsent | yes | |
description_length | stringpublishedinferredabsent | yes | Computed from the same octet_length as description, so the two cannot disagree: published when the row carries text, absent when it does not. |
employer_website | stringpublishedinferredabsent | yes | Provenance of employer_website. Like source_requisition_id it only ever comes from the page the sweep parsed, so it is published or absent, never inferred. |
employment_type | stringpublishedinferredabsent | yes | |
engagement | stringpublishedinferredabsent | yes | Provenance of the engagement array (db/232): published when the
source's own field or page stated the relationship, inferred when
an audited deterministic rule of ours established it (the payload
then also carries engagement_confidence), absent when the posting
does not establish it (the value key is then omitted and
?engagement=unknown selects the row). Never inferred from silence. |
expires_at | stringpublishedinferredabsent | yes | Provenance of expires_at: published when the row carries one, from either source (see the Job schema), and absent while the vacancy is live. |
language | string | no | "detected" when the row publishes a language (our detector's reading of the advert text, not the source's declaration), and "absent" while the row's language is null (the key was omitted then until 17 Oct 2026). It never reads published or inferred. |
location | stringpublishedinferredabsent | yes | |
posted_at | stringpublishedinferredabsent | yes | published when the source stated the date or timestamp. Workday publishes a date only (see posted_at_precision): its rows read inferred until the posting's own start date has been read from Workday (on the next re-verification, about daily), because the list API only says "Posted N Days Ago" and we derive the date from that. |
region | stringpublishedinferredabsent | yes | published when the row carries a region, absent when it does not. It does NOT separate the region the source declared from the ones db/078 derives from the location text or the city gazetteer, and it does not separate the listing column from the value extracted off the page (db/144). Read it as "we established a region", not as a quotation. |
remote | stringpublishedinferredabsent | yes | |
salary | stringpublishedinferredabsent | yes | |
salary_currency | stringpublishedinferredabsent | yes | Provenance of salary_currency; the same verdict as salary. |
salary_max | stringpublishedinferredabsent | yes | Provenance of salary_max, and always the same verdict as salary: published only when the source stated the figure in a field of its own, inferred when we read it out of the advert text, absent when there is no figure. |
salary_period | stringpublishedinferredabsent | yes | Provenance of salary_period; the same verdict as salary. |
seniority | stringpublishedinferredabsent | yes | |
source_requisition_id | stringpublishedinferredabsent | yes | Provenance of source_requisition_id. The value only ever comes from the page the verify sweep parsed (db/144), so this is published or absent, never inferred. |
source_type | stringpublishedinferredabsent | yes | inferred 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. |
working_schedule | stringpublishedinferredabsent | yes |
$ curl -s -H "Authorization: Bearer $JOA_KEY" 'https://api.jobopportunitiesapi.org/v1/jobs?limit=1' \
| jq '.data[0] | {title, company, remote, remote_inferred, category, category_confidence, field_sources}'
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-separatedComma-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 12.6% of your rows have a
salary". They do, and require_fields=salary returns
307,796 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.
{
"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"
}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
$ 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" }
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.salaryispublished. salary_source: parsed_description- A real figure quoted in the advert body (an explicit amount, currency and period in a pay sentence), which we read out and normalised; the advert's own words are kept with it. We chose the number and the period, so
field_sources.salaryisinferred. - No
salary_source - There is no salary.
field_sources.salaryisabsent.
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_salarystringtruestructuredanytrue (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_salarynumberLower bound on salary_min_annual_eur. Only rows whose salary could be
normalised to an annual EUR figure can match (the current share is on
/public/coverage, field salary_eur), so this is a narrow filter by
nature, not a broken one. It compares every normalisable figure,
published by the source or read out of the advert text
(field_sources.salary and salary_source say which); add
has_salary=true to compare only salaries the source published.
max_salarynumberUpper bound on salary_min_annual_eur. Same scope 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 27 October 2026, 11:29 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.