Job Opportunities API

Check the data. Then trust it.

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.

Last verified 2026-08-22 · Assumes: Provenance, the published / inferred / absent trichotomy. · Markdown copy

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_inferred is always present, including when it is false, 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_url and every other field are untouched; description_length counts 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_only when 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_only and 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
FieldTypeAlways presentMeaning
agencystringdirectagencyumbrellanoWho 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_urlstringno
attributionstringnoPresent only on roles an employer pushed to us directly: show the listing with these words. Absent on every other row.
canonical_urlstringnoPresent 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.
categorystringno
category_confidencenumber · nullablenoClassifier confidence in category. Below 0.6 no category is published at all, so this is always null or >= 0.6.
citystringno
closed_atstring · nullablenoJOA 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_reasonstring · nullableexpired_upstreamnot_seenapply_link_deademployer_closeddeadline_passedno
companystringyes
company_logostringno
company_more_rolesintegernoThe 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_slugstringyes
compensation_typestringcommission_onlynocommission_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_categoryarraynoThe 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_confidencenumbernoPresent ONLY when field_sources.contract_category is inferred: the measured precision of the rule, 0..1.
countrystringno
country_confidencestringhighlownoPresent 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.
descriptionstringnoThe 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_lengthnumbernoSize 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_truncatedbooleannoTrue 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_websitestringnoThe 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_typestringnoOne 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.
engagementarraynoThe 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_confidencenumbernoPresent 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_atstringnoWhen 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_sourcesFieldSourcesyesPer-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_atstringnoWhen 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_descriptionbooleanyes
idstring · uuidyes
languagestring · nullablenoISO 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_basisstring · nullablenoWhat 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_confidencenumber · nullablenoThe 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_atstring · date-timeyesThe 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_viastringposting_pageats_apiboard_apisource_listfirst_harvestpartner_feedverifieryesWhich 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.
locationstringno
posted_atstringno
posted_at_precisionstringdatenoPresent 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_typestringemployer_atsgovernmentdirectaggregatoryesThe provider-level class, in the original vocabulary. Kept so a query written against it still resolves.
regionstringnoState/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.
relevanceobjectnoPresent 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.
remotestringremotehybridon_siteno
remote_inferredbooleanyesAlways 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_currencystringnoISO code, as the source states it. Same sources as salary_min.
salary_maxnumbernoThe top of the range, same sources as salary_min. Absent when the source states a single figure or none at all.
salary_minnumbernoThe 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_eurnumbernosalary_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_periodstringnohour, day, week, month or year, as the source states it. Same sources as salary_min.
salary_sourcestringstructuredparsed_descriptionnoWhere 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.
senioritystringno
slugstringyes
sourcestringyesThe 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_idstringnoThe 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_typestringatscareer_sitepublic_agencyaggregatoragencyunknownyesPer-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.
statusstringliveclosedyes
titlestringyes
upstream_expired_atstringnoWhen 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_stalebooleannoPresent (true) only when last_verified_at is older than 18 hours. An honest label, never a removal.
working_schedulestringfull_timepart_timeflexiblenoThe 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_confidencenumbernoPresent ONLY when field_sources.working_schedule is inferred.
One complete row
$ curl -s -H "Authorization: Bearer $JOA_KEY" 'https://api.jobopportunitiesapi.org/v1/jobs?limit=1' | jq '.data[0]'
The response below is an illustration: /public/jobs?limit=1 could not be read when this page was built, so run the command to see today's.

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.

FieldTypeAlways presentMeaning
agencystringpublishedinferredabsentyes
categorystringpublishedinferredabsentyes
compensation_typestringinferredabsentnoAlways 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_categorystringpublishedinferredabsentyes
countrystringpublishedinferredllm_inferredunknownabsentyesHow 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.
descriptionstringpublishedinferredabsentyes
description_lengthstringpublishedinferredabsentyesComputed from the same octet_length as description, so the two cannot disagree: published when the row carries text, absent when it does not.
employer_websitestringpublishedinferredabsentyesProvenance 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_typestringpublishedinferredabsentyes
engagementstringpublishedinferredabsentyesProvenance 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_atstringpublishedinferredabsentyesProvenance of expires_at: published when the row carries one, from either source (see the Job schema), and absent while the vacancy is live.
languagestringno"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.
locationstringpublishedinferredabsentyes
posted_atstringpublishedinferredabsentyespublished 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.
regionstringpublishedinferredabsentyespublished 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.
remotestringpublishedinferredabsentyes
salarystringpublishedinferredabsentyes
salary_currencystringpublishedinferredabsentyesProvenance of salary_currency; the same verdict as salary.
salary_maxstringpublishedinferredabsentyesProvenance 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_periodstringpublishedinferredabsentyesProvenance of salary_period; the same verdict as salary.
senioritystringpublishedinferredabsentyes
source_requisition_idstringpublishedinferredabsentyesProvenance 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_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.
working_schedulestringpublishedinferredabsentyes
`field_sources` keyDescribesCan be `published`?
remoteremote, remote_inferredYes: a minority of sources state it.
employment_typeemployment_typeYes.
engagementengagement, engagement_confidenceYes: the posting's own stated relationship. inferred only through audited rules; absent = the posting does not establish it.
contract_categorycontract_category, contract_category_confidenceYes: only when the posting states the contract kind.
agencyagencyYes.
working_scheduleworking_schedule, working_schedule_confidenceYes.
categorycategory, category_confidenceNo: always inferred.
seniorityseniorityNo: always inferred.
salarysalary_min, salary_max, salary_currency, salary_period, salary_sourceYes, when salary_source is structured.
locationcountry, city, locationYes.
countrycountryYes: 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_atposted_atYes.
descriptiondescription, has_descriptionYes.
source_typesource_typeReserved: 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_max may equal salary_min for a single figure.
salary_currency
The currency as stated.
salary_period
The period as stated: annual, monthly, hourly, and so on.
salary_source
structured (a field the source published) or parsed_description (a real figure we read out of the advert text). Absent when there is no salary.
salary_min_annual_eur
salary_min converted 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
FieldTypeAlways presentMeaning
careers_urlstringno
countrystringno
first_seenstringno
industrystringno
logostringno
namestringno
open_rolesintegernoRoles retrievable from THIS API. Never a number the API cannot honour.
org_typestringno
own_site_rolesintegernoVacancies seen on the company own careers page.
sectorsarrayno
slugstringno
source_typesarraynoaggregator and agency are reserved and match no rows today; see the source_type enum.
websitestringno
website_verifiedstringnoWhen 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
BehaviourFieldsHow to handle it
Always presentid, 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_sourcesSafe 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 nullablecategory_confidence, closed_at, closed_reason, salary_min_annual_eurCheck for null, not for absence.
Only when staleverification_staletrue 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 emptycompany_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_atCheck 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 requestdescriptionNeeds 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 responsescompleteness, 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.