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.
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 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 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.
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.
That endpoint is a directory to eyeball, not a population to measure. It is documented that way in company data, 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.
// 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:
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: 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 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:
- Mid-Senior level
- 198,375
- Associate
- 99,023
- Entry level
- 78,095
- Management
- 28,749
- Director
- 26,465
- Internship
- 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#
Bound the window first
time_frame=30dbefore anything else. You almost never want 2009.Count with `limit=1`, don't count rows you fetched
Send the filter with
limit=1and readtotal. Neverlimit=0— it's falsy, so it's replaced by the default 50.Page the set, don't sample the top of it
For a distribution rather than an anecdote, walk the whole filtered set with
offsetand dedupe byid. One page is the newest page.Report the denominator
Publish the ratio your claim rests on next to the claim itself.
Name the population
Not "the market". The thing you measured.
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 has the walk, the sort order, and the limits on every endpoint.
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
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.
6 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