Developer guides

Posted salary data from job ads: Greenhouse, Ashby and Lever pay fields, normalised

Three applicant tracking systems expose posted pay as structured data in their public job-board APIs: Greenhouse (pay_input_ranges, opt-in), Ashby (compensation, opt-in) and Lever (salaryRange). Source: Arcadium Data, data as of .

Posted pay is the cleanest salary signal there is: it is what the employer is willing to say in public, it is dated, and it is tied to a real job. Pay laws in the US states, and from 2026 in the EU, push more employers to publish it, so the data set grows every month.

The raw fields are easy to read and hard to compare. Amounts come in cents or whole units, per hour or per year, in dozens of currencies, with ranges that mix base pay and bonus. This guide shows where each system keeps pay, a normalisation that holds up, and the percentiles that come out.

The official routes: the public job-board APIs

Greenhouse

The Job Board API GET https://boards-api.greenhouse.io/v1/boards/{token}/jobs?pay_transparency=true adds a pay_input_ranges array to each job. A range has min_cents, max_cents, currency_type, a title and a blurb. Amounts are in cents (divide by 100). A job can carry several ranges, for example one per location band, and the period is not a field: it is usually only in the title or blurb, so default to a year and look for "hour" in the text.

Ashby

GET https://api.ashbyhq.com/posting-api/job-board/{token}?includeCompensation=true adds compensation with compensationTiers[].components[] (or summaryComponents). A component has compensationType (Salary, Bonus, Commission, EquityPercentage, ...), interval, currencyCode, minValue and maxValue. Keep only Salary components, or your bonus targets become the salary.

Lever

GET https://api.lever.co/v0/postings/{token}?mode=json has salaryRange with min, max, currency and interval. Many Lever boards leave it empty and write the range into the description instead.

curl

curl -s "https://boards-api.greenhouse.io/v1/boards/stripe/jobs?pay_transparency=true" | head -c 600
curl -s "https://api.ashbyhq.com/posting-api/job-board/ashby?includeCompensation=true" | head -c 600

Python

import requests

board = requests.get("https://boards-api.greenhouse.io/v1/boards/stripe/jobs",
                     params={"pay_transparency": "true"}, timeout=60).json()
for job in board["jobs"]:
    for r in job.get("pay_input_ranges") or []:
        lo, hi = (r.get("min_cents") or 0) / 100, (r.get("max_cents") or 0) / 100
        print(job["title"], lo, hi, r.get("currency_type"))

Pay in the text

