# Build against the Job Opportunities API

Everything needed to build a working integration, in one file. Nothing here requires another document. Written for a program; a person may prefer https://jobopportunitiesapi.org/docs.

**Generated:** 11 September 2026, 18:49 UTC  
**Superseded by:** the live API and `https://jobopportunitiesapi.org/openapi.json`. Where this file and the API disagree, the API is right.

---

## 1. The eight rules

Get these right and nothing else on this page will surprise you.

1. **Base URL is `https://api.jobopportunitiesapi.org`.** The website host `https://jobopportunitiesapi.org` is edge-protected and may answer a non-browser client with a bot challenge that reads as HTTP 403. Never call the API there.
2. **Auth is one header:** `Authorization: Bearer <key>` on `/v1/*`. Nothing at all on `/public/*` — a key there is neither required nor useful. The bare key without the `Bearer` prefix is also accepted, and the scheme token is case-insensitive.
3. **`next_cursor` is opaque.** It looks like `<timestamp>|<uuid>`. Never parse it, rebuild it, split it or compare it. Store the string; send the string.
4. **Read `field_sources` before you trust a field.** Every job row carries, per field, `published` (the source stated it), `inferred` (we derived it) or `absent` (no value). `category` and `seniority` are ALWAYS `inferred`.
5. **402 is not 429.** 429 means slow down and retry after `Retry-After`. 402 means the month's record allowance is spent — do not retry until the 1st.
6. **The meter counts ROWS, not requests.** `limit=200` costs one request and 200 records. Ask for what you will use.
7. **Enumerate vocabularies at run time** from `/public/facets` and `/public/providers`. An unknown `provider` or `source_type` is a 422 with the value echoed back, never a silently empty page.
8. **Handle unknown enum values conservatively.** `/v1/changes` gained `change: "withdrawn"`; clients that ignored unknown kinds kept serving rows that had been retracted, including rows removed at an employer's request. Default branch: stop serving the row.

---

## 2. First request, with no account

```bash
curl -s 'https://api.jobopportunitiesapi.org/public/jobs?country=FR&limit=3'
```

`/public/*` needs no key, returns the same rows the paid endpoints return, caps pages at 50 rows and does not page (a `cursor` there is a 402). It is rate-limited per IP at about 2 requests/second sustained, 40/minute, with a 10-minute block on breach. `Access-Control-Allow-Origin: *`, so it is callable from browser JavaScript.

With a key:

```bash
export JOA_KEY='...'   # https://jobopportunitiesapi.org/login — free plan, no card
curl -s -H "Authorization: Bearer $JOA_KEY" 'https://api.jobopportunitiesapi.org/v1/me'
curl -s -H "Authorization: Bearer $JOA_KEY" 'https://api.jobopportunitiesapi.org/v1/jobs?country=IE&limit=25'
```

---

## 3. Every endpoint

| endpoint | key | returns |
| --- | --- | --- |
| GET /v1/changes | yes | Delta feed — created, updated, withdrawn and delisted, in change order. |
| GET /v1/companies | yes | Employers whose live roles this API returns. |
| GET /v1/companies/{slug} | yes | One employer. |
| GET /v1/export | yes | Bulk export of the full corpus, as a stream of NDJSON. |
| GET /v1/jobs | yes | List listings, newest first. |
| GET /v1/jobs/{id} | yes | One listing, with its description. |
| GET /v1/jobs/closed | yes | Roles that have left their source, most recently closed first. |
| GET /v1/jobs/expired | yes | Ids of roles that have come off the ledger |
| GET /v1/me | yes | Your key, plan and quota. |
| GET /v1/meta/facets | yes | Every filter value with its live count. |
| GET /v1/meta/freshness | yes | How recently the ledger was verified, and how much of it is inferred. |
| GET /v1/meta/providers | yes | Every source the ledger is built from, with its live row count. |

Keyless mirrors, no key required:

