# Salary data

> How salaries are parsed and normalised, why you must compare on annual_*, and the salary_gte range-overlap semantics.

Source: https://hyperjobs.io/docs/guides/salary

About **37%** of postings carry a parsed salary — `coverage_pct.salary` on
[`/v1/meta`](/docs/api-reference/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"
  }
}
```

<Fields>
  <ResponseField name="min" type="number | null">
    The bottom of the advertised range, **in whatever `unit` says**. Not
    annualised. Not comparable across postings.
  </ResponseField>
  <ResponseField name="max" type="number | null">
    The top of the advertised range, in `unit`. Frequently null — plenty of
    postings state a floor and no ceiling.
  </ResponseField>
  <ResponseField name="currency" type="string | null">
    ISO currency code. **Not normalised** — see the warning below.
  </ResponseField>
  <ResponseField name="unit" type="string | null">
    The raw period the posting quoted: `YEAR`, `HOUR`, `MONTH`, `WEEK`, or
    `DAY`. This is what `min` and `max` are denominated in.
  </ResponseField>
  <ResponseField name="annual_min" type="number | null">
    `min` normalised to a yearly figure. **This is what you compare on.**
  </ResponseField>
  <ResponseField name="annual_max" type="number | null">
    `max` normalised to a yearly figure. Null whenever `max` is null.
  </ResponseField>
  <ResponseField name="summary" type="string | null">
    The raw human-readable string, e.g. `"$180k–$240k"`. For display only —
    never parse it, the parsed values are right there.
  </ResponseField>
</Fields>

<Note>
  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`](/docs/guides/companies)
  on the company object.
</Note>

## Always compare on `annual_*`

<Warning title="`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.
</Warning>

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

<Warning title="`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.
</Warning>

## The `salary_gte` filter means "could pay this much"

<Warning title="`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.
</Warning>

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;
});
```

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

## 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](/docs/guides/deduplication#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](/docs/pagination#walking-a-full-result-set). The 200
rows above are one page, newest-first, which is a recency sample and not a
random one.

## Next

<CardGroup cols={2}>
  <Card title="Filtering jobs" icon="Funnel" href="/docs/guides/filtering#salary">
    `has_salary`, `salary_gte`, `salary_lte`, `salary_currency`, and the rest
    of the vocabulary.
  </Card>
  <Card title="The Job object" icon="Braces" href="/docs/objects/job">
    Every field on a posting, salary included.
  </Card>
</CardGroup>
