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.
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
| Figure | Rows | What it counts |
|---|---|---|
live_listings | 3,424,320 | 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 | 224,091 | 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,703,931 | Roles that came off their source, retained with their closure date and reason. |
ledger_rows | 9,352,342 | live_listings + withheld_listings + closed_listings. It reconciles exactly. |
employers | 189,837 | Distinct employers with at least one row you can retrieve. |
countries | 249 | Distinct ISO country codes present on live rows. |
posted_last_7d | 191,940 | Live 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_atin the future, a missing apply URL, a shape that usually indicates a broken scrape. These can be re-admitted deliberately withquality=allon 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.
| Reason | Re-admittable? | How |
|---|---|---|
quality_removed | Never | There is no parameter. The guarantee is not optional. |
quality_gated | Yes, paid keys | ?quality=all. Rows arrive with quality_verdict, the rule id and the matched host. |
optout_hidden | Never | Removal 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
| Field | Type | Meaning |
|---|---|---|
status | live \| closed | Which population this row is in right now. |
closed_at | timestamp \| null | When we moved the row out of the served set. |
closed_reason | expired_upstream \| not_seen \| null | Why. expired_upstream is the source's statement; not_seen is our inference from repeated absence. |
upstream_expired_at | timestamp | When 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.
| Figure | Value | Meaning |
|---|---|---|
measured_at | 11 September 2026, 22:52 UTC | When the snapshot behind this report was taken. |
generated_at | 12 September 2026, 00:20 UTC | When this response was assembled. |
live_listings | 3,424,320 | Rows /v1/jobs will serve. |
ledger_rows | 9,352,342 | Live + withheld + closed. |
closures.last_24h | 52,341 | Roles that left their source in the last day. |
closures.last_7d | 1,640,058 | …and in the last week. |
closures.total | 5,703,931 | Retained: 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.
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
$ 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
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
| Identifier | Type | Stable? | Use it for |
|---|---|---|---|
job.id | uuid | Permanent | The primary key of a listing in your own store. |
job.slug | string | Stable in practice; derived from title + uuid prefix | Readable URLs. /v1/jobs/{id} accepts it. |
company.slug | string | Not guaranteed — see above | Display and links. Re-resolve on 404. |
company.website | domain | As stable as the company's domain | Joining to a CRM. Filter with company_domain. |
next_cursor | opaque token | Valid until the ordering changes | Handing 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.