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

Source: https://hyperjobs.io/blog/your-sample-is-not-random

*HyperJobs Team · 7 April 2026 · 8 min read*

You pulled 200 jobs to have a look at the data. You skimmed the salaries, counted
the remote ones, noticed most of them were mid-senior, and formed an impression.

Every one of those 200 is the newest thing we have. Nothing about them is
representative of anything.

There is no random sample in this API. There is no `sort` parameter, no `ORDER BY
RANDOM()`, no reservoir, no seed. Every endpoint has exactly one fixed order, and
if you don't go looking for that fact, you will publish a number that rests on it.

## The order is not yours to choose

[`/v1/jobs`](/docs/api-reference/jobs-list) is always newest-first by `posted_at`,
falling back to `scraped_at` when a posting carries no publish date. `posted_at`
itself falls back to the ATS creation date when the board doesn't publish one. So
the sort key is really a chain: the board's posting date, then the ATS record's
creation date, then our first sight of the row.

That chain is not configurable, and there is no way to ask for anything else.
Which means the default 50 rows, and a `limit=200` page, are both the same object:
**a recency sample, not a random one.** They are the top of a list, and the list
has an opinion.

This matters more than it sounds, because recency is correlated with nearly
everything you might want to measure. Which boards refreshed in the last hour.
Which employers post in bursts. Whatever was seasonal last week. A recency sample
inherits all of it, and a random sample would inherit none of it — which is why
you wanted one.

## `/v1/companies` is the sharp edge

The jobs endpoint at least lets you page. The directory endpoint does not, and its
failure mode is the cleanest illustration of the whole problem.

[`/v1/companies`](/docs/api-reference/companies-list) has one parameter: `limit`,
default 50, clamped to 200. No filters. No `offset`. No `total`. It is sorted by
`data_tier` — `remco` first — then by follower count descending.

<Warning title="200 is not a page size here. It's the ceiling on the entire endpoint.">
  With no `offset`, `limit=200` doesn't get you the first page of the directory.
  It gets you the *only* page. There is no second request that returns different
  companies, because there is no parameter that would express one.

  The dataset holds **158,545** companies. The most you can ever retrieve from
  this endpoint, by any combination of parameters, in any number of requests, is
  200. The other **158,345** are unreachable there.

  The fixed sort makes it worse. `remco` sorts first, and there are 12,392 `remco`
  profiles — about 8% of the dataset. Since 12,392 is far more than 200, the
  200-row ceiling is consumed entirely by `remco` records before the sort ever
  reaches `linkedin` or `free`. **You will never see a non-`remco` company from
  this endpoint at any `limit`.**

  It is not a sample of the dataset. It is the least representative 200 companies
  in it, by construction — best-documented tier, most-followed first.
</Warning>

That endpoint is a directory to eyeball, not a population to measure. It is
documented that way in [company data](/docs/guides/companies), and the guide is
blunt about it: with no filters and no offset, up to 200 tier-and-follower-sorted
rows is genuinely all it does.

## The documented alternative has its own bias

The answer is to stop asking the companies endpoint for companies. Filter jobs
server-side, read the embedded `job.company` off each row, dedupe by `slug`, and
apply firmographic predicates in your own code. The firmographics arrived with the
job — you're not paying an extra request.

```js
// Illustrative. Page properly for a real distribution.
const { data } = await fetch(
  "https://api.hyperjobs.io/v1/jobs?work_arrangement=Remote&time_frame=30d&limit=200",
  { headers: { Authorization: `Bearer ${process.env.HYPERJOBS_KEY}` } },
).then((r) => r.json());

const companies = new Map();
for (const job of data) {
  // No slug means the minimal fallback shape — no firmographics on it at all.
  if (job.company?.slug) companies.set(job.company.slug, job.company);
}
```

This reaches companies the directory endpoint cannot. But be honest about what it
is: **a sample weighted by how many jobs each company posts.** A company with 400
open roles is 400 times more likely to appear than one with a single opening. Big
employers, high-churn employers and multi-location employers dominate; a
ten-person company hiring one engineer shows up once or not at all.

