- Documentation
- Guides
Salary data
How salaries are parsed and normalised, why you must compare on annual_*, and the salary_gte range-overlap semantics.
About 37% of postings carry a parsed salary — coverage_pct.salary on
/v1/meta gives the live figure. The rest don't
state one, and no amount of filtering will conjure them: this is a parsing
result, not a coverage gap we're working through. A posting either advertised a
figure or it didn't.
That number sets the ceiling on every salary question you can ask. has_salary=true
doesn't narrow the dataset to the jobs that pay well — it narrows it to the jobs
that said what they pay, which is a third of the market and not a random third.
The salary object#
{
"salary": {
"min": 180000,
"max": 240000,
"currency": "USD",
"unit": "YEAR",
"annual_min": 180000,
"annual_max": 240000,
"summary": "$180k–$240k"
}
}minThe bottom of the advertised range, in whatever unit says. Not
annualised. Not comparable across postings.
maxThe top of the advertised range, in unit. Frequently null — plenty of
postings state a floor and no ceiling.
currencyISO currency code. Not normalised — see the warning below.
unitThe raw period the posting quoted: YEAR, HOUR, MONTH, WEEK, or
DAY. This is what min and max are denominated in.
annual_minmin normalised to a yearly figure. This is what you compare on.
annual_maxmax normalised to a yearly figure. Null whenever max is null.
summaryThe raw human-readable string, e.g. "$180k–$240k". For display only —
never parse it, the parsed values are right there.
The whole salary object is null when every field would be null. It isn't an
object full of nulls — check job.salary before reaching into it. This is the
same collapse-to-null behaviour as hq and funding
on the company object.
Always compare on annual_*#
`min` and `max` are in different units on different postings
unit is per-posting. An hourly role's min and a salaried role's min are
numbers in the same JSON field that mean entirely different things, and nothing
in the response stops you from comparing them.
| Job A | Job B | |
|---|---|---|
unit | HOUR | YEAR |
min | 45 | 190000 |
max | 60 | 240000 |
annual_min | 93,600 | 190,000 |
annual_max | 124,800 | 240,000 |
Compare on min and Job A looks four thousand times cheaper than Job B. It
isn't — it pays about half. Sort a mixed result set by min and every hourly
posting in it sinks to the bottom regardless of what it actually pays; filter
min >= 100000 and you drop a $60/hour contract worth $124,800 a year.
There is no failure mode here that looks like a failure. The numbers are real, the comparison runs, and the answer is wrong.
annual_min and annual_max are the only cross-posting-comparable fields.
Use min/max/unit for display — "$45–60/hr" is what the candidate needs to
see — and annual_* for every filter, sort, threshold, and aggregate.
The normalisation is done at parse time and guards against the arithmetic that
trips up hand-rolled versions of this: an hourly rate has to be multiplied by
~2,080, not by 52, and a semi-monthly figure is already half-monthly, so
multiplying it by 12 double-counts. If you're annualising min yourself, you're
re-implementing a solved problem and will probably reintroduce one of those.
The salary_gte and salary_lte filters compare against these annualised
bounds, so an hourly posting competes fairly with a salaried one server-side too.
Currency is not normalised#
`annual_min` in EUR and `annual_min` in USD are both annual, and still not comparable
Normalisation is temporal only. It converts a period into a year. It does not convert a currency into another currency.
// Wrong: mixes currencies into one distribution.
const avg = jobs.reduce((s, j) => s + j.salary.annual_min, 0) / jobs.length;A €95,000 role and a $95,000 role contribute the identical number to that average. Any benchmark, histogram, or "top paying" list built this way is silently wrong in proportion to how international your result set is.
Either pin to one currency server-side — salary_currency=USD (comma = OR,
so USD,EUR works when you'll convert yourself) — or convert with your own
rates. The API never converts for you.
The salary_gte filter means "could pay this much"#
`salary_gte` is a range-overlap test, not a floor test
salary_gte=N matches every posting whose advertised band reaches N:
CAST(salary_annual_max AS REAL) >= NSo salary_gte=200000 returns a job advertised at $90k–$210k, because its
ceiling clears 200k. That's the documented semantics — the result set is
"jobs that could pay N", not "jobs that pay at least N". It composes with
salary_lte=M (annual_min <= M) to select every band that overlaps
the interval [N, M].
One honest caveat: a posting with no upper bound has
annual_max: null, the comparison against null is never true, and it won't
match salary_gte — even when its floor obviously qualifies. A job listed
at "$250,000+" vanishes from salary_gte=200000, and the highest-paying
postings are exactly the ones most likely to be open-ended. For a strict
floor, compare salary.annual_min client-side — below.
There is no salary_min parameter — sending it is a 400. An early draft of
this API had one, with these same overlap semantics while its name promised a
floor; the rename to salary_gte is the fix.
A true floor filter#
Cut the result set server-side with has_salary=true and salary_currency,
then apply the real predicate client-side:
const key = process.env.HYPERJOBS_KEY;
const qs = new URLSearchParams({
skill: "rust",
has_salary: "true",
salary_currency: "USD",
limit: "200",
});
const { data } = await fetch(`https://api.hyperjobs.io/v1/jobs?${qs}`, {
headers: { Authorization: `Bearer ${key}` },
}).then((r) => r.json());
const FLOOR = 150_000;
const matches = data.filter((j) => {
// has_salary only guarantees min OR max is set — annual_min can still be null
// on a "up to $200k" posting.
const s = j.salary;
return s?.annual_min != null && s.annual_min >= FLOOR;
});has_salary=true tests salary_min IS NOT NULL OR salary_max IS NOT NULL. It
is not a promise that both bounds exist, and definitely not that annual_min
does. The null check above isn't defensive padding — a "$200k maximum, no
floor" posting will reach it.
Building a comp benchmark#
Getting this right is mostly the three rules above applied at once. The
histogram below buckets on annual_min, pins to one currency, and refuses rows
that can't answer the question.
async function histogram({ skill, currency = "USD", width = 25_000 }) {
const qs = new URLSearchParams({
skill,
has_salary: "true",
salary_currency: currency,
limit: "200",
});
const { data } = await fetch(`https://api.hyperjobs.io/v1/jobs?${qs}`, {
headers: { Authorization: `Bearer ${process.env.HYPERJOBS_KEY}` },
}).then((r) => r.json());
const buckets = new Map();
for (const job of data) {
const s = job.salary;
if (s?.annual_min == null) continue; // "up to $X" rows can't answer a floor question
// Bucket on the annualised floor — `min` would put hourly roles in the $0 bin.
const bucket = Math.floor(s.annual_min / width) * width;
buckets.set(bucket, (buckets.get(bucket) ?? 0) + 1);
}
return [...buckets.entries()].sort((a, b) => a[0] - b[0]);
}Two things to say out loud when you publish the result:
It describes postings, not people. Each row is one advertised range, not one filled job. A company that posts the same role in twelve cities contributes twelve observations to your distribution — collapsing folds those into one row if that's not what you want.
It describes postings that stated a salary. That's about 37% of the dataset, self-selected. Report it as "the range advertised by roles that disclose", because that's the only claim the data actually supports.
For a distribution rather than a sample, page the full filtered set with
cursor — see pagination. The 200
rows above are one page, newest-first, which is a recency sample and not a
random one.
Next#
has_salary, salary_gte, salary_lte, salary_currency, and the rest
of the vocabulary.
Every field on a posting, salary included.