Job Opportunities API

Check the data. Then trust it.

ledger

The data model

live + withheld + closed = ledger_rows, and it reconciles exactly. Almost every misunderstanding about this product traces back to this one equation.

Last verified 2026-08-22 · Assumes: Nothing. This is the central concept. · Markdown copy

1The three populations

Every row in the ledger is exactly one of live, withheld or closed. Add them up and you get ledger_rows, with nothing left over.

In depthWhy it exists, what it is not, what people get wrong
live
Rows a caller can obtain from /v1/jobs: not closed, not opted out, and not held back by the quality gate. This is the only figure you can reproduce by paging the API, and it is the number the website quotes.
withheld
Rows present in the ledger and deliberately not served. Three reasons, each with its own count — see Withheld rows.
closed
Roles that came off their source. Retained with the date and the reason, queryable with status=closed. See Closed roles.
ledger_rows
All three, summed. The size of the record, not the size of the product.

The reason to publish all four rather than only the first is that the difference between them is the editorial policy. A product that serves 3.7 million rows out of a 7.4 million row ledger has made about half of its decisions in the gap, and hiding the gap would make those decisions unauditable. Publishing it invites the obvious question — what is in the other half — and the answer is on this page.

Exact contractTypes, defaults, ranges, errors, edge cases
FigureRowsWhat it counts
live_listings3,424,320Rows 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_listings224,091Rows 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_listings5,703,931Roles that came off their source, retained with their closure date and reason.
ledger_rows9,352,342live_listings + withheld_listings + closed_listings. It reconciles exactly.
employers189,837Distinct employers with at least one row you can retrieve.
countries249Distinct ISO country codes present on live rows.
posted_last_7d191,940Live rows whose posted_at falls in the last seven days.

Reconciliation, computed from the same response: 3,424,320 + 224,091 + 5,703,931 = 9,352,342 and ledger_rows is 9,352,342 they match exactly, which is the guarantee.

The report also carries stale: false and age_seconds: 5,313. 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, 22: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.

live_listings here and employer_direct_live on /public/stats are the same population by different names. /public/stats also carries a broader live_listings that includes the quality-gated rows, which is why the two endpoints appear to disagree — see /public/stats for the exact mapping. /public/coverage is canonical.

2Withheld rows — held, not served

Rows we keep but do not serve, for three reasons: a broken employer-direct promise, reversible doubt, or a verified employer opt-out.

In depthWhy it exists, what it is not, what people get wrong
quality_removed
The row breaches the employer-direct guarantee, or it came from a discovery-only source. Typically the apply link resolves to an aggregator, an agency or a government portal rather than to the employer. No query parameter re-admits these. That is the point of the guarantee: it is not a default you can turn off.
quality_gated
Reversible doubt. A posted_at in the future, a missing apply URL, a shape that usually indicates a broken scrape. These can be re-admitted deliberately with quality=all on a paid key, and each returned row then carries its verdict, the rule id that matched, and the host that triggered it — so you can judge for yourself rather than trusting the gate.
optout_hidden
A site owner asked to be removed and we verified the request. These are never re-admitted by any parameter, and the removal propagates to every endpoint including the closure feeds. See Employer opt-out.

Why keep them at all? Because deleting them would make the ledger unable to answer why a row is not there, and would make the same row re-enter and re-leave every time the source is re-read. Retention is what makes “we deliberately do not serve this” a checkable statement rather than an absence.

Exact contractTypes, defaults, ranges, errors, edge cases

The counts per reason are on /public/coverage under withheld_reasons, and they sum to withheld_listings. quality=all moves quality_gated rows into the result set and nothing else; it is documented in full in the parameter reference.

ReasonRe-admittable?How
quality_removedNeverThere is no parameter. The guarantee is not optional.
quality_gatedYes, paid keys?quality=all. Rows arrive with quality_verdict, the rule id and the matched host.
optout_hiddenNeverRemoval is at the owner's request and propagates everywhere.

3Closed roles

When a role leaves its source it is marked closed with a date and a reason, and stays queryable forever. Closures are kept, not deleted.

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

Two things make closures worth having. The first is correctness: if you are mirroring this data you need to know when to stop serving a row, and a row that simply vanished from a listing endpoint is indistinguishable from one that fell off the end of your pagination. The second is signal: the date a company stopped recruiting for a role is often more informative than the date they started.

There are three ways to consume closures, in increasing cost. /v1/jobs/expired returns ids, closure dates and reasons only — about ninety per cent smaller than the equivalent page of rows, and enough to mark your own records stale. /v1/changes folds closures into a single ordered stream with creations and updates. /v1/jobs/closed returns the full rows, with every filter /v1/jobs takes.

