Ask how many jobs there are and you get one answer
health, meta, and an unfiltered search used to report three different counts. Now they report one — here's what each number still means.
Revised 2026-07-16 for the v1 launch. This post originally ran as "Ask how many jobs there are and you get three answers" — a defence of three endpoints reporting three different counts. The draft it described never shipped: v1 aligned the counters. The post now covers what one count means, and the misreadings that survive the alignment.
Ask this API how many jobs it holds and you get the same answer everywhere.
/v1/health says 596,768.
/v1/meta says 596,768. An unfiltered
/v1/jobs?limit=1 says total: 596768.
One number, one population: live postings — no duplicates, nothing past its
expires_at. Every counting endpoint applies the same two exclusions, so a
count you read anywhere is a count of rows you can actually retrieve.
It wasn't designed that way the first time, and the original design is worth a paragraph because the misreadings it trained are still out there.
The three populations, retired#
A pre-release draft of this API reported three nested counts:
| Endpoint | Reported | Excluded duplicates? | Excluded expired? |
|---|---|---|---|
/v1/health | every row in the table | No | No |
/v1/meta | non-duplicate rows | Yes | No |
/v1/jobs | total | Yes | Yes |
Each was "correct" for its own population, and the draft's documentation — including the first version of this post — taught you which one to quote for which claim, warned you not to derive one from another by subtraction (the exclusions overlap, so the arithmetic double-counts), and conceded that a dashboard quoting the health figure as "jobs available" would overstate by a plausible, unfalsifiable, permanently-wrong 1%.
That's three populations, two documented traps, and a warning label — to avoid
filtering two conditions in a count query. v1 deleted the whole taxonomy: if a
row can't come back from a search, no endpoint counts it. The duplicates and
the expired rows still exist in the pool (dedup
flags losers rather than deleting them,
and expiry feeds /v1/jobs/expired) — they
just no longer leak into numbers that claim to describe what's searchable.
The general rule the draft taught survives its retirement, because it's about
your dashboards rather than our endpoints: read a count from something whose
conditions match the claim you're making. The API now makes the easy reading
the correct one — the claim "jobs you can search" and the number on /v1/meta
finally describe the same rows.
Counting something narrower#
The dataset-size question has one answer. Filtered questions have
/v1/jobs/count:
curl -G https://api.hyperjobs.io/v1/jobs/count \
-H "Authorization: Bearer $HYPERJOBS_KEY" \
--data-urlencode "country=DE" \
--data-urlencode "seniority=Director,Management"Same filter vocabulary as /v1/jobs, count only — the draft-era idiom of
?limit=1 and reading total still works, but the dedicated endpoint says
what it's doing. Either way the number is an exact COUNT(*) over your
filters, not an estimate.
Exactness has a price worth knowing: a very broad query pays to count every
matching row regardless of how few you fetch. If all you need is "are there
any", fetch limit=1 and check data.length rather than asking for the
count. See pagination.
The feed counts the same population now#
In the draft, /v1/jobs/feed was a fourth
population — it served expired rows, on the theory that a sync consumer has to
see a posting die to expire its own copy. The theory was right and the
mechanism was wrong: it made every mirror accumulate dead jobs unless the
client filtered expires_at by hand, and it meant feed rows and index rows
could never be counted against each other.
v1 keeps the requirement and moves it where it belongs: the feed serves the
same live population as everything else, and expiry is its own endpoint —
/v1/jobs/expired streams
{id, expired_at} for the rows to delete. Your mirror consumes births and
deaths from two feeds instead of inferring deaths from a population mismatch.
Feed sync has the loop.
The freshness trap#
This one survives the redesign completely intact, and it's the most expensive misreading left.
/v1/health will happily report ok on a dataset that stopped updating hours
ago. It checks that the data is readable, not that it's current. Liveness, not
freshness. Ingestion can be dead in a ditch and the probe stays green, because
the rows are still there and still readable — which is all the endpoint ever
claimed to check.
For freshness, read newest from /v1/meta. If it
drifts more than a couple of hours from now, ingestion is behind, and that
tells you more than any count will.
The two alerts answer different questions and you want both. /v1/health
for "is it up", newest for "is it current". A monitoring setup with only the
first will page you after your users notice.
And one more thing /v1/health can't do: validate a key. It never reads one —
an expired, revoked, or misspelled key gets the same 200 as a good one. Check
a key against an endpoint that requires one and look for a
401; /v1/meta is the cheap choice, and it needs a key now
precisely because it does real work.
Which number to quote#
| You want | Read |
|---|---|
| Dataset size | jobs from /v1/meta — or /v1/health; they agree |
| Freshness | newest from /v1/meta |
| Count for any filter | /v1/jobs/count |
| Per-source counts | sources from /v1/meta |
| Per-field coverage | coverage_pct from /v1/meta |
| Liveness | /v1/health — free, no key, no rate limit |
And underneath all of it: every number on this page is a snapshot. The index refreshes hourly. A "we track 596,768 jobs" line in your UI starts drifting the moment you ship it.
// Illustrative. 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();The short version#
One population, one count, two remaining jobs for your monitoring: /v1/health
for "is it up", meta.newest for "is it current". The number you quote is the
number your users can search — which is the property the draft's three answers
never had, and the reason they're gone.
Dataset coverage has the live-count details, the field completeness table, and the rest of the numbers you shouldn't hardcode.
Keep reading
Reading salary data honestly
What the salary fields mean, why coverage is partial by construction, and what salary_gte's range-overlap semantics do and don't promise.
8 min read
Your sample is not random
This API sorts newest-first and never randomly. What a limit=200 page actually measures, and why /v1/companies can only ever show you 200.
8 min read
How to measure remote hiring share
The query that answers it, and the ways it still lies to you: two remote fields, sparse classifiers, provenance mix, and the archive tail.
7 min read