# Filtering jobs

> The full filter vocabulary for /v1/jobs, with the exact matching semantics behind each one.

Source: https://hyperjobs.io/docs/guides/filtering

Every filter on [`/v1/jobs`](/docs/api-reference/jobs-list) is a query
parameter. Separate parameters combine with **AND**; within one parameter, a
comma means **OR** — with a single exception, `skill`, where a comma stays AND.
The same filter set drives [`/v1/jobs/count`](/docs/api-reference/jobs-count)
when you want the number without the rows.

```bash
# Remote Python roles in the US or Canada, posted in the last week, with a salary.
curl -G https://api.hyperjobs.io/v1/jobs \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "skill=python" \
  --data-urlencode "work_arrangement=Remote" \
  --data-urlencode "country=US,Canada" \
  --data-urlencode "has_salary=true" \
  --data-urlencode "time_frame=7d"
```

## Bad input is a 400

Every parameter name and value is validated. An unknown parameter, an empty
value, a misspelled enum member, an unrecognised ISO code — each returns
`400 {"error": "invalid request", "param": "…", "hint": "…"}` naming the
offending parameter — nothing is silently dropped.

The practical consequence: **an empty result set means nothing matched.** A
typo'd filter can never masquerade as "no jobs found" — a request that only
"works" by ignoring a bad parameter fails loudly instead. See
[errors](/docs/errors).

## Always applied

Two conditions are on every `/v1/jobs` query and can't be turned off:

- **Duplicates are excluded.** The same posting syndicated to several boards is
  collapsed to one row. See [duplicates](/docs/guides/deduplication).
- **Expired postings are excluded.** Anything past its `expires_at` is gone.

<Note>
  [`/v1/jobs/feed`](/docs/api-reference/jobs-feed) applies the same two
  exclusions. Deletions reach you separately through
  [`/v1/jobs/expired`](/docs/api-reference/jobs-expired) — see
  [feed sync](/docs/guides/feed-sync).
</Note>