| endpoint | returns |
| --- | --- |
| GET /public/jobs | listings, max 50 rows, no cursor |
| GET /public/jobs/{id} | one listing, description always included |
| GET /public/companies | employers |
| GET /public/companies/{slug} | one employer |
| GET /public/facets | every filter vocabulary with live counts |
| GET /public/providers | every redistributable source with row counts |
| GET /public/coverage | the canonical coverage report — live + withheld + closed |
| GET /public/coverage/countries | per-country coverage |
| GET /public/coverage/employers | per-employer coverage |
| GET /public/freshness | verification recency and field coverage |
| GET /public/stats | convenience summary with DIFFERENT definitions — prefer /public/coverage |
| GET /public/plans | the purchasable price list |
| GET /public/openapi.json | the machine contract |

---

## 4. Pagination

`/v1/jobs`, `/v1/jobs/closed` and `/v1/companies` return `{ data, next_cursor, has_more }`. Loop on `has_more`, not on `data.length` — a short page can still have more behind it. `/v1/companies` also accepts `offset`, capped at 100,000 (beyond that is a 422; use the cursor for deep paging).

`/v1/changes` and `/v1/jobs/expired` take `since` and return `next_since`, a keyset cursor of the same character. Same rule: pass it back verbatim.

`/v1/export` streams NDJSON ordered by `id`; resume with `after=<last id>`.

```python
def jobs(get, **filters):
    """Yield every row matching the filters. `get` is your HTTP helper."""
    cursor = None
    while True:
        params = {**filters, "limit": 200}
        if cursor:
            params["cursor"] = cursor   # opaque: never parse it
        page = get("/v1/jobs", **params)
        yield from page["data"]
        if not page.get("has_more") or not page.get("next_cursor"):
            return
        cursor = page["next_cursor"]
```

---

## 5. Errors

Every error body is `{ "error": "<stable code>", "message": "<human>", "docs": "<url>" }`. Branch on `error`.

| status | error | retry | meaning |
| --- | --- | --- | --- |
| 401 | unauthorized | no | Missing, unknown, revoked or expired key — all four identical by design. |
| 402 | record_quota_exhausted | **no** | Month's record allowance spent. Resets on the 1st. |
| 402 | key_required_to_page | no | A cursor on a keyless request. Take a free key. |
| 403 | upgrade_required | no | Endpoint not in your plan. `X-JOA-Required-Feature` names it: delta_feed or bulk_export. |
| 404 | not_found | no* | *A company slug can move — re-resolve rather than delete. |
| 422 | bad_provider / bad_source_type / bad_cursor / bad_timestamp / field_never_published | no | A value we will not guess at. The message names it. |
| 429 | rate_limited | yes | Over the per-minute or per-day request limit. Wait `Retry-After` seconds. |
| 500 | query_failed | no | Broken. Retrying will not help. |
| 503 | timeout | yes | Query ran past its time limit. Retry in ~5s and narrow the filter. |
| 503 | unavailable | yes | A dependency was briefly unreachable. |

`require_fields=category` and `require_fields=seniority` are a deliberate 422 (`field_never_published`): both are read off the job title by a classifier, so no row can ever satisfy them and an empty page would look like a coverage problem.

---

## 6. Rate limits and the record meter

Two independent counters. Request limits (per minute, per day) protect the service. The monthly **record** allowance is the licence — one record per row delivered, on any endpoint that returns rows. `/v1/me` and the `/v1/meta/*` endpoints cost nothing; `/public/*` is unmetered.

| plan | price | records/month | req/day | req/min | delta_feed | bulk_export |
| --- | --- | --- | --- | --- | --- | --- |
| explore | Free | 1,000 | 5,000 | 30 | false | false |
| growth | €80/month | 60,000 | 100,000 | 120 | true | false |
| signal | €299/month | 400,000 | 400,000 | 300 | true | true |
| scale | €899/month | 2,000,000 | 1,000,000 | 600 | true | true |

Response headers on every keyed call:

| header | meaning |
| --- | --- |
| X-RateLimit-Limit / -Remaining / -Reset | Requests per day, left today, and the Unix time of the next UTC midnight. |
| X-RateLimit-Records-Limit / -Used / -Remaining | The monthly record allowance. `-Used` includes the current response. |
| Retry-After | On 429 and 503. On a daily breach this can be hours. |
| X-JOA-Required-Feature | On 403: `delta_feed` or `bulk_export`. |
| X-JOA-Country-Lock | On a country-locked key: the ISO code it is restricted to. |

---

## 7. The data model

`live + withheld + closed = ledger_rows`, and it reconciles exactly.