Exact contractTypes, defaults, ranges, errors, edge cases
FieldTypeMeaning
statuslive \| closedWhich population this row is in right now.
closed_attimestamp \| nullWhen we moved the row out of the served set.
closed_reasonexpired_upstream \| not_seen \| nullWhy. expired_upstream is the source's statement; not_seen is our inference from repeated absence.
upstream_expired_attimestampWhen the vacancy was proved dead at its source, which can be earlier than closed_at.

status defaults to live on every listing endpoint. status=closed returns only closures, status=any returns both and every row carries its own status. The parameter is paid-endpoints-only: on /public/* it is ignored and you get live rows.

FigureValueMeaning
measured_at11 September 2026, 22:52 UTCWhen the snapshot behind this report was taken.
generated_at12 September 2026, 00:20 UTCWhen this response was assembled.
live_listings3,424,320Rows /v1/jobs will serve.
ledger_rows9,352,342Live + withheld + closed.
closures.last_24h52,341Roles that left their source in the last day.
closures.last_7d1,640,058…and in the last week.
closures.total5,703,931Retained: kept indefinitely.

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

The cheap way to keep a mirror honest: ids only, since your last sync
curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs/expired?since=2026-08-21T00:00:00Z&limit=1000'

4Checking the equation yourself

One keyless request returns all four figures. Add the first three and compare with the fourth — if they ever disagree, that is a defect worth reporting.

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

The reconciliation is the guarantee. It says that every row is accounted for in exactly one bucket, that nothing has been quietly dropped, and that the served population is a stated subset rather than whatever survived a pipeline. It is cheap to verify and you should verify it rather than take it on trust — that is the whole idea.

The report is measured on a schedule rather than per request, because counting it per request took twenty-four seconds and returned a 503 instead. So it carries measured_at, age_seconds and a stale flag. stale: true means the snapshot is older than its refresh window; the numbers are then indicative rather than current.

Exact contractTypes, defaults, ranges, errors, edge cases
The equation, live
$ curl -s https://api.jobopportunitiesapi.org/public/coverage \
  | jq '{live_listings, withheld_listings, closed_listings, ledger_rows, measured_at, stale}'
{
  "age_seconds": 5313,
  "closed_listings": 5703931,
  "countries": 249,
  "definitions": {
    "closed_listings": "Roles that came off their source, retained with their closure date and reason.",
    "ledger_rows": "live_listings + withheld_listings + closed_listings. It reconciles exactly.",
    "live_listings": "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.",
    "measured_at": "When these figures were counted. The report is measured on a schedule, not per request, because counting it per request took 24 seconds and returned 503 instead.",
    "withheld_listings": "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."
  },
  "employers": 189837,
  "excluded_sources": [
    "adzuna",
    "apify_all_jobs",
    "apify_fantastic_jobs",
    "apify_job_listings",
    "apify_xing",
    "arbeitnow",
    "arbeitsagentur",
    "careerjet",
    "eures",
… 101 more lines
Real response, fetched from /public/coverage when this page was built (12 September 2026, 00:20 UTC). Run the command yourself and you will get today's rows, not these.
…and the assertion, as a one-liner that exits non-zero if it ever fails
curl -s https://api.jobopportunitiesapi.org/public/coverage | jq -e '
  (.live_listings + .withheld_listings + .closed_listings) == .ledger_rows
' && echo 'ledger reconciles'

5Identity: ids, slugs and domains

A listing's uuid is permanent. Its slug is a readable alias. A company's slug is readable but is not currently guaranteed stable — key on the domain where you can.

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

Every listing has a id (a uuid) and a slug. The uuid is the identity: it never changes, it survives a role being closed and re-opened, and it is what /v1/changes and /v1/jobs/expired refer to. The slug is a human-readable alias built from the title and the first eight characters of the uuid, which is what makes it unique. GET /v1/jobs/{id} accepts either.

Companies are the harder case. A company has a slug and, on about a third of rows, a website domain. The slug is derived from the display name, and display names are corrected over time — an employer whose ATS token was jj becomes johnson-johnson once the name is resolved properly. That correction is a genuine improvement and it moves the slug.

Exact contractTypes, defaults, ranges, errors, edge cases
IdentifierTypeStable?Use it for
job.iduuidPermanentThe primary key of a listing in your own store.
job.slugstringStable in practice; derived from title + uuid prefixReadable URLs. /v1/jobs/{id} accepts it.
company.slugstringNot guaranteed — see aboveDisplay and links. Re-resolve on 404.
company.websitedomainAs stable as the company's domainJoining to a CRM. Filter with company_domain.
next_cursoropaque tokenValid until the ordering changesHanding back verbatim, nothing else.

company_domain takes bare domains — stripe.com, not https://stripe.com/careers — though a value with a scheme and a path is tolerated and reduced. About 37% of companies carry a domain at all; the rest can never match that filter, which is a coverage fact rather than a bug, and it is why the recommendation is “where you have one” rather than “always”.

This page was rendered 12 September 2026, 00:20 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.