Job Opportunities API

Check the data. Then trust it.

api

API overview

Hosts, versioning, caching, CORS and the HTTP conventions every endpoint follows. Read the hosts section first — getting it wrong costs people hours.

Last verified 2026-08-22 · Assumes: Basic HTTP. No JOA knowledge. · Markdown copy

1Two hosts, and which one to send requests to

Send API requests to api.jobopportunitiesapi.org. The website host is edge-protected and may answer a non-browser client with a challenge that reads as a 403.

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

This is the first thing to get right and the most common way to lose an afternoon. The two names look interchangeable and are not.

api.jobopportunitiesapi.org
The API. Every /v1/* and /public/* path. Programmatic clients are expected here, so it is not challenged and it is not rate-limited by the edge — the limits it applies are its own, per key or per IP.
jobopportunitiesapi.org
The website. HTML pages, the documentation you are reading, the OpenAPI spec, /llms.txt, and the site's own JSON routes under /api/. It sits behind a managed challenge for traffic that is neither a verified crawler nor an allowlisted path, because it is where two million job rows are rendered as HTML.
Exact contractTypes, defaults, ranges, errors, edge cases
PathHostKey?Notes
/v1/*api.YesThe licensed surface.
/public/*api.NoKeyless mirror, capped pages, IP-limited.
/healthapi.NoLiveness. Returns 200 and nothing interesting.
/openapi.json, /openapi.yamlbothNoThe same document on either host. On the website host it is explicitly exempt from the challenge.
/llms.txtwebsiteNoShort doorway for machine readers; also exempt.
/docs/*websiteNoThis documentation, and its Markdown mirror.
/api/*websiten/aThe site's own JSON routes, used by its own pages. Not part of the public API and not documented as such.
The right host and the wrong host, side by side
# Right: the API host answers a plain client.
$ curl -s -o /dev/null -w '%{http_code}\n' 'https://api.jobopportunitiesapi.org/public/jobs?limit=1'
200
# The website host is for browsers. A non-browser client may be challenged.
$ curl -s -o /dev/null -w '%{http_code}\n' 'https://jobopportunitiesapi.org/openapi.json'
200   # allowlisted, deliberately — the spec must be fetchable

2Versioning and how changes are made

The version is in the path: /v1. Fields are added, never silently repurposed, and breaking changes would take a new path rather than a flag.

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

The working rule is that a client written against /v1 today should keep working. That commits us to a few specific behaviours, which are worth stating because they are what you are allowed to rely on:

  • New fields appear without warning. Parse permissively; do not fail on an unrecognised key.
  • New enum values appear without warning. change: "withdrawn" on /v1/changes is a real example — an integration that switched on change and ignored unknown values kept serving rows that had been withdrawn. Have a default branch, and make it conservative.
  • Unknown query parameters are ignored, so sending one from a newer client against an older deployment is not an error.
  • Existing fields do not change meaning. When the meaning had to widen, a new value was added beside the old one instead: has_salary=any was added rather than changing what has_salary=true returns, precisely so that a shipped query did not start returning different rows.
  • Vocabulary shrinks are visible, not silent. A provider that leaves /public/providers makes a query naming it a 422, not an empty page.
Exact contractTypes, defaults, ranges, errors, edge cases

The spec's info.version is a date, and the document is rendered by the running API rather than checked in beside it — so the spec you fetch describes the deployment you are talking to, including its current coverage figures. Diffing /openapi.json between two dates is a reasonable way to see what moved.

Two legacy vocabularies are kept alive on purpose. provider_type still carries the original employer_ats / government / direct spellings, and the source_type filter accepts them and maps them onto ats / public_agency / career_site. Neither is deprecated in the sense of scheduled for removal.

3CORS and calling from a browser

Access-Control-Allow-Origin is * on the API, so client-side JavaScript can call it directly — but a key in browser JavaScript is a published key.

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

The permissive CORS header is there for /public/*, where there is nothing to protect: a keyless endpoint called from a browser is exactly the intended use, and it is why a small site can be built against this API with no backend at all.

Exact contractTypes, defaults, ranges, errors, edge cases
Access-Control-Allow-Origin
* on API responses.
Access-Control-Allow-Headers
Authorization, Content-Type.
Preflight
Simple GETs with only an Authorization header do trigger a preflight; it is answered.
Credentials
Cookies are not used by the API at all. Do not send credentials: "include".
Keyless, straight from a browser console — this works as written
const r = await fetch(
  'https://api.jobopportunitiesapi.org/public/jobs?country=NL&limit=5'
);
const { data } = await r.json();
console.table(data.map(j => ({ title: j.title, company: j.company, city: j.city })));

4Caching, and what the response headers tell you

Keyless endpoints are edge-cached for five to sixty minutes. Keyed endpoints are not cached at all. cf-cache-status says which you got.

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

Caching on the keyless surface is why it can be generous: a hundred readers asking the same question cost one query. It also means a keyless response can be a few minutes behind the ledger, which matters if you are testing a change and wondering why it has not appeared.

Keyed responses are metered, so they are never cached at the edge — a cached response would either bill you for rows you did not receive or deliver rows nobody was billed for.

Exact contractTypes, defaults, ranges, errors, edge cases
EndpointCacheWhy that number
/public/jobs300sListing pages move constantly; five minutes is invisible to a reader.
/public/jobs/{slug}600sA single vacancy rarely changes within ten minutes.
/public/companies*600sCompany rows change on the refresh cycle, not continuously.
/public/facets300sCounts move; the vocabulary rarely does.
/public/coverage600sMeasured on a schedule anyway — see measured_at.
/public/coverage/countries900sSame.
/public/providers900sThe vocabulary changes on the order of weeks.
/public/freshness900sSame.
/public/plans300sIt is the price list; it must not lag a change.
/public/openapi.json3600sThe document is rendered from the ledger every ten minutes upstream of this.
/v1/*not cachedMetered.

Useful response headers: cf-cache-status (HIT, MISS, DYNAMICDYNAMIC means not cacheable, which is what /v1 returns), age on a cached response, and the X-RateLimit-* family on keyed ones. See rate-limit headers.

5HTTP conventions

GET only, JSON in, JSON out, UTF-8 throughout, RFC3339 timestamps in UTC, and unknown query parameters are ignored rather than refused.

In depthWhy it exists, what it is not, what people get wrong
Methods
GET on everything documented here. There is no write path on /v1. The POST endpoints under /public/ belong to the website (subscribe, contact, opt-out, checkout) and are not part of the data API.
Timestamps
RFC3339, always UTC, always with the Z suffix. Date-only values are accepted where a parameter says so (posted_after=2026-08-01).
Booleans
true / false in query strings. Anything else is treated as unset rather than as an error.
Lists
Comma-separated, no spaces. Each list parameter documents its own maximum length; a longer list is truncated rather than refused.
Case
Filter values are matched case-insensitively — category=engineering and category=Engineering are the same query. Parameter names are case-sensitive.
Encoding
UTF-8. Percent-encode values in the query string as usual; a + is a literal plus, not a space.
Empty results
A 200 with data: []. An empty page is never an error, except where a value is unknown to the API — then it is a 422 with the value echoed back.
Exact contractTypes, defaults, ranges, errors, edge cases

Two behaviours are clamps rather than errors, and knowing which is which saves debugging. limit is capped at the plan maximum — a value above it is silently reduced, and a non-numeric value falls back to the default. Comma-separated lists are truncated to the per-parameter maximum. Everything else that is malformed — an unknown source_type or provider, an unparseable timestamp, a malformed cursor — is a 422 that names the problem.

One deliberate exception to the clamp rule: include_description=true with limit above 50 is refused with a 422, not clamped. A silently reduced page there would make a caller think they had read everything.

CodeWhat it means
200A page of listings.
401Missing, unknown, revoked or expired key. All four are reported identically.
402The plan's monthly RECORD allowance is spent. Distinct from 429: a 429 means slow down and retry, a 402 means the licence is used up until the 1st of the month. Do not retry. Every metered response carries X-RateLimit-Records-Remaining, so this is visible long before it happens.
422A parameter we will not guess at: an unknown source_type or provider, an unparseable timestamp, a malformed cursor. Note the deliberate exceptions, which are clamps rather than errors: limit is capped at the plan maximum (a non-numeric value falls back to the default), and comma-separated lists are truncated to the per-parameter maximum shown on each. Unknown query parameters are ignored.
429Over your plan's per-minute or per-day limit. Retry-After says how long.

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