- **live** — rows `/v1/jobs` will serve you.
- **withheld** — held and deliberately not served: `quality_removed` (breaches the employer-direct guarantee; no parameter re-admits these), `quality_gated` (reversible doubt; `?quality=all` re-admits them on a paid key), `optout_hidden` (a verified employer opt-out; never re-admitted).
- **closed** — roles that left their source, retained with `closed_at` and `closed_reason` (`expired_upstream` or `not_seen`). Query with `status=closed` or `status=any`.

| figure | rows |
| --- | --- |
| live_listings | 3,412,993 |
| withheld_listings | 224,891 |
| closed_listings | 5,695,531 |
| ledger_rows | 9,333,415 |
| employers | 189,464 |
| countries | 249 |

_Measured 11 September 2026, 16:52 UTC, `stale: false`. Read the current figures from `GET https://api.jobopportunitiesapi.org/public/coverage`._

**Provenance.** Every job row carries `field_sources`, one entry per field, valued `published` / `inferred` / `absent`. `category` and `seniority` are always `inferred`. `salary` is `published` only when the source stated it in a field (`salary_source: structured`); a figure parsed out of the advert text is `inferred` (`salary_source: parsed_description`). Modelled or estimated salaries never appear under any parameter.

`require_fields=<a,b>` returns only rows where every named field is `published`, and adds a `completeness` block to the response.

---

## 8. Every parameter of `GET /v1/jobs`

