Job Opportunities API

Check the data. Then trust it.

endpoints

Listings

The four endpoints that return job rows: the live list, one listing, the closure list, and the cheap id-only closure feed.

Last verified 2026-08-22 · Assumes: Authentication, pagination. · Markdown copy

1GET /v1/jobs

The main endpoint. Live listings, newest first, keyset-paged, with about thirty-five filters.

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

Sorted by posted_at DESC NULLS LAST, id DESC. The id component makes the ordering total, so rows sharing a posting date cannot swap places between two requests and be counted twice or skipped. Rows with no posted_at sort last rather than being excluded.

Offsets are not offered here. At this scale rows shift constantly and an offset double-counts when they do; the cursor asks for “rows after this one”, which stays correct however much moved. Pagination in full.

The keyless mirror is /public/jobs, which returns the same rows with a 50-row cap, no cursor, and the paid-only parameters ignored. It is the right thing to point a browser at.

Exact contractTypes, defaults, ranges, errors, edge cases
limitintegerdefault 251–200
cursorstring

The next_cursor from the previous page.

statusstringdefault live
liveclosedany

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_typestringcomma-separated
staffingjobboardall

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.

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

categorystringcomma-separated

Comma-separated. uncategorised selects rows with no confident classification.

countrystringcomma-separated

Comma-separated ISO-3166 alpha-2.

citystring
statestringcomma-separated

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.

remotestring

remote, hybrid, on_site, or not_stated.

employment_typestringcomma-separated

Comma-separated; not_stated selects rows with none.

senioritystringcomma-separated

Comma-separated; not_stated selects rows with none.

providerstringcomma-separated

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_providerstring

Same vocabulary as provider, removed instead of kept.

source_typestringcomma-separated

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_typestring
companystringcomma-separated

Comma-separated company slugs.

exclude_categorystring
exclude_countrystring
remote_confirmedboolean

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_salarystring
truestructuredany

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_descriptionboolean

true returns only the 2,932,043 live rows (80.6%) that carry a description.

require_fieldsstringcomma-separated

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_descriptionboolean

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_afterstring

Date or RFC3339 timestamp.

verified_afterstring

Only rows re-confirmed at their source since this instant.

qstring

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.

titlestring

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_excludestring

Drop rows whose title matches these words. Same matching as title.

description_containsstring

Full-text over the advert body. Only ever matches rows that have one, so it implies has_description=true.

company_domainstringcomma-separated

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_domainstring

Same vocabulary as company_domain, removed instead of kept.

min_salarynumber

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_salarynumber

Upper bound on salary_min_annual_eur. Same 2.0% caveat as min_salary.

34 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug — please say so with the thumbs-down below.

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.

Records charged: one per row returned. limit=200 costs 200 records and one request. See the record meter.