Larger employers (and Workday boards) write the range into the description: "The annual salary range is EUR 70,000 to 90,000" or "$45 to $60 per hour". Parse these with a window around a currency symbol or code and a period word, and read numbers by locale (45.000,50 in Germany, 120'000 in Switzerland, 60k). Flag contradictions: in one case a text said "Annual Salary Range ... $75,898 to 75,902 (Hourly Role)"; the right call was yearly, since no hourly rate reaches that, and our parser now converts hourly figures of 20,000 or more to yearly.

Normalisation that holds up

StepRule
AnnualiseHour x 2080, day x 260, week x 52, month x 12, year x 1 (full time, 40 hours, 52 weeks)
Infer a missing periodBelow EUR 200 an hour, 600 to 15,000 a month, 15,000 and up a year; leave 200 to 600 unknown
CurrencyConvert to EUR with a dated monthly rate table, keep the original
PlausibilityReject annual pay below EUR 2,000 or above EUR 1,500,000; it is a wrong period, a bonus or a typo
MidpointAverage of the bounds; with one bound, flag it
ConfidenceHigh: stated period, both bounds, structured field, max at most twice min. Lower for text sources, single bounds, wide ranges and inferred periods

TypeScript

const FACTOR = { hour: 2080, day: 260, week: 52, month: 12, year: 1 } as const;
type Period = keyof typeof FACTOR;

export function annual(min: number, max: number, period: Period, eurPerUnit: number) {
  const f = FACTOR[period] * eurPerUnit;
  const [lo, hi] = [min * f, max * f];
  if (hi < 2_000 || lo > 1_500_000) return null; // not a full-time salary
  return { lo: Math.round(lo), hi: Math.round(hi), mid: Math.round((lo + hi) / 2) };
}

Also remember that posted pay is a range for a role, not what people earn, that the same role is posted many times, and that employers with pay laws may post wide ranges. Compare medians over a stable panel, not single ads.

Aggregating without fooling yourself

Once you have annual figures, three habits keep the statistics honest. First, deduplicate: the same job is often posted in several locations or re-posted, so key on employer plus requisition id plus country, and count each role once per week. Second, cap the weight of one employer; a company with 500 near-identical ads should not decide the median, so weight each employer equally within a cell or limit its share. Third, publish the sample next to the number: how many postings, how many employers, which week. A median of 12 ads from 3 employers is an anecdote; we do not publish cells under 10 postings or 3 employers.

Map titles to occupation groups with a standard classification such as ISCO-08, not with raw title strings: "Software Engineer II", "Backend Developer" and "Softwareentwickler (m/w/d)" belong in one cell. Keep the local currency and the EUR figure side by side, because exchange-rate moves alone can shift a EUR median for a country that posts in another currency. Finally, note what posted ranges are: the width of a range is often a policy choice, and in places with new pay laws, ranges widen to stay safe. The midpoint is a fair estimate of the offer; the bounds are not.

The fast route: salary percentiles by role and country

Arcadium Data reads all three fields plus pay in the text, normalises with the rules above, maps each title to an occupation group (ISCO) and publishes weekly percentiles per country. Cells need at least 10 postings from 3 employers. One role in one country is open without a key:

Calls on this page work without a key from one IP (10 requests a minute, 100 a day, short answers, point lookups). A free key lifts the limits: sign up. Routes, parameters and fields: OpenAPI.

curl

curl -s "https://api.arcadiumdata.io/v1/pay/bands?role=software%20engineer&country=DE"

Python

import requests

data = requests.get("https://api.arcadiumdata.io/v1/pay/bands?role=software%20engineer&country=DE", timeout=30).json()
for b in data["data"]:
    print(b["week"], b["country"], b["p25Eur"], b["medianEur"], b["p75Eur"], b["n"])

TypeScript

const res = await fetch('https://api.arcadiumdata.io/v1/pay/bands?role=software%20engineer&country=DE');
const data = (await res.json()) as { data: { week: string; p25Eur: number; medianEur: number; p75Eur: number; n: number; nEmployers: number }[] };
for (const b of data.data) console.log(b.week, b.p25Eur, b.medianEur, b.p75Eur, b.n);

Response (abridged)

{
  "week": "2026-W43",
  "role": { "input": "software engineer", "iscoGroup": "25", "iscoLabel": "Information and communications technology professionals" },
  "data": [{
    "country": "DE", "n": 232, "nEmployers": 87,
    "p25Eur": 71474, "medianEur": 90000, "p75Eur": 115000,
    "localCurrency": "EUR", "medianLocal": 89037, "hourlyShare": 0.086
  }]
}

The answer is for the occupation group the title maps to (here ISCO 25), with the number of postings and employers behind it, so you can judge the sample. A free key lists many roles and countries at once (isco, role, country, week). Per-company pay share is in GET /v1/companies/{domain} under payShare. For the policy view, see the pay transparency guide.

Comparison

Public ATS APIsSalary surveysArcadium Data /v1/pay/bands
What it measuresPosted pay of open jobsPay of peoplePosted pay of open jobs
FreshnessTodayYearlyWeekly
Currency and periodRaw, you normaliseNormalisedAnnual EUR plus local
CoverageBoards you findSampleDocumented panel of boards
Sample size shownYou countOften hiddenn and employers per cell
KeyNoneOften paidNone for one role and country

Get the data without the plumbing

Arcadium Data serves these datasets from one REST API and one MCP server, with sources, licences and dates on every answer. No key is needed to try the routes in this guide.

Sources

Last updated 2026-10-25. This guide is ours and independent of the organisations it describes. Found a mistake? Tell us and we correct it.

Questions

Where does Greenhouse store salary data?

In pay_input_ranges, returned when you add pay_transparency=true to the Job Board API. Each range has min_cents, max_cents, currency_type, a title and a blurb. Amounts are in cents.

How do I get compensation from Ashby?

Call the posting API with includeCompensation=true and read compensationTiers[].components[]. Keep components whose compensationType is Salary, since bonus and equity components sit next to them.

How do I compare hourly and yearly pay?

Annualise with fixed factors: hour x 2080, day x 260, week x 52, month x 12. Convert to one currency at a dated rate and reject results outside a plausible full-time band.

Is posted pay what people earn?

No. It is the range an employer states for a role. It is a good, dated signal of the market for new hires, but it differs from actual pay of current staff.

How many postings are behind a salary band?

Every cell in our API carries the number of postings and employers, and cells under 10 postings or 3 employers are not published. Always read the sample size next to a percentile.

Cite as: Arcadium Data, "Posted salary data from job ads: Greenhouse, Ashby and Lever pay fields, normalised", https://arcadiumdata.io/guides/posted-salary-data-from-job-ads/, data as of 2026-10-25.