Job Opportunities API

Check the data. Then trust it.

ledger

Coverage and honesty

How to read /public/coverage, why the weak numbers are published as prominently as the strong ones, and what measured_at and stale actually mean.

Last verified 2026-08-22 · Assumes: The data model. · Markdown copy

1The coverage report

One keyless endpoint returns the whole picture: the three populations, per-field completeness, the source breakdown, and the sources we refuse.

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

/public/coverage is the canonical report and everything else on this site defers to it. It is deliberately keyless and deliberately complete: the same response carries the figures that flatter the product and the figures that do not, so there is no version of it that could be quoted selectively without the reader noticing.

It is measured on a schedule rather than counted per request. Counting it per request took twenty-four seconds and returned a 503 instead: a report that times out is not a more honest report. So every response carries measured_at, age_seconds and a stale boolean, and you should read those before you read anything else in it.

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.

The whole report
$ curl -s https://api.jobopportunitiesapi.org/public/coverage | jq 'del(.fields, .definitions, .excluded_sources)'
{
  "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.",
    "live_listings": "Rows 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.",
    "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.",
    "population": "Every count here is over the rows GET /v1/jobs serves by default (database view joa.v_served_v1), measured in ONE pass of the database at generated_at (db/393). A facet chip's count is what the matching filter returned at that moment.",
    "providers": "Providers with at least one served row. A redistributable provider with none is listed under providers_not_serving (/public/coverage) or not_serving (/public/providers), is still accepted by ?provider=, and is not counted.",
    "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; 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."
  },
  "employers": 139921,
  "excluded_sources": [
    "adzuna",
    "apify_all_jobs",
    "apify_fantastic_jobs",
    "apify_job_listings",
    "apify_xing",
    "arbeitnow",
    "arbeitsagentur",
    "careerjet",
    "eures",
    "france_travail",
… 160 more lines
Real response, fetched from /public/coverage when this page was built (27 October 2026, 11:28 UTC). Run the command yourself and you will get today's rows, not these.

2Per-field completeness

How many live rows carry each field, as a count and a share, with a sentence saying what the gap is rather than leaving you to guess.

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

The point of publishing this is that a percentage on its own is not information. “City: 71%” could mean the pipeline is broken or it could mean that a lot of sources state only a free-text location: those are different problems with different consequences for you. So every row carries a note saying which it is.

Read the weak rows first. salary_eur and seniority are the two fields people most often plan around and are the two with the thinnest coverage; if your product needs either of them on most rows, that is worth knowing on day one rather than in week three.

Exact contractTypes, defaults, ranges, errors, edge cases
FieldLive rows carrying itShareWhat the gap is
apply_url2,914,565100%Every row links to the employer's own application page.
country2,732,57393.8%ISO country. Stated by the source, or inferred from the place; field_sources.country tells you which. Absent where the location names no unique country.
category2,731,00393.7%Inferred by us and published only above 0.6 confidence.
remote2,911,27699.9%Stated by the employer, or inferred: field_sources tells you which.
city2,349,66380.6%Absent where the source gives a region or a remote-only role.
description2,781,00395.4%Full advert text. The largest gap in the ledger, and the honest number.
seniority1,328,25145.6%Inferred from the title. Most titles do not state one.
salary_eur514,24417.6%Structured salary only. We never publish an estimate as a fact.
engagement_stated00%The posting states the relationship (employee / vendor / other). Most do not.
engagement_established00%Stated by the source, plus rows our audited rules established. Never guessed from silence.
contract_category_stated00%The posting states the contract kind (permanent, fixed-term, ...). Most do not, especially US postings.
contract_category_established00%Stated by the source, plus rows our audited rules established. Never guessed from silence.
agency_stated00%The posting or feed states the direct / agency / umbrella arrangement.
agency_established00%Stated, plus rows classified from the source's own agency status.
working_schedule_stated00%The posting states full-time / part-time / flexible.
working_schedule_established00%Stated by the source, plus rows our audited rules established.

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.

These counts are ledger-wide over live rows. They are not conditioned on your filters, and the intersection with your filters is at most the smallest of them. For the per-market view (which is usually the one that decides anything) use the per-country table. To retrieve only rows that carry a field as published, use `require_fields`.

3Per-country coverage

How much data sits behind each country code: live rows, how many carry a description, how many carry a published salary, and how many employers.

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

This is the answer to “is there enough of this in my market”, and it is the table to read before planning around any ledger-wide percentage. The variation between markets is enormous: description coverage runs from the high sixties to the high nineties, and published salary from a fraction of a per cent to a double-digit share, so a single global figure will mislead you in one direction or the other.

live
Rows you can retrieve for that country right now.
described / described_pct
How many of them carry an advert body, as a count and a share.
with_salary
How many carry a salary the employer published, not one we read out of the text.
employers
Distinct companies behind those rows. A high live with a low employers means a few large hirers dominate.
Exact contractTypes, defaults, ranges, errors, edge cases
Every country in the ledger: 165 rows, sorted by live listings
ISOLiveWith a descriptionDescribedWith a published salaryEmployers
US1,391,8841,332,21595.7%488,13877,320
DE180,303164,48291.2%25,55924,415
GB97,40695,20397.7%28,72213,279
IN88,05777,37487.9%5773,622
CA67,55165,98597.7%26,4205,386
FR59,22857,74997.5%11,7693,810
NL40,96739,09695.4%18,8866,320
SE22,43822,25899.2%5994,826
AU21,76021,42298.4%2,0472,172
MX19,32717,78892%4191,653
ES18,58317,67995.1%2,1182,451
SG15,48315,19298.1%2691,669
CN15,27713,07785.6%1021,216
IT14,27713,98898%3,5731,614
BE12,45311,22490.1%8572,067
MY12,24611,68895.4%89967
PL12,15311,67296%9811,652
BR12,12011,32893.5%2611,358
JP11,72211,30296.4%1,9101,389
PH11,41811,05896.8%4681,232
CH11,34310,87495.9%6082,015
AT9,2618,82395.3%5,2971,867
AE8,7098,24994.7%187980
IE7,6987,45696.9%1,1521,274
PT5,8475,10387.3%392871
TH5,6875,43195.5%90701
NO5,5735,32995.6%2751,113
TW5,3595,18296.7%45568
ZA5,0234,87697.1%190829
ID4,8604,71297%49601
HU4,7794,15887%79521
VN4,6544,25991.5%68558
RO4,6454,28892.3%195758
HK4,1824,03896.6%39708
DK3,8573,75097.2%160834
SA3,8063,52692.6%65596
GR3,7963,64295.9%241592
FI3,7323,29588.3%455744
CO3,6823,55896.6%208748
KR3,1293,03296.9%30654
NZ3,1163,04897.8%287613
CZ3,0542,87394.1%171611
AR2,5702,48396.6%132638
IL2,5512,49497.8%30494
EG2,4942,36394.7%40415
PE2,0281,97397.3%140289
CL1,9871,90695.9%102324
UA1,9791,85593.7%53326
TR1,9521,78791.5%28458
BG1,8941,76193%115380
LT1,7811,72797%911249
LU1,4111,37397.3%99355
MA1,4101,34495.3%15237
SK1,3391,28996.3%493273
CR1,1481,08794.7%21275
QA1,0481,03799%9143
PK1,04499595.3%71246
RS98894395.4%42297
CY82580097%78199
NG72070497.8%5177
PR70765592.6%50133
GE68667598.4%30196
HR67465897.6%43201
MT65463196.5%64191
JO64760994.1%70103
LV63661997.3%286156
LK60159599%587
PA58257097.9%23177
EC54752796.3%1997
DO53753098.7%11113
GT53548991.4%15108
KE52348893.3%11185
EE51850797.9%161167
TN45242894.7%21121
SI42637888.7%69113
KZ36736398.9%11114
RU36525168.8%1375
MO32725778.6%433
MV31130899%014
UY31030598.4%588
GH29427894.6%16114
KW28127196.4%591
TZ27126798.5%253
MU25424998%547
OM24720884.2%862
SV24724097.2%4068
BH23321893.6%256
AM23021593.5%680
HN20319495.6%1864
BD20119094.5%887
SR20119798%1221
VE20120099.5%1857
LI19218998.4%7333
CI17717196.6%160
KY17617498.9%2249
LB16816698.8%364
CD146146100%1045
NI14214098.6%349
ME14113897.9%013
PY14114099.3%324
DZ13212897%343
MD13212594.7%1447
MK13212897%1070
BS12911790.7%240
JM12812496.9%856
PG128128100%032
UG12412399.2%148
NP12311593.5%141
UZ12311391.9%643
AZ11911697.5%454
BA118118100%353
ZM11311299.1%043
ET11210896.4%346
NE112112100%5629
KH10910798.2%244
JE108108100%937
AL10710598.1%762
AW107107100%09
IQ104104100%129
XK10210098%2026
BM1009999%329
BB979597.9%420
MZ898696.6%036
MM8888100%128
MG848398.8%124
GI7878100%4419
GG7777100%130
CM767396.1%133
SN757194.7%136
FJ727198.6%116
RW726894.4%134
IS7171100%530
MC6464100%324
NA6464100%027
PF6363100%311
BO6161100%130
GU595694.9%628
GY575393%011
LA5757100%429
SC5757100%012
AO5656100%024
SS554683.6%013
TT555294.5%022
IM535094.3%723
SO4949100%121
SL4646100%2315
BY4242100%119
BJ414097.6%116
IR4141100%013
SY3838100%526
SZ3737100%17
MW3636100%120
GF3434100%214
BW3333100%017
SD332781.8%219
BZ302583.3%010
MQ3030100%317
GP2929100%314
TC2929100%03
BN2828100%013
TD262492.3%016
HT2525100%011
IO2525100%93
LY2525100%08
RE2525100%416

165 countries with 25 or more live rows (236 with at least one: the countries figure of /public/coverage), rendered from /public/coverage/countries, which needs no key. Only ISO 3166-1 codes (plus XK) count as countries. It is not a list of the world’s countries and it moves as employers post and roles close. Measured 27 October 2026, 10:52 UTC.

Your market, in one keyless request
curl -s https://api.jobopportunitiesapi.org/public/coverage/countries \
  | jq '.countries[] | select(.country=="DE")'

4Freshness: measured_at, stale, and the refresh cycle

The ledger is refreshed on a schedule, not on a webhook. Every report says when it was measured and whether that is older than it should be.

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

There are two different clocks and confusing them is easy. last_refreshed is when the ledger itself was last rebuilt from its sources. measured_at is when the coverage snapshot was counted. The second is always at or after the first, and it is the one the figures on a report belong to.

/public/coverage and /public/site-stats serve a daily snapshot, measured and frozen once a day after 04:40 UTC so that every page and endpoint shows the same figures for the same day; refresh and stale_after_hours in the response say so. stale: true means measured_at is older than stale_after_hours (30 for the daily snapshot): a daily snapshot was missed, usually because a refresh failed. If the daily snapshot is unavailable, /public/coverage falls back to its 3-hourly measurement and the threshold is 6 hours. stale does not mean the data is wrong; it means the counts are indicative and you should re-read the endpoint before quoting them anywhere that matters.

Per-row freshness is a separate thing again. Every listing carries last_verified_at: the last time JOA positively confirmed that vacancy at its source: a request that found the posting live (its own page, or its record in the employer's ATS API), or the posting listed in a fresh read of the employer's own job list; if nothing is newer, the time it was first harvested from that source. For a role the employer pushes to JOA it is the partner's last push or heartbeat. Nothing else moves it: not a rebuild of the ledger, not a timer. last_verified_via says which kind of confirmation it was (posting_page, ats_api, board_api, source_list, first_harvest, partner_feed, or verifier for a check made before 23 Oct 2026 whose method was not recorded). An Ashby confirmation reads Ashby's own job board, its system of record, and finds the posting listed there: that is a read of Ashby's system of record rather than of the individual posting URL, and it counts as page-verified for exactly that reason. It is labelled board_api (24 Oct 2026) so a buyer can tell a board-level read from a posting-level check; the counting is unchanged. verified_after filters on it, which is the parameter you want if you are asking “what has been re-confirmed since yesterday” rather than “what is new”.

Exact contractTypes, defaults, ranges, errors, edge cases
FigureValueMeaning
measured_at27 October 2026, 10:52 UTCWhen the snapshot behind this report was taken.
generated_at27 October 2026, 11:13 UTCWhen this response was assembled.
live_listings2,914,565Rows /v1/jobs will serve.
ledger_rows12,334,935Live + withheld + closed.
closures.last_24h55,708Roles that left their source in the last day.
closures.last_7d440,884…and in the last week.
closures.total8,653,347Retained: kept indefinitely.

Measured 27 October 2026, 10: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.

FieldWhereWhat it times
measured_at/public/coverage, /public/freshnessWhen the counts were taken.
age_seconds/public/coverageSeconds since measured_at.
stale/public/coverage, /public/site-statsmeasured_at is older than stale_after_hours (30 h for the daily snapshot).
last_refreshed/public/coverage, /public/statsWhen the ledger was last rebuilt from sources.
generated_at/public/freshnessWhen that response was assembled.
last_verified_atevery job rowThe last positive confirmation at the source: the posting's own page or ATS API record found live, or the posting listed in a fresh read of the employer's job list; otherwise its first harvest. last_verified_via names which.
first_seen_atevery job rowWhen the vacancy first entered this ledger. Populated on 100% of rows.
posted_atjob rows, where statedThe source's own posting date. Often absent. Sort or backfill on first_seen_at instead.

5/public/stats and why it looks like it disagrees

It does not disagree: it publishes a broader live_listings that includes gated rows, and names the narrower one employer_direct_live. Coverage is canonical.

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

This is the single most confusing thing on the keyless surface, so it is worth stating precisely. /public/stats carries two counts where /public/coverage carries one:

stats.employer_direct_live
The rows you can actually retrieve. Identical to coverage.live_listings. This is the number the website quotes and the one to use.
stats.live_listings
A broader population: not delisted and not opted out, but including rows held back by the quality gate. Larger than what you can fetch.
stats.not_employer_direct_live
The difference between the two: the quality-gated rows. live_listings − not_employer_direct_live = employer_direct_live.
stats.companies
Employers with at least one row in the broader population, so it is larger than coverage.employers, which counts employers with at least one retrievable row.
Exact contractTypes, defaults, ranges, errors, edge cases
The two endpoints side by side
$ curl -s https://api.jobopportunitiesapi.org/public/stats | jq 'del(.definitions)'
{
  "as_of": "2026-10-27",
  "companies": 185348,
  "definitions": {
    "as_of": "The UTC day of the daily snapshot these values come from. Every page and statistics endpoint shows the same values for the same as_of (see /public/site-stats).",
    "authority": "For a reconciling report with per-field completeness, use /public/coverage: live + withheld + closed = ledger_rows, exactly.",
    "companies": "Employers with at least one live listing. Like live_listings, this INCLUDES employers whose every live listing is withheld by the quality gate, so it is larger than the number of employers you can reach. Use employer_direct_companies for that.",
    "employer_direct_companies": "Employers a caller can actually obtain from /v1/jobs: at least one live listing that is not opted out and not withheld. This is the number you can reproduce by paging the API, and the one /public/coverage publishes as employers.",
    "employer_direct_live": "Rows a caller can actually obtain from /v1/jobs: live, not opted out, and not withheld. This is the number you can reproduce by paging the API, and the one the website quotes.",
    "live_listings": "Rows in the ledger that are not delisted and not opted out. This INCLUDES rows withheld by the quality gate, so it is larger than what you can retrieve. Use employer_direct_live for that.",
    "measured_at": "When the snapshot's counts were measured from the database.",
    "not_employer_direct_live": "Rows held back by the quality gate: aggregator or agency destinations, and discovery-only sources. live_listings minus this equals employer_direct_live.",
    "posted_last_7d": "Listings whose posted_at falls in the last seven days, counted over live_listings (withheld rows included). /public/coverage posted_last_7d counts the served rows only."
  },
… 9 more lines
Compare `employer_direct_live` here with `live_listings` on `/public/coverage`. They are the same population. The response also carries a `definitions` object spelling all of this out, which is worth reading once. Real response, fetched from /public/stats when this page was built (27 October 2026, 11:28 UTC). Run the command yourself and you will get today's rows, not these.

There is a third figure in the same neighbourhood: posted_last_7d appears on both endpoints and is computed over the respective population, so it too differs. Use coverage's.

6What each public count counts, and as of when

Every public count is taken over the rows GET /v1/jobs serves, in one pass of the database with one timestamp. Two surfaces show the same number when they show the same timestamp; otherwise each states its own.

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

Once every three hours one database pass counts the served rows (the view joa.v_served_v1, exactly the default filter of /v1/jobs) and writes, in a single transaction stamped with one time, the facet chips, the provider table, the country table and the coverage report. So a facet chip's count is what that filter returned at that moment, and the per-provider counts add up to live_listings exactly. Once a day one of those passes is frozen, and that frozen copy is what the website and the headline endpoints show all day.

The daily measured_at is the generated_at of the pass that was frozen, so when the two timestamps are equal every number agrees exactly. The per-country table lists countries with 25 or more served rows; countries_total adds the ones below that.
CountDefinitionWhereTimestamp
live_listingsRows /v1/jobs serves by default./public/coverage, /public/site-stats, the websitedaily: as_of, measured_at
countriesDistinct ISO 3166-1 alpha-2 codes (plus XK, Kosovo) with at least one served row. Any other value is not a country: it is published as country unknown./public/coverage, /public/site-stats (countries_with_live_roles), countries_total on /public/coverage/countriesdaily: measured_at
providersProviders with at least one served row. A licensed provider with none is listed separately (providers_not_serving, not_serving), is still accepted by ?provider=, and is not counted./public/coverage (providers, provider_rows), /public/site-stats (providers_with_live_roles)daily: measured_at
employersDistinct employers with at least one served row./public/coverage, /public/site-statsdaily: measured_at
facet chipsServed rows per value, from the same pass./public/facetsevery 3 h: generated_at
provider rowsServed rows per provider, from the same pass as the facets./public/providersevery 3 h: generated_at (equal to /public/facets)

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.