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.
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 class | Providers | Live rows |
|---|---|---|
direct | 3 | 1,733,065 |
employer_ats | 17 | 1,660,680 |
government | 3 | 10,289 |
Measured 11 September 2026, 01: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
| Figure | Rows | What it counts |
|---|---|---|
live_listings | 3,404,034 | Rows a caller can obtain from /v1/jobs: not delisted, not opted out, and not withheld by the quality gate. This is the number you can reproduce by paging the API. |
withheld_listings | 227,783 | Rows 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. |
closed_listings | 5,664,972 | Roles that came off their source, retained with their closure date and reason. |
ledger_rows | 9,296,789 | live_listings + withheld_listings + closed_listings. It reconciles exactly. |
employers | 189,008 | Distinct employers with at least one row you can retrieve. |
countries | 249 | Distinct ISO country codes present on live rows. |
posted_last_7d | 195,027 | Live rows whose posted_at falls in the last seven days. |
Reconciliation, computed from the same response: 3,404,034 + 227,783 + 5,664,972 = 9,296,789 and ledger_rows is 9,296,789 — they match exactly, which is the guarantee.
The report also carries stale: false and age_seconds: 6,393. 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 11 September 2026, 01: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.
Closures carry one of two reasons. 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_domainis 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=salarygives 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 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, and /public/jobs with your real filters for whether the rows look like what you expected. That is an afternoon and no account.
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 "$A/public/jobs?country=DE&category=Engineering&limit=5" | jq '.data[].title'This page was rendered 11 September 2026, 03:39 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.