Job Opportunities API

Check the data. Then trust it.

api

Filtering cookbook

How the filters combine, how to exclude rather than include, which text filter to reach for, and which queries are expensive enough to time out.

Last verified 2026-08-22 · Assumes: The parameter reference. · Markdown copy

1Twenty questions, and the query for each

The queries people actually write, as copyable requests. Every one of them is sent against the live API before this site is allowed to build.

In depthWhy it exists, what it is not, what people get wrong
QuestionQuery
Engineering roles in Germany?country=DE&category=Engineering
…that are remote, as stated by the employer?country=DE&category=Engineering&remote=remote&remote_confirmed=true
…with a salary the employer published?country=DE&require_fields=salary
Anything in three countries at once?country=DE,AT,CH
Everywhere except the US?exclude_country=US
Everything except sales roles?exclude_category=Sales
Only roles from Greenhouse and Lever?provider=greenhouse,lever
Everything except one provider?exclude_provider=workday
Only employer career sites, no ATS?source_type=career_site
Roles at three companies I care about?company_domain=stripe.com,figma.com,linear.app
Roles at one company by slug?company=but
Senior roles only?seniority=senior
Roles where seniority was not stated?seniority=not_stated
Full-time only?employment_type=full_time
Posted in the last week?posted_after=2026-08-16
Re-confirmed since yesterday?verified_after=2026-08-22T00:00:00Z
Anything mentioning Kubernetes in the advert?description_contains=kubernetes&country=NL
Titles containing “engineer”, excluding “sales engineer”?title=engineer&title_exclude=sales
Roles that have an advert body at all?has_description=true
Closed roles, so I can mark my mirror stale?status=closed — or better, /v1/jobs/expired

Prefix any of these with the endpoint: https://api.jobopportunitiesapi.org/v1/jobs. On the keyless mirror (/public/jobs) they all work except the four marked paid-endpoints-only in the parameter reference, which are ignored rather than refused.

