All posts
Insights

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.

HyperJobs Team6 min read

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:

EndpointReportedExcluded duplicates?Excluded expired?
/v1/healthevery row in the tableNoNo
/v1/metanon-duplicate rowsYesNo
/v1/jobstotalYesYes

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:

BASH
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 wantRead
Dataset sizejobs from /v1/meta — or /v1/health; they agree
Freshnessnewest from /v1/meta
Count for any filter/v1/jobs/count
Per-source countssources from /v1/meta
Per-field coveragecoverage_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.

JS
// 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.