| parameter | type | default | allowed | list | description |
| --- | --- | --- | --- | --- | --- |
| limit | integer | 25 | 1–200 | — | — |
| cursor | string | — | — | — | The next_cursor from the previous page. |
| status | string | live | live, closed, any | — | `live` (default) returns open vacancies. `closed` returns roles that have left their source. `any` returns both; every row carries `status`. Paid endpoints only. |
| include_poster_type | string | — | staffing, jobboard, all | csv | Re-admits vacancies whose poster is a staffing agency or a job board. Excluded by DEFAULT: the posting is real and the apply link is the poster's own, but the poster is not the employer, and one agency with 18,000 listings can flood a category until search stops being useful. Comma-separated. Paid endpoints only. An unrecognised value is a 422. |
| quality | string | — | all | — | `all` re-admits rows we have GATED -- reversible doubt, such as a future `posted_at` or a missing apply URL. It never re-admits rows we have REMOVED: those breach the employer-direct guarantee and no query parameter may opt back into it. Each returned row carries its verdict, its rule id and the matched host. Paid endpoints only. |
| category | string | — | — | csv | Comma-separated. uncategorised selects rows with no confident classification. |
| country | string | — | — | csv | Comma-separated ISO-3166 alpha-2. |
| city | string | — | — | — | — |
| state | string | — | — | csv | Comma-separated two-letter US state codes, e.g. `OH` or `OH,TX`. Absent where we could not establish the state from the source; deliberately absent for ambiguous city names, so this filter under-reports rather than placing a job in the wrong state. |
| remote | string | — | — | — | `remote`, `hybrid`, `on_site`, or `not_stated`. |
| employment_type | string | — | — | csv | Comma-separated; `not_stated` selects rows with none. |
| seniority | string | — | — | csv | Comma-separated; `not_stated` selects rows with none. |
| provider | string | — | — | csv | Comma-separated list of the exact source systems to include, e.g. `greenhouse,lever,workday`. This is the ATS or board a vacancy came from, finer than source_type which buckets them. A name we do not publish is a 422, never a silently empty page. Up to 12 values. |
| exclude_provider | string | — | — | — | Same vocabulary as `provider`, removed instead of kept. |
| source_type | string | — | — | csv | Comma-separated provenance filter. The legacy spellings `employer_ats`, `government` and `direct` are still accepted and map onto `ats`, `public_agency` and `career_site`. Filter values are matched case-insensitively: `category=engineering` and `category=Engineering` are the same query. Enumerate the legal values with /v1/meta/facets. |
| exclude_source_type | string | — | — | — | — |
| company | string | — | — | csv | Comma-separated company slugs. |
| exclude_category | string | — | — | — | — |
| exclude_country | string | — | — | — | — |
| remote_confirmed | boolean | — | — | — | `true` returns only listings whose remote status the SOURCE stated — 375,960 of 3,636,740 live rows (10.3%). Without it you also receive the 3,255,840 (89.5%) where we inferred it from the location text, the title, or the presence of a named workplace city. |
| has_salary | string | — | true, structured, any | — | `true` (and `structured`) returns only rows whose salary the SOURCE published — the meaning this parameter has always had, kept so that adding derived salaries does not change the results of a query you already ship. `any` also includes figures we read out of the advert text (`salary_source: parsed_description`, reported as `inferred`). AI estimates are never published under any value. |
| has_description | boolean | — | — | — | `true` returns only the 2,932,043 live rows (80.6%) that carry a description. |
| require_fields | string | — | — | csv | Comma-separated. Returns only rows where EVERY named field is `published` in `field_sources` — a value the source carried, never one we derived. The response then also contains a `completeness` object saying how many live rows carry each of them. This is the answer to "only 6.2% of your rows have a salary". They do — and `require_fields=salary` returns 227,252 rows of which 100% carry a figure an employer actually wrote, with no estimate anywhere in the response. Check the per-country split at `/public/coverage/countries` before you spend a record. `category` and `seniority` are refused with 422: both are read off the job title by our classifier, so they are `inferred` by construction and no row can ever satisfy them. An empty page would look like a coverage problem; the error says what it is. `source_type` is accepted and currently matches nothing — the per-row classification exists in the schema and no live row carries one yet. The `completeness` block reports that as a count rather than leaving you to infer it from an empty page. |
| include_description | boolean | — | — | — | Return the full advert text in a `description` field. Off by default: descriptions average 2,581 bytes, so a 200-row page would be a 516 KB response nobody asked for. With this on, `limit` may not exceed 50 — a larger request is refused with 422 rather than quietly clamped. |
| posted_after | string | — | — | — | Date or RFC3339 timestamp. |
| verified_after | string | — | — | — | Only rows re-confirmed at their source since this instant. |
| q | string | — | — | — | Full-text over title, company name and location — NOT the description. That is deliberate, not a limitation: use `description_contains` for the advert body. Stemming is off (`simple` dictionary), so `q=engineer` does not match `engineering`. |
| title | string | — | — | — | Full-text over the job title only, e.g. `?title=engineer`. It ANDs with every other filter, including `?description_contains=kubernetes` over the advert body — but the two full-text filters together are the most expensive query this API can be asked, because both indexes are GIN and the matching rows still have to be fetched to be ordered by posted_at. Send them together on a narrow country or category, not on the whole ledger. |
| title_exclude | string | — | — | — | Drop rows whose title matches these words. Same matching as `title`. |
| description_contains | string | — | — | — | Full-text over the advert body. Only ever matches rows that have one, so it implies `has_description=true`. |
| company_domain | string | — | — | csv | Comma-separated bare domains, e.g. `stripe.com,figma.com` — no scheme and no path. The join key you already have in a CRM. 37.2% of companies carry a domain; the rest can never match this filter. |
| exclude_company_domain | string | — | — | — | Same vocabulary as company_domain, removed instead of kept. |
| min_salary | number | — | — | — | Lower bound on `salary_min_annual_eur`. Selects ONLY rows with structured salary we could normalise — 2.0% of the ledger — so this is a narrow filter by nature, not a broken one. |
| max_salary | number | — | — | — | Upper bound on salary_min_annual_eur. Same 2.0% caveat as min_salary. |

Different parameters AND together; a comma-separated list ORs within one parameter. There is no OR across parameters. Values are matched case-insensitively; parameter names are not. Unknown parameters are ignored.

---

## 9. Fields

### Job

