# 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.

Source: https://hyperjobs.io/blog/three-numbers-that-disagree

*HyperJobs Team · 26 May 2026 · 6 min read*

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

Ask this API how many jobs it holds and you get the same answer everywhere.
[`/v1/health`](/docs/api-reference/health) says 596,768.
[`/v1/meta`](/docs/api-reference/meta) says 596,768. An unfiltered
[`/v1/jobs?limit=1`](/docs/api-reference/jobs-list) 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](/blog/collapsing-duplicate-postings),
and expiry feeds [`/v1/jobs/expired`](/docs/api-reference/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`](/docs/api-reference/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.

<Note>
  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](/docs/pagination).
</Note>

## The feed counts the same population now

In the draft, [`/v1/jobs/feed`](/docs/api-reference/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`](/docs/api-reference/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](/docs/guides/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`](/docs/api-reference/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`](/docs/errors); `/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`](/docs/api-reference/meta) — or `/v1/health`; they agree |
| Freshness | `newest` from [`/v1/meta`](/docs/api-reference/meta) |
| Count for any filter | [`/v1/jobs/count`](/docs/api-reference/jobs-count) |
| Per-source counts | `sources` from [`/v1/meta`](/docs/api-reference/meta) |
| Per-field coverage | `coverage_pct` from [`/v1/meta`](/docs/api-reference/meta) |
| Liveness | [`/v1/health`](/docs/api-reference/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](/docs/reference/coverage) has the live-count details, the
field completeness table, and the rest of the numbers you shouldn't hardcode.
