List listings, newest first.
Keyset pagination on (posted_at DESC NULLS LAST, id DESC). Pass the next_cursor from each response back as cursor. Offsets are not offered here: at this scale they double-count when rows shift. A non-zero offset is a 422 offset_not_supported, not silently ignored (it used to return page one again).
Bad input is refused, never silently widened, and a refused request costs no records (checked before metering, 18 Oct 2026): limit outside 1-200 or not a whole number -> 422 bad_limit; q, title, title_exclude or description_contains of 200 bytes or more -> 422 filter_too_long (these used to be dropped, returning the unfiltered ledger); a text filter with no word or number (operators only) -> 422 query_has_no_terms; a boolean flag that is not true or false (any case; since 18 Oct 2026 1/0/yes/no are refused too, they used to be accepted silently) -> 422 bad_boolean; has_salary other than true/structured/any/false -> 422 bad_has_salary; min_salary / max_salary outside 0-10,000,000 (annual EUR) or not finite -> 422; sort while ranked search is not enabled on this deployment -> 422 sort_unavailable; a NUL byte or invalid UTF-8 in any value -> 422 bad_parameter_value; an impossible cursor -> 422 bad_cursor. A plan whose page cap is below the requested limit (within 1-200) is served at the cap and says so in X-JOA-Limit-Applied and Warning.
Request
- Base URL: https://api.jobopportunitiesapi.org
- URL: https://api.jobopportunitiesapi.org/v1/jobs
- Auth: HTTP bearer
Query parameters
1-200 (default 25). Anything else is a 422 bad_limit, not clamped.
The next_cursor from the previous page.
live (default) returns open vacancies. closed returns roles that have left their source. any returns both; every row carries status. Paid endpoints only.
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.
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.
Comma-separated. uncategorised selects rows with no confident classification.
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.
Comma-separated ISO-3166 alpha-2.
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.
remote, hybrid, on_site, or not_stated.
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).
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.
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.
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 — 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.
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.
Comma-separated; not_stated selects rows with none.
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.
Same vocabulary as provider, removed instead of kept.
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.
Comma-separated company slugs.
ISO 639-1 two-letter language code of the posting text (e.g. de, fr, en). Comma-separated, up to 12 languages (OR).
Exclude postings in these ISO 639-1 language codes. Comma-separated, up to 12 languages. Rows with undetected language are kept.
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.
true returns only listings whose remote status the SOURCE stated — 230,184 of 1,974,500 live rows (11.7%). Without it you also receive the 1,739,292 (88.1%) where we inferred it from the location text, the title, or the presence of a named workplace city.
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.
true returns only the 1,861,942 live rows (94.3%) that carry a description.
Comma-separated. Returns only rows where EVERY named field is published in field_sources — a value the source carried, never one we derived. The response then also contains a completeness object saying how many live rows carry each of them.
This is the answer to "only 6.4% of your rows have a salary". They do — and require_fields=salary returns 125,995 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.
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).
Date or RFC3339 timestamp.
Only rows re-confirmed at their source since this instant.
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: 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.
- -berlin — exclude rows matching this term. An accented query also tries its accent-folded form (développeur matches stored developpeur); the reverse needs index-side unaccenting. 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.
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. A plain value keeps the old AND-of-words behaviour.
Drop rows whose title matches these words. Same matching — and the same extended syntax (phrases, prefix, OR, exclusions) — as title.
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. 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.
Comma-separated bare domains, e.g. stripe.com,figma.com — no scheme and no path. The join key you already have in a CRM. 37.2% of companies carry a domain; the rest can never match this filter.
Same vocabulary as company_domain, removed instead of kept.
Lower bound on salary_min_annual_eur. Selects ONLY rows with structured salary we could normalise — 2.0% of the ledger — so this is a narrow filter by nature, not a broken one.
Upper bound on salary_min_annual_eur. Same 2.0% caveat as min_salary.
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: the parameter is ignored until the ranked mode is enabled on the deployment (422s and the ranked order then apply).
Response
A page of listings.
Changes
No recorded changes to this operation across all 1 revision of this API.