# List jobs

> Search the job index. Every filter and how they combine, cursor paging, and how collapsing folds the same role posted to several boards into one row.

Source: https://hyperjobs.io/docs/api-reference/jobs-list

<EndpointHeader method="GET" path="/v1/jobs" />

Search ~597k non-duplicate postings. Every parameter is optional; with none, you
get the 50 most recent jobs. Different parameters combine with **AND**; within a
multi-value parameter, comma-separated values are **OR** — with one exception,
`skill`, where every listed value is required. Invalid parameters and values are
rejected with a `400` that names the offending parameter, and `title` /
`description` are Google-style searches rather than substring matches. See
[filtering](/docs/guides/filtering) for the conceptual tour; this page is the
exact contract.

<CodeGroup>
```bash title="cURL"
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" \
  --data-urlencode "time_frame=7d" \
  --data-urlencode "limit=50"
```

```js title="Node"
const qs = new URLSearchParams({
  skill: "python",
  work_arrangement: "Remote",
  country: "US",
  time_frame: "7d",
  limit: "50",
});

const res = await fetch(`https://api.hyperjobs.io/v1/jobs?${qs}`, {
  headers: { Authorization: `Bearer ${process.env.HYPERJOBS_KEY}` },
});
const { data, total, next_cursor } = await res.json();
```

```python title="Python"
import os, requests

res = requests.get(
    "https://api.hyperjobs.io/v1/jobs",
    params={
        "skill": "python",
        "work_arrangement": "Remote",
        "country": "US",
        "time_frame": "7d",
        "limit": 50,
    },
    headers={"Authorization": f"Bearer {os.environ['HYPERJOBS_KEY']}"},
    timeout=30,
)
res.raise_for_status()
payload = res.json()
```
</CodeGroup>

## Always applied

Two conditions are on every query and can't be disabled:

- **Duplicates are excluded.** One row per posting, however many boards
  syndicated it. See [duplicates](/docs/guides/deduplication).
- **Expired postings are excluded.** Anything past its `expires_at` is gone.
  [`/v1/jobs/feed`](/docs/api-reference/jobs-feed) excludes them too;
  [`/v1/jobs/expired`](/docs/api-reference/jobs-expired) is the purge feed that
  tells you *which* ids went away.

Results are always ordered newest-first by `posted_at`, falling back to
`created_at` (when we first indexed the posting) when it carries no publish
date. This is not configurable — no `sort` parameter exists. See
[pagination](/docs/pagination#sorting).

## Query parameters

<Fields title="Text search">
  <ParamField query="title" type="string">
    Search job titles. Google-style: `machine learning` (AND),
    `"machine learning"` (phrase), `rust OR go`, `-intern` (exclude). No longer
    a plain substring match. Invalid search syntax is a `400`, not an empty
    result.
  </ParamField>
  <ParamField query="description" type="string">
    Search title + description combined — not description-only. Same syntax as
    `title`. The slowest filter in the set; always combine it with something
    narrower.
  </ParamField>
  <ParamField query="title_advanced" type="string">
    Boolean title search: `&` AND · `|` OR · `!` NOT · `<->` adjacency ·
    `term:*` prefix · `'multi word phrase'` · parentheses. Example:
    `(rust | go) & !intern`.
  </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>

<Note>
  The advanced params need the full-text index, and return a `400` on a
  deployment where it isn't built — check the `full_text_search` flag on
  [`/v1/meta`](/docs/api-reference/meta) before relying on them. Plain `title`
  and `description` always work.
</Note>

<Fields title="Company">
  <ParamField query="exclude_organization" type="string (comma-separated)">
    Exclude companies by **exact** name, case-insensitive. Comma-separated —
    `exclude_organization=Crossmark,TieTalent` drops both.
  </ParamField>
  <ParamField query="company_industry" type="string (comma-separated)">
    Company LinkedIn industry (exact, case-insensitive), comma = OR — e.g.
    `Software Development`.
  </ParamField>
  <ParamField query="company_size" type="string (comma-separated)">
    Company size bucket, comma = OR — e.g.
    `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)">
    Company HQ country — full name or ISO-2, comma = OR.
  </ParamField>
  <ParamField query="domain" type="string (comma-separated)">
    Company website domain (exact), comma = OR — e.g. `stripe.com`.
  </ParamField>