| | `/v1/companies` | Dedupe from `/v1/jobs` |
| --- | --- | --- |
| Reach | 200, ever | Anything matching your filter |
| Ordering | `remco` tier, then followers | Recency of their postings |
| Selection | Best-documented, most-followed | Weighted by posting volume |
| Firmographics | Full record | Full record, on rows with a `slug` |
| Honest claim | "some companies we hold" | "companies hiring for X, per posting" |

Neither column is a random sample of 158,545 companies. There isn't one. Pick the
bias you can describe in a sentence, then write that sentence down.

## `total` counted before your predicate ran

The other half of the trap. When you filter client-side, `total` does not follow
you:

```js
console.log(`${matches.length} of ${data.length} (${total} total)`);
```

`total` is an exact `COUNT(*)` — but over *the API's* filters, computed before your
code ran. It has no idea your predicate exists. Report `total` next to a
client-side-filtered list and you've published a denominator for a population you
didn't measure. To count the filtered set you have to page the whole thing.

## A null is silence, not a zero

Coverage is the same problem wearing different clothes. From the
[coverage snapshot](/docs/reference/coverage): `salary` is present on 36.0% of
postings (217,221 of 603,335), `skills` on 83.7%, `taxonomies` on 63.9%.

Those aren't gaps being worked through. They're selection. A posting either said
the thing or it didn't, and whatever drove the disclosure is plausibly correlated
with what you're trying to measure — which is why `has_salary=true` narrows to
roles that *said what they pay*, not to roles that pay well. Report it as "the
range advertised by roles that disclose".
[Reading salary data honestly](/blog/salary-data-honestly) works through that one
properly; the point here is that it generalises.

The same reading applies field by field. `visa_sponsorship: null` means the
posting was silent, **not** that sponsorship is unavailable.
`education_requirements: null` does not mean no degree is required — the five
education values together cover well under a third of the dataset, because most
postings state no requirement at all. Filter on education and you exclude the
large majority of jobs that simply didn't say.

And where a classifier did reach, the distribution is lopsided:

<Bars caption="seniority, from the documented snapshot. These are postings the classifier reached — not the whole dataset. Mid-Senior level is nearly half of everything classified, which makes it a weak filter on its own." items={[{ label: "Mid-Senior level", value: 198375, display: "198,375", accent: true }, { label: "Associate", value: 99023, display: "99,023" }, { label: "Entry level", value: 78095, display: "78,095" }, { label: "Management", value: 28749, display: "28,749" }, { label: "Director", value: 26465, display: "26,465" }, { label: "Internship", value: 11603, display: "11,603" }]} />

Slicing on `seniority=Mid-Senior level` barely narrows anything. Slicing on
`Internship` selects a corner so small that a single bulk-posting employer can
move it.

## Without a `time_frame`, you're measuring 2009

The newest posting is `2026-07-08`. The oldest is `2009-12-05`. An unbounded query
searches all of it.

That archive is not a hiring signal. It's rows that nothing forced out — mostly
boards that went dark without declaring expiry, so there was never a diff to reap
them. Aggregate over it and you're mixing a decade of dead listings into a claim
about now.

**Default to a `time_frame` and treat its absence as a bug.** It also bounds the
drift: results are newest-first over a live index, so a long offset walk sees
rows shift under it.

## What to actually do

<Steps>
  <Step title="Bound the window first">
    `time_frame=30d` before anything else. You almost never want 2009.
  </Step>
  <Step title="Count with `limit=1`, don't count rows you fetched">
    Send the filter with `limit=1` and read `total`. Never `limit=0` — it's falsy,
    so it's replaced by the default 50.
  </Step>
  <Step title="Page the set, don't sample the top of it">
    For a distribution rather than an anecdote, walk the whole filtered set with
    `offset` and dedupe by `id`. One page is the newest page.
  </Step>
  <Step title="Report the denominator">
    Publish the ratio your claim rests on next to the claim itself.
  </Step>
  <Step title="Name the population">
    Not "the market". The thing you measured.
  </Step>
</Steps>

The honest sentence is almost always narrower than the one you wanted to write:
*"of postings that disclose X, in the last N days"*. It's less quotable. It's also
the one that survives someone checking it.

[Pagination](/docs/pagination) has the walk, the sort order, and the limits on
every endpoint.
