- API reference
- Endpoints
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.
https://api.hyperjobs.io/v1/jobsSearch ~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 for the conceptual tour; this page is the
exact contract.
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"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.
- Expired postings are excluded. Anything past its
expires_atis gone./v1/jobs/feedexcludes them too;/v1/jobs/expiredis 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.
Query parameters#
Text search
titleSearch 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.
descriptionSearch title + description combined — not description-only. Same syntax as
title. The slowest filter in the set; always combine it with something
narrower.
title_advancedBoolean title search: & AND · | OR · ! NOT · <-> adjacency ·
term:* prefix · 'multi word phrase' · parentheses. Example:
(rust | go) & !intern.
description_advancedBoolean search on title + description. Same operators as title_advanced.
organizationCase-insensitive substring match on the hiring company name.
organization=stripe matches "Stripe" and "Stripes Co".
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 before relying on them. Plain title
and description always work.
Company
exclude_organizationExclude companies by exact name, case-insensitive. Comma-separated —
exclude_organization=Crossmark,TieTalent drops both.
company_industryCompany LinkedIn industry (exact, case-insensitive), comma = OR — e.g.
Software Development.
company_sizeCompany size bucket, comma = OR — e.g.
51-200 employees,201-500 employees.
employee_count_gteMinimum company employee count (inclusive).
employee_count_ltMaximum company employee count (exclusive).
funding_gteMinimum company total funding in USD (Crunchbase, inclusive).
funding_ltMaximum company total funding in USD (exclusive).
company_countryCompany HQ country — full name or ISO-2, comma = OR.
domainCompany website domain (exact), comma = OR — e.g. stripe.com.
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.
Location
countryCountry — 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.
cityCity name, comma = OR — city=Berlin,Amsterdam.
regionState / province / region, comma = OR — region=California.
continentContinent, comma = OR. One of Africa, Antarctica, Asia, Europe,
North America, Oceania, South America.
remotetrue (or 1) → remote jobs only; false (or 0) → on-site/hybrid only.
Anything else is a 400.
work_arrangementWork arrangement, comma = OR. One or more of On-site, Remote, Hybrid.
More expressive than remote, which can't say "hybrid".
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.
Classification
skillRequired 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.
taxonomiesJob family, comma = OR (overlap match), Title-Case — Software,
Healthcare, Finance & Accounting. See
enums for all 28.
employment_typeEmployment type, comma = OR. One or more of FULL_TIME, PART_TIME,
CONTRACTOR, TEMPORARY, INTERN, PER_DIEM, VOLUNTEER, OTHER.
senioritySeniority level, comma = OR. One or more of Internship, Entry level,
Associate, Mid-Senior level, Director, Management.
experience_levelYears-of-experience bucket, comma = OR. One or more of 0-2, 2-5,
5-10, 10+.
educationEducation requirement, comma = OR (overlap match), lower-case. One or more
of high school, professional certificate, associate degree,
bachelor degree, postgraduate degree.
languageJob-description language — full name (English) or ISO-639-1 (en),
comma = OR.
visa_sponsorshiponly → jobs that mention visa sponsorship; exclude → hide them.
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.
curl "…/v1/jobs?skill=python,aws" # tagged BOTH — skill is AND
curl "…/v1/jobs?seniority=Director,Management" # either — everything else is OREnum 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.
Salary
has_salarytrue → only jobs with structured salary data. About 37% of the pool
qualifies.
salary_gteAnnualized salary floor: matches jobs whose salary range reaches this
value (annual_max >= value). Range-overlap semantics — see the warning
below.
salary_lteAnnualized salary ceiling: matches jobs whose range starts at or below this
value (annual_min <= value).
salary_currencyISO-4217 currency of the posted salary, comma = OR, case-insensitive —
USD,EUR.
salary_gte means "could pay this much"
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 explains the
annualisation.
Source
sourceATS / source platform, comma = OR. Valid values are the keys of sources
in /v1/meta — public slugs like
greenhouse,lever. See the sources reference.
source_typeProvenance 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.
exclude_sourceExclude source platforms, comma-separated. Same vocabulary as source.
One vocabulary everywhere: the filter input, the
keys of /v1/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).
Time
time_framePosting-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.
posted_gteJobs posted on/after this instant — 2026-07-01T00:00:00Z. The absolute
alternative to time_frame.
posted_ltJobs posted before this instant.
created_gteJobs we first indexed on/after this instant. Use for incremental sync — or better, use the feed.
created_ltJobs we first indexed before this instant.
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.
Output
description_formatInclude 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.
collapseFold 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.
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 once you know which job you care about.
Paging
limitResults 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.
offsetRows to skip — minimum 0, maximum 100000. Fine for shallow pages; for deep
pagination prefer cursor. Ignored when cursor is passed.
cursorOpaque 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.
Counting cheaply: /v1/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.
Response#
dataThe page of matching jobs, newest first. Every field is documented in
the Job object; company is embedded, so there's no
second request. With collapse, each job also carries openings.
totalExact 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 when the count is all
you want. With collapse, it counts groups.
limitThe effective page size.
offsetThe effective offset. Omitted when you paginate with cursor.
next_cursorOpaque 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.
collapsedPresent only when collapse was applied. Absent otherwise, not false.
{
"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"
}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.
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 exceeded, or monthly quota exhausted. |
500 | Server error. Retry with backoff. |
{
"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 400s — 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.