Results are always newest-first. That isn't configurable — see
[pagination](/docs/pagination#sorting).

## The four matching modes

Every filter behaves in one of four ways, and knowing which is which explains
almost every surprising result:

| Mode | Filters | Behaviour |
| --- | --- | --- |
| **Search** | `title`, `description` (+ `_advanced` variants) | Google-style full-text: words AND, `"quoted phrases"`, `OR`, `-exclusions`. |
| **Substring** | `organization` | Case-insensitive "contains". |
| **Value** | `country`, `city`, `region`, `continent`, `seniority`, `work_arrangement`, `experience_level`, `language`, `source`, `salary_currency`, the `company_*` family | Matches the whole value, comma = OR. Enum-backed ones 400 on a value outside the vocabulary. |
| **Token** | `skill`, `taxonomies`, `employment_type`, `education` | Matches discrete values inside the job's list. Comma = OR — except `skill`, where it's AND. |

## Text search

<Fields>
  <ParamField query="title" type="string">
    Search job titles. Google-style: `machine learning` (AND), `"machine
    learning"` (phrase), `rust OR go`, `-intern` (exclude).
  </ParamField>
  <ParamField query="description" type="string">
    Search title + description combined. Same syntax as `title`. The slowest
    filter — combine it with others.
  </ParamField>
  <ParamField query="title_advanced" type="string">
    Boolean title search: `&` AND · `|` OR · `!` NOT · `<->` adjacency ·
    `term:*` prefix · `'multi word phrase'` · parentheses.
  </ParamField>
  <ParamField query="description_advanced" type="string">
    Boolean search on title + description. Same operators as `title_advanced`.
  </ParamField>
  <ParamField query="organization" type="string">
    Case-insensitive substring match on the hiring company name.
    `organization=stripe` matches "Stripe" and "Stripes Co".
  </ParamField>
</Fields>

```bash
# Rust or Go, no interns — the simple syntax covers most needs.
curl -G https://api.hyperjobs.io/v1/jobs \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "title=rust OR go -intern"

# The same query in boolean form, with a prefix wildcard.
curl -G https://api.hyperjobs.io/v1/jobs \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "title_advanced=(rust | go) & !intern"
```

Malformed syntax — an unbalanced quote, a dangling operator — returns
`400 {"error": "invalid search syntax", "param": "…", "hint": "…"}` rather
than matching nothing.

<Note>
  Search matches words, but results still come back **newest-first**, not by
  relevance — there is no relevance ranking. `description=` remains the one
  filter that can make a request noticeably slow; always pair it with something
  narrower. The `_advanced` variants require the full-text index and return a
  400 on deployments without it — check the `full_text_search` flag on
  [`/v1/meta`](/docs/api-reference/meta).
</Note>

## Skills

<Fields>
  <ParamField query="skill" type="string (comma-separated, AND)">
    Required skills — every listed skill must be present. Exact tokens:
    `skill=python`, `skill=c++`, `skill=react`.
  </ParamField>
</Fields>

Skills are stored as canonical tokens, and the well-known aliases are
translated for you on the way in: `c++`, `c#`, `.net`, `node.js`, `react.js`,
`vue.js`, and `golang` all work even though they're stored as `cpp`, `csharp`,
`dotnet`, `node`, `react`, `vue`, and `go`.

<Warning title="Displayed skill values don't always round-trip">
  The `skills` array in a response is *display*-formatted. Feeding a displayed
  value back as a filter doesn't always work:

  | You see | Stored as | `?skill=` with the displayed value |
  | --- | --- | --- |
  | `Machine Learning` | `machinelearning` | **0 rows** — use `machinelearning` |
  | `CI/CD` | `cicd` | **0 rows** — use `cicd` |
  | `Quality Assurance` | `qualityassurance` | **0 rows** |
  | `C++` | `cpp` | Works — aliased |

  The rule: multi-word display names collapse to one lower-case token. If a
  displayed skill has a space or a slash in it, strip them before filtering.
  `skill` is a free-text filter, so this is the one remaining way a plausible
  value returns zero rows instead of a 400.
  [Skills & taxonomies](/docs/guides/skills-and-taxonomies) has the full list.
</Warning>

## Category and level

<Fields>
  <ParamField query="taxonomies" type="string (comma-separated, OR)">
    Job family, overlap match. 28 Title-Case values — `Software`, `Healthcare`,
    `Finance & Accounting`, `Trades`… See [enums](/docs/reference/enums#taxonomies).
  </ParamField>
  <ParamField query="seniority" type="string (comma-separated, OR)">
    One of `Internship`, `Entry level`, `Associate`, `Mid-Senior level`,
    `Director`, `Management`.
  </ParamField>
  <ParamField query="experience_level" type="string (comma-separated, OR)">
    Years-of-experience bucket. One of `0-2`, `2-5`, `5-10`, `10+`.
  </ParamField>
  <ParamField query="employment_type" type="string (comma-separated, OR)">
    `FULL_TIME`, `PART_TIME`, `CONTRACTOR`, `TEMPORARY`, `INTERN`, `PER_DIEM`,
    `VOLUNTEER`, `OTHER`. Upper-case.
  </ParamField>
  <ParamField query="education" type="string (comma-separated, OR)">
    `high school`, `professional certificate`, `associate degree`,
    `bachelor degree`, `postgraduate degree`. Lower-case, overlap match.
  </ParamField>
</Fields>

<Note>
  These are still exact and case-sensitive — but a wrong value is now a 400
  with the allowed list in the hint. `seniority=mid-senior level` errors
  loudly instead of returning an empty set, so a casing mistake can no longer
  masquerade as "no jobs match".
</Note>

## Comma means OR — except `skill`

Every multi-value filter treats commas as OR — you get a union in one
request. (An early draft of this API read commas as AND everywhere, which
made a union cost one request per value merged client-side.)

```bash
# Software OR Healthcare — the union, in one request.
curl "…/v1/jobs?taxonomies=Software,Healthcare"

# Roles in Germany OR the Netherlands, at Director OR Management level.
curl "…/v1/jobs?country=DE,NL&seniority=Director,Management"
```

The one exception is `skill`, which is deliberately AND — it expresses
*required* skills, and `skill=react,typescript,graphql` means all three:

```bash
# NOT "Python or AWS". This is "tagged BOTH Python AND AWS".
curl "…/v1/jobs?skill=python,aws"
```

Different parameters still combine with AND, so `country=DE,NL&remote=true`
is "(Germany OR Netherlands) AND remote". There's no OR *across* parameters —
"remote OR posted this week" is still two requests merged by `id`.

A bad member inside a comma list — `taxonomies=Software,Healthcareee` —
returns a 400 naming the value, not a quietly smaller result set.

## Locations

<Fields>
  <ParamField query="country" type="string (comma-separated, OR)">
    Country — full name or ISO-2 code. `US,Germany` both work. An unknown
    ISO-2 code is a 400.
  </ParamField>
  <ParamField query="city" type="string (comma-separated, OR)">
    City name.
  </ParamField>
  <ParamField query="region" type="string (comma-separated, OR)">
    State / province / region — `California`, `Bavaria`.
  </ParamField>
  <ParamField query="continent" type="string (comma-separated, OR)">
    One of the seven continents — `Europe`, `North America`…
    See [enums](/docs/reference/enums).
  </ParamField>
  <ParamField query="remote" type="boolean">
    `true` → remote jobs only; `false` → on-site/hybrid only. Accepts
    `true`/`false`/`1`/`0`.
  </ParamField>
  <ParamField query="work_arrangement" type="string (comma-separated, OR)">
    `Remote`, `Hybrid`, or `On-site`. More precise than `remote`, which can't
    express hybrid.
  </ParamField>
</Fields>

<Warning title="`location` is gone, and matching is whole-value now">
  The pre-release draft's `location` alias was removed — sending it is a 400.
  The geo parameters are `country`, `city`, `region`, and `continent`.

  Matching is against the whole value, not a substring: `country=United` no
  longer matches "United Kingdom" and "United Arab Emirates". Say what you
  mean — `country=GB,US`, or spell the names out.

  ```bash
  curl "…/v1/jobs?country=US"                  # ✓ ISO-2 works now
  curl "…/v1/jobs?country=United States,DE"    # ✓ names and codes mix freely
  curl "…/v1/jobs?location=United States"      # ✗ — 400, unknown parameter
  ```
</Warning>

<Note>
  There's still no geo search — no radius, no bounding box. Coordinates are
  exposed on the Job object (`location.coords`) so you can compute distance
  client-side, but they aren't filterable server-side.
</Note>

## Salary

<Fields>
  <ParamField query="has_salary" type="boolean">
    Only postings with a parsed salary. About 37% of the dataset qualifies.
  </ParamField>
  <ParamField query="salary_gte" type="number">
    Annualised salary floor with **range-overlap** semantics: matches jobs
    whose advertised band *reaches* the value (`annual_max >= X`).
  </ParamField>
  <ParamField query="salary_lte" type="number">
    Annualised ceiling: matches jobs whose band starts at or below the value
    (`annual_min <= Y`).
  </ParamField>
  <ParamField query="salary_currency" type="string (comma-separated, OR)">
    ISO-4217 currency of the posted salary — `USD,EUR`. Case-insensitive.
  </ParamField>
</Fields>

<Warning title="salary_gte means “could pay this much”">
  `salary_gte=200000` matches a job advertised at $90k–$210k, because the
  band reaches 200k. That's the documented range-overlap semantics — the
  result set is "jobs that could pay X", not "jobs whose floor is at least X".
  Postings with no upper bound (`annual_max: null`) don't match at all. For a
  strict floor, compare `salary.annual_min` client-side —
  [Salary data](/docs/guides/salary) has the full treatment.
</Warning>

## Company

Every job embeds its [company](/docs/objects/company); these parameters filter
on that profile. A job whose company hasn't been verified won't match any of
them.

<Fields>
  <ParamField query="company_industry" type="string (comma-separated, OR)">
    Company LinkedIn industry, exact and case-insensitive —
    `Software Development`.
  </ParamField>
  <ParamField query="company_size" type="string (comma-separated, OR)">
    Size bucket — `51-200 employees,201-500 employees`.
  </ParamField>
  <ParamField query="employee_count_gte" type="integer">
    Minimum company employee count (inclusive).
  </ParamField>
  <ParamField query="employee_count_lt" type="integer">
    Maximum company employee count (exclusive).
  </ParamField>
  <ParamField query="funding_gte" type="integer">
    Minimum company total funding in USD (Crunchbase, inclusive).
  </ParamField>
  <ParamField query="funding_lt" type="integer">
    Maximum company total funding in USD (exclusive).
  </ParamField>
  <ParamField query="company_country" type="string (comma-separated, OR)">
    Company HQ country — full name or ISO-2.
  </ParamField>
  <ParamField query="domain" type="string (comma-separated, OR)">
    Company website domain, exact — `stripe.com`.
  </ParamField>
  <ParamField query="exclude_organization" type="string (comma-separated)">
    Exclude companies by exact name, case-insensitive — the way to drop a
    staffing agency that floods your results.
  </ParamField>
</Fields>

```bash
# Python roles at big software companies — not the job's country, the employer's.
curl -G https://api.hyperjobs.io/v1/jobs \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "skill=python" \
  --data-urlencode "company_industry=Software Development" \
  --data-urlencode "employee_count_gte=1000"
```

## Time

<Fields>
  <ParamField query="time_frame" type="<number><m|h|d>">
    Only postings newer than the window. `15m`, `2h`, `24h`, `7d`, `30d`.
  </ParamField>
  <ParamField query="posted_gte" type="ISO-8601 date">
    Jobs posted on/after this instant — `2026-07-01T00:00:00Z`. The absolute
    form of `time_frame`.
  </ParamField>
  <ParamField query="posted_lt" type="ISO-8601 date">
    Jobs posted before this instant.
  </ParamField>
  <ParamField query="created_gte" type="ISO-8601 date">
    Jobs *we first indexed* on/after this instant — the checkpoint parameter
    for [incremental sync](/docs/guides/feed-sync).
  </ParamField>
  <ParamField query="created_lt" type="ISO-8601 date">
    Jobs we first indexed before this instant.
  </ParamField>
</Fields>

<Note>
  `m` means **minutes** — `time_frame=30m` is half an hour. The units are `m`
  (minutes), `h` (hours), `d` (days); there is no week or month unit, and an
  unparseable value like `1week` is a 400, not silently all-time. There is no
  `posted_since` alias (400) — use `time_frame` or `posted_gte`.
</Note>

The window measures against the posting date, falling back to the ATS creation
date and then to when we first indexed it.

## Everything else

<Fields>
  <ParamField query="source" type="string (comma-separated, OR)">
    The platform the posting came from, by its public slug — `greenhouse`,
    `ashby`, `lever`. Valid values are the keys of
    [`/v1/meta`](/docs/api-reference/meta) → `sources`; see the
    [sources reference](/docs/reference/sources).
  </ParamField>
  <ParamField query="exclude_source" type="string (comma-separated)">
    Exclude platforms — `exclude_source=linkedin` for direct-from-ATS rows only.
  </ParamField>
  <ParamField query="language" type="string (comma-separated, OR)">
    The detected language of the posting — full name (`English`) or ISO-639-1
    (`en`).
  </ParamField>
  <ParamField query="visa_sponsorship" type="&quot;only&quot; | &quot;exclude&quot;">
    `only` → postings that mention sponsorship; `exclude` → hide them.
  </ParamField>
  <ParamField query="collapse" type="&quot;true&quot;">
    Fold multi-location repeats into one row per company + title, with an
    `openings` count. See [collapsing](/docs/guides/deduplication#collapsing).
  </ParamField>
  <ParamField query="description_format" type="&quot;text&quot; | &quot;html&quot;" default="omitted">
    Include the description. Omitted by default — the field is absent, not null.
  </ParamField>
</Fields>

<Note>
  `?source=lever` works now — one vocabulary everywhere. Filter input, the
  `/v1/meta` keys, and the `source` **field** on every Job payload all use the
  public slug, so a value you read in a response feeds straight back as a
  filter. (The old stored value `lever.co` is still accepted as input.)
</Note>

## Debugging an empty result set

Most of the old debugging ritual is obsolete: a misspelled parameter, a bad
enum member, or broken search syntax is now a 400 that names the problem, so
an unexpectedly empty response almost always means the filters are valid but
**too tight**. Two things to check:

- **An accidental AND.** `skill` ANDs its commas, and separate parameters
  always AND. Drop filters one at a time and watch `total` — or cheaper, point
  the same query at [`/v1/jobs/count`](/docs/api-reference/jobs-count).
- **A skill token that didn't round-trip.** `skill` is free-text, so
  `skill=Machine Learning` returns zero rows without an error — the stored
  token is `machinelearning`. See [the warning above](#skills).
