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.
To learn how many rows match, add include_total=true: the page then also carries total_estimate (exact up to a 10,000 row limit) and total_estimate_status (exact, at_least or timed_out). The count has its own 4-second budget, is not charged as records, and never costs you the page. Counting results.
Employer flooding. Employers post in bursts and the order is newest first, so one employer can fill a page (measured 24 Oct 2026: the newest 50 rows of the public browse hold 12 distinct employers). Two opt-in parameters control that, and with neither sent the response is exactly what it has always been. collapse=company returns at most ONE row per employer per request: the newest row the walk reaches for that company in the published order, with the roles behind it hidden. max_per_company=N returns at most N rows of one employer per request (1-200). The order never changes and every count still counts matching ROWS: include_total and the facet chips are unaffected by either. Only the rows returned are charged as records. With ?company=, ?country= or any other filter both work inside the filtered set, exactly as the rows do.
The collapsed shape. With collapse=company every returned row carries company_more_roles, 0 included (it is absent without the parameter, so existing payloads keep their exact bytes): the number of further roles of this employer that the request read and hid behind this row. It counts the rows the request read (a request reads at most 2,000 rows per collapsed page), so roles past that are not counted; ?company=<company_slug> lists an employer's complete set. Hidden rows are NOT deferred: next_cursor resumes after the last row the request decided on, so a hidden row never returns on a later page, and a collapsed page carries at most limit rows and often fewer. Pagination in full.
There used to be a keyless mirror at /public/jobs. It answered the same rows with a 50-row cap and no cursor, and it was the right thing to point a browser at, until an anonymous caller pacing itself under the keyless rate limit turned out to be able to walk the whole ledger through it anyway, one bounded page at a time. /public/jobs now answers 401 to anyone but jobopportunitiesapi.org's own page rendering, key or no key: read Keyless access for what is still open (statistics, not rows). Point a browser at /v1/jobs from your own server instead, never with the key embedded client-side.
Exact contractTypes, defaults, ranges, errors, edge cases
limitintegerdefault 251 to 2001-200 (default 25). Anything else is a 422 bad_limit, not clamped.
cursorstringThe next_cursor from the previous page.
collapsestringcompanyEmployer flood control, opt-in (24 Oct 2026). company returns at
most ONE row per employer per request: the newest row the walk reaches
for that company in the published order, with the roles behind it read
and hidden. Every returned row then carries
company_more_roles. Without this parameter the response is exactly
what it has always been.
The rows behind a collapsed row are NOT deferred: next_cursor
resumes after the last row the request decided on, so a hidden row
never returns on a later page. A collapsed page therefore carries at
most limit rows and often fewer (one employer flooding the window
collapses to one row). Ordering is never changed: the published
posted_at DESC NULLS LAST, id DESC contract holds. Counts are
untouched: include_total and every count surface still count
matching ROWS, not employers. Only the rows actually returned are
charged as records.
max_per_companyinteger1 to 200Employer flood control, opt-in (24 Oct 2026): at most N rows of one
employer per request (1-200). Rows beyond N are read and hidden where
they stand, never deferred to a later page, so a page carries at most
limit rows and often fewer. Counts (include_total, the facet
chips) still count every matching row. max_per_company=1 agrees
with collapse=company and may be sent with it; any other value
together with collapse=company is a 422
flood_params_conflict, because the two contradict each other.
Unlike collapse=company it adds no field to the rows.
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 thousands of listings (18,000 in one case, measured 19 Aug 2026) 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.
familystringAlias for category (the facet key in /v1/meta/facets is named
family; the filter parameter was category). Both names are
accepted everywhere category is.
countrystringcomma-separatedComma-separated ISO-3166 alpha-2, at most 60 codes (more is a 422 too_many_values, never truncated).
citystringcomma-separatedComma-separated city names, matched exactly but case-insensitively in
every script (city=αθήνα finds Αθήνα); accents must match. A
two-letter term that is a US state code or a country code (what
country= accepts, plus UK for GB) qualifies the other city terms
instead of naming a second city, so city=Exeter, RI returns only
Exeter in Rhode Island: the US state reading wins a collision, and a
qualifier with no city term left (such as city=GB) is still matched
as a city value.
statestringcomma-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: full-time, part-time, contract, temporary, internship
(case-insensitive; full_time / part_time are accepted too); not_stated
selects rows with none. Matches exactly the employment_type value the
response shows, including values read from the posting page by the
verification sweep (since 6 Oct 2026; before that only the harvested
value was matched, about a sixth as many rows).
Coverage, measured 23 Oct 2026: 39.3% of served rows carry a value
(942,302 of 2,399,121); the rest say not_stated, because the source
publishes no employment type. A contract-duration word alone
(Permanent, Regular, CDI, Employee) is not read as Full-time.
engagementstringcomma-separatedComma-separated (OR, up to 8, case-insensitive): employee, vendor,
other, or unknown (alias not_stated). The employment RELATIONSHIP
the posting states: employee is payroll employment under the posting
country's law (including temp-agency and zero-hours work); vendor is
a commercial/self-employed arrangement (freelance, B2B, 1099, C2C,
Brazilian PJ); other is a stated hybrid national form that everyday
understanding puts on neither side (Polish umowa zlecenie, Italian
co.co.co., umbrella/portage arrangements, statutory non-employment
internships, volunteering). The raw national term is retained
internally and refines these over time. The row value is an ARRAY
(dual-option adverts such as "CLT ou PJ" carry both options, main one
first); the filter matches any element. unknown selects rows where
the posting does not establish the relationship (nothing is ever
inferred from silence), so at launch most rows are unknown. An
unrecognised value is a 422 naming the legal list.
contract_categorystringcomma-separatedComma-separated (OR, up to 8, case-insensitive): permanent,
fixed_term, project_based, seasonal, apprenticeship,
internship, other, or unknown (alias not_stated). The contract
KIND the posting states. contract_category=permanent returns only
postings that STATE permanence (CDI, unbefristet, tempo indeterminato,
contrato indefinido, ...). A posting that merely says "Full-time" is
unknown, because most employers do not state the contract kind and
this API does not guess it. The row value is an ARRAY (dual-option
adverts); the filter matches any element. other is a stated form none
of the named values can represent (zero-hours, casual, per diem,
Minijob, Werkstudent, ...), never a dumping ground for uncertainty.
agencystringcomma-separatedComma-separated (OR, case-insensitive): direct, agency,
umbrella, or unknown (alias not_stated). Who the employer of
record is: the advertiser itself, a temporary-work agency (the
engagement is then employee, because EU and national law make agency workers
employees of the agency, and contract_category describes the
contract WITH the agency), or an umbrella / portage company. unknown
selects rows where it is not established.
working_schedulestringcomma-separatedComma-separated (OR, case-insensitive): full_time, part_time,
flexible, or unknown (alias not_stated). The stated schedule;
flexible covers adverts offering either schedule. Distinct from the
frozen employment_type field, which this API keeps unchanged.
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_countrystringlanguagestringcomma-separatedISO 639-1 two-letter language code of the posting text (e.g. de, fr, en).
Comma-separated, up to 12 languages (OR).
language_excludestringcomma-separatedExclude postings in these ISO 639-1 language codes. Comma-separated, up to 12 languages. Rows with undetected language are kept.
country_confidencestringhighlowServer-side filter on how trustworthy the job's country is (see field_sources.country).
high keeps only rows that have a country that is NOT low-trust: published, or inferred
by a rule or by gazetteer and language model agreeing (measured precision about 99.5%).
low returns only the llm_inferred rows, whose country a language model alone read out of
the location text (measured precision about 95%). Omit it to receive everything. Any other
value is a 422 bad_country_confidence and no records are billed.
compensation_typestringcommission_onlyServer-side filter for commission-only postings (db/370, DQ rule v5).
The only legal value is commission_only; any other value is a 422
bad_compensation_type and no records are billed. Informational field
(see the compensation_type description on the Job schema).
exclude_compensation_typestringcommission_onlyExclude postings the commission-only rule flagged (db/370). The only
legal value is commission_only, matching the API's existing
exclude_* convention (exclude_category, exclude_country, ...).
remote_confirmedbooleantrue returns only listings whose remote status the SOURCE stated:
284,334 of 2,437,027 live rows (11.7%).
Without it you also receive the 2,147,930
(88.1%) 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,318,210 live rows (95.1%) 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 12.6% of your rows have a
salary". They do, and require_fields=salary returns
307,796 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 averaged 2,581 bytes (measured 5 Aug 2026), 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.
Personal email addresses in the text are replaced with
[personal contact removed]; company mailboxes and phone numbers are
kept (see the description field).
posted_afterstringDate or RFC3339 timestamp.
verified_afterstringOnly rows whose last_verified_at (the last positive confirmation at the source, see the Job schema) is at or after this instant.
listed_afterstringOnly rows that entered this API after this instant (date or RFC3339 timestamp), as recorded by the served-set ledger behind /v1/changes: new rows, and rows that came back after a withdrawal or re-opened after a closure, each counted from its latest entry. Use it to poll for new jobs, on any plan. posted_after cannot do that: posted_at is the employer's own date and is often days older than the moment a job reaches the API. Rows served since before 2026-10-23 06:11 UTC count from their first_seen_at. Poll with listed_after = <start of your previous poll> minus a few minutes and de-duplicate on id. On /v1/jobs/closed it selects closed rows that had entered after 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. Terms are ANDed. Without extended syntax a term
matches whole words only (no stemming): q=engineer does not match
engineering. Extended syntax, applied whenever it is used:
- "customer support": exact phrase, adjacent and in order.
- entwickler*: prefix match, which finds entwicklerin and the German
gender-inclusive entwickler/in (one token to the tokenizer).
Inside an extended query, terms of 5+ letters are prefix-matched
automatically (remote finds remotely); quote a term for an
exact match.
- developer OR entwickler: either term. OR binds LOOSER than
adjacency (2026-10-25): a b OR c d is (a b) OR (c d), so four
word-pair alternatives written one after the other really are four
alternatives. Until that date OR bound tighter and the same query
read as a AND (b OR c) AND d.
- (a OR b) c: parentheses group. They decide what an OR binds to.
Around plain words with no OR inside they change nothing.
- -berlin: exclude rows matching this term. The dash must touch the
term; a free-standing dash is punctuation.
A value the grammar cannot honour is refused with 422
query_syntax (an unmatched parenthesis, an empty group, an OR
with no term on one side, a bare AND or NOT), never read as
something else. Nothing is charged.
Matching is case-insensitive in every script and accent-insensitive
(2026-10-23): λογιστής, Λογιστής and ΛΟΓΙΣΤΗΣ are one query,
and developpeur finds Développeur as développeur does.
Localized syntax examples, one per customer language: German
entwickler* and the exact phrase "software entwickler"; French
développeur* and "service client"; Spanish
desarrollador* and "marketing digital"; Dutch verpleegkundige*
(prefix finds the plural). Works the same in every language the
ledger carries.
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.
Extended syntax (2026-10-07): quoted phrases ("software entwickler"),
prefix (entwickler* or any bare word of 5+ letters), OR, and
-word exclusions work exactly as they do on q, matched against
the title only. Parentheses group and OR binds last (2026-10-25),
as on q: a b OR c d is (a b) OR (c d). A plain value keeps the old AND-of-words behaviour.
Case-insensitive in every script and accent-insensitive, as q
(2026-10-23): title=ärztin, title=Ärztin and title=arztin
are one query.
title_excludestringDrop rows whose title matches these words. Same matching, and the
same extended syntax (phrases, prefix, OR, exclusions,
parentheses), as title.
description_containsstringFull-text over the advert body. Only ever matches rows that have one,
so it implies has_description=true.
Extended syntax (2026-10-07): quoted phrases ("customer support"),
prefix (kundenbetreuung* or any bare word of 5+ letters), OR, and
-word exclusions work exactly as they do on q, with parentheses
grouping and OR binding last (2026-10-25). A phrase matches
the words in that order, anywhere in the body. Broad phrases over
millions of adverts are the most expensive query this API can be
asked; prefer a country or category alongside. A plain value keeps
the old AND-of-words behaviour. Case-insensitive in every script
(2026-10-23) for words the advert writes in lower case, Capitalised
or in capitals. An accented term also finds the unaccented spelling;
an unaccented term does not yet find accented advert text.
company_domainstringcomma-separatedComma-separated bare domains, e.g. stripe.com,figma.com: no scheme
and no path, at most 100 (more is a 422 too_many_values, never
truncated). 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. Only rows whose salary could be
normalised to an annual EUR figure can match (the current share is on
/public/coverage, field salary_eur), so this is a narrow filter by
nature, not a broken one. It compares every normalisable figure,
published by the source or read out of the advert text
(field_sources.salary and salary_source say which); add
has_salary=true to compare only salaries the source published.
max_salarynumberUpper bound on salary_min_annual_eur. Same scope as min_salary.
sortstringrelevancesort=relevance orders by where and how the query matches instead of
recency: relevance-scored, band-weighted (title, category, company,
location), tolerant of equivalents and abbreviations. Ordering-only:
scores are comparable within a response, never across queries. Each
row then carries a relevance object {score, tier} (tier one of
exact, synonym, abbreviation, typo). Requires q and an API key;
q must be under 200 bytes; other sort values are rejected.
Deployment-gated. While the ranked mode is not enabled on the
deployment (the case today), any sort value is refused with
422 sort_unavailable and nothing is charged; results are always
newest first. It is never silently ignored.
include_totalbooleandefault falseAdd total_estimate and total_estimate_status to the response: the
number of rows matching the filters, exact up to 10,000 and
page-independent. Costs one extra bounded query (at most 4 seconds;
past that the page is still returned, with
total_estimate_status: timed_out). Not charged as records.
49 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. |
400 | A parameter the endpoint does not take. unknown_parameter: the name
is not a parameter of this endpoint; the message names it and, when a
valid parameter is within a small edit distance, suggests it
("unknown parameter: 'countyr'; did you mean 'country'?").
unsupported_parameter: a real parameter of another endpoint that has
no meaning here (for example limit on /v1/export). Refused before
the meter: nothing is charged. |
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 allowance resets. The monthly window is the CALENDAR month in UTC: it resets on the 1st of the next month at 00:00 UTC, and the body gives the exact instant. Do not retry before then. Every metered response carries X-RateLimit-Records-Remaining and X-RateLimit-Records-Reset (the reset instant, Unix seconds), so this is visible long before it happens. |
422 | A parameter value we will not guess at: an unknown source_type or provider, an unparseable timestamp, a malformed cursor, a limit outside 1-200 or not a whole number (bad_limit), a flag that is not true/false (bad_boolean), a comma-separated list longer than its maximum (too_many_values), a text
filter the query grammar cannot honour (query_syntax: an unmatched
parenthesis, an empty group, an OR with no term on one side, a bare
AND or NOT, since 2026-10-25). Nothing is charged. The one visible reduction: a limit within 1-200 but above the plan's page cap is reduced to the cap and flagged in X-JOA-Limit-Applied. Unknown parameter NAMES are a 400 (BadRequest), never ignored. |
429 | Over your plan's per-minute or per-day limit. Retry-After says how long (seconds to the next minute boundary, or to UTC midnight on a daily breach). The body says exactly when the limit resets (resets_at, resets_at_text) and, when a higher plan exists, links a same-key upgrade (upgrade_url). |
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'
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. |
400 | A parameter the endpoint does not take. unknown_parameter: the name
is not a parameter of this endpoint; the message names it and, when a
valid parameter is within a small edit distance, suggests it
("unknown parameter: 'countyr'; did you mean 'country'?").
unsupported_parameter: a real parameter of another endpoint that has
no meaning here (for example limit on /v1/export). Refused before
the meter: nothing is charged. |
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 allowance resets. The monthly window is the CALENDAR month in UTC: it resets on the 1st of the next month at 00:00 UTC, and the body gives the exact instant. Do not retry before then. Every metered response carries X-RateLimit-Records-Remaining and X-RateLimit-Records-Reset (the reset instant, Unix seconds), so this is visible long before it happens. |
404 | No such row. |
410 | The employer withdrew this listing (a role the employer pushed to us directly that was paused, unlisted, closed, expired or deleted). Final for this id: stop serving it. |
429 | Over your plan's per-minute or per-day limit. Retry-After says how long (seconds to the next minute boundary, or to UTC midnight on a daily breach). The body says exactly when the limit resets (resets_at, resets_at_text) and, when a higher plan exists, links a same-key upgrade (upgrade_url). |
/public/jobs/{slug} used to be a keyless mirror that always included the description. It now needs a key too, same as /public/jobs. See /public/jobs and /public/jobs/{id}. Both the keyless and keyed forms still 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
H="Authorization: Bearer $JOA_KEY"
slug=$(curl -s -H "$H" "$A/v1/jobs?limit=1" | jq -r '.data[0].slug')
id=$(curl -s -H "$H" "$A/v1/jobs?limit=1" | jq -r '.data[0].id')
curl -s -H "$H" "$A/v1/jobs/$slug" | jq '{title: .data.title, has_description: .data.has_description}'
curl -s -H "$H" "$A/v1/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 to 200cursorstringclosed_afterstringclosed_beforestringclosed_reasonstringexpired_upstreamnot_seenapply_link_deademployer_closeddeadline_passedcategorystringcomma-separatedComma-separated. uncategorised selects rows with no confident classification.
familystringAlias for category (the facet key in /v1/meta/facets is named
family; the filter parameter was category). Both names are
accepted everywhere category is.
countrystringcomma-separatedComma-separated ISO-3166 alpha-2, at most 60 codes (more is a 422 too_many_values, never truncated).
citystringcomma-separatedComma-separated city names, matched exactly but case-insensitively in
every script (city=αθήνα finds Αθήνα); accents must match. A
two-letter term that is a US state code or a country code (what
country= accepts, plus UK for GB) qualifies the other city terms
instead of naming a second city, so city=Exeter, RI returns only
Exeter in Rhode Island: the US state reading wins a collision, and a
qualifier with no city term left (such as city=GB) is still matched
as a city value.
statestringcomma-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: full-time, part-time, contract, temporary, internship
(case-insensitive; full_time / part_time are accepted too); not_stated
selects rows with none. Matches exactly the employment_type value the
response shows, including values read from the posting page by the
verification sweep (since 6 Oct 2026; before that only the harvested
value was matched, about a sixth as many rows).
Coverage, measured 23 Oct 2026: 39.3% of served rows carry a value
(942,302 of 2,399,121); the rest say not_stated, because the source
publishes no employment type. A contract-duration word alone
(Permanent, Regular, CDI, Employee) is not read as Full-time.
engagementstringcomma-separatedComma-separated (OR, up to 8, case-insensitive): employee, vendor,
other, or unknown (alias not_stated). The employment RELATIONSHIP
the posting states: employee is payroll employment under the posting
country's law (including temp-agency and zero-hours work); vendor is
a commercial/self-employed arrangement (freelance, B2B, 1099, C2C,
Brazilian PJ); other is a stated hybrid national form that everyday
understanding puts on neither side (Polish umowa zlecenie, Italian
co.co.co., umbrella/portage arrangements, statutory non-employment
internships, volunteering). The raw national term is retained
internally and refines these over time. The row value is an ARRAY
(dual-option adverts such as "CLT ou PJ" carry both options, main one
first); the filter matches any element. unknown selects rows where
the posting does not establish the relationship (nothing is ever
inferred from silence), so at launch most rows are unknown. An
unrecognised value is a 422 naming the legal list.
contract_categorystringcomma-separatedComma-separated (OR, up to 8, case-insensitive): permanent,
fixed_term, project_based, seasonal, apprenticeship,
internship, other, or unknown (alias not_stated). The contract
KIND the posting states. contract_category=permanent returns only
postings that STATE permanence (CDI, unbefristet, tempo indeterminato,
contrato indefinido, ...). A posting that merely says "Full-time" is
unknown, because most employers do not state the contract kind and
this API does not guess it. The row value is an ARRAY (dual-option
adverts); the filter matches any element. other is a stated form none
of the named values can represent (zero-hours, casual, per diem,
Minijob, Werkstudent, ...), never a dumping ground for uncertainty.
agencystringcomma-separatedComma-separated (OR, case-insensitive): direct, agency,
umbrella, or unknown (alias not_stated). Who the employer of
record is: the advertiser itself, a temporary-work agency (the
engagement is then employee, because EU and national law make agency workers
employees of the agency, and contract_category describes the
contract WITH the agency), or an umbrella / portage company. unknown
selects rows where it is not established.
working_schedulestringcomma-separatedComma-separated (OR, case-insensitive): full_time, part_time,
flexible, or unknown (alias not_stated). The stated schedule;
flexible covers adverts offering either schedule. Distinct from the
frozen employment_type field, which this API keeps unchanged.
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_countrystringlanguagestringcomma-separatedISO 639-1 two-letter language code of the posting text (e.g. de, fr, en).
Comma-separated, up to 12 languages (OR).
language_excludestringcomma-separatedExclude postings in these ISO 639-1 language codes. Comma-separated, up to 12 languages. Rows with undetected language are kept.
country_confidencestringhighlowServer-side filter on how trustworthy the job's country is (see field_sources.country).
high keeps only rows that have a country that is NOT low-trust: published, or inferred
by a rule or by gazetteer and language model agreeing (measured precision about 99.5%).
low returns only the llm_inferred rows, whose country a language model alone read out of
the location text (measured precision about 95%). Omit it to receive everything. Any other
value is a 422 bad_country_confidence and no records are billed.
compensation_typestringcommission_onlyServer-side filter for commission-only postings (db/370, DQ rule v5).
The only legal value is commission_only; any other value is a 422
bad_compensation_type and no records are billed. Informational field
(see the compensation_type description on the Job schema).
exclude_compensation_typestringcommission_onlyExclude postings the commission-only rule flagged (db/370). The only
legal value is commission_only, matching the API's existing
exclude_* convention (exclude_category, exclude_country, ...).
remote_confirmedbooleantrue returns only listings whose remote status the SOURCE stated:
284,334 of 2,437,027 live rows (11.7%).
Without it you also receive the 2,147,930
(88.1%) 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,318,210 live rows (95.1%) 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 12.6% of your rows have a
salary". They do, and require_fields=salary returns
307,796 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 averaged 2,581 bytes (measured 5 Aug 2026), 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.
Personal email addresses in the text are replaced with
[personal contact removed]; company mailboxes and phone numbers are
kept (see the description field).
posted_afterstringDate or RFC3339 timestamp.
verified_afterstringOnly rows whose last_verified_at (the last positive confirmation at the source, see the Job schema) is at or after this instant.
listed_afterstringOnly rows that entered this API after this instant (date or RFC3339 timestamp), as recorded by the served-set ledger behind /v1/changes: new rows, and rows that came back after a withdrawal or re-opened after a closure, each counted from its latest entry. Use it to poll for new jobs, on any plan. posted_after cannot do that: posted_at is the employer's own date and is often days older than the moment a job reaches the API. Rows served since before 2026-10-23 06:11 UTC count from their first_seen_at. Poll with listed_after = <start of your previous poll> minus a few minutes and de-duplicate on id. On /v1/jobs/closed it selects closed rows that had entered after 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. Terms are ANDed. Without extended syntax a term
matches whole words only (no stemming): q=engineer does not match
engineering. Extended syntax, applied whenever it is used:
- "customer support": exact phrase, adjacent and in order.
- entwickler*: prefix match, which finds entwicklerin and the German
gender-inclusive entwickler/in (one token to the tokenizer).
Inside an extended query, terms of 5+ letters are prefix-matched
automatically (remote finds remotely); quote a term for an
exact match.
- developer OR entwickler: either term. OR binds LOOSER than
adjacency (2026-10-25): a b OR c d is (a b) OR (c d), so four
word-pair alternatives written one after the other really are four
alternatives. Until that date OR bound tighter and the same query
read as a AND (b OR c) AND d.
- (a OR b) c: parentheses group. They decide what an OR binds to.
Around plain words with no OR inside they change nothing.
- -berlin: exclude rows matching this term. The dash must touch the
term; a free-standing dash is punctuation.
A value the grammar cannot honour is refused with 422
query_syntax (an unmatched parenthesis, an empty group, an OR
with no term on one side, a bare AND or NOT), never read as
something else. Nothing is charged.
Matching is case-insensitive in every script and accent-insensitive
(2026-10-23): λογιστής, Λογιστής and ΛΟΓΙΣΤΗΣ are one query,
and developpeur finds Développeur as développeur does.
Localized syntax examples, one per customer language: German
entwickler* and the exact phrase "software entwickler"; French
développeur* and "service client"; Spanish
desarrollador* and "marketing digital"; Dutch verpleegkundige*
(prefix finds the plural). Works the same in every language the
ledger carries.
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.
Extended syntax (2026-10-07): quoted phrases ("software entwickler"),
prefix (entwickler* or any bare word of 5+ letters), OR, and
-word exclusions work exactly as they do on q, matched against
the title only. Parentheses group and OR binds last (2026-10-25),
as on q: a b OR c d is (a b) OR (c d). A plain value keeps the old AND-of-words behaviour.
Case-insensitive in every script and accent-insensitive, as q
(2026-10-23): title=ärztin, title=Ärztin and title=arztin
are one query.
title_excludestringDrop rows whose title matches these words. Same matching, and the
same extended syntax (phrases, prefix, OR, exclusions,
parentheses), as title.
description_containsstringFull-text over the advert body. Only ever matches rows that have one,
so it implies has_description=true.
Extended syntax (2026-10-07): quoted phrases ("customer support"),
prefix (kundenbetreuung* or any bare word of 5+ letters), OR, and
-word exclusions work exactly as they do on q, with parentheses
grouping and OR binding last (2026-10-25). A phrase matches
the words in that order, anywhere in the body. Broad phrases over
millions of adverts are the most expensive query this API can be
asked; prefer a country or category alongside. A plain value keeps
the old AND-of-words behaviour. Case-insensitive in every script
(2026-10-23) for words the advert writes in lower case, Capitalised
or in capitals. An accented term also finds the unaccented spelling;
an unaccented term does not yet find accented advert text.
company_domainstringcomma-separatedComma-separated bare domains, e.g. stripe.com,figma.com: no scheme
and no path, at most 100 (more is a 422 too_many_values, never
truncated). 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. Only rows whose salary could be
normalised to an annual EUR figure can match (the current share is on
/public/coverage, field salary_eur), so this is a narrow filter by
nature, not a broken one. It compares every normalisable figure,
published by the source or read out of the advert text
(field_sources.salary and salary_source say which); add
has_salary=true to compare only salaries the source published.
max_salarynumberUpper bound on salary_min_annual_eur. Same scope as min_salary.
45 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. 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.
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 to 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 27 October 2026, 11:29 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.