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

**What this covers:** Twenty questions, and the query for each; How filters combine; Excluding rather than including; Which text filter to use; Queries that can time out, and how to make them fast.

**Assumed knowledge:** [The parameter reference](./api-parameters.md).

**Canonical HTML:** https://jobopportunitiesapi.org/docs/api/filtering  
**Machine-readable index:** https://jobopportunitiesapi.org/docs/ai/index.md  
**Last verified:** 2026-08-22  
**Superseded by:** the live API at https://api.jobopportunitiesapi.org and its spec at https://jobopportunitiesapi.org/openapi.json — where this file and the API disagree, the API is right.

---

<a id="filtering-cookbook"></a>

## 1. Twenty 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.

### 1.1 In depth

| 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=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](./api-parameters.md), which are ignored rather than refused.

### 1.2 Exact contract

Three of them, run

```console
$ 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": "ca9ce4ee-48da-424d-b883-e73e25e55c8d",
      "slug": "junior-production-engineer-ca9ce4ee",
      "title": "Junior Production Engineer",
      "company": "Robco",
      "company_slug": "robco",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/robco.png",
      "category": "Engineering",
      "category_confidence": 0.8,
      "country": "DE",
      "city": "Munich",
      "location": "Munich",
      "remote": "on_site",
      "remote_inferred": false,
      "seniority": "Entry",
      "posted_at": "2026-09-08T22:13:44Z",
      "first_seen_at": "2026-09-09T00:22:29Z",
      "last_verified_at": "2026-09-09T01:01:28Z",
      "status": "live",
      "closed_at": null,
      "closed_reason": null,
      "apply_url": "https://jobs.ashbyhq.com/robco/db2df1fe-2198-455d-97bf-7669ad0e199d/application",
      "source": "ashby",
      "source_type": "ats",
      "provider_type": "employer_ats",
      "has_description": true,
… 55 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 file was built (9 September 2026, 02:50 UTC)._

**See also**

- [How filters combine](./api-filtering.md#combining-filters)
- [Excluding rather than including](./api-filtering.md#exclusions)
- [The complete table](./api-parameters.md#params-jobs-full)

<a id="combining-filters"></a>

## 2. How filters combine

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

### 2.1 In depth

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

> **An include and an exclude on the same value can never match** — `?category=Sales&exclude_category=Sales` ANDs to nothing and returns an empty page with a 200. The API does not detect the contradiction — the website's own filter UI does, and clears the other side when you pick one, precisely because “no results” with no explanation is the worst possible feedback.

### 2.2 Exact contract

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.

**See also**

- [Excluding rather than including](./api-filtering.md#exclusions)
- [require_fields — the honest subset](./ledger-provenance.md#require-fields)
- [The filter panel, including Allow and Exclude](./website-ledger.md#site-facets)

<a id="exclusions"></a>

## 3. Excluding rather than including

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

### 3.1 In depth

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

### 3.2 Exact contract

> **Exclusion does not remove rows that lack the field** — `?exclude_country=US` keeps rows with **no** country at all, because they do not match `US`. Include and exclude on the same field are therefore not complements: `country=US` and `exclude_country=US` do not partition the ledger. Same for category, provider and the rest.

`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](./api-parameters.md#filter-vocabularies).

**See also**

- [Controlled vocabularies — never hard-code these](./api-parameters.md#filter-vocabularies)
- [The filter panel, including Allow and Exclude](./website-ledger.md#site-facets)
- [Provenance and serving — source, provider, quality, poster type, status](./api-parameters.md#params-provenance-filters)

<a id="text-vs-title-vs-description"></a>

## 4. Which 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.

### 4.1 In depth

- **Looking for a role by name** — `title=engineer`. Narrowest and cheapest of the three.
- **Looking for a company or a place** — `q=berlin` — `q` 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`.

> **No stemming** — The index uses the `simple` dictionary, so `engineer` does not match `engineering` and `manage` does not match `manager`. Send the forms you want. This is deliberate: stemming across the languages in this ledger produces more wrong matches than right ones.

### 4.2 Exact contract

`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](./ledger-coverage.md#coverage-fields-section).

A skill search, narrowed to one country so it stays fast

```bash
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)"'
```

**See also**

- [Text — q, title, description_contains](./api-parameters.md#params-text-search)
- [Queries that can time out, and how to make them fast](./api-filtering.md#expensive-queries)

<a id="expensive-queries"></a>

## 5. Queries 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.

### 5.1 In depth

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.

> **This is a countdown, not a fixed bug** — The corpus is growing into the timeout. A query shape measured at 3.15 seconds in mid-August 2026 had been recorded at 10.09 seconds under load days earlier. Write the retry now.

> **Adding a filter can make a query slower, not faster** — This is the counter-intuitive one. `?country=NL&limit=20` answers in about half a second; `?country=NL&category=Engineering&limit=20` measured anywhere from 0.5 seconds to past the 10-second timeout on 2026-08-23, on the same afternoon. Ordering is by `posted_at`, so an unfiltered page can stop at the first twenty rows, while a selective filter has to keep reading until it has found twenty that match. **A narrow filter is not automatically a cheap one.** Measure the shape you are actually going to ship, and write the retry either way.

### 5.2 Exact contract

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

**See also**

- [Every status code](./api-errors.md#error-table)
- [GET /v1/export](./endpoints-export.md#endpoint-export)
- [A retry policy that is correct](./api-errors.md#retry-policy)

---

## Where to go next

This file is part of **API**. Others in the same group:

- [API overview](./api-overview.md) — Hosts, versioning, caching, CORS and the HTTP conventions every endpoint follows. Read the hosts section first — getting it wrong costs people hours.
- [Authentication](./api-authentication.md) — One header, three accepted spellings, and a deliberate refusal to tell an attacker which kind of wrong a wrong key is.
- [Keyless access](./api-keyless.md) — What /public/* gives you with no account: real rows, one page at a time, bounded so that evaluating is free and extracting is not.
- [Rate limits](./api-rate-limits.md) — Two independent ceilings — requests per minute and per day — plus a monthly record allowance that is a licence rather than a throttle. They fail differently.
- [Pagination](./api-pagination.md) — Keyset cursors, not offsets. One rule matters more than the rest: hand next_cursor back exactly as you received it.

Always useful:

- [index.md](./index.md) — the map of every file here
- [BUILD-A-SITE.md](./BUILD-A-SITE.md) — the paste-whole brief for building against this API
- [quickstart.md](./quickstart.md) — zero to a first authenticated response
- [api-errors.md](./api-errors.md) — every status code and whether to retry it