A first page
$ curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs?country=FR&limit=2'
{
  "data": [
    {
      "id": "c07a4cfa-a289-47a0-9392-9b56e677988c",
      "slug": "consultant-senior-tech-risk-it-m-a-26-27-h-f-c07a4cfa",
      "title": "Consultant Senior Tech Risk & IT M&A 26-27 (H/F)",
      "company": "MAZARS",
      "company_slug": "mazars",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/mazars.png",
      "category_confidence": null,
      "country": "FR",
      "city": "Levallois-Perret",
      "location": "Levallois-Perret, IDF, France",
      "remote": "hybrid",
      "remote_inferred": false,
      "posted_at": "2026-09-11T17:14:36Z",
      "first_seen_at": "2026-09-11T18:21:59Z",
      "last_verified_at": "2026-09-11T18:50:41Z",
      "status": "live",
      "closed_at": null,
      "closed_reason": null,
      "apply_url": "https://jobs.smartrecruiters.com/MAZARS/6af53f5d-2d5f-47e9-9723-aaad463f83b1",
      "source": "smartrecruiters",
      "source_type": "ats",
      "provider_type": "employer_ats",
      "has_description": false,
      "field_sources": {
        "remote": "published",
        "employment_type": "absent",
        "category": "absent",
        "seniority": "absent",
        "salary": "absent",
        "location": "inferred",
        "posted_at": "published",
        "description": "absent",
        "source_type": "inferred"
      }
    },
    {
      "id": "716a7025-9b08-439d-a35e-aa5a31fd0487",
… 41 more lines
The response shown comes from the keyless mirror of the same query, because this page is built without a key. The rows are identical. Real response, fetched from /public/jobs?country=FR&limit=2 when this page was built (11 September 2026, 18:56 UTC). Run the command yourself and you will get today's rows, not these.
Walk every page of a filtered slice
A=https://api.jobopportunitiesapi.org
H="Authorization: Bearer $JOA_KEY"
cursor=''
while : ; do
  page=$(curl -s -H "$H" --get "$A/v1/jobs" \
          --data-urlencode 'country=IE' \
          --data-urlencode 'limit=200' \
          ${cursor:+--data-urlencode "cursor=$cursor"})
  echo "$page" | jq -c '.data[] | {id, title, company}'
  [ "$(echo "$page" | jq -r '.has_more')" = 'true' ] || break
  cursor=$(echo "$page" | jq -r '.next_cursor')
done

2GET /v1/jobs/{id}

One listing, with its description. Takes either the uuid or the public slug.

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

Unlike the list endpoint, this one includes the advert body by default — you are asking for one row, so the size argument does not apply. It costs one record.

A closed listing returns 404 unless you pass include_closed=true, in which case it comes back with status: "closed" and its closed_at and closed_reason populated. That default is the safe one: a mirror fetching a row it already knows about should not silently start serving a dead vacancy.

Exact contractTypes, defaults, ranges, errors, edge cases
idstringrequired

The uuid or the public slug.

include_closedboolean

Return the row even if it has been delisted. It carries status="closed".

2 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug — please say so with the thumbs-down below.

CodeWhat it means
200The listing.
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.
404No such row.
429Over your plan's per-minute or per-day limit. Retry-After says how long.

The keyless mirror is /public/jobs/{slug}, which always includes the description. Both accept the uuid and the slug; the uuid is the stable identity and the slug is a readable alias built from the title plus the first eight characters of the uuid.

Fetch a row by uuid, then the same row by slug
A=https://api.jobopportunitiesapi.org
slug=$(curl -s "$A/public/jobs?limit=1" | jq -r '.data[0].slug')
id=$(curl -s   "$A/public/jobs?limit=1" | jq -r '.data[0].id')
curl -s "$A/public/jobs/$slug" | jq '{title: .data.title, has_description: .data.has_description}'
curl -s "$A/public/jobs/$id"   | jq '{title: .data.title}'

3GET /v1/jobs/closed

Roles that have left their source, most recently closed first, with every filter /v1/jobs takes.

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

This is the full-row view of closures: the same job objects, with status: "closed", closed_at and closed_reason populated. Use it when you want the content of what closed — for a chart of which categories a company stopped hiring for, say.

When you only need to mark rows stale in your own store, this is the expensive way to do it. /v1/jobs/expired returns ids, dates and reasons only, is about ninety per cent smaller, and charges the same one record per row — so it is cheaper in bandwidth and time, though not in records.

Exact contractTypes, defaults, ranges, errors, edge cases
limitintegerdefault 251–200
cursorstring
closed_afterstring
closed_beforestring
closed_reasonstring
expired_upstreamnot_seen
categorystringcomma-separated

Comma-separated. uncategorised selects rows with no confident classification.

countrystringcomma-separated

Comma-separated ISO-3166 alpha-2.

citystring
statestringcomma-separated

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.

remotestring

remote, hybrid, on_site, or not_stated.

employment_typestringcomma-separated

Comma-separated; not_stated selects rows with none.

senioritystringcomma-separated

Comma-separated; not_stated selects rows with none.

providerstringcomma-separated

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_providerstring

Same vocabulary as provider, removed instead of kept.

source_typestringcomma-separated

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_typestring
companystringcomma-separated

Comma-separated company slugs.

exclude_categorystring
exclude_countrystring
remote_confirmedboolean

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_salarystring
truestructuredany

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_descriptionboolean

true returns only the 2,932,043 live rows (80.6%) that carry a description.

require_fieldsstringcomma-separated

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_descriptionboolean

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_afterstring

Date or RFC3339 timestamp.

verified_afterstring

Only rows re-confirmed at their source since this instant.

qstring

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.

titlestring

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_excludestring

Drop rows whose title matches these words. Same matching as title.

description_containsstring

Full-text over the advert body. Only ever matches rows that have one, so it implies has_description=true.

company_domainstringcomma-separated

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_domainstring

Same vocabulary as company_domain, removed instead of kept.

min_salarynumber

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_salarynumber

Upper bound on salary_min_annual_eur. Same 2.0% caveat as min_salary.

34 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug — please say so with the thumbs-down below.

Plan: paid keys only. Records charged: one per row.

What closed in Ireland recently
curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs/closed?country=IE&limit=5' \
  | jq -r '.data[] | "\(.closed_at)  \(.closed_reason)  \(.company) — \(.title)"'

4GET /v1/jobs/expired

Ids, closure dates and reasons only — the cheap half of the closure feed, and the right way to keep a mirror honest.

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

Keyset-paged exactly like /v1/changes: pass since on the first call and the returned next_since from then on. next_since is a cursor of the same character as next_cursor — a timestamp and a uuid — and is subject to the same rule: hand it back verbatim, never parse it.

An empty page echoes your cursor back rather than returning null, so a polling loop keeps working once it is current. You do not need a special case for “caught up”.

Exact contractTypes, defaults, ranges, errors, edge cases
sincestringrequired

RFC3339 timestamp, or a next_since cursor from a previous call.

limitintegerdefault 10001–10000

1-10000, default 1000.

2 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug — please say so with the thumbs-down below.

The response, from a real call
{
  "count": 2,
  "data": [
    { "id": "02c7da8e-188a-4e89-b754-1e0969078b0e",
      "closed_at": "2026-08-22T19:04:56.842089Z",
      "closed_reason": "not_seen" },
    { "id": "037eac30-ad91-4f43-a0d7-cd6be3bcddc9",
      "closed_at": "2026-08-22T19:04:56.842089Z",
      "closed_reason": "not_seen" }
  ],
  "next_since": "2026-08-22T19:04:56.842089Z|037eac30-ad91-4f43-a0d7-cd6be3bcddc9"
}

Plan: requires delta_feed. A plan without it gets a 403 with X-JOA-Required-Feature: delta_feed. Records charged: one per id returned.

Mark your own rows stale, then remember where you got to
A=https://api.jobopportunitiesapi.org
since=$(cat .joa-expired-cursor 2>/dev/null || echo '2026-08-21T00:00:00Z')
page=$(curl -s -H "Authorization: Bearer $JOA_KEY" --get "$A/v1/jobs/expired" \
        --data-urlencode "since=$since" --data-urlencode 'limit=1000')
echo "$page" | jq -r '.data[] | "\(.id)\t\(.closed_reason)"'
echo "$page" | jq -r '.next_since' > .joa-expired-cursor

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