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.
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
| Question | Query |
|---|---|
| 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=DE |
| 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, with your key. There is no keyless row tier anymore: /public/jobs answered these until PUBLIC_LISTINGS_GATE and now needs a key too (see Keyless access). All four paid-only parameters in the parameter reference are honoured on /v1.
Exact contractTypes, defaults, ranges, errors, edge cases
$ A=https://api.jobopportunitiesapi.org/v1/jobs $ H="Authorization: Bearer $JOA_KEY" $ curl -s -H "$H" "$A?country=DE&category=Engineering&limit=2" | jq -r '.data[].title' $ curl -s -H "$H" "$A?exclude_category=Sales&limit=2" | jq -r '.data[].category' $ curl -s -H "$H" "$A?provider=greenhouse&limit=2" | jq -r '.data[].source'
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
| Exclude | Parameter | Its include twin |
|---|---|---|
| A category | exclude_category | category |
| A country | exclude_country | country |
| A source bucket | exclude_source_type | source_type |
| A provider | exclude_provider | provider |
| A company by domain | exclude_company_domain | company_domain |
| Words in a title | title_exclude | title |
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=berlin.qcovers 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=salesalongsidetitle=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.
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)"'5The query grammar: quotes, adjacency, parentheses and OR
Quotes make a phrase, side-by-side words AND together, parentheses group, and OR binds LOOSER than adjacency. That is the whole grammar, and since 25 October 2026 it reads the way everyone expects.
In depthWhy it exists, what it is not, what people get wrong
The value of title, title_exclude, description_contains and q is not a plain string. It is a small grammar: quotes, adjacency, OR, parentheses, * and a leading dash. Every behaviour on this page is what the parser actually does, re-checked against the live API on 25 October 2026.
The precedence, stated once: parentheses first, adjacency (an implicit AND) next, `OR` last. So a b OR c d is (a b) OR (c d), and four word pairs written one after the other really are four alternatives.
a b OR c d is (a b) OR (c d)Start with the case that prompted this change. A customer sent this, meaning four word pairs: "change lead", "change manager", "training lead", "training manager":
curl -s -H "Authorization: Bearer $JOA_KEY" --get \
https://api.jobopportunitiesapi.org/v1/jobs \
--data-urlencode 'title="change" "lead" OR "change" "manager" OR "training" "lead" OR "training" "manager"' \
--data-urlencode 'country=GB' \
--data-urlencode 'include_total=true' --data-urlencode 'limit=1'It returns 88 rows. Until 25 October 2026 it returned 1 (2 on the day the customer ran it), and the query was not wrong: the grammar did not mean what it looked like. OR used to bind TIGHTER than adjacency, so that line parsed as:
change AND (lead OR change) AND (manager OR training) AND (lead OR training) AND managerwhich simplifies to a title containing change and manager and either lead or training. One narrow thing, not four alternatives. Today the same request is the union of the four pairs: 12 + 47 + 10 + 21 titles, two of which are in common, which is the 88.
Parentheses are grouping now, so the same query can say what it means. The same request with a parenthesis around each pair returns the same 88 rows. Until 25 October parentheses were dropped as punctuation, dropping them broke the pairing, and that version returned 0 rows with no word about it. That silent zero is the trap behind most "my query returns nothing" reports, and it is why the parser now refuses what it cannot honour instead of dropping it.
curl -s -H "Authorization: Bearer $JOA_KEY" --get \
https://api.jobopportunitiesapi.org/v1/jobs \
--data-urlencode 'title=("change" "lead") OR ("change" "manager") OR ("training" "lead") OR ("training" "manager")' \
--data-urlencode 'country=GB' \
--data-urlencode 'include_total=true' --data-urlencode 'limit=1'| You write | It means |
|---|---|
"change manager" | A phrase. The words must appear adjacent and in order. A one-word quote is an exact word, and a quoted term never takes the automatic prefix. |
"change" "manager" | An implicit AND. Both words in the field, in any order, at any distance. |
change | Automatic prefix for bare terms of five or more letters, once the query uses any of this grammar. A query that uses none of it is a plain whole-word search. |
lead OR manager | OR, binding LOOSER than adjacency. It joins whole groups of adjacent words: a b OR c d is (a b) OR (c d). |
(a OR b) c | Parentheses group. They decide what an OR binds to. Around plain words with no OR inside they change nothing: (a b) is a b. |
a OR b -c | The exclusion joins its own group: a OR (b AND NOT c). Write (a OR b) -c for the other reading. |
-(a OR b) | Excludes the whole group. The dash must touch the parenthesis. |
-manager | Exclusion, when the dash touches the term. A free-standing dash is title punctuation and is ignored. |
engineer* | Explicit prefix, applied to the last word of the term. |
Exact contractTypes, defaults, ranges, errors, edge cases
How the parse works, in one paragraph. The value is lexed into terms, phrases, OR keywords, exclusions, explicit prefixes and parentheses. Adjacent items form an AND group; an OR joins whole groups; a parenthesis wraps a group so an OR knows what it binds to. That is the precedence: the AND step happens inside each group, and the OR step happens last. A plain value (no quotes, no OR, no *, no attached dash) is never parsed at all: it goes to the text index as written.
The counts below were read with include_total=true, which returns total_estimate with total_estimate_status: exact for a query this narrow. Every row is one request per column, made on 25 October 2026 with country=GB, before and after the precedence change, against the same ledger. Row counts move daily as roles close; the parse does not.
| The request (title=, country=GB) | Until 25 Oct 2026 | Now |
|---|---|---|
"change manager" | 29 | 29 |
"change" "manager" | 47 | 47 |
develop "engineer" | 129 | 129 |
"develop" "engineer" | 0 | 0 |
"change" -"manager" | 83 | 83 |
| The customer's query, exactly as sent | 1 | 88 |
| The same query, parentheses on each pair | 0 | 88 |
"change lead" OR "change manager" OR "training lead" OR "training manager" | 50 | 50 |
"change" "lead" OR "change" "manager" | 47 | 58 |
("change" "lead") OR ("change" "manager") | 1 | 58 |
The workaround we sent them, title="change" OR "training" "lead" OR "manager" | 88 | 12,854 |
One more way to see the precedence. title="change" "lead" OR "change" "manager" is 58: the union of the two pairs (12 and 47 titles, one in common). title="change" ("lead" OR "manager") "training" is 0, because it asks for all three words in one title. The parentheses choose which.
6Queries 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.
- Add a country. The cheapest and most effective narrowing available.
- Add a category, or a provider. Both are indexed and both cut the candidate set hard.
- Drop the second full-text filter.
titleanddescription_containstogether are the pathological case: both indexes are GIN and the matching rows still have to be fetched to be ordered byposted_at. - Reduce `require_fields`. Each additional required field is another condition over the provenance block.
- Lower `limit`. Less to fetch and less to serialise, and fewer records charged.
Exact contractTypes, defaults, ranges, errors, edge cases
| Shape | Cost | Do this instead |
|---|---|---|
?limit=200 with no filters | Cheap | Fine. |
?country=DE&category=Engineering | Cheap | Fine. |
?description_contains=x over the whole ledger | Expensive | Add country or category. |
?title=x&description_contains=y | Most expensive | Narrow to one country, or run them as two queries and intersect. |
?require_fields=salary,location,description | Moderate | Ask for the fields you actually need. |
?include_description=true&limit=50 | Large response, not a slow query | Fetch descriptions for the rows you need, not for every page. |
/v1/export with wide filters | Streams; cannot time out the same way | Use 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 27 October 2026, 11:28 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.