endpoints
Listings
The four endpoints that return job rows: the live list, one listing, the closure list, and the cheap id-only closure feed.
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–200cursorstringThe next_cursor from the previous page.
statusstringdefault liveliveclosedanylive (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-separatedstaffingjobboardallRe-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.
qualitystringallall 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-separatedComma-separated. uncategorised selects rows with no confident classification.
countrystringcomma-separatedComma-separated ISO-3166 alpha-2.
citystringstatestringcomma-separatedComma-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.
remotestringremote, hybrid, on_site, or not_stated.
employment_typestringcomma-separatedComma-separated; not_stated selects rows with none.
senioritystringcomma-separatedComma-separated; not_stated selects rows with none.
providerstringcomma-separatedComma-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_providerstringSame vocabulary as provider, removed instead of kept.
source_typestringcomma-separatedComma-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_typestringcompanystringcomma-separatedComma-separated company slugs.
exclude_categorystringexclude_countrystringremote_confirmedbooleantrue 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_salarystringtruestructuredanytrue (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_descriptionbooleantrue returns only the 2,932,043 live rows (80.6%) that carry a description.
require_fieldsstringcomma-separatedComma-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_descriptionbooleanReturn 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_afterstringDate or RFC3339 timestamp.
verified_afterstringOnly rows re-confirmed at their source since this instant.
qstringFull-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.
titlestringFull-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_excludestringDrop rows whose title matches these words. Same matching as title.
description_containsstringFull-text over the advert body. Only ever matches rows that have one,
so it implies has_description=true.
company_domainstringcomma-separatedComma-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_domainstringSame vocabulary as company_domain, removed instead of kept.
min_salarynumberLower 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_salarynumberUpper 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.
| 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. |
Records charged: one per row returned. limit=200 costs 200 records and one request. See the record meter.
$ 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
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')
done2GET /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
idstringrequiredThe uuid or the public slug.
include_closedbooleanReturn 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.
| Code | What it means |
|---|---|
200 | The listing. |
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. |
404 | No such row. |
429 | Over 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.
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–200cursorstringclosed_afterstringclosed_beforestringclosed_reasonstringexpired_upstreamnot_seencategorystringcomma-separatedComma-separated. uncategorised selects rows with no confident classification.
countrystringcomma-separatedComma-separated ISO-3166 alpha-2.
citystringstatestringcomma-separatedComma-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.
remotestringremote, hybrid, on_site, or not_stated.
employment_typestringcomma-separatedComma-separated; not_stated selects rows with none.
senioritystringcomma-separatedComma-separated; not_stated selects rows with none.
providerstringcomma-separatedComma-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_providerstringSame vocabulary as provider, removed instead of kept.
source_typestringcomma-separatedComma-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_typestringcompanystringcomma-separatedComma-separated company slugs.
exclude_categorystringexclude_countrystringremote_confirmedbooleantrue 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_salarystringtruestructuredanytrue (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_descriptionbooleantrue returns only the 2,932,043 live rows (80.6%) that carry a description.
require_fieldsstringcomma-separatedComma-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_descriptionbooleanReturn 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_afterstringDate or RFC3339 timestamp.
verified_afterstringOnly rows re-confirmed at their source since this instant.
qstringFull-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.
titlestringFull-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_excludestringDrop rows whose title matches these words. Same matching as title.
description_containsstringFull-text over the advert body. Only ever matches rows that have one,
so it implies has_description=true.
company_domainstringcomma-separatedComma-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_domainstringSame vocabulary as company_domain, removed instead of kept.
min_salarynumberLower 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_salarynumberUpper 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.
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
sincestringrequiredRFC3339 timestamp, or a next_since cursor from a previous call.
limitintegerdefault 10001–100001-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.
{
"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.
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-cursorThis 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.