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.
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
| Path | Host | Key? | Notes |
|---|---|---|---|
/v1/* | api. | Yes | The licensed surface. |
/public/* | api. | No | Keyless mirror, capped pages, IP-limited. |
/health | api. | No | Liveness. Returns 200 and nothing interesting. |
/openapi.json, /openapi.yaml | both | No | The same document on either host. On the website host it is explicitly exempt from the challenge. |
/llms.txt | website | No | Short doorway for machine readers; also exempt. |
/docs/* | website | No | This documentation, and its Markdown mirror. |
/api/* | website | n/a | The site's own JSON routes, used by its own pages. Not part of the public API and not documented as such. |
# 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/changesis a real example — an integration that switched onchangeand 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=anywas added rather than changing whathas_salary=truereturns, precisely so that a shipped query did not start returning different rows. - Vocabulary shrinks are visible, not silent. A provider that leaves
/public/providersmakes 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-HeadersAuthorization, Content-Type.- Preflight
- Simple GETs with only an
Authorizationheader do trigger a preflight; it is answered. - Credentials
- Cookies are not used by the API at all. Do not send
credentials: "include".
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
| Endpoint | Cache | Why that number |
|---|---|---|
/public/jobs | 300s | Listing pages move constantly; five minutes is invisible to a reader. |
/public/jobs/{slug} | 600s | A single vacancy rarely changes within ten minutes. |
/public/companies* | 600s | Company rows change on the refresh cycle, not continuously. |
/public/facets | 300s | Counts move; the vocabulary rarely does. |
/public/coverage | 600s | Measured on a schedule anyway — see measured_at. |
/public/coverage/countries | 900s | Same. |
/public/providers | 900s | The vocabulary changes on the order of weeks. |
/public/freshness | 900s | Same. |
/public/plans | 300s | It is the price list; it must not lag a change. |
/public/openapi.json | 3600s | The document is rendered from the ledger every ten minutes upstream of this. |
/v1/* | not cached | Metered. |
Useful response headers: cf-cache-status (HIT, MISS, DYNAMIC — DYNAMIC 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
GETon everything documented here. There is no write path on/v1. ThePOSTendpoints 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
Zsuffix. Date-only values are accepted where a parameter says so (posted_after=2026-08-01). - Booleans
true/falsein 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=engineeringandcategory=Engineeringare 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.
| Code | What it means |
|---|---|
200 | A page of listings. |
401 | Missing, unknown, revoked or expired key. All four are reported identically. |
402 | The 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. |
422 | A 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. |
429 | Over 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.