start
Overview
What the Job Opportunities API is, what it deliberately is not, and the ten minutes of reading that will save you the most time.
This site documents one product completely: the public ledger at jobopportunitiesapi.org, the REST API behind it, and every control on the website. It is written so that a person can skim it and a program can read it, and there is a plain-Markdown copy of every page at /docs/ai/index.md for anything that is not a browser.
1What the Job Opportunities API is
A ledger of job openings taken from employers' own career pages, their applicant tracking systems, and government feeds, readable free, licensed as an API.
In depthWhy it exists, what it is not, what people get wrong
Most job data products are aggregators: they collect postings from wherever they appear, including from each other, and sell you the union. That produces a large catalogue with an unknowable provenance: you cannot tell whether a row came from the employer, from a staffing agency reposting it, or from another aggregator that had already lost track of where it came from.
This one is built the other way round. A row enters the ledger only from a source we have classified and are permitted to redistribute: the employer's own careers page, the employer's ATS (Greenhouse, Lever, Workday, SmartRecruiters and the rest), or a public employment agency. Every row carries the source it came from, and every field on that row carries whether the source stated it or we derived it. That per-field provenance is the distinctive thing here and it is why the catalogue is smaller than an aggregator's.
The second distinctive thing is that the ledger keeps what it removes. A role that closes is not deleted: it is marked closed, with the date and the reason, and stays queryable. So the data answers questions about hiring over time, not only about hiring right now.
Exact contractTypes, defaults, ranges, errors, edge cases
- Transport
- HTTPS only. HTTP/2 and HTTP/3 at the edge. No websockets, no gRPC, no GraphQL.
- Format
- JSON on every endpoint except
/v1/export, which is NDJSON (one object per line). - Encoding
- UTF-8 throughout. Job titles and company names are not transliterated.
- Character of the API
- Read-only. There is no endpoint on
/v1that writes anything. - Versioning
- The path carries it:
/v1. See Versioning and change policy. - CORS
Access-Control-Allow-Origin: *on the API, so browser code can call it directly.- Auth
- A bearer key on
/v1/*. Nothing at all on most of/public/*, except the four listing routes, which need a key too now; see Keyless access.
The machine contract is /openapi.json (and /openapi.yaml). Both are keyless, both are served by the running API rather than checked in beside it, and every parameter and field table on this site is generated from them at build time. Where this site and the spec disagree, the spec is right and the difference is a bug in this site.
# No key, no account. The headline numbers, live. $ curl -s 'https://api.jobopportunitiesapi.org/public/coverage' | jq '{live_listings, employers, countries}' { "age_seconds": 2142, "as_of": "2026-10-27", "closed_listings": 8653347, "closure_date_note": "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.", "countries": 236, "definitions": { "as_of_times": "/public/facets and /public/providers carry the latest snapshot (every 3 hours, generated_at). /public/coverage, /public/coverage/countries, /public/stats, /public/site-stats and the website carry the snapshot frozen once a day (as_of, measured_at = that snapshot's generated_at). Two surfaces agree exactly when their timestamps are equal; otherwise each states its own.", "closed_listings": "Roles 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.", "countries": "Distinct country codes with at least one served row. Only ISO 3166-1 alpha-2 codes (plus XK, Kosovo) are countries; any other value is published as country unknown and filtered by no country= value.", "employers": "Distinct employers with at least one served row.", "ledger_rows": "live_listings + withheld_listings + closed_listings. It reconciles exactly.", … 178 more lines
2What it is not
Not an aggregator, not a job board, not a salary benchmark, and not the largest catalogue available. Each of those is a deliberate loss.
In depthWhy it exists, what it is not, what people get wrong
Stating the losses plainly is the point. If one of these is what you need, another product will serve you better and finding that out on this page costs you five minutes instead of a procurement cycle.
- Not an aggregator
- Rows from job aggregators are not merely deprioritised, they are excluded at the source level and never enter the served set. See the excluded sources. They are named.
- Not a job board
- There is no application flow, no candidate account, no CV store and no employer dashboard for managing a posting.
apply_urlpoints at the employer's own application page and that is where a candidate goes. - Not a compensation benchmark
- Salary is published only where the source published it, or where a real figure was quoted in the advert text and we normalised it. Modelled or estimated salaries never reach this API by any route. See Salary.
- Not the largest catalogue
- An aggregator that redistributes everything will always have more rows. The trade is that you can tell where each of these came from.
- Not a real-time feed
- The ledger refreshes on a schedule, not on a webhook. See Freshness for what that means in hours.
- Not a person-level dataset
- There are no candidates, no applicants and no personal data in the ledger at all. A job row names a company, not a human.
Exact contractTypes, defaults, ranges, errors, edge cases
The exclusion is implemented as a join, not a filter, which is a stronger guarantee than it sounds. joa.providers.redistributable defaults to false and the projection that builds the served set inner-joins on it. A provider added upstream tomorrow therefore cannot leak into the public API before a human has classified it: the default is exclusion, and there is no query parameter that overrides it.
Similarly, rows whose poster is a staffing agency or a job board are excluded by default from every listing endpoint. They can be re-admitted deliberately with include_poster_type, on a paid key, and they arrive labelled. What can never be re-admitted is a row we have judged to breach the employer-direct guarantee: no parameter value opts back into that. See quality and withheld rows.
3Every endpoint, in one table
Twelve keyed endpoints under /v1 and ten keyless ones under /public, plus four more under /public that now need a key too. This table is generated from the live specification, so it is never behind the API.
In depthWhy it exists, what it is not, what people get wrong
The split used to be the licence, not the capability: /public/* served the same rows as /v1/* with a page-size cap and a modest rate limit, so evaluating cost nothing. That held for STATISTICAL routes and still does: /public/coverage, /public/facets, /public/providers, /public/freshness and the rest read the same live ledger, but a patient anonymous caller could pace itself under the old rate limit and still walk the whole ledger through /public/jobs//public/companies, which was never the offer. Those four routes now need a key, the same one /v1/* uses.
If you are deciding whether to buy, the STATISTICAL routes are still enough to decide without an account: that was always the pitch behind /public/coverage and its siblings. Seeing an actual row now needs the free key from the quickstart, which costs nothing and takes about a minute.
Every one of these is also reachable as an MCP tool for an AI agent that would rather call a tool than parse a REST response: same auth, same metering, same data.
Exact contractTypes, defaults, ranges, errors, edge cases
| Endpoint | Key required | What it returns |
|---|---|---|
GET /public/companies/{slug}/open-roles-history | no | BETA: partial data. One company's open-roles count over 30/90/365 days; real daily snapshots from 2026-10-06. |
GET /public/market-signals/history | no | BETA: partial data. Daily trend of Market Signals segments (salary percentiles, time-to-fill, listing counts) over 30/90/365 days. |
GET /public/site-stats | no | The site's headline numbers, one name and one definition each (daily snapshot). |
GET /v1/changes | yes | Delta feed: created, updated, withdrawn and delisted, in change order. |
GET /v1/companies | yes | Employers whose live roles this API returns. |
GET /v1/companies/{slug} | yes | One employer. |
GET /v1/export | yes | Bulk export of the full corpus, as a stream of NDJSON. |
GET /v1/export-packs | yes | BETA: list this account's export packs. |
GET /v1/export-packs/{id} | yes | BETA: one export pack's status. |
GET /v1/help | yes | This document, with every endpoint and parameter, the query grammar, the limits, the error codes and a worked example per endpoint. |
GET /v1/jobs | yes | List listings, newest first. |
GET /v1/jobs/{id} | yes | One listing, with its description. |
GET /v1/jobs/closed | yes | Roles that have left their source, most recently closed first. |
GET /v1/jobs/expired | yes | Ids of roles that have come off the ledger |
GET /v1/me | yes | Your key, plan and quota. |
GET /v1/me/export-estimate | yes | Approximately how many rows a search would return, and whether the CSV export tool would use your plan's remaining allowance. |
GET /v1/me/usage | yes | Requests served per day, for the dashboard's usage chart. |
GET /v1/meta/facets | yes | Every filter value with its live count. |
GET /v1/meta/freshness | yes | How recently the ledger was verified, and how much of it is inferred. |
GET /v1/meta/providers | yes | Every source the ledger is built from, with its live row count. |
The keyless mirrors are /public/facets, /public/providers, /public/freshness, /public/coverage, /public/coverage/countries, /public/coverage/employers, /public/stats, /public/plans and /public/openapi.json. /public/jobs, /public/jobs/{slug}, /public/companies and /public/companies/{slug} used to be on this list; they now need a key, same as their /v1 twins. All of it is documented in full on the keyless endpoints page.
4What to read, in what order
Developers should read the API group first. Buyers should read the ledger group first. Programs should fetch /docs/ai/BUILD-A-SITE.md and nothing else.
In depthWhy it exists, what it is not, what people get wrong
- If you are integrating today: Quickstart, then Authentication, Pagination, Errors. That is about fifteen minutes and it is the part that, skipped, costs the most later.
- If you are deciding whether to buy: What the ledger is, Coverage and honesty, then Plans and the record meter. The meter is the most commonly misunderstood commercial mechanic here; read it before you compare prices.
- If you are a program reading files: /docs/ai/BUILD-A-SITE.md. It is self-contained and needs no other file.
- If you are an AI agent that calls tools: the MCP server at
api.jobopportunitiesapi.org/mcpwraps the same ledger as six tools, with the same key and the same metering as the REST API. - If you are looking for one specific thing: the search box at the top of the left-hand navigation searches every section title and summary on the site, and every section has a copyable deep link.
Exact contractTypes, defaults, ranges, errors, edge cases
Three habits will save you the most time, and each of them is a section on this site rather than a line here: never parse next_cursor; never hard-code a filter vocabulary; and always read field_sources before you trust a field.
This page was rendered 27 October 2026, 11:28 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.