# API overview

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

**What this covers:** Two hosts, and which one to send requests to; Versioning and how changes are made; CORS and calling from a browser; Caching, and what the response headers tell you; HTTP conventions.

**Assumed knowledge:** Basic HTTP. No JOA knowledge.

**Canonical HTML:** https://jobopportunitiesapi.org/docs/api/overview  
**Machine-readable index:** https://jobopportunitiesapi.org/docs/ai/index.md  
**Last verified:** 2026-08-22  
**Superseded by:** the live API at https://api.jobopportunitiesapi.org and its spec at https://jobopportunitiesapi.org/openapi.json — where this file and the API disagree, the API is right.

---

<a id="hosts"></a>

## 1. Two 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.

### 1.1 In depth

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.

> **A 403 from the website host is usually the challenge, not a permission error** — If a script gets a 403 from `jobopportunitiesapi.org` for a path that works in a browser, it met the bot challenge. Move the request to the API host. Nothing on `/v1` or `/public` is served from the website host, so there is never a reason to call the API there.

### 1.2 Exact contract

| 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. |

> **`/api` on the website is two different things** — [`/api`](https://jobopportunitiesapi.org/api) with no trailing path is a page — the API sales page. `/api/<something>` is the website's internal JSON. Neither is the API host. It is an unfortunate collision and it is documented here rather than renamed, because the sales page's URL has been linked to from outside.

### 1.3 Worked examples

The right host and the wrong host, side by side

```console
# 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
```


**See also**

- [The header](./api-authentication.md#auth-bearer)
- [What you get without an account](./api-keyless.md#keyless-what-you-get)
- [The crawler policy](./website-legal.md#site-robots)

<a id="versioning"></a>

## 2. Versioning 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.

### 2.1 In depth

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.

### 2.2 Exact contract

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.

**See also**

- [The four change kinds — and the one that breaks integrations](./endpoints-changes.md#change-kinds)
- [Controlled vocabularies — never hard-code these](./api-parameters.md#filter-vocabularies)
- [The three source classes](./ledger-sourcing.md#source-classes)

<a id="cors-and-browsers"></a>

## 3. CORS 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.

### 3.1 In depth

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.

> **Do not put a `/v1` key in front-end code** — Anything in a browser bundle is public. A key there can be read out of your JavaScript by anyone who opens the network tab, and it will be — the meter is per key, so the bill is yours. Call `/v1` from your server and let your own front end talk to your server.

### 3.2 Exact contract

- **`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

```javascript
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 })));
```

**See also**

- [What you get without an account](./api-keyless.md#keyless-what-you-get)
- [The header](./api-authentication.md#auth-bearer)
- [JavaScript and TypeScript](./recipes-languages.md#recipe-javascript)

<a id="caching-headers"></a>

## 4. Caching, 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.

### 4.1 In depth

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.

### 4.2 Exact contract

| 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](./api-rate-limits.md#limit-headers).

**See also**

- [The response headers](./api-rate-limits.md#limit-headers)
- [The limits, exactly](./api-keyless.md#keyless-limits)

<a id="conventions-http"></a>

## 5. HTTP conventions

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

### 5.1 In depth

- **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.

### 5.2 Exact contract

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 | meaning |
| --- | --- |
| 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. |

**See also**

- [The shape of an error](./api-errors.md#error-shape)
- [How to read these tables](./api-parameters.md#params-how-to-read)
- [422 — a value we will not guess at](./api-errors.md#error-422)

---

## Where to go next

This file is part of **API**. Others in the same group:

- [Authentication](./api-authentication.md) — One header, three accepted spellings, and a deliberate refusal to tell an attacker which kind of wrong a wrong key is.
- [Keyless access](./api-keyless.md) — What /public/* gives you with no account: real rows, one page at a time, bounded so that evaluating is free and extracting is not.
- [Rate limits](./api-rate-limits.md) — Two independent ceilings — requests per minute and per day — plus a monthly record allowance that is a licence rather than a throttle. They fail differently.
- [Pagination](./api-pagination.md) — Keyset cursors, not offsets. One rule matters more than the rest: hand next_cursor back exactly as you received it.
- [Errors](./api-errors.md) — Every status the API emits, its JSON shape, whether it is worth retrying, and what to do about it. Two of them are routinely confused and it is expensive.

Always useful:

- [index.md](./index.md) — the map of every file here
- [BUILD-A-SITE.md](./BUILD-A-SITE.md) — the paste-whole brief for building against this API
- [quickstart.md](./quickstart.md) — zero to a first authenticated response
- [api-errors.md](./api-errors.md) — every status code and whether to retry it
