api
Fields
Every field on a job row and a company row, with its type, whether it is always present, and whether it can ever be our inference rather than the source's statement.
1The job row
Identity, employer, classification, place, salary, timestamps, provenance and the apply link. Generated from the specification.
In depthWhy it exists, what it is not, what people get wrong
Fifteen fields are required and always present; the rest appear only when there is something to put in them. Two behaviours are worth internalising before you write a parser.
- Optional fields are omitted, not nulled, with one deliberate exception.
remote_inferredis always present, including when it isfalse, because it used to be omitted and that made a stated value and an absent one identical on the wire. - `closed_at` and `closed_reason` are present and `null` on a live row, rather than omitted, so a consumer can switch on them without a presence check.
- `description` is present only when you asked for it with
include_description=true, and only on rows that have one. - Personal email addresses are removed from `description`. An email address that identifies one person, a name-based address such as
jane.doe@…, is replaced with[personal contact removed]. Company contacts stay: role mailboxes (jobs@,careers@,hr@,info@and their equivalents in other languages). Phone numbers are not changed on this path. Roles an employer listed with us directly keep their text exactly as the employer published it. The rest of the text,apply_urland every other field are untouched;description_lengthcounts the stored text before the replacement. The same applies to the MCP tools. The bulk export is stricter and removes every email address and phone number. - New fields appear without warning. Parse permissively.
- `compensation_type` is derived from the description by rule, can be wrong in rare cases (see field_sources). It carries
commission_onlywhen the description states the role is paid entirely by commission with no base salary, and is omitted otherwise: absence does not mean the role has a base salary, only that the rule found no commission-only claim. It is informational only and never hides a listing. Filterable with?compensation_type=commission_onlyand excludable with?exclude_compensation_type=commission_only. - `company_more_roles` appears only with `collapse=company` (24 Oct 2026), on every row of such a response, 0 included. It is the number of further roles of this employer that the request read and hid behind this row: the collapsed page shows one row per employer and this says what it folded away. It counts the rows the request read (at most 2,000 per request), never more, so
?company=<company_slug>is the way to an employer's complete set. Without the parameter the key is absent and the payload is byte-identical to what this API always served.
Exact contractTypes, defaults, ranges, errors, edge cases
| Field | Type | Always present | Meaning |
|---|---|---|---|
agency | stringdirectagencyumbrella | no | Who the employer of record is: direct: the advertiser; agency:
a temporary-work agency (the engagement is then employee, per EU
and national law, and contract_category describes the contract with
the agency: permanent agency contracts exist); umbrella: an
umbrella / portage company. OMITTED when not established
(?agency=unknown). No confidence key by design. |
apply_url | string | no | |
attribution | string | no | Present only on roles an employer pushed to us directly: show the listing with these words. Absent on every other row. |
canonical_url | string | no | Present only on roles an employer pushed to us directly: the role's own page, where people apply and which search engines should index. Link to it, not to our page. Absent on every other row. |
category | string | no | |
category_confidence | number · nullable | no | Classifier confidence in category. Below 0.6 no category is published at all, so this is always null or >= 0.6. |
city | string | no | |
closed_at | string · nullable | no | JOA records as the closure date the day its daily verification confirmed that an advert had gone. Before late September 2026 that verification ran in large catch-up batches, so closure dates recorded before then show when JOA confirmed a closure, not when the employer closed the role; daily verification applies from late September 2026. |
closed_reason | string · nullableexpired_upstreamnot_seenapply_link_deademployer_closeddeadline_passed | no | |
company | string | yes | |
company_logo | string | no | |
company_more_roles | integer | no | The additional-roles count of the collapsed shape (24 Oct 2026).
Present on EVERY row when the request used collapse=company,
0 included: the number of further roles of this employer that the
request read and hid behind this row. Absent otherwise, so a payload
without the parameter keeps its exact bytes.
It counts the rows the request read (a request reads at most 2,000
rows to fill a collapsed page), so roles past that are not counted.
?company=<company_slug> lists an employer's complete set. |
company_slug | string | yes | |
compensation_type | stringcommission_only | no | commission_only when the job description states the role is paid
entirely by commission, with no base salary (db/370, DQ rule v5: a
fixed pattern match on the description text, checked by hand on a
100-row judged sample at 98% precision before shipping). OMITTED
when the rule found no such claim: this is NOT the same as "has a
base salary"; most postings simply do not say either way.
Informational only: it NEVER hides a listing, and it can be wrong
in rare cases, same as every other rule-derived field on this
object. Filterable with ?compensation_type=commission_only and
excludable with ?exclude_compensation_type=commission_only.
field_sources.compensation_type is always inferred when the key
is present: this is never something a source publishes in a
structured field, only something read out of the advert text. |
contract_category | array | no | The contract kind the posting establishes (db/232): permanent is
returned only when the posting STATES an open-ended contract (CDI,
unbefristet, tempo indeterminato, indefinido, ...); fixed_term
ends on a date or fixed period; project_based ends on completion
of a defined project; seasonal when the source says seasonal;
apprenticeship is a statutory work-and-study contract;
internship includes traineeships; other is a STATED form none
of these represent (zero-hours, casual, per diem, Minijob,
Werkstudent, ...). An ARRAY (dual-option adverts), main option
first. OMITTED when the posting states none (most postings);
filterable as ?contract_category=unknown. For vendors the
category only describes the term of the engagement. |
contract_category_confidence | number | no | Present ONLY when field_sources.contract_category is inferred:
the measured precision of the rule, 0..1. |
country | string | no | |
country_confidence | stringhighlow | no | Present ONLY when WE filled the country (field_sources.country is inferred after
a gazetteer-plus-model agreement, or llm_inferred). high: gazetteer and a language
model agree. low: a language model alone read the location text (llm_inferred;
measured precision about 95%, see field_sources.country). Omitted on every other row. |
description | string | no | The full advert text. Present ONLY when the request set include_description=true, and only on the 95.1% of rows that have one (2,318,210 live rows).
Personal email addresses are removed: an email address that identifies one person (a name-based address such as jane.doe@...) is replaced with [personal contact removed]. Company mailboxes stay in the text: role mailboxes (jobs@, careers@, hr@, info@ and their equivalents in other languages). Phone numbers are not changed on this path. A role an employer listed with us directly keeps its text exactly as the employer published it. Nothing else in the text, apply_url and every other field are unchanged. description_length counts the stored text, before this replacement. The bulk GET /v1/export removes every email address and phone number instead. |
description_length | number | no | Size of description in bytes. Always present: 0 means the row carries no advert text, exactly as has_description:false does. Computed with octet_length, which reads the length out of the TOAST header without fetching the text. The text itself still needs include_description=true. |
description_truncated | boolean | no | True while the stored advert text is known to be cut short at its source: harvested under an old 4,000-character limit and not yet re-fetched, or longer than the 100,000-character safety ceiling. JOA does not shorten the text it serves; this flag says the ledger's own copy is incomplete. Always present. |
employer_website | string | no | The employer's own site as THIS posting states it:
hiringOrganization.url or sameAs, or the ATS's own field (db/144).
A different claim from website on /v1/companies, which is verified
against the domain's own page. |
employment_type | string | no | One of Full-time, Part-time, Contract, Temporary, Internship, or absent (present on 39.3% of served rows, 23 Oct 2026). A contract-duration word the source gives without hours (Permanent, Regular, CDI, Employee) is not read as Full-time: such a row shows none. A row that already carried one keeps it; otherwise this is the employmentType the page itself declares (db/144), normalised to the same five values (db/220; a declared value that maps to none of them is not shown). Either way the value is the source's own, which is why field_sources.employment_type is published whenever the key is present, and ?employment_type= filters on exactly this value. UNCHANGED and frozen since the contract fields (engagement, contract_category, agency, working_schedule) shipped: existing integrations keep byte-identical behaviour. Rough old-to-new reading: Contract/Temporary mostly correspond to engagement=vendor or contract_category=fixed_term, Internship to contract_category=internship, but the new fields come from what the posting STATES, so they are populated independently, not translated from this one. |
engagement | array | no | The employment relationship the posting establishes (db/232, plan
v2.1): employee: an employment contract with some employer of
record running payroll (the advertiser, a temp agency or an
umbrella company); vendor: a commercial or civil-law service
relationship (freelance, B2B, 1099, C2C, Brazilian PJ: the person
invoices); other: a stated hybrid national form that everyday
understanding puts on neither side (Polish umowa zlecenie /
umowa o dzieło, Italian co.co.co., Spanish TRADE, umbrella/portage
arrangements, statutory non-employment internships such as the
French stage, volunteering). The raw national term is retained
internally. An ARRAY because some adverts offer two arrangements
("CLT ou PJ", "UoP lub B2B"): main option first, at most 3 values,
usually exactly 1. OMITTED when the posting does not establish it,
which is most postings; absence is filterable as
?engagement=unknown and never means "employee by default". |
engagement_confidence | number | no | Present ONLY when field_sources.engagement is inferred: the
measured precision of the rule that established the value, 0..1. A
published value carries no confidence: it is the source's words. |
expires_at | string | no | When the vacancy stops being valid, from either source: the timestamp the radar PROVED it dead (upstream_expired_at), or the date the posting itself states (db/144, schema.org validThrough). A row that already carried the first keeps it. Absent while the vacancy is live. Same shape as every other timestamp here: a date from the posting renders as midnight of that date. |
field_sources | FieldSources | yes | 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. |
first_seen_at | string | no | When the vacancy first entered this ledger. Populated on 100% of rows, unlike posted_at which the source often omits, so this is the field to sort or backfill by when you need every row to have a date. |
has_description | boolean | yes | |
id | string · uuid | yes | |
language | string · nullable | no | ISO 639-1 code of the advert text, detected automatically from the description (or the title when there is no description). null when the text is mixed, too short or the detection is not confident. Measured precision >= 99% on a random sample; null does not mean English. |
language_basis | string · nullable | no | What evidence the detection used: "text" (the description), "title" (the advert text was missing or too short), "title+country" (the title is English, the advert text is missing or too short, and the job is in the US, GB, CA, AU, IE, NZ, SG or IN; measured precision 99.4% against description-based labels on 28,797 rows) or "none". null whenever language is null. field_sources.language reports "detected" when a value is published (our detection of the advert text, never the source's declaration). |
language_confidence | number · nullable | no | The detector's own confidence (0.00-1.00) for the published language; null whenever language is null. A label is stored only at >=0.80 with a description and >=0.90 title-only; labels with language_basis "title+country" carry a score between 0.50 and 0.85 (the title score, capped at 0.85), so they are always below the other bases. |
last_verified_at | string · date-time | yes | The last time JOA positively confirmed this vacancy at its source: a request that found the posting live (its own page, or its record in the employer's ATS API), or the posting listed in a fresh read of the employer's own job list; if nothing is newer, the time it was first harvested from that source. For a role the employer pushes to JOA, the partner's last push or heartbeat. Nothing else moves it (not a rebuild of the ledger, not a timer). Same value everywhere: list, detail, export, changes, closed, MCP, and the verified_after filter. |
last_verified_via | stringposting_pageats_apiboard_apisource_listfirst_harvestpartner_feedverifier | yes | Which kind of confirmation last_verified_at is. posting_page = the posting's own page was fetched (or rendered) and judged live; ats_api = the employer's ATS API returned this posting as live; board_api = Ashby's own job board (its system of record) listed this posting as live; source_list = the posting was listed in a fresh read (at most 3 hours old) of the employer's own job list; first_harvest = no confirmation newer than the harvest that first brought it in; partner_feed = a role the employer pushes to JOA; verifier = a verifier confirmation made before 23 Oct 2026 whose method was not recorded.
board_api is a board-level read, not a check of the individual posting URL: JOA asks
Ashby's posting API for the employer's live board and finds the posting listed on it. It is
Ashby's own system of record, so the verdict counts as a page-level confirmation exactly as
it did before this label existed (24 Oct 2026); the label exists so a buyer can tell a
board-level read from a posting-level check. |
location | string | no | |
posted_at | string | no | |
posted_at_precision | stringdate | no | Present only when the source publishes a calendar date and no time: Workday, and (since 23 Oct 2026) any row whose posted_at was taken from the date the posting page itself states (schema.org datePosted or the ATS's own published date, read by the verification sweep) because the harvested record carried none. posted_at then carries that date at a fixed 12:00:00Z, so the day is the same in every timezone from UTC-11 to UTC+11; the time part carries no information. Omitted when posted_at is a full timestamp. |
provider_type | stringemployer_atsgovernmentdirectaggregator | yes | The provider-level class, in the original vocabulary. Kept so a query written against it still resolves. |
region | string | no | State/province code where one could be established: US two-letter codes today, absent on every non-US row and on cities that resolve to more than one state. db/078 resolves it from the source's declared region first, then a state named in the free-text location, then an unambiguous city; db/144 adds the region the page itself declares (schema.org addressRegion) where the listing column has none. field_sources.region reports published whenever the row carries one and does not separate those derivations, so read it as "we established a region", not as a quotation. |
relevance | object | no | Present only on rows served with sort=relevance. score is an
ordering-only integer (comparable within one response, never
across queries); tier is the match class: exact, synonym,
abbreviation or typo. |
remote | stringremotehybridon_site | no | |
remote_inferred | boolean | yes | Always present. True when we derived remote rather than read
it. It used to be omitted when false, which made a stated value and
an absent one identical on the wire. |
salary_currency | string | no | ISO code, as the source states it. Same sources as salary_min. |
salary_max | number | no | The top of the range, same sources as salary_min. Absent when the source states a single figure or none at all. |
salary_min | number | no | The source's own figure. A row that already carried one keeps it; otherwise this is the baseSalary the page declares, or the ATS's salaryRange (db/144). Absent, not null, when there is no figure, which is the case for most employers. |
salary_min_annual_eur | number | no | salary_min converted to an annual EUR figure, so a row quoting USD/hour and one quoting GBP/year are comparable. NULL when the period or the currency is unrecognised, never guessed, because reading an hourly rate as a salary is wrong by a factor of 2080. Converted with the indicative rates in /v1/meta/freshness, not a settlement rate. |
salary_period | string | no | hour, day, week, month or year, as the source states it. Same sources as salary_min. |
salary_source | stringstructuredparsed_description | no | Where the figure came from. structured is a field the source itself published. parsed_description is a real figure quoted in the advert text that WE read out and normalised: we chose the number and the period, so field_sources.salary reports it as inferred, never published.
This marker describes the LISTING row only. A figure that arrived
from the extracted side table (db/144) is the source's own baseSalary
or salaryRange field, and field_sources.salary reports it as
published while salary_source itself is absent. |
seniority | string | no | |
slug | string | yes | |
source | string | yes | The provider id, e.g. greenhouse, company_site, workday. The full published list is /public/providers; eures, arbeitsagentur and france_travail were named here until 2026-08-15, when they became discovery-only and left that list. |
source_requisition_id | string | no | The identifier the SOURCE uses for this vacancy (an ATS job id, a Workday requisition id, a schema.org identifier), so a support ticket or a buyer's own ATS lookup can quote it. Absent on every row the verify sweep has not extracted yet (db/144); joa.listings has no column for it, which is why the payload carried it for 0% of rows before. |
source_type | stringatscareer_sitepublic_agencyaggregatoragencyunknown | yes | Per-row provenance where the source has been classified per row,
otherwise the provider's class. Snapshot of the distribution on
5 Aug 2026: ats 1,063,129, career_site 672,526, public_agency 320,727;
the current split by source class is in /public/coverage (sources).
RESERVED VALUES: aggregator and agency are accepted by the filter
but currently match no rows. Every aggregator source is marked
non-redistributable, so that inventory never enters the ledger at
all, and no provider is classified agency yet. Filtering on either
returns an empty page. That is the data, not a fault. |
status | stringliveclosed | yes | |
title | string | yes | |
upstream_expired_at | string | no | When the radar proved the vacancy dead, as opposed to when we removed it. Still the radar's column and nothing else; expires_at is the key that also carries the posting's own validThrough date. |
verification_stale | boolean | no | Present (true) only when last_verified_at is older than 18 hours. An honest label, never a removal. |
working_schedule | stringfull_timepart_timeflexible | no | The stated schedule. flexible covers adverts offering either
schedule. OMITTED when not established
(?working_schedule=unknown). Distinct from, and independent of,
the frozen employment_type field above. |
working_schedule_confidence | number | no | Present ONLY when field_sources.working_schedule is inferred. |
$ curl -s -H "Authorization: Bearer $JOA_KEY" 'https://api.jobopportunitiesapi.org/v1/jobs?limit=1' | jq '.data[0]'
2field_sources
Per-field provenance, each key marked published, inferred or absent. Present on every job row, always, with every key.
In depthWhy it exists, what it is not, what people get wrong
This block is required and complete: every key is present on every row, so you never have to test for their existence. Read it before the fields it describes.
Two of them can never be published, by construction: category and seniority are read off the job title. One of them is always inferred today for a different reason: source_type's per-row column is null on every live row, so the provider's class is the only path taken.
seniority comes from fixed keyword rules on the title, not from a model guess about the job: words such as intern, trainee, Praktikant, Ausbildung, stagiaire, alternance, becario (Intern); junior, new grad, júnior (Entry); medior, pleno (Mid); senior, Sr., confirmé, sênior, Leitende (Senior); lead, principal, staff engineer (Lead); manager, supervisor, Teamleiter, Filialleiter, responsable, chef de projet, gerente, foreman (Manager); director, head of, directeur, Abteilungsleiter (Director); chief, CEO, president, founder (Executive). A title that names no level (“Software Engineer”, “Registered Nurse”) has no seniority: we leave it empty rather than guess, which is why the field is null on roughly half of all rows. The multilingual rules were checked by hand on a country-stratified sample of 309 titles they classify (98.4% correct) before they were switched on.
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 |
| `field_sources` key | Describes | Can be `published`? |
|---|---|---|
remote | remote, remote_inferred | Yes: a minority of sources state it. |
employment_type | employment_type | Yes. |
engagement | engagement, engagement_confidence | Yes: the posting's own stated relationship. inferred only through audited rules; absent = the posting does not establish it. |
contract_category | contract_category, contract_category_confidence | Yes: only when the posting states the contract kind. |
agency | agency | Yes. |
working_schedule | working_schedule, working_schedule_confidence | Yes. |
category | category, category_confidence | No: always inferred. |
seniority | seniority | No: always inferred. |
salary | salary_min, salary_max, salary_currency, salary_period, salary_source | Yes, when salary_source is structured. |
location | country, city, location | Yes. |
country | country | Yes: when the recorded origin says the source's own countries array carried it. inferred when a rule or a repair of ours set it, llm_inferred when a language model alone read the location text, unknown when nothing records where the country came from. |
posted_at | posted_at | Yes. |
description | description, has_description | Yes. |
source_type | source_type | Reserved: always inferred today. |
3The salary fields
Six fields describing money, one of which is a normalised comparison figure that is null whenever normalising would have required a guess.
In depthWhy it exists, what it is not, what people get wrong
salary_min,salary_max- The figures as stated, in the source's own currency and period.
salary_maxmay equalsalary_minfor a single figure. salary_currency- The currency as stated.
salary_period- The period as stated: annual, monthly, hourly, and so on.
salary_sourcestructured(a field the source published) orparsed_description(a real figure we read out of the advert text). Absent when there is no salary.salary_min_annual_eursalary_minconverted to an annual EUR figure so rows can be compared. Null when the period or currency is unrecognised.
The null in that last row is the important part. Reading an hourly rate as an annual salary is wrong by a factor of about 2080, so an unrecognised period produces no figure rather than a wrong one. min_salary and max_salary filter on this column, which is why they select a narrow slice.
Exact contractTypes, defaults, ranges, errors, edge cases
Conversion uses indicative rates published on /v1/meta/freshness, not a settlement rate. For anything financial, take salary_min, salary_currency and salary_period and convert them yourself with a rate you control.
4The company row
Identity, website, sectors, and two different counts of open roles that mean different things.
In depthWhy it exists, what it is not, what people get wrong
open_roles is roles retrievable from this API, never a number the API cannot honour. own_site_roles is vacancies we watched on the company's own careers page under a source we do not redistribute. A company can have zero of the first and many of the second; those are the “discovered” companies, returned only with include_discovered=true, and they are evidence a company is hiring rather than inventory you can buy.
website is present only where we fetched the domain and found the company's own name on it; website_verified carries when. An absent website means we could not prove one, not that the company has none.
Exact contractTypes, defaults, ranges, errors, edge cases
| Field | Type | Always present | Meaning |
|---|---|---|---|
careers_url | string | no | |
country | string | no | |
first_seen | string | no | |
industry | string | no | |
logo | string | no | |
name | string | no | |
open_roles | integer | no | Roles retrievable from THIS API. Never a number the API cannot honour. |
org_type | string | no | |
own_site_roles | integer | no | Vacancies seen on the company own careers page. |
sectors | array | no | |
slug | string | no | |
source_types | array | no | aggregator and agency are reserved and match no rows today; see the source_type enum. |
website | string | no | |
website_verified | string | no | When we fetched this domain and found the company own name on it. |
source_types is an array and can contain the reserved values aggregator and agency, which match no rows today, the same reservation as on the job row's source_type.
5Nullability, in one table
Which fields are guaranteed, which are omitted when empty, which are explicitly null, and which appear only on request.
In depthWhy it exists, what it is not, what people get wrong
| Behaviour | Fields | How to handle it |
|---|---|---|
| Always present | id, slug, title, company, company_slug, remote_inferred, last_verified_at, last_verified_via, status, source, source_type, provider_type, has_description, description_length, description_truncated, field_sources | Safe to read without a guard. description_truncated is true while JOA's stored copy of the advert is known to be cut short at its source (being re-fetched). last_verified_at is the last time JOA positively confirmed that vacancy at its source: a request that found the posting live (its own page, or its record in the employer's ATS API), or the posting listed in a fresh read of the employer's own job list; if nothing is newer, the time it was first harvested from that source. For a role the employer pushes to JOA it is the partner's last push or heartbeat. Nothing else moves it: not a rebuild of the ledger, not a timer. last_verified_via says which kind of confirmation it was (posting_page, ats_api, board_api, source_list, first_harvest, partner_feed, or verifier for a check made before 23 Oct 2026 whose method was not recorded). board_api is Ashby's own job board (its system of record) listing the posting as live: a read of Ashby's system of record rather than of the individual posting URL, which is why it is labelled separately and why it counts as page-verified, as it always has. |
| Present but nullable | category_confidence, closed_at, closed_reason, salary_min_annual_eur | Check for null, not for absence. |
| Only when stale | verification_stale | true when the row's last positive source confirmation (last_verified_at, as defined above) is older than 18 hours. Absent = confirmed within 18h. An honest label, never a removal: stale rows stay served, labeled. |
| Omitted when empty | company_logo, category, country, city, location, remote, employment_type, engagement, contract_category, agency, working_schedule, the *_confidence keys beside them (present only on inferred values), seniority, all salary_*, posted_at, apply_url, upstream_expired_at, first_seen_at | Check for absence. In most languages a missing key and a null read the same; if yours distinguishes them, treat both as “no value”. |
| Only on request | description | Needs include_description=true, and only appears on rows where has_description is true. Personal email addresses in it are replaced with [personal contact removed]. |
| Only on some responses | completeness, location_ambiguous, location_countries (page level) | completeness only when you passed require_fields; the two location fields only when the request carries ?city=. |
Exact contractTypes, defaults, ranges, errors, edge cases
apply_url is in the “omitted when empty” list for completeness, but in practice it is on effectively every live row: a row without one is exactly the shape the quality gate holds back. Check the current figure on the coverage report rather than trusting this sentence.
For a language with strict types, the safe modelling is: required fields non-optional, everything else optional, and field_sources a required struct of nine required enums. Do not model the enums as closed: a new value is a compatible change and your parser should not fail on one.
This page was rendered 27 October 2026, 11:27 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.