Guides / Salary data

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#

JSON
{
  "salary": {
    "min": 180000,
    "max": 240000,
    "currency": "USD",
    "unit": "YEAR",
    "annual_min": 180000,
    "annual_max": 240000,
    "summary": "$180k–$240k"
  }
}
min
number | null

The bottom of the advertised range, in whatever unit says. Not annualised. Not comparable across postings.

max
number | null

The top of the advertised range, in unit. Frequently null — plenty of postings state a floor and no ceiling.

currency
string | null

ISO currency code. Not normalised — see the warning below.

unit
string | null

The raw period the posting quoted: YEAR, HOUR, MONTH, WEEK, or DAY. This is what min and max are denominated in.

annual_min
number | null

min normalised to a yearly figure. This is what you compare on.

annual_max
number | null

max normalised to a yearly figure. Null whenever max is null.

summary
string | null

The 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 AJob B
unitHOURYEAR
min45190000
max60240000
annual_min93,600190,000
annual_max124,800240,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.

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

SQL
CAST(salary_annual_max AS REAL) >= N

So 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:

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

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

Was this page helpful?