Job Opportunities API

Check the data. Then trust it.

ledger

Sourcing and refusals

The three source classes, the providers inside each, the redistribution flag that gates them, and the sources we fetch for ourselves and never republish.

Last verified 2026-08-22 · Assumes: The data model — live, withheld and closed. · Markdown copy

1The three source classes

Every published row comes from an employer's own site, an employer's applicant tracking system, or a public employment agency. There is no fourth class.

In depthWhy it exists, what it is not, what people get wrong
direct — the employer's own careers page
Read straight from the employer's site. The most first-party source there is: nobody stands between the vacancy and the row. This is the largest class by a wide margin.
employer_ats — the employer's applicant tracking system
Greenhouse, Lever, Workday, SmartRecruiters, Personio, Teamtailor, Ashby, Workable, Recruitee, BambooHR, Oracle HCM and the rest. The employer publishes into the ATS; we read the ATS's own public feed. The apply link goes to the employer's ATS-hosted application, which is where a candidate would apply anyway.
government — public employment services
National employment agencies publishing on employers' behalf, where the licence permits redistribution. Currently a small share of rows.

A row carries the class twice, in two vocabularies, and this trips people up. provider_type holds the original vocabulary (employer_ats, government, direct, aggregator) and is kept so that a query written against it still resolves. source_type holds the newer, finer per-row vocabulary (ats, career_site, public_agency, aggregator, agency, unknown). The source_type filter accepts the legacy spellings too and maps them across.

Exact contractTypes, defaults, ranges, errors, edge cases
Source classProvidersLive rows
direct31,733,131
employer_ats171,662,793
government310,227

Measured 11 September 2026, 10:52 UTC · rendered from /public/coverage, which needs no key. These figures move; the endpoint is always right and this page is only as fresh as its last build.

Legacy value (`provider_type`)Maps to (`source_type`)Still accepted by the filter?
employer_atsatsYes
governmentpublic_agencyYes
directcareer_siteYes
aggregatoraggregatorYes — but it matches no rows; see below

2Every provider, with its live row count

The provider field names the exact system a row came from. This list is the vocabulary the provider filter accepts — read it at run time, not from here.

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

provider is finer than source_type: it names Greenhouse rather than “an ATS”. That matters when you care about the shape of the data rather than its origin — description coverage, for instance, varies from about 60% to nearly 100% depending on which system published the row, and the table below shows it per provider.

Exact contractTypes, defaults, ranges, errors, edge cases
providerLabelLive rowsWith a descriptionsource_classsource_type
company_siteCompany career site1,886,3541,585,677 (84%)directcareer_site
workdayWorkday629,719275,792 (43.7%)employer_atsats
oracleOracle HCM234,539203,066 (86.5%)employer_atsats
greenhouseGreenhouse158,958158,940 (99.9%)employer_atsats
smartrecruitersSmartRecruiters100,05598,333 (98.2%)employer_atsats
paylocityPaylocity85,09479,647 (93.5%)employer_atsats
teamtailorTeamtailor69,60769,546 (99.9%)employer_atsats
ashbyAshby68,08468,084 (100%)employer_atsats
workableWorkable63,79263,790 (99.9%)employer_atsats
leverLever49,90449,180 (98.5%)employer_atsats
personioPersonio48,38744,039 (91%)employer_atsats
recruiteeRecruitee35,82435,804 (99.9%)employer_atsats
breezyBreezy HR35,02334,342 (98%)employer_atsats
bamboohrBambooHR25,92125,327 (97.7%)employer_atsats
joinjoin.com16,37216,320 (99.6%)employer_atsats
ripplingRippling15,90915,573 (97.8%)employer_atsats
pinpointPinpoint14,63914,639 (100%)employer_atsats
ukgUKG Pro11,05811,058 (100%)employer_atsats
jobtech_swedenJobTech Sweden10,32910,329 (100%)governmentpublic_agency
af_employer_directEmployer career site (Sweden)902900 (99.7%)directcareer_site
nav_norwayNAV Norge1450 (0%)governmentpublic_agency
extension_submissionDirect submission65 (83.3%)directcareer_site

22 providers. This list is the vocabulary the provider and exclude_provider filters accept: a name that is not here is a 422, never a silently empty page. Rendered from /public/providers. Read it at request time rather than pinning this list — on 2026-08-15 three names left it and a documented example became a 422 the next morning.

provider and exclude_provider take up to twelve comma-separated values each, and an unrecognised one is a 422 with the name echoed back — never a silently empty page. That is deliberate: an empty page from a typo is indistinguishable from an empty page from a narrow filter, and only one of them is your bug.

