Job Opportunities API

Check the data. Then trust it.

ledger

What the ledger is

Employer-direct openings, kept as a record rather than a feed. What that phrase actually commits us to, and who it suits.

Last verified 2026-08-22 · Assumes: Nothing. · Markdown copy

1“Employer-direct”: what it means and what it excludes

A row is employer-direct when it came from the employer's own careers page, their applicant tracking system, or a government employment feed, and applies at their URL.

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

The phrase is doing two jobs. The first is about where the row came from: a source we have classified as the employer publishing their own vacancy, or a public agency publishing on employers' behalf. The second is about where the row sends you: apply_url points at the employer's own application page, not at a board's redirect and not at an agency's landing page.

Both halves matter, and the second is the one that is easy to lose. A vacancy can be scraped from an employer's ATS and still carry an apply link that goes through an intermediary; that row does not meet the promise, and it is withheld rather than served. This is why the served count is smaller than the ledger count, and why the difference is published rather than hidden.

What it does not mean is that a human at the employer typed the row into this system. It means we can name the system it came out of and show you the link it resolves to. Everything beyond that (whether the salary was stated, whether the role is really remote) is answered per field, not per row, by `field_sources`.

Exact contractTypes, defaults, ranges, errors, edge cases

Mechanically, redistribution is a join, not a filter. joa.providers.redistributable defaults to false, and the projection that builds the served set inner-joins on it. So a provider added upstream tomorrow cannot appear in this API before a human has classified it: the default is exclusion. That is a stronger property than a WHERE provider NOT IN (…) blacklist, which fails open on anything new.

On top of that, listings whose poster is a staffing agency or a job board are held out of the served set by default even when the source itself is redistributable. They can be re-admitted deliberately on a paid key with include_poster_type=staffing,jobboard,all, and they arrive labelled. See Poster type.

Source classProvidersLive rows
employer_ats281,743,573
direct61,156,908
government414,083

Measured 27 October 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.

2A ledger, not a feed

Rows are not deleted when a role closes. They are marked closed, with a date and a reason, and stay queryable, so the data answers questions about time.

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

A job feed's job is to tell you what is open now. Everything else is noise, so a feed drops rows the moment they stop being current. That makes a feed cheap and makes it impossible to ask when a company started hiring, how long a role stayed open, or which teams a company stopped recruiting for in the last quarter.

A ledger keeps the entries. When a role leaves its source, the row gets a closed_at and a closed_reason and moves out of the default result set: it is still there, and status=closed or status=any will return it. Roughly two rows in five in this ledger are closed rather than live, and that half of the data is the half most people underuse.

The practical consequence for an integrator is that you do not have to build the history yourself. You do not need to snapshot the API nightly and diff it to find out that a role disappeared: /v1/changes tells you, /v1/jobs/expired gives you the cheap id-only version, and /v1/jobs/closed gives you the rows.

Exact contractTypes, defaults, ranges, errors, edge cases
FigureRowsWhat it counts
live_listings2,914,565Rows a caller can obtain from /v1/jobs: exactly the API's default filter (database view joa.v_served_v1). This is the number you can reproduce by paging the API.
withheld_listings767,023Rows present in the ledger and deliberately not served. quality_removed breaches the employer-direct guarantee or comes from a discovery-only source; quality_gated is reversible doubt; optout_hidden is a verified employer opt-out; duplicate_hold is a re-crawl of an advert already served under another id; poster_hold is an employer whose adverts are held by poster type; other_api_hold covers portal holds, partner withdrawals and 'Copy of' re-posts. The reasons add up to withheld_listings exactly.
closed_listings8,653,347Roles that came off their source, retained with a closure date and reason. 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.
ledger_rows12,334,935live_listings + withheld_listings + closed_listings. It reconciles exactly.
employers139,921Distinct employers with at least one row you can retrieve.
countries236Distinct ISO country codes present on live rows.
posted_last_7d235,268Live rows whose posted_at falls in the last seven days.

Reconciliation, computed from the same response: 2,914,565 + 767,023 + 8,653,347 = 12,334,935 and ledger_rows is 12,334,935: they match exactly, which is the guarantee.

The report also carries stale: false and age_seconds: 2,142. stale: true means the snapshot behind these figures is older than its refresh window; treat the numbers as indicative and re-read the endpoint before quoting them.

Measured 27 October 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.

Crawled closures carry one of three reasons. apply_link_dead means the posting's own apply link kept failing at its source for two weeks (404, a closed page, the ATS saying closed, a host that no longer resolves), re-checked several times. expired_upstream means the source itself declared the vacancy over: an expiry date passed, or the ATS marked it closed. not_seen means the vacancy stopped appearing where it had been appearing, for long enough that its absence is a signal rather than a hiccup. The distinction matters if you are measuring hiring velocity: expired_upstream is a statement by the employer, not_seen is an inference by us.

3Who this suits, and who it does not

It suits anyone who needs to say where a row came from. It does not suit anyone who needs the largest possible catalogue.

In depthWhy it exists, what it is not, what people get wrong
Job boards and vertical marketplaces
You get rows that link to the employer, so your users apply somewhere real, and you can state your sourcing policy honestly. The trade is fewer rows in thin categories than an aggregator would sell you.
Sales and go-to-market teams
Hiring is a buying signal, and company_domain is the join key you already have in a CRM. The closure feed matters as much as the live one: a company that stopped hiring for a role is a different signal from one that never started.
Labour-market research
Because closures are retained with dates and reasons, and because per-field provenance is on every row, you can state exactly what your dataset does and does not know. That is usually the hard part of publishing.
Products that need a salary distribution
This is the poorest fit. Salary is published on a small minority of rows and is never modelled. require_fields=salary gives you a clean subset with a real figure on every row. Read the per-country split before you plan around it.
Anyone who needs every posting in a market
Not this. An aggregator that redistributes everything will always have more rows; what it cannot tell you is which of them the employer actually posted.
Exact contractTypes, defaults, ranges, errors, edge cases

The honest way to decide is to measure rather than to read. Every STATISTICAL figure you need is keyless: /public/coverage for the ledger-wide picture, /public/coverage/countries for your market, /public/facets for whether your category exists at the volume you need. Seeing whether the actual rows look like what you expected takes the free key from the quickstart: /public/jobs used to answer that one keylessly and no longer does. That is still an afternoon, and the key costs nothing.

The evaluation: three keyless requests, then a keyed one
A=https://api.jobopportunitiesapi.org
curl -s "$A/public/coverage"                | jq '{live_listings, employers, measured_at, stale}'
curl -s "$A/public/coverage/countries"      | jq '.countries[] | select(.country=="DE")'
curl -s "$A/public/facets"                  | jq '.data.family[:10]'
curl -s -H "Authorization: Bearer $JOA_KEY" "$A/v1/jobs?country=DE&category=Engineering&limit=5" | jq '.data[].title'

This page was rendered 27 October 2026, 11:30 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.