Roles that have left their source, most recently closed first.
Keyset pagination on (closed_at DESC, id DESC).
Closure dates: 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.
closed_reason is not decoration. expired_upstream means the radar proved the vacancy dead — two misses at least an hour apart, re-checked against the board listing or the job URL itself. not_seen means it simply stopped appearing at its source for the grace period, which is usually a closure and occasionally a board that broke. employer_closed and deadline_passed are stated by the employer itself, for roles the employer pushed to us directly: it closed the role, or its application deadline passed. A role an employer paused, unlisted or deleted is not a closure and is never here; /v1/changes reports it as withdrawn.
Every filter from GET /v1/jobs also applies here.
Request
- Base URL: https://api.jobopportunitiesapi.org
- URL: https://api.jobopportunitiesapi.org/v1/jobs/closed
- Auth: HTTP bearer
Query parameters
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.
Response
A page of closed listings.
Changes
No recorded changes to this operation across all 1 revision of this API.