- API reference
- Data reference
Dataset coverage
How much data there is, how complete each field is, how often it refreshes, and how fast you can query it.
What's actually in the dataset, measured rather than estimated. Every number on
this page is a snapshot — the index changes hourly, so read
/v1/meta rather than hardcoding any of
it.
Scale#
| Count | |
|---|---|
| Postings, all rows | 603,335 |
| Postings, excluding duplicates | 596,768 |
| Marked as duplicates | 6,567 |
| Expired | 8,482 |
| Companies | 158,545 |
| ATS boards tracked | 108,389 |
One live count everywhere#
The counters agree
/v1/health and
/v1/meta report the same number: live
postings — no duplicates, no expired rows —
which is exactly the population
/v1/jobs searches. An unfiltered
/v1/jobs?limit=1 returns the same figure as total.
(In a pre-release draft, /v1/health counted every row including duplicates
and expired postings, and /v1/meta excluded only duplicates — three subtly
different totals. That asymmetry never shipped.) /v1/health is free,
unauthenticated, and fast enough to poll.
/v1/jobs/feed sees the same
population — it excludes duplicates and expired postings.
Expiries arrive on their own feed,
/v1/jobs/expired — see
feed sync.
Field completeness#
`coverage_pct` is the live version of this table
/v1/meta now publishes coverage_pct — per-field
fill rates for country, skills, work_arrangement, seniority, taxonomies,
salary, education, and language, remeasured about every 15 minutes. That's
the canonical live source. The table below is a snapshot, kept for reading
the shape of the dataset and the fields coverage_pct doesn't cover.
Percentages are of all 603,335 rows.
| Field | Populated | Share |
|---|---|---|
description | 600,321 | 99.5% |
company resolved to a full profile | 603,333 | 99.9997% |
skills | 504,787 | 83.7% |
taxonomies | 385,246 | 63.9% |
salary | 217,221 | 36.0% |
location.remote is true | 85,069 | 14.1% |
Read these as filter costs. has_salary=true discards about two thirds of the
dataset before any of your other filters apply, and taxonomies= discards a
third — not because those jobs are irrelevant, but because nobody classified
them. Filtering on a sparse field is a much stronger statement than it looks.
Two rows are worth reading twice:
companyresolves for all but two postings. The minimal fallback shape is genuinely rare — and still worth handling, because a null-pointer on a path that fires twice in 600,000 rows is a bug you'll find in production.location.remoteis true for 14.1%, but 127,895 postings are classifiedwork_arrangement=Remote. Different fields, different evidence. See the comparison.
Date range#
| Bound | Date |
|---|---|
| Newest posting | 2026-07-08T20:12:11.000Z |
| Oldest posting | 2009-12-05 |
The 2009 end of that range is an archive, not a hiring signal. Old postings
survive as rows because nothing forced them out, and the overwhelming majority
are long since expired or filled — /v1/jobs
excludes anything past its expires_at, but a board that quietly went dark
without declaring expiry leaves postings behind.
Default to a time_frame unless you specifically
want history:
# Almost always what you want. Without it, you're searching back to 2009.
curl -G https://api.hyperjobs.io/v1/jobs \
-H "Authorization: Bearer $HYPERJOBS_KEY" \
--data-urlencode "time_frame=7d"newest from /v1/meta is the freshness check worth
alerting on: if it drifts more than a couple of hours from now, ingestion is
behind, and that tells you more than any count.
Refresh cadence#
Ingestion runs hourly. Each cycle works the stalest boards first — least recently scraped, in order — so no board is starved and freshness degrades evenly rather than in clumps.
Boards aren't all refreshed at the same rate, because they don't all cost the same to fetch:
| Tier | Providers | Cadence | Boards per cycle |
|---|---|---|---|
| Fast / direct | greenhouse, ashby, lever, smartrecruiters, personio, teamtailor, breezy, pinpoint, jazzhr, recruiterbox, rippling, phenom | Every cycle | 9,000 |
| Heavy | oracle, icims, successfactors, cornerstone, plus sharded workday and bamboohr | Every 3rd cycle | 16,000 |
| Proxy-only | workable, recruitee | Every 3rd cycle | 8,000 |
linkedin | Every cycle — last-hour delta | — |
The fast tier serves clean APIs and can be hit directly, so it runs every hour. The heavy and proxy tiers are slower or rate-limited at the source, so they run every third cycle in larger batches — the same throughput, spent less often. Between the tiers, the whole pool of 108,389 boards is covered every few hours.
What this means for freshness
A new posting on a Greenhouse or Ashby board typically appears within the hour. A posting on a Workday or iCIMS board can take several. LinkedIn is refreshed as an hourly delta of the previous hour.
So posted_at is a good ordering key and a
good "newer than X" filter, but the lag between a job going live and appearing
here varies by source. Don't build anything that
assumes uniform latency across boards.
Dead boards disable themselves#
A board that fails five consecutive times is disabled automatically and stops consuming cycles. Companies delete their boards, migrate ATS, and go out of business; without this the rotation would fill with corpses and starve the live boards.
A disabled board's existing postings don't vanish — they age out through expiry like anything else.
Expiry and reaping#
Postings are removed by board diffing. Every time a board is re-fetched, the postings that were there last time and aren't there now are marked expired. The employer taking a job down is the signal; we don't guess.
Two consequences:
- Expiry is as fresh as the board's tier. A removed Greenhouse role is reaped within the hour; a removed Workday role can linger for several. A posting is never expired earlier than the next time its board is checked.
- A dark board strands its postings. If a board stops responding entirely and is disabled, there's no diff to compute, so its postings sit until something else expires them. That's most of what the long tail back to 2009 is.
Some boards declare an expires_at directly, and
that's honoured. Most don't, which is why expires_at is usually null on a
perfectly live posting — a null expires_at doesn't mean the job runs
forever.
/v1/jobs hides expired postings for you.
/v1/jobs/{id} is more forgiving: an
expired posting stays retrievable by id while the row is still in the pool, so
check expires_at rather than assuming a 200 means live. To purge a mirror,
read /v1/jobs/expired — the ids as they
expire, built to be replayed as deletes. And if a cached id does return
404 (purged, or folded as a
duplicate), treat it as "delete my copy".
Query performance#
Every `/v1/jobs` query scans the full dataset
The structured-filter hot path is unindexed. A /v1/jobs request scans and
sorts on the order of 600,000 rows regardless of how selective your filters
are — no structured-filter combination gets you an index seek. (There is one
index: the text-search params are FTS-backed.)
Practically:
- Narrow filters don't run faster, but they return faster. The scan costs what it costs; the response size is what you control.
description=is still the slowest filter. It's a Google-style search over title + description (FTS-backed, not a raw substring scan), but pair it with something narrower anyway.- Deep
offsetpagination gets worse the deeper you go. A highoffsetstill scans and sorts everything before discarding the rows it skipped. Usecursorinstead — the keyset cursor fromnext_cursoris stable at any depth. - Don't fetch descriptions in bulk.
description_formaton alimit=200list is the usual reason a request feels slow. List without them, then pull individual postings from/v1/jobs/{id}.
This is a real constraint, not a warning to route around: requests do complete,
and normal filtered queries are fine. It means you should build against the
feed for bulk sync rather than paging
/v1/jobs to the end.
For anything resembling "give me the whole dataset", use
/v1/jobs/feed and page it with
next_cursor. It's built for exactly that, and it doesn't degrade as you get
further in the way offset does. Feed sync has the
loop.
Don't hardcode these numbers#
Everything above rots. The dataset grows every hour, and a "we track 603,335 jobs" line in your UI starts drifting the moment you ship it.
| You want | Read |
|---|---|
| Total jobs, companies | /v1/meta |
| Freshness | newest from /v1/meta |
| Per-field fill rates | coverage_pct from /v1/meta |
Valid ?source= values, with counts | sources from /v1/meta |
| Count for any filter | total from /v1/jobs?…&limit=1 |
| Liveness | /v1/health — free, no key, no rate limit |
// One request, at build or request time. Never a constant in your source tree.
const { jobs, companies, newest } = await (
await fetch("https://api.hyperjobs.io/v1/meta", {
headers: { Authorization: `Bearer ${process.env.HYPERJOBS_KEY}` },
})
).json();Related#
The live counts these numbers approximate.
Syncing the feedThe right way to pull the dataset in bulk and keep it current.
Sources & ATS coverageWhich boards feed the index, and which tier each one is in.
Duplicates & collapsingWhat the 6,567 duplicate rows are, and why they're hidden.