Enumerate the vocabulary before you filter on it
$ curl -s https://api.jobopportunitiesapi.org/public/providers \
  | jq -r '.data[] | "\(.provider)\t\(.live_listings)\t\(.source_type)"'
{
  "data": [
    {
      "provider": "company_site",
      "label": "Company career site",
      "live_listings": 1886354,
      "with_description": 1585677,
      "with_description_pct": 84,
      "source_class": "direct",
      "source_type": "career_site"
    },
    {
      "provider": "workday",
      "label": "Workday",
      "live_listings": 629719,
      "with_description": 275792,
      "with_description_pct": 43.7,
      "source_class": "employer_ats",
      "source_type": "ats"
    },
    {
      "provider": "oracle",
      "label": "Oracle HCM",
      "live_listings": 234539,
      "with_description": 203066,
      "with_description_pct": 86.5,
… 178 more lines
Real response, fetched from /public/providers when this page was built (11 September 2026, 13:40 UTC). Run the command yourself and you will get today's rows, not these.

3The redistribution flag — a join, not a filter

A source is published only if it is explicitly marked redistributable. The default is false, so anything new is excluded until a human classifies it.

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

This is the single most important implementation detail in the sourcing story, and it is worth understanding the difference it makes. A blacklist — “publish everything except these names” — fails open: a source added upstream on Tuesday is published on Tuesday, and nobody finds out until someone notices. An allowlist enforced as an inner join fails closed: the same source produces zero rows in the served set until it is classified.

That is why the excluded list can be printed rather than promised. It is not a statement about intent; it is a description of a column whose default is exclusion.

Exact contractTypes, defaults, ranges, errors, edge cases

joa.providers.redistributable is a boolean defaulting to false. The projection that builds the served set inner-joins listings to providers on that flag. A provider row also carries source_class, source_type, a licence URL, when the licence was last checked and by whom, any required attribution text, and a structured restrictions object — so the classification is a record, not a memory.

One more guarantee sits alongside it, in the same query: every salary column is wrapped in a condition that refuses to emit an AI-estimated figure. Estimated salaries exist elsewhere in the group and cannot reach this API by any route. See Salary provenance.

4The sources we fetch and never republish

Named, not merely counted. These are fetched for our own products and are excluded from this API because we are not licensed to redistribute them.

In depthWhy it exists, what it is not, what people get wrong
  • adzuna
  • apify_all_jobs
  • apify_fantastic_jobs
  • apify_job_listings
  • apify_xing
  • arbeitnow
  • arbeitsagentur
  • careerjet
  • eures
  • france_travail
  • himalayas
  • jobicy
  • jooble
  • landing_jobs
  • remoteok
  • remotive
  • the_muse
  • weworkremotely
  • working_nomads

19 sources excluded, listed by name in /public/coverage under excluded_sources. Measured 11 September 2026, 10:52 UTC.

There are two reasons on that list and they are not the same reason.

Aggregators — the large majority
Job aggregators and marketplaces whose terms do not permit us to redistribute their inventory. We read them for our own products; nothing from them enters this ledger's served set. Republishing them would also defeat the point of the product: an aggregator's row cannot tell you where it originally came from.
Three public agencies, discovery-only since 2026-08-15
eures, arbeitsagentur and france_travail. These were published until that date and are now used for discovery only — they tell us an employer is hiring, and we then look for the vacancy at the employer. The apply link on those rows resolves to the agency's portal rather than to the employer, which does not meet the employer-direct promise this API is sold on.
Exact contractTypes, defaults, ranges, errors, edge cases

The list on this page is rendered from excluded_sources on /public/coverage, which reads it from the provider table at request time — so it is the actual contents of the column, not a copy of it. The per-source licence detail (the URL, when it was last checked, any attribution required) is recorded internally and is not currently exposed on a public endpoint; if you need it for a procurement review, ask at /contact and it will be sent to you.

A row from an excluded source is not merely filtered out of responses — it never enters the served set, so it is counted as quality_removed in `withheld_listings` rather than as a live row you are not allowed to see.

5Staffing agencies and job boards

Real vacancies whose poster is not the employer. Excluded by default, re-admittable deliberately on a paid key, and always labelled.

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

This is a different category from an excluded source. The posting is genuine and the apply link is the poster's own — it is not a stale copy of somebody else's row. What it is not is the employer. A staffing agency advertising a role it is recruiting for is doing something legitimate, and a row saying so is useful; it simply is not what “employer-direct” means.

The practical reason for the default is volume. One agency with eighteen thousand listings can flood a category until search stops being useful, and the flooding is invisible unless you already know to look for it. Excluding by default and re-admitting on request puts the choice in the caller's hands rather than in the shape of the data.

Exact contractTypes, defaults, ranges, errors, edge cases
include_poster_typestringcomma-separated
staffingjobboardall

Re-admits vacancies whose poster is a staffing agency or a job board. Excluded by DEFAULT: the posting is real and the apply link is the poster's own, but the poster is not the employer, and one agency with 18,000 listings can flood a category until search stops being useful. Comma-separated. Paid endpoints only. An unrecognised value is a 422.

qualitystring
all

all re-admits rows we have GATED -- reversible doubt, such as a future posted_at or a missing apply URL. It never re-admits rows we have REMOVED: those breach the employer-direct guarantee and no query parameter may opt back into it. Each returned row carries its verdict, its rule id and the matched host. Paid endpoints only.

2 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.

include_poster_type is a paid-endpoints-only parameter: on /public/* it is ignored. An unrecognised value is a 422. It is comma-separated, so include_poster_type=staffing,jobboard and include_poster_type=all are both valid.

The same query with and without agency postings, so you can see the difference
A=https://api.jobopportunitiesapi.org
H="Authorization: Bearer $JOA_KEY"
curl -s -H "$H" "$A/v1/jobs?country=GB&category=Engineering&limit=1" | jq '.data|length'
curl -s -H "$H" "$A/v1/jobs?country=GB&category=Engineering&include_poster_type=all&limit=1" | jq '.data|length'

This page was rendered 11 September 2026, 13:40 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.