| field | type | always present | meaning |
| --- | --- | --- | --- |
| apply_url | string | no | — |
| category | string | no | — |
| category_confidence | number · nullable | no | Classifier confidence in `category`. Below 0.6 no category is published at all, so this is always null or >= 0.6. |
| city | string | no | — |
| closed_at | string · nullable | no | — |
| closed_reason | string · nullable | no | — |
| company | string | yes | — |
| company_logo | string | no | — |
| company_slug | string | yes | — |
| country | string | no | — |
| description | string | no | The full advert text. Present ONLY when the request set include_description=true, and only on the 80.6% of rows that have one (2,932,043 live rows). |
| employment_type | string | no | — |
| field_sources | FieldSources | yes | Per-field provenance. `published` means the value was carried by the source. `inferred` means WE produced it — it may be right, it is not a quotation. `absent` means no value, which is a different failure from a guess and is reported as such. `category` and `seniority` are ALWAYS `inferred`: both are read off the job title by a classifier, never from a field the employer filled in. `salary` is `published` when the source stated it in a field (`salary_source: structured`), and `inferred` when we read it out of the advert text (`salary_source: parsed_description`). It is never an estimate: the projection refuses to emit Erioun's AI salary predictions at all, so a modelled figure cannot reach this API by any route. |
| first_seen_at | string | no | When the vacancy first entered this ledger. Populated on 100% of rows, unlike posted_at which the source often omits — so this is the field to sort or backfill by when you need every row to have a date. |
| has_description | boolean | yes | — |
| id | string · uuid | yes | — |
| last_verified_at | string | yes | When we last confirmed this vacancy still exists at its source. |
| location | string | no | — |
| posted_at | string | no | — |
| provider_type | string | yes | The provider-level class, in the original vocabulary. Kept so a query written against it still resolves. |
| remote | string | no | — |
| remote_inferred | boolean | yes | **Always present.** True when we derived `remote` rather than read it. It used to be omitted when false, which made a stated value and an absent one identical on the wire. |
| salary_currency | string | no | — |
| salary_max | number | no | — |
| salary_min | number | no | — |
| salary_min_annual_eur | number | no | salary_min converted to an annual EUR figure, so a row quoting USD/hour and one quoting GBP/year are comparable. NULL when the period or the currency is unrecognised — never guessed, because reading an hourly rate as a salary is wrong by a factor of 2080. Converted with the indicative rates in /v1/meta/freshness, not a settlement rate. |
| salary_period | string | no | — |
| salary_source | string | no | Where the figure came from. `structured` is a field the source itself published. `parsed_description` is a real figure quoted in the advert text that WE read out and normalised — we chose the number and the period, so `field_sources.salary` reports it as `inferred`, never `published`. |
| seniority | string | no | — |
| slug | string | yes | — |
| source | string | yes | The provider id, e.g. greenhouse, company_site, workday. The full published list is /public/providers; eures, arbeitsagentur and france_travail were named here until 2026-08-15, when they became discovery-only and left that list. |
| source_type | string | yes | Per-row provenance where the source has been classified per row, otherwise the provider's class. Live distribution: ats 1,063,129, career_site 672,526, public_agency 320,727. RESERVED VALUES: `aggregator` and `agency` are accepted by the filter but currently match no rows. Every aggregator source is marked non-redistributable, so that inventory never enters the ledger at all, and no provider is classified `agency` yet. Filtering on either returns an empty page — that is the data, not a fault. |
| status | string | yes | — |
| title | string | yes | — |
| upstream_expired_at | string | no | When the radar proved the vacancy dead, as opposed to when we removed it. |

Optional fields are **omitted**, not nulled — except `remote_inferred`, which is always present including when false, and `closed_at` / `closed_reason`, which are present and null on a live row. `description` appears only with `include_description=true` (which caps `limit` at 50; a larger request is a 422, not a clamp).

### Company

| field | type | meaning |
| --- | --- | --- |
| careers_url | string | — |
| country | string | — |
| first_seen | string | — |
| industry | string | — |
| logo | string | — |
| name | string | — |
| open_roles | integer | Roles retrievable from THIS API. Never a number the API cannot honour. |
| org_type | string | — |
| own_site_roles | integer | Vacancies seen on the company own careers page. |
| sectors | array | — |
| slug | string | — |
| source_types | array | `aggregator` and `agency` are reserved and match no rows today; see the source_type enum. |
| website | string | — |
| website_verified | string | When we fetched this domain and found the company own name on it. |

**Company slugs are not currently guaranteed stable** between refreshes: duplicate rows can compete for the same bare slug. Key on `company_domain` where you have one (about 37% of companies carry a domain), and treat a 404 on a previously valid slug as “re-resolve”, not “gone”. Any live row for the domain carries the company's current slug.

---

## 10. Keeping data current

