Job Opportunities API

Check the data. Then trust it.

start

Quickstart

From nothing to a real response in one command, and to an authenticated one in about a minute. No card at any point.

Last verified 2026-10-17 · Assumes: A terminal with curl, or any HTTP client. No account. · Markdown copy

1Step one: a real response, with no account (statistics)

Everything STATISTICAL under /public/* answers without a key: coverage, facets, freshness, providers, the price list. This is real data, not a sandbox: the same ledger the paid endpoints read. Job and company ROWS are a step two thing now: see below.

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

The keyless surface exists so that evaluating this product costs you nothing and requires no conversation. /public/coverage, /public/facets, /public/providers and /public/freshness read live off the same projection as /v1, so a figure you see here is the same figure a paid key would compute. What is NOT keyless anymore is actual rows: /public/jobs and /public/companies used to answer one capped page without a key; a patient anonymous caller could still walk the whole ledger through that page cap, so both now require the free key from step two.

The STATISTICAL routes carry no per-address rate limit at all: they are O(1) reads of a precomputed snapshot, not a walk of the ledger, so there is nothing to bound. See Keyless access for the complete list and for what changed.

Exact contractTypes, defaults, ranges, errors, edge cases

Access-Control-Allow-Origin: * is set on these too, so this works from a browser console and from client-side JavaScript with no proxy. Responses are edge-cached (/public/coverage for 600 seconds, /public/facets for 300), so a repeated request may not reach the origin at all, and cf-cache-status in the response headers tells you which happened.

The headline numbers, no key
$ curl -s 'https://api.jobopportunitiesapi.org/public/coverage' \
  | jq '{live_listings, employers, countries, measured_at, stale}'
{
  "age_seconds": 2142,
  "as_of": "2026-10-27",
  "closed_listings": 8653347,
  "closure_date_note": "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.",
  "countries": 236,
  "definitions": {
    "as_of_times": "/public/facets and /public/providers carry the latest snapshot (every 3 hours, generated_at). /public/coverage, /public/coverage/countries, /public/stats, /public/site-stats and the website carry the snapshot frozen once a day (as_of, measured_at = that snapshot's generated_at). Two surfaces agree exactly when their timestamps are equal; otherwise each states its own.",
    "closed_listings": "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.",
    "countries": "Distinct country codes with at least one served row. Only ISO 3166-1 alpha-2 codes (plus XK, Kosovo) are countries; any other value is published as country unknown and filtered by no country= value.",
    "employers": "Distinct employers with at least one served row.",
    "ledger_rows": "live_listings + withheld_listings + closed_listings. It reconciles exactly.",
… 178 more lines
Pipe it through `jq` if you have it; the shape is the same either way. Real response, fetched from /public/coverage when this page was built (27 October 2026, 11:28 UTC). Run the command yourself and you will get today's rows, not these.

2Step two: get a key

Sign in with your email at /login, click through the link it sends, and create a free key on the dashboard. No password, no card.

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

Sign-in is passwordless. You give an email address, the site emails you a one-time link, and following that link signs you in. There is no password to choose, to forget or to leak, and there is nothing in the database that could be stolen and replayed as one.

The free plan (Explore) is created from the dashboard with one click and needs no payment details. It gives you the search, job, company and closed-job endpoints against the live ledger (the delta feed starts on Growth), with a monthly record allowance rather than a trial period. It does not expire. The exact allowance is in the plans table, rendered from the live price list rather than typed here.

Exact contractTypes, defaults, ranges, errors, edge cases
  1. Open /login and enter an email address.
  2. The site POSTs to its own /api/auth/request, which asks the API for a magic link and mails it. Nothing is stored against the address until you follow the link.
  3. Following the link hits /auth/verify, which exchanges the one-time token for a session cookie and redirects to /dashboard.
  4. On the dashboard, Create a free key calls POST /public/auth/keys with that session. The response carries the secret; the page shows it once.
  5. Send it as Authorization: Bearer <secret> from then on.

The session is a signed value in a cookie, not a row in a session table, and it lasts 30 days. Signing out clears the cookie. Neither the login flow nor the dashboard is part of the API contract (they are the website), but they are documented in full under Sign-in and the dashboard.

3Step three: your first authenticated call

Send the key as a bearer token to api.jobopportunitiesapi.org. Everything under /v1 uses exactly this header.

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

The first call worth making is /v1/me, because it answers the two questions you will ask again later: which plan is this key on, and how much of it have I used. The answers are records_used_month against records_limit_month (the billed unit), and usage_today (requests today, the burst limit). It costs no records.

After that, /v1/jobs is the endpoint you will spend most of your time in. It takes about thirty-five parameters; you do not need to learn them, you need to know that the reference exists and is generated from the specification rather than written by hand.

Exact contractTypes, defaults, ranges, errors, edge cases

Three forms of the header are accepted: Authorization: Bearer <key>, Authorization: bearer <key> (the scheme token is case-insensitive) and the bare Authorization: <key> with no scheme at all. Bearer is the canonical form and the one to write; the tolerance exists so that a client library that strips the scheme does not fail confusingly. See Authentication.

Every metered response carries X-RateLimit-Records-Remaining. Watch it rather than waiting for a 402. The 402 is not something to retry: it means the month's licence is spent.

Who am I, and what is this key allowed to do
# Replace $JOA_KEY with the secret from the dashboard.
$ export JOA_KEY='your-api-key-here'
$ curl -s -H "Authorization: Bearer $JOA_KEY" https://api.jobopportunitiesapi.org/v1/me
{
  "created": "2026-10-17",
  "key_id": "00000000-0000-4000-8000-000000000000",
  "key_prefix": "joadocs",
  "limits": { "max_page_size": 200, "per_day": 100000, "per_minute": 120, "ranked_per_day": 0 },
  "plan": "growth",
  "records_country_lock": null,
  "records_limit_month": 60000,
  "records_reset_at": "2026-11-01T00:00:00Z",
  "records_used_month": 125,
  "renews_or_expires": "2026-11-17",
  "status": "active",
  "usage_today": 3
}
Captured 2026-08-22 from a throwaway Signal key created for this build and revoked after it. Your response names your plan and your prefix. The request is re-sent against the live API on every build of this site; if it stopped answering 2xx the site would not ship.
Two Irish roles, with their provenance
$ curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs?country=IE&limit=2'
The response shown is fetched by this page's own server-side rendering, authenticated the same way joa-web's own pages are (see api/publicgate.go), not with a key you could reuse. On `/v1` the rows are identical; what changes is that `limit` may go to 200 and the paid-only parameters are honoured. The response below is an illustration: /public/jobs?country=IE&limit=2 could not be read when this page was built, so run the command to see today's.

4Step four: the three things to get right before you ship

Treat next_cursor as opaque, read field_sources before trusting a field, and handle 402 differently from 429. Everything else is detail.

In depthWhy it exists, what it is not, what people get wrong
next_cursor is opaque
It looks like a timestamp and a uuid joined by a pipe, and it is not: it is a token to hand back verbatim. Parsing it, reconstructing it, or storing its halves separately will work until the day the ordering changes, and then silently skip or repeat rows. Pagination.
field_sources before the field
Every row carries, per field, whether the source published it, whether we inferred it, or whether it is absent. A remote flag we inferred from the location text is not the same fact as one the employer ticked, and the row tells you which it is. Provenance.
402 is not 429
429 means slow down and retry. 402 means this month's record allowance is spent and retrying will not help until the first of the month. A client that treats them the same will hammer a wall for three weeks. Errors.
Exact contractTypes, defaults, ranges, errors, edge cases

Two more that cost less but still cost. First, do not hard-code a filter vocabulary: providers, categories and source types are enumerable at run time from /public/facets and /public/providers, and a value that has left the list is a 422 rather than an empty page, which is a feature, but only if you read it. Second, keep company identity on company_domain where you have it: slugs are not currently guaranteed stable across refreshes. Company slugs.

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.