</Fields>

<Note>
  The company-axis filters match against the verified company profile, so a
  posting whose employer never resolved to one can't match them — see
  [the three company shapes](/docs/objects/job#company-has-three-shapes).
</Note>

<Fields title="Location">
  <ParamField query="country" type="string (comma-separated)">
    Country — full name or ISO-2 code, comma = OR. `country=US,Germany` finds
    jobs in either. An unknown ISO-2 code is a `400`. There is no `location`
    parameter (a pre-release draft had one) — sending it is a `400`; use `country`.
  </ParamField>
  <ParamField query="city" type="string (comma-separated)">
    City name, comma = OR — `city=Berlin,Amsterdam`.
  </ParamField>
  <ParamField query="region" type="string (comma-separated)">
    State / province / region, comma = OR — `region=California`.
  </ParamField>
  <ParamField query="continent" type="string (comma-separated)">
    Continent, comma = OR. One of `Africa`, `Antarctica`, `Asia`, `Europe`,
    `North America`, `Oceania`, `South America`.
  </ParamField>
  <ParamField query="remote" type="boolean">
    `true` (or `1`) → remote jobs only; `false` (or `0`) → on-site/hybrid only.
    Anything else is a `400`.
  </ParamField>
  <ParamField query="work_arrangement" type="string (comma-separated)">
    Work arrangement, comma = OR. One or more of `On-site`, `Remote`, `Hybrid`.
    More expressive than `remote`, which can't say "hybrid".
  </ParamField>
</Fields>

<Note>
  There's still no geo search — no radius, no coordinates, no bounding box.
  Location filtering matches names (country, city, region, continent), though
  postings do carry `location.coords` and `location.timezones` for client-side
  work — see [the Job object](/docs/objects/job).
</Note>

<Fields title="Classification">
  <ParamField query="skill" type="string (comma-separated)">
    Required skills — comma = **AND** (every listed skill must be present), the
    one filter where comma isn't OR. `skill=python,aws` means both.

    Stored as lower-case single tokens; well-known aliases are translated on
    the way in — `c++` → `cpp`, `c#` → `csharp`, `.net` → `dotnet`,
    `node.js` → `node`, `golang` → `go` among them. Multi-word display values
    don't round-trip: the `Machine Learning` you read back is stored as
    `machinelearning`, so `?skill=Machine Learning` finds nothing. See
    [skills & taxonomies](/docs/guides/skills-and-taxonomies).
  </ParamField>
  <ParamField query="taxonomies" type="string (comma-separated)">
    Job family, comma = OR (overlap match), Title-Case — `Software`,
    `Healthcare`, `Finance & Accounting`. See
    [enums](/docs/reference/enums#taxonomies) for all 28.
  </ParamField>
  <ParamField query="employment_type" type="string (comma-separated)">
    Employment type, comma = OR. One or more of `FULL_TIME`, `PART_TIME`,
    `CONTRACTOR`, `TEMPORARY`, `INTERN`, `PER_DIEM`, `VOLUNTEER`, `OTHER`.
  </ParamField>
  <ParamField query="seniority" type="string (comma-separated)">
    Seniority level, comma = OR. One or more of `Internship`, `Entry level`,
    `Associate`, `Mid-Senior level`, `Director`, `Management`.
  </ParamField>
  <ParamField query="experience_level" type="string (comma-separated)">
    Years-of-experience bucket, comma = OR. One or more of `0-2`, `2-5`,
    `5-10`, `10+`.
  </ParamField>
  <ParamField query="education" type="string (comma-separated)">
    Education requirement, comma = OR (overlap match), lower-case. One or more
    of `high school`, `professional certificate`, `associate degree`,
    `bachelor degree`, `postgraduate degree`.
  </ParamField>
  <ParamField query="language" type="string (comma-separated)">
    Job-description language — full name (`English`) or ISO-639-1 (`en`),
    comma = OR.
  </ParamField>
  <ParamField query="visa_sponsorship" type="&quot;only&quot; | &quot;exclude&quot;">
    `only` → jobs that mention visa sponsorship; `exclude` → hide them.
  </ParamField>
</Fields>

<Warning title="Comma means OR — except on skill">
  Every multi-value filter treats commas as OR: `country=US,DE` is either,
  `employment_type=FULL_TIME,CONTRACTOR` is either. The exception is `skill`,
  which stays **AND** — `skill=python,aws` requires both.

  ```bash
  curl "…/v1/jobs?skill=python,aws"          # tagged BOTH — skill is AND
  curl "…/v1/jobs?seniority=Director,Management"  # either — everything else is OR
  ```

  Enum values are still matched exactly, case and all — but a wrong value is
  now a `400` whose hint lists the allowed values, instead of a silent empty
  result. `seniority=mid-senior level` tells you it wanted `Mid-Senior level`.
</Warning>

<Fields title="Salary">
  <ParamField query="has_salary" type="boolean">
    `true` → only jobs with structured salary data. About 37% of the pool
    qualifies.
  </ParamField>
  <ParamField query="salary_gte" type="number">
    Annualized salary floor: matches jobs whose salary **range reaches** this
    value (`annual_max >= value`). Range-overlap semantics — see the warning
    below.
  </ParamField>
  <ParamField query="salary_lte" type="number">
    Annualized salary ceiling: matches jobs whose range starts at or below this
    value (`annual_min <= value`).
  </ParamField>
  <ParamField query="salary_currency" type="string (comma-separated)">
    ISO-4217 currency of the posted salary, comma = OR, case-insensitive —
    `USD,EUR`.
  </ParamField>
</Fields>

<Warning title="salary_gte means &quot;could pay this much&quot;">
  `salary_gte` is a **range-overlap** filter: it matches every job whose
  advertised band reaches the value. A role listed at $90k–$210k matches
  `salary_gte=200000`, because $200k is inside its band. That's the documented
  semantics — "the job could pay this much" — not "the floor is at least this
  much".

  For a strict floor, filter with `has_salary=true` and compare
  `salary.annual_min` client-side. And note there is no `salary_min` parameter —
  sending it is a `400`. [Salary data](/docs/guides/salary) explains the
  annualisation.
</Warning>

<Fields title="Source">
  <ParamField query="source" type="string (comma-separated)">
    ATS / source platform, comma = OR. Valid values are the keys of `sources`
    in [`/v1/meta`](/docs/api-reference/meta) — public slugs like
    `greenhouse,lever`. See the [sources reference](/docs/reference/sources).
  </ParamField>
  <ParamField query="source_type" type="string (comma-separated)">
    Provenance class, comma = OR. `ats` — a board the employer authors and
    manages applicants on, the canonical record (join.com included);
    `jobboard` — third-party boards (LinkedIn, Wellfound, …); `aggregator` —
    re-listing sites. Cross-source duplicates are already folded, so this
    filters *provenance*, not duplication — `source_type=ats` doesn't remove
    mirror copies (dedup did), it selects records the employer authored.
  </ParamField>
  <ParamField query="exclude_source" type="string (comma-separated)">
    Exclude source platforms, comma-separated. Same vocabulary as `source`.
  </ParamField>
</Fields>

<Note>
  One vocabulary everywhere: the filter input, the
  keys of [`/v1/meta`](/docs/api-reference/meta)`.sources`, and the `source`
  **field** on every job payload all use the same public slug (the stored
  value `lever.co` is still accepted as filter input, but you'll never see it
  in a response).
</Note>

<Fields title="Time">
  <ParamField query="time_frame" type="&lt;number&gt;&lt;m|h|d&gt;">
    Posting-age shortcut: `15m`, `2h`, `24h`, `7d`, `30d`. **`m` is minutes** —
    there's no week or month unit. Measured against the posting date. A value
    that doesn't parse is a `400`, and there is no `posted_since` alias.
  </ParamField>
  <ParamField query="posted_gte" type="ISO 8601 date">
    Jobs posted on/after this instant — `2026-07-01T00:00:00Z`. The absolute
    alternative to `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. Use for incremental sync —
    or better, use the [feed](/docs/api-reference/jobs-feed).
  </ParamField>
  <ParamField query="created_lt" type="ISO 8601 date">
    Jobs we first indexed before this instant.
  </ParamField>
</Fields>

<Note>
  Watch the unit: `m` is **minutes**, not months — `time_frame=30m` is half an
  hour, not two and a half years. (An early draft of this API used `m` for
  months.) And an unparseable window is a `400` rather than a silently ignored
  filter, so `time_frame=1week` can't return all of history with a straight face.
</Note>

<Fields title="Output">
  <ParamField query="description_format" type="&quot;text&quot; | &quot;html&quot;" default="omitted">
    Include the job description, as clean plain `text` or sanitized `html`.
    Omit the parameter and the `description` key is **absent** from the
    response entirely, not null — smaller payloads. Any other value is a `400`.
  </ParamField>
  <ParamField query="collapse" type="&quot;true&quot; | &quot;title,organization&quot;">
    Fold repeats of the same role into one row per company + title, adding an
    `openings` count to each job. The representative row is the **newest** in
    its group. `total` becomes the number of groups, not the number of
    postings, and the response carries no `next_cursor` — page collapsed
    results with `offset`. See [collapsing](/docs/guides/deduplication#collapsing).
  </ParamField>
</Fields>

<Warning title="Descriptions are omitted by default — and that's usually what you want">
  Descriptions are multi-kilobyte. Requesting 200 of them when you wanted titles
  is the most common way to make this API feel slow. Ask for them on the
  [single-job endpoint](/docs/api-reference/jobs-retrieve) once you know which
  job you care about.
</Warning>

<Fields title="Paging">
  <ParamField query="limit" type="integer" default="50">
    Results per page — minimum **1**, maximum **200**. Out-of-range values are
    a `400`, including `limit=0` and negative numbers — there is no
    negative-limit-returns-everything backdoor.
  </ParamField>
  <ParamField query="offset" type="integer" default="0">
    Rows to skip — minimum 0, maximum 100000. Fine for shallow pages; for deep
    pagination prefer `cursor`. Ignored when `cursor` is passed.
  </ParamField>
  <ParamField query="cursor" type="string">
    Opaque keyset cursor from the previous page's `next_cursor` — stable at any
    depth, unlike offset. Page until `next_cursor` comes back `null`. A
    malformed cursor is a `400`.
  </ParamField>
</Fields>

<Note>
  Counting cheaply: [`/v1/jobs/count`](/docs/api-reference/jobs-count) takes
  the same filter set and returns just `{ count }` — no need for the old
  `limit=1`-and-read-`total` trick, though that still works.
</Note>

## Response

<Fields>
  <ResponseField name="data" type="Job[]">
    The page of matching jobs, newest first. Every field is documented in
    [the Job object](/docs/objects/job); `company` is embedded, so there's no
    second request. With `collapse`, each job also carries `openings`.
  </ResponseField>
  <ResponseField name="total" type="integer">
    Exact count of **all** matching rows, not the page size. It's a real
    `COUNT(*)` over the same filters rather than an estimate, so a very broad
    query pays to count rows it will never return — use
    [`/v1/jobs/count`](/docs/api-reference/jobs-count) when the count is all
    you want. With `collapse`, it counts groups.
  </ResponseField>
  <ResponseField name="limit" type="integer">
    The effective page size.
  </ResponseField>
  <ResponseField name="offset" type="integer">
    The effective offset. **Omitted** when you paginate with `cursor`.
  </ResponseField>
  <ResponseField name="next_cursor" type="string | null">
    Opaque cursor for the next page; `null` when you've reached the end. Pass
    it back as `?cursor=`. Absent on `collapse` responses — page those with
    `offset`. See [pagination](/docs/pagination).
  </ResponseField>
  <ResponseField name="collapsed" type="true">
    Present **only** when `collapse` was applied. Absent otherwise, not `false`.
  </ResponseField>
</Fields>

```json title="Response"
{
  "data": [
    {
      "id": "ashby:7c1f2a94-3e17-4d8b-9c05-1f6ab2e40d33",
      "title": "Staff Backend Engineer",
      "company": {
        "slug": "linear",
        "name": "Linear",
        "domain": "linear.app",
        "website": "https://linear.app",
        "logo": "https://logo.hyperjobs.io/linear.app",
        "industry": "Software Development",
        "employee_count": 120
      },
      "location": {
        "remote": true,
        "countries": ["United States"],
        "cities": null,
        "regions": null,
        "coords": null,
        "timezones": ["America/New_York"],
        "continent": "North America",
        "raw": ["Remote (US)"]
      },
      "employment_type": ["FULL_TIME"],
      "work_arrangement": "Remote",
      "office_days": null,
      "remote_location": ["United States"],
      "department": "Engineering",
      "team": "Sync Engine",
      "requisition_id": "ENG-142",
      "seniority": "Mid-Senior level",
      "experience_level": "5-10",
      "salary": {
        "min": 190000,
        "max": 240000,
        "currency": "USD",
        "unit": "YEAR",
        "annual_min": 190000,
        "annual_max": 240000,
        "summary": "$190,000 – $240,000 a year"
      },
      "working_hours": 40,
      "skills": ["typescript", "graphql", "postgresql"],
      "keywords": ["distributed systems", "real-time sync"],
      "taxonomies": ["Software"],
      "education_requirements": ["bachelor degree"],
      "visa_sponsorship": null,
      "job_language": "en",
      "requirements_summary": "8+ years building distributed backend systems…",
      "core_responsibilities": "Own the sync engine's server-side…",
      "benefits": ["Equity", "Health, dental & vision", "401(k) matching"],
      "hiring_manager": null,
      "apply_url": "https://jobs.ashbyhq.com/linear/7c1f2a94…",
      "url": "https://jobs.ashbyhq.com/linear/7c1f2a94…",
      "source": "ashby",
      "source_type": "ats",
      "source_domain": "jobs.ashbyhq.com",
      "posted_at": "2026-07-08T14:02:11.000Z",
      "expires_at": null,
      "modified_at": "2026-07-12T08:30:05.000Z",
      "modified_fields": ["salary_max"],
      "created_at": "2026-07-08T14:10:22.000Z",
      "updated_at": "2026-07-14T02:15:40.000Z"
    }
  ],
  "total": 21497,
  "limit": 50,
  "offset": 0,
  "next_cursor": "eyJwIjoiMjAyNi0wNy0wOCAxNDowMjoxMSIsImlkIjoiYXNoYnk6N2MxZjJhOTQuLi4ifQ"
}
```

<Note>
  `employment_type`, `education_requirements`, and `benefits` are real JSON
  arrays — no `JSON.parse` needed (a pre-release draft returned them as raw
  JSON strings).
  `description` is absent above because it wasn't requested. `apply_url` falls
  back to `url`, `posted_at` falls back to the ATS creation date, and
  `updated_at` falls back to `created_at` — so none of the three are null in
  practice. `location` is never null either: `remote` defaults to `false`, so
  the object is always there even when every list inside it is empty.
</Note>

## Errors

| Status | Cause |
| --- | --- |
| `400` | Invalid parameter or value. The body names the parameter and hints the fix. |
| `401` | Missing, unknown, or revoked key. |
| `429` | [Rate limit](/docs/rate-limits) exceeded, or monthly quota exhausted. |
| `500` | Server error. Retry with backoff. |

```json title="400"
{
  "error": "invalid request",
  "param": "seniority",
  "hint": "invalid value \"mid-senior level\" — allowed: Internship, Entry level, Associate, Mid-Senior level, Director, Management"
}
```

Validation is strict: unknown parameters, empty values, bad enum
members, and malformed numbers, dates, or cursors are all `400`s — nothing is
silently coerced or dropped, so a typo'd filter can never
masquerade as an empty result. Hints point at `/openapi.json`, the live
machine-readable spec (served without a key). See [errors](/docs/errors).