Exact contractTypes, defaults, ranges, errors, edge cases
Three of them, run
$ A=https://api.jobopportunitiesapi.org/public/jobs
$ curl -s "$A?country=DE&category=Engineering&limit=2" | jq -r '.data[].title'
$ curl -s "$A?exclude_category=Sales&limit=2" | jq -r '.data[].category'
$ curl -s "$A?provider=greenhouse&limit=2" | jq -r '.data[].source'
{
  "data": [
    {
      "id": "322858b6-3bc1-47a2-8007-839ea33039af",
      "slug": "flight-software-engineer-322858b6",
      "title": "Flight Software Engineer",
      "company": "The Exploration Company",
      "company_slug": "the-exploration-company",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/the-exploration.png",
      "category": "Engineering",
      "category_confidence": 0.8,
      "country": "DE",
      "city": "Munich",
      "location": "Munich, Germany",
      "remote": "on_site",
      "remote_inferred": true,
      "posted_at": "2026-09-11T09:04:22Z",
      "first_seen_at": "2026-09-11T09:23:55Z",
      "last_verified_at": "2026-09-11T09:50:45Z",
      "status": "live",
      "closed_at": null,
      "closed_reason": null,
      "apply_url": "https://jobs.ashbyhq.com/the-exploration-company/446ca823-dbc2-4f82-8a31-169690b75cf2/application",
      "source": "ashby",
      "source_type": "ats",
      "provider_type": "employer_ats",
      "has_description": true,
      "field_sources": {
… 54 more lines
The output shown is from the first of the three. Real response, fetched from /public/jobs?country=DE&category=Engineering&limit=2 when this page was built (11 September 2026, 10:39 UTC). Run the command yourself and you will get today's rows, not these.

2How filters combine

Different parameters AND together. A comma-separated list ORs within one parameter. There is no way to OR across two different parameters.

In depthWhy it exists, what it is not, what people get wrong

?country=DE&category=Engineering means German and engineering. ?country=DE,AT means German or Austrian. Those are the only two combinators, and knowing that is most of what you need to predict a result set.

There is no OR across parameters — you cannot ask for “engineering roles in Germany or any role in Austria” in one request. Send two requests and merge. That is a deliberate limit: an expression language over 34 parameters would be a query planner's problem and a support burden, and two requests cost two records more than one.

Exact contractTypes, defaults, ranges, errors, edge cases

Order of evaluation does not matter and neither does parameter order in the query string. Repeating a parameter (?country=DE&country=AT) is not the way to send a list — use the comma form; the repeated form takes the first occurrence and ignores the rest.

require_fields composes with everything and applies after the filters, not instead of them: ?require_fields=salary&country=DE is German rows that carry a published salary, and the completeness block reports the ledger-wide count for salary rather than the German one — the intersection is at most the smaller of the two.

3Excluding rather than including

Five parameters exclude: category, country, source_type, provider and company_domain. There is no general negation.

In depthWhy it exists, what it is not, what people get wrong
ExcludeParameterIts include twin
A categoryexclude_categorycategory
A countryexclude_countrycountry
A source bucketexclude_source_typesource_type
A providerexclude_providerprovider
A company by domainexclude_company_domaincompany_domain
Words in a titletitle_excludetitle

Everything else can only be included. If you need “not senior”, ask for the seniorities you do want, or fetch and filter your side — the vocabulary is small and enumerable.

Exact contractTypes, defaults, ranges, errors, edge cases

exclude_provider shares the provider vocabulary, so an unknown name is a 422 there too. That is the exact shape of the failure that produced the build gate described in controlled vocabularies.

4Which text filter to use

q for a quick search over title, company and location. title for titles only. description_contains for the advert body — and it is the expensive one.

In depthWhy it exists, what it is not, what people get wrong
Looking for a role by name
title=engineer. Narrowest and cheapest of the three.
Looking for a company or a place
q=berlinq covers title, company name and location together.
Looking for a skill or a technology
description_contains=kubernetes. Only this one reads the advert body.
Excluding a kind of role
title_exclude=sales alongside title=engineer.
Exact contractTypes, defaults, ranges, errors, edge cases

description_contains implies has_description=true, since it can only match rows that have a body — you do not need to send both. Roughly nine live rows in ten carry one; check the current figure on the coverage report.

A skill search, narrowed to one country so it stays fast
curl -s -H "Authorization: Bearer $JOA_KEY" --get \
  https://api.jobopportunitiesapi.org/v1/jobs \
  --data-urlencode 'description_contains=kubernetes' \
  --data-urlencode 'country=NL' \
  --data-urlencode 'limit=5' \
  | jq -r '.data[] | "\(.company)\t\(.title)"'

5Queries that can time out, and how to make them fast

Two full-text filters together across the whole ledger is the most expensive request this API takes. Narrow it by country or category first.

In depthWhy it exists, what it is not, what people get wrong

There is a ten-second statement timeout. Cross it and you get a 503 with Retry-After, not a 500 — the failure is honest and retryable. But retrying an expensive query unchanged mostly produces another timeout, so the useful response is to narrow it.

  1. Add a country. The cheapest and most effective narrowing available.
  2. Add a category, or a provider. Both are indexed and both cut the candidate set hard.
  3. Drop the second full-text filter. title and description_contains together are the pathological case: both indexes are GIN and the matching rows still have to be fetched to be ordered by posted_at.
  4. Reduce `require_fields`. Each additional required field is another condition over the provenance block.
  5. Lower `limit`. Less to fetch and less to serialise — and fewer records charged.
Exact contractTypes, defaults, ranges, errors, edge cases
ShapeCostDo this instead
?limit=200 with no filtersCheapFine.
?country=DE&category=EngineeringCheapFine.
?description_contains=x over the whole ledgerExpensiveAdd country or category.
?title=x&description_contains=yMost expensiveNarrow to one country, or run them as two queries and intersect.
?require_fields=salary,location,descriptionModerateAsk for the fields you actually need.
?include_description=true&limit=50Large response, not a slow queryFetch descriptions for the rows you need, not for every page.
/v1/export with wide filtersStreams; cannot time out the same wayUse after= to resume rather than restarting.

If you routinely need a whole slice of the corpus, /v1/export is the right tool rather than a paged listing — it streams NDJSON ordered by id, resumes exactly, and does not re-run an ordering query per page.

This page was rendered 11 September 2026, 10:39 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.