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.

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 200

1-200 (default 25). Anything else is a 422 bad_limit, not clamped.

cursorstring

The next_cursor from the previous page.

collapsestring
company

Employer 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 200

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

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.

familystring

Alias 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-separated

Comma-separated ISO-3166 alpha-2, at most 60 codes (more is a 422 too_many_values, never truncated).

citystringcomma-separated

Comma-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-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: 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-separated

Comma-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-separated

Comma-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-separated

Comma-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-separated

Comma-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-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
languagestringcomma-separated

ISO 639-1 two-letter language code of the posting text (e.g. de, fr, en). Comma-separated, up to 12 languages (OR).

language_excludestringcomma-separated

Exclude postings in these ISO 639-1 language codes. Comma-separated, up to 12 languages. Rows with undetected language are kept.

country_confidencestring
highlow

Server-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_typestring
commission_only

Server-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_typestring
commission_only

Exclude 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_confirmedboolean

true 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_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,318,210 live rows (95.1%) 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 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_descriptionboolean

Return 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_afterstring

Date or RFC3339 timestamp.

verified_afterstring

Only rows whose last_verified_at (the last positive confirmation at the source, see the Job schema) is at or after this instant.

listed_afterstring

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

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

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

Drop rows whose title matches these words. Same matching, and the same extended syntax (phrases, prefix, OR, exclusions, parentheses), as title.

description_containsstring

Full-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-separated

Comma-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_domainstring

Same vocabulary as company_domain, removed instead of kept.

min_salarynumber

Lower 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_salarynumber

Upper bound on salary_min_annual_eur. Same scope as min_salary.

sortstring
relevance

sort=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 false

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

CodeWhat it means
200A page of listings.
400A 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.
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 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.
422A 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.
429Over 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.

A first page
$ curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs?country=FR&limit=2'
The response shown is fetched by this page's own server-side rendering, which authenticates the same way joa-web's job and company pages do (see api/publicgate.go), not with a public key you could reuse. The rows are identical to what `/v1/jobs` returns. The response below is an illustration: /public/jobs?country=FR&limit=2 could not be read when this page was built, so run the command to see today's.
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.
400A 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.
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 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.
404No such row.
410The 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.
429Over 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.

Fetch a row by uuid, then the same row by slug
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 200
cursorstring
closed_afterstring
closed_beforestring
closed_reasonstring
expired_upstreamnot_seenapply_link_deademployer_closeddeadline_passed
categorystringcomma-separated

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

familystring

Alias 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-separated

Comma-separated ISO-3166 alpha-2, at most 60 codes (more is a 422 too_many_values, never truncated).

citystringcomma-separated

Comma-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-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: 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-separated

Comma-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-separated

Comma-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-separated

Comma-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-separated

Comma-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-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
languagestringcomma-separated

ISO 639-1 two-letter language code of the posting text (e.g. de, fr, en). Comma-separated, up to 12 languages (OR).

language_excludestringcomma-separated

Exclude postings in these ISO 639-1 language codes. Comma-separated, up to 12 languages. Rows with undetected language are kept.

country_confidencestring
highlow

Server-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_typestring
commission_only

Server-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_typestring
commission_only

Exclude 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_confirmedboolean

true 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_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,318,210 live rows (95.1%) 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 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_descriptionboolean

Return 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_afterstring

Date or RFC3339 timestamp.

verified_afterstring

Only rows whose last_verified_at (the last positive confirmation at the source, see the Job schema) is at or after this instant.

listed_afterstring

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

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

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

Drop rows whose title matches these words. Same matching, and the same extended syntax (phrases, prefix, OR, exclusions, parentheses), as title.

description_containsstring

Full-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-separated

Comma-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_domainstring

Same vocabulary as company_domain, removed instead of kept.

min_salarynumber

Lower 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_salarynumber

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

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

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

limitintegerdefault 10001 to 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 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.