Seed once, then poll `/v1/changes` — it charges only for rows that moved, where re-reading a listing charges for every row you look at.

```python
since = load_cursor() or "2026-08-01T00:00:00Z"
while True:
    body = get("/v1/changes", since=since, limit=500)
    for change in body["data"]:
        kind, job = change["change"], change["job"]
        if kind in ("created", "updated"):
            upsert(job)
        else:
            # delisted, withdrawn, AND anything added after this was written.
            # Retracting is the safe default: `withdrawn` was new once, and
            # clients that ignored it kept serving rows we had retracted.
            retract(job["id"])
    since = body["next_since"]      # opaque
    save_cursor(since)              # AFTER applying the batch
    if body["count"] < 500:
        break
```

Change kinds: `created` (insert), `updated` (upsert), `delisted` (the vacancy is gone), `withdrawn` (**we** retracted the row — it may still exist, but we no longer stand behind the link; this is also how an employer opt-out reaches you). Treat `withdrawn` exactly as `delisted`.

Cheaper still, when you only need to mark rows stale: `/v1/jobs/expired` returns ids, closure dates and reasons only.

---

## 11. A complete client

```python
"""Job Opportunities API client. Standard library only."""
import json, os, random, time, urllib.error, urllib.parse, urllib.request

API = "https://api.jobopportunitiesapi.org"
KEY = os.environ["JOA_KEY"]
RETRYABLE = {429, 503}   # everything else is a decision, not a hiccup

def get(path, **params):
    url = API + path + ("?" + urllib.parse.urlencode(params) if params else "")
    req = urllib.request.Request(url, headers={
        "Authorization": f"Bearer {KEY}", "Accept": "application/json",
    })
    for attempt in range(5):
        try:
            with urllib.request.urlopen(req, timeout=90) as r:
                return json.load(r)
        except urllib.error.HTTPError as e:
            if e.code not in RETRYABLE or attempt == 4:
                raise RuntimeError(f"{e.code} {e.read().decode()[:300]}") from None
            wait = int(e.headers.get("Retry-After") or 2 ** attempt)
            time.sleep(wait + random.uniform(0, 1))

def jobs(**filters):
    cursor = None
    while True:
        params = {**filters, "limit": filters.pop("limit", 200)}
        if cursor:
            params["cursor"] = cursor
        page = get("/v1/jobs", **params)
        yield from page["data"]
        if not page.get("has_more") or not page.get("next_cursor"):
            return
        cursor = page["next_cursor"]

if __name__ == "__main__":
    print(get("/v1/me"))
    for n, job in enumerate(jobs(country="IE", category="Engineering"), 1):
        stated = job["field_sources"]["remote"] == "published"
        print(job["company"], job["title"], "remote_stated=", stated)
        if n >= 20:
            break
```

---

## 12. Obligations if you republish this data

- **Link `apply_url` directly.** Do not proxy or rewrite it. The promise of this data is that a candidate reaches the employer.
- **Honour `delisted` and `withdrawn`.** Stop serving those rows. `withdrawn` includes employer opt-outs, so ignoring it means publishing a vacancy for an employer who asked to be removed.
- **Do not present `inferred` values as the employer's statement.** `field_sources` is on every row so that you can tell the difference, and say which you are showing.
- **Do not present any salary as a market estimate.** No modelled salary exists in this data; a figure is either the employer's or one parsed from their own advert text.
- **Show a freshness date.** `last_verified_at` is on every row.
- **The terms are at https://jobopportunitiesapi.org/terms.** They govern; this list is a summary.

---

## 13. Where to go for more

- `https://jobopportunitiesapi.org/docs/ai/index.md` — every documentation page as Markdown
- `https://jobopportunitiesapi.org/openapi.json` · `https://jobopportunitiesapi.org/openapi.yaml` — the machine contract, keyless
- `https://jobopportunitiesapi.org/docs` — the same documentation as HTML, for a person
- `https://api.jobopportunitiesapi.org/public/coverage` — the canonical coverage report
- `https://api.jobopportunitiesapi.org/public/facets` · `https://api.jobopportunitiesapi.org/public/providers` — the filter vocabularies
- `https://jobopportunitiesapi.org/login` — a free key, no card
- `hello@jobopportunitiesapi.org` — a person
