Data reference / Dataset coverage

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 rows603,335
Postings, excluding duplicates596,768
Marked as duplicates6,567
Expired8,482
Companies158,545
ATS boards tracked108,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.

FieldPopulatedShare
description600,32199.5%
company resolved to a full profile603,33399.9997%
skills504,78783.7%
taxonomies385,24663.9%
salary217,22136.0%
location.remote is true85,06914.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:

  • company resolves 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.remote is true for 14.1%, but 127,895 postings are classified work_arrangement=Remote. Different fields, different evidence. See the comparison.

Date range#

BoundDate
Newest posting2026-07-08T20:12:11.000Z
Oldest posting2009-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:

BASH
# 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:

TierProvidersCadenceBoards per cycle
Fast / directgreenhouse, ashby, lever, smartrecruiters, personio, teamtailor, breezy, pinpoint, jazzhr, recruiterbox, rippling, phenomEvery cycle9,000
Heavyoracle, icims, successfactors, cornerstone, plus sharded workday and bamboohrEvery 3rd cycle16,000
Proxy-onlyworkable, recruiteeEvery 3rd cycle8,000
LinkedInlinkedinEvery 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 offset pagination gets worse the deeper you go. A high offset still scans and sorts everything before discarding the rows it skipped. Use cursor instead — the keyset cursor from next_cursor is stable at any depth.
  • Don't fetch descriptions in bulk. description_format on a limit=200 list 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 wantRead
Total jobs, companies/v1/meta
Freshnessnewest from /v1/meta
Per-field fill ratescoverage_pct from /v1/meta
Valid ?source= values, with countssources from /v1/meta
Count for any filtertotal from /v1/jobs?…&limit=1
Liveness/v1/health — free, no key, no rate limit
JS
// 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();
Was this page helpful?