endpoints
Keyless endpoints
Everything under /public/*, what each returns, and which of them have no keyed equivalent at all. Four routes used to be in this list and are not anymore: see the table below it.
1The complete list
Ten GET endpoints that need no key, plus a handful of POST routes that belong to the website rather than to the data API. Job and company ROWS are not among the ten. See “The four that moved behind a key” below.
In depthWhy it exists, what it is not, what people get wrong
| Endpoint | Returns | Cache | Keyed twin |
|---|---|---|---|
GET /public/facets | Every filter vocabulary with live counts. | 300s | /v1/meta/facets |
GET /public/providers | Every redistributable source with row counts. | 900s | /v1/meta/providers |
GET /public/freshness | Verification recency, field coverage, closures. | 900s | /v1/meta/freshness |
GET /public/coverage | The canonical coverage report. | 600s | none |
GET /public/coverage/countries | Per-country coverage. | 900s | none |
GET /public/coverage/employers | Per-employer coverage. | 600s | none |
GET /public/stats | Convenience summary. Read this first. | 300s | none |
GET /public/plans | The purchasable price list. | 300s | none |
GET /public/launch-offer | The current launch discount, if any: code, percent off and when it ends. | 60s | none |
GET /public/openapi.json / .yaml | The machine contract, rendered live. | 3600s | /v1/openapi.json |
GET /public/ai-prompt | An integration brief for a model, from the API host. | 600s | none |
GET /public/market-signals | Salary percentiles and time-to-fill by job family, country and seniority. | 3600s | none |
GET /public/market-signals/history | BETA. A daily trend of the same segments, up to 365 days. | 3600s | none |
GET /public/companies/{slug}/open-roles-history | BETA. One company's open-roles count, daily. | 3600s | none |
GET /public/status | Service status. | not available | none |
GET /health | Liveness. Not under /public. | not available | none |
| The four that moved behind a key | Now needs | Difference from before |
|---|---|---|
GET /public/jobs | A key (/v1/jobs) | No keyless tier anymore: 401 to anyone without a key, joa-web's own rendering excepted. |
GET /public/jobs/{id} | A key (/v1/jobs/{id}) | Same change. |
GET /public/companies | A key (/v1/companies) | Same change. |
GET /public/companies/{slug} | A key (/v1/companies/{slug}) | Same change. |
Exact contractTypes, defaults, ranges, errors, edge cases
There are also POST routes under /public/: subscribe, unsubscribe, contact, submissions, optout/request, optout/verify, checkout, auth/request, auth/verify and the key-management routes. Those belong to the website: they are what its forms post to, most are protected by a Turnstile token or a signed session, and none of them is part of the data API. They are documented under The website.
2GET /public/launch-offer
The currently active launch discount, if any. It is keyless, so a client (or the pricing page itself) never has to hard-code a code or an end date.
In depthWhy it exists, what it is not, what people get wrong
Answers an empty object when no discount is currently running. When one is, it carries the discount code, the percentage off and the RFC3339 timestamp it ends at, and, where a next offer is already scheduled, a next object with the same three fields for it. The same response drives the banner on /pricing, so the website can never show a discount this endpoint does not confirm.
Exact contractTypes, defaults, ranges, errors, edge cases
{ "code": "LAUNCH25", "percent": 25, "ends_at": "2026-10-31T21:59:59Z" }A launch discount, where one is active, is applied automatically to a new monthly checkout: there is no code to enter. See The launch discount for the full rules, including why it is never combined with the annual price.
$ curl -s https://api.jobopportunitiesapi.org/public/launch-offer { "code": "LAUNCH25", "ends_at": "2026-10-31T21:59:59Z", "percent": 25 }
3/public/jobs and /public/jobs/{id}: moved behind a key
These two used to answer keylessly, capped at fifty rows with no cursor. They now return 401 to anyone who is not jobopportunitiesapi.org's own page rendering: not to a keyless caller, and not to a key either. Rows come from /v1/jobs and /v1/jobs/{id} now, with a free Explore key or better.
In depthWhy it exists, what it is not, what people get wrong
A patient anonymous caller pacing itself under the keyless rate limit could still walk the whole ledger through this route, one 50-row page at a time, forever: 294 rows a second was measured with no throttle at all, and the throttle alone only made that slower, not impossible. /v1/jobs is unchanged: same projection, same fields, same provenance block, normal cursor paging, metered on your plan.
Exact contractTypes, defaults, ranges, errors, edge cases
Access-Control-Allow-Origin: * is still set on /v1/*, so a signed request CAN be made from a browser, but do not put a paid key in client-side JavaScript where every visitor can read it out of the network tab. Call /v1/jobs from a server, a serverless function, or a build step, and let the client fetch YOUR endpoint instead. That is also why the earlier, keyless version of this example was safe to run straight from a browser and this one is not: nothing secret was ever in it.
const params = new URLSearchParams({ country: 'NL', limit: '20' });
const res = await fetch(`https://api.jobopportunitiesapi.org/v1/jobs?${params}`, {
headers: { Authorization: `Bearer ${process.env.JOA_API_KEY}` },
});
const { data } = await res.json();
// ...render `data` into HTML server-side, or return it from your own API route.$ curl -s 'https://api.jobopportunitiesapi.org/public/jobs?country=NL&limit=2' { "error": "key_required", "message": "Listings require an API key. Get a free Explore key (no card) at https://jobopportunitiesapi.org/register. Statistics under /public/stats, /public/facets and /public/coverage remain open.", "docs": "https://jobopportunitiesapi.org/api" }
4The coverage family
Four keyless reports with no keyed equivalent: the canonical coverage report, per country, per employer, and a convenience summary.
In depthWhy it exists, what it is not, what people get wrong
/public/coverage- Canonical. The three populations, the reconciliation, per-field completeness, the source breakdown, the named excluded sources, and
measured_at/age_seconds/stale. Everything on this site defers to it. /public/coverage/countries- Per ISO code: live rows, how many carry a description, how many carry a published salary, how many employers.
/public/coverage/employers- The same idea per employer: how much data sits behind each company.
/public/stats- A convenience summary with different definitions, which is why it looks like it disagrees. The exact mapping.
These are keyless on purpose and there is no keyed version. A coverage report you need an account to read is a marketing claim; one anybody can fetch is an audit.
Exact contractTypes, defaults, ranges, errors, edge cases
| Figure | Rows | What it counts |
|---|---|---|
live_listings | 2,914,565 | 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. |
withheld_listings | 767,023 | 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. |
closed_listings | 8,653,347 | 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. |
ledger_rows | 12,334,935 | live_listings + withheld_listings + closed_listings. It reconciles exactly. |
employers | 139,921 | Distinct employers with at least one row you can retrieve. |
countries | 236 | Distinct ISO country codes present on live rows. |
posted_last_7d | 235,268 | Live 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.
5The spec, the AI brief, plans and status
Four small endpoints that describe the product rather than the data, all keyless and all safe to fetch on start-up.
In depthWhy it exists, what it is not, what people get wrong
/public/openapi.jsonand/public/openapi.yaml- The machine contract, rendered by the running API rather than checked in beside it, so its coverage figures are current and it describes the deployment you are talking to. Also served from the website host at
/openapi.json, which is the URL people paste. /public/ai-prompt- An integration brief written for a model, served from the API host with no website involved. Useful when a program has the API host and nothing else. The fuller version is /docs/ai/BUILD-A-SITE.md.
/public/plans- The purchasable price list: price, request limits, record allowance, and the
delta_feed/bulk_export/single_countryentitlements. Only active plans appear, so if a plan is not here you cannot buy it. /public/status- Service status, as rendered on /status.
Exact contractTypes, defaults, ranges, errors, edge cases
| Plan | Price | Records / month | Requests / day | Requests / min | Delta feed | Bulk export |
|---|---|---|---|---|---|---|
Exploreexplore | Free | 1,000 | 5,000 | 30 | no | no |
Starterstarter | €29/month | 10,000 | 20,000 | 60 | no | no |
Growthgrowth | €80/month | 60,000 | 100,000 | 120 | yes | no |
Signalsignal | €299/month | 400,000 | 400,000 | 300 | yes | yes |
Scalescale | €899/month | 2,000,000 | 1,000,000 | 600 | yes | yes |
Starter (annual)starter_annual | €261 | 10,000 | 20,000 | 60 | no | no |
Growth (annual)growth_annual | €720 | 60,000 | 100,000 | 120 | yes | no |
Signal (annual)signal_annual | €2,691 | 400,000 | 400,000 | 300 | yes | yes |
Scale (annual)scale_annual | €8,091 | 2,000,000 | 1,000,000 | 600 | yes | yes |
Rendered from /public/plans when this page was built. Only purchasable plans appear there, so this table is the price list: if a plan is not here, you cannot buy it. Compare on /pricing.
A=https://api.jobopportunitiesapi.org
curl -s "$A/public/openapi.json" | jq '{version: .info.version, paths: (.paths|keys|length)}'
curl -s "$A/public/plans" | jq -r '.data[] | "\(.plan)\t\(.price_cents/100)EUR\t\(.records_per_month) records"'
curl -s "$A/public/coverage" | jq '{live_listings, measured_at, stale}'6Market Signals history: BETA
Two keyless routes that add a daily trend on top of the point-in-time figures above: /public/market-signals/history (segment trend) and /public/companies/{slug}/open-roles-history (one company's open-roles count). Both are BETA, partial data: the company series has real daily snapshots from 2026-10-06 (two earlier ones on 2026-09-28 and 2026-10-04), and each company point carries a method label.
In depthWhy it exists, what it is not, what people get wrong
Every earlier release of this product could only ever answer 'what does the market look like right now': the underlying rollup (joa.market_signal_segments) is overwritten in place on each refresh, and a company's open_roles was always a single live counter. Nothing kept yesterday's figure. These two routes are the first surfaces built on a daily snapshot of each, so a caller can finally ask 'how has this changed' rather than only 'what is this today'.
Exact contractTypes, defaults, ranges, errors, edge cases
/public/market-signals/history?family&country&seniority&days=30|90|365- Same three axes and the same 30-listing publish floor as
/public/market-signals(no title, no company; see that endpoint's own reasoning).daysdefaults to 30 when omitted and is refused (422bad_days) for anything else.pointsis one entry per day a snapshot exists for that segment (sparse, not zero-filled), each carrying its ownstate(published/suppressed) exactly like the live endpoint's top-levelstate. /public/companies/{slug}/open-roles-history?days=30|90|365- A plain
open_rolescount for one company, one point per snapshot day (each with amethodofsnapshotorreconstructed), never a job row, never a salary or time-to-fill figure (a per-employer pay breakdown stays out of every public surface, same posture as the live Market Signals endpoint refusing?company=). An unknown slug returns200with an emptypointsarray rather than404, the same degrade-do-not-error shape as every other aggregate-only route on this site.
$ curl -s 'https://api.jobopportunitiesapi.org/public/market-signals/history?family=Engineering&country=DE&seniority=Senior&days=30' { "data": { "beta": true, "partial_data": true, "history_starts": "2026-09-28", "note": "BETA -- partial data. This history began the day...", "segment": { "family": "Engineering", "country": "DE", "seniority": "Senior" }, "points": [ { "date": "2026-09-28", "state": "published", "listings": 50 } ] } }
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.