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_listings3,406,509Rows 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_listings226,450Rows 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,679,106Roles that came off their source, retained with their closure date and reason.
ledger_rows9,312,065live_listings + withheld_listings + closed_listings. It reconciles exactly.
employers189,037Distinct employers with at least one row you can retrieve.
countries249Distinct ISO country codes present on live rows.
posted_last_7d198,363Live rows whose posted_at falls in the last seven days.

Reconciliation, computed from the same response: 3,406,509 + 226,450 + 5,679,106 = 9,312,065 and ledger_rows is 9,312,065 they match exactly, which is the guarantee.

The report also carries stale: false and age_seconds: 5,871. 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, 07: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": 5871,
  "closed_listings": 5679106,
  "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": 189037,
  "excluded_sources": [
    "adzuna",
    "apify_all_jobs",
    "apify_fantastic_jobs",
    "apify_job_listings",
    "apify_xing",
    "arbeitnow",
    "arbeitsagentur",
    "careerjet",
    "eures",
    "france_travail",
    "himalayas",
    "jobicy",
    "jooble",
    "landing_jobs",
    "remoteok",
    "remotive",
    "the_muse",
… 93 more lines
Real response, fetched from /public/coverage when this page was built (11 September 2026, 09:41 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_url3,406,509100%Every row links to the employer's own application page.
country2,511,29173.7%ISO country. Absent where the source states only a free-text location.
category3,216,60094.4%Inferred by us and published only above 0.6 confidence.
remote3,399,11799.8%Stated by the employer, or inferred — field_sources tells you which.
city2,450,80471.9%Absent where the source gives a region or a remote-only role.
description2,710,06379.6%Full advert text. The largest gap in the ledger, and the honest number.
seniority1,533,59745%Inferred from the title. Most titles do not state one.
salary_eur222,7126.5%Structured salary only. We never publish an estimate as a fact.

Measured 11 September 2026, 07: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 — 170 rows, sorted by live listings
ISOLiveWith a descriptionDescribedWith a published salaryEmployers
US1,573,2031,195,87676%182,267104,136
DE168,538158,03493.8%7,86629,231
GB110,48395,69486.6%5,77211,758
IN72,17457,95180.3%9855,273
FR63,25659,34793.8%2,8014,071
CA59,60754,32191.1%4,6516,115
NL50,46946,55592.2%5,0067,119
SE31,59130,79997.5%515,532
AU23,13020,63689.2%2863,218
ES21,73518,52485.2%5082,966
BE16,53114,56588.1%1462,343
IT14,23912,95191%3191,825
CH14,20713,08592.1%1082,265
HU14,1222,90020.5%21480
MX14,04211,45181.5%1981,748
PL12,88710,91984.7%2791,906
BR12,8379,86576.8%2961,552
SG12,6429,99979.1%1111,635
PH11,4559,84285.9%2771,289
MY10,2408,84286.3%7471,512
AT10,0839,46093.8%3,0712,094
AE10,0818,99789.2%7721,172
JP9,9748,54285.6%1071,841
CN9,8327,40875.3%651,187
HR8,8298,59197.3%211,170
IE8,5507,41986.8%3131,442
NO8,3497,70292.3%1441,296
ZA7,0666,49091.8%667982
PT6,9566,18989%115902
DK5,5725,13592.2%45830
GR5,5605,21993.9%57654
FI5,1204,42486.4%73939
ID5,0134,34986.8%12906
TH4,7493,89582%61639
CO4,7034,01785.4%84896
SA4,4964,01089.2%27618
RO4,4893,96388.3%36762
NZ4,1203,77691.7%31576
HK3,7392,93178.4%164737
EG3,7203,24987.3%44512
TW3,5992,75576.5%29520
VN3,5352,90782.2%21547
CZ3,3092,69681.5%41606
LT3,0402,79892%85261
AR3,0202,55784.7%91680
GE3,0042,05968.5%24612
KR2,8402,33182.1%17600
UA2,7642,54992.2%31366
BG2,7072,40788.9%75409
PE2,4591,85975.6%15321
IL2,3401,99285.1%22398
CL2,1381,78783.6%9340
TR1,8391,49481.2%8430
MA1,7671,58889.9%4242
UN1,6521,63298.8%485352
LU1,6281,41787%22358
NG1,5131,47697.6%5269
PK1,4611,40296%46253
QA1,3741,15183.8%13169
CR1,20287672.9%8237
RS1,1381,05392.5%11335
SK1,13693782.5%26217
MT1,1301,06394.1%35231
CY1,1021,05595.7%28238
EE92487995.1%17200
LK91584792.6%4106
GT81872188.1%16109
LV77371392.2%34165
KE74969192.3%7214
EC67359989%587
JO63249678.5%0127
TN62152183.9%5115
PA56944678.4%2145
KZ52547991.2%9116
LB45333874.6%5145
DO44038387%0103
SI41733079.1%5117
PR40427167.1%875
KW39135791.3%086
AZ38436895.8%167226
MV35934094.7%017
UY34828782.5%0107
BD33930890.9%5125
BH32626982.5%386
LI32432299.4%036
MU32231999.1%035
RU30930097.1%393
OM28921474%182
SV28326593.6%7267
TZ26926598.5%346
AM26824491%176
AL25223593.3%36102
HN23020990.9%1771
GH22320792.8%891
SR22218081.1%125
VE21420294.4%465
BM19918693.5%012
SW197197100%099
MD18916888.9%051
IQ18417394%041
NI18317897.3%047
NP15511674.8%134
AD15114596%21135
MK14312990.2%267
DZ14111581.6%039
FJ14010877.1%021
ET13711181%046
JM12711187.4%549
SO125125100%120
BA12111695.9%053
AO1178471.8%020
XK11511499.1%027
KH1118778.4%446
MZ11010595.5%025
BO11010292.7%035
BY107107100%022
GI10510499%027
CI102102100%036
UG102102100%035
CD10110099%130
MM986869.4%131
JE898898.9%119
TT898595.5%019
MO886169.3%017
ME886776.1%018
IM8585100%218
BS847488.1%124
SN847994%030
SP8181100%014
UZ807695%032
CM7878100%026
AW776381.8%06
GG7676100%015
MC725981.9%130
IS725981.9%033
KY705984.3%117
LA685073.5%234
PG685783.8%020
ZM686494.1%027
PF676191%29
MQ656498.5%119
IR625588.7%116
HO6161100%03
PY615488.5%032
NA605693.3%018
RW584882.8%024
GY585391.4%016
SC5252100%07
BB504590%013
RE494898%125
MG464495.7%014
SZ4646100%06
SL444193.2%012
JA4141100%016
GA4040100%012
BW3939100%018
NE373491.9%424
LY363597.2%013
TD3333100%011
SD312787.1%113
WS312787.1%06
AF302480%014
PO3030100%07
GP2828100%013
ZW2727100%013
GU272696.3%115
LR2626100%07
HT262596.2%112
SM251560%06
SY252496%016

170 countries, rendered from /public/coverage/countries, which needs no key. The count is of ISO codes we have live rows for — it is not a list of the world’s countries and it moves as employers post and roles close. Measured 11 September 2026, 07: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.

stale: true means the snapshot is older than its refresh window — usually because a refresh is running long or has failed. It 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 we confirmed that vacancy still existed at its source. 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_at11 September 2026, 07:52 UTCWhen the snapshot behind this report was taken.
generated_at11 September 2026, 09:30 UTCWhen this response was assembled.
live_listings3,406,509Rows /v1/jobs will serve.
ledger_rows9,312,065Live + withheld + closed.
closures.last_24h90,740Roles that left their source in the last day.
closures.last_7d1,631,534…and in the last week.
closures.total5,679,106Retained: kept indefinitely.

Measured 11 September 2026, 07: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/coveragemeasured_at is older than the refresh window.
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 rowWhen we last confirmed this vacancy exists at its source.
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)'
{
  "companies": 234711,
  "definitions": {
    "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.",
    "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."
  },
  "employer_direct_companies": 189037,
  "employer_direct_live": 3352026,
  "last_refreshed": "2026-09-11T08:54:30Z",
… 4 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 (11 September 2026, 09:41 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.

This page was rendered 11 September 2026, 09:41 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.