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
| Step | Rule |
|---|---|
| Annualise | Hour x 2080, day x 260, week x 52, month x 12, year x 1 (full time, 40 hours, 52 weeks) |
| Infer a missing period | Below EUR 200 an hour, 600 to 15,000 a month, 15,000 and up a year; leave 200 to 600 unknown |
| Currency | Convert to EUR with a dated monthly rate table, keep the original |
| Plausibility | Reject annual pay below EUR 2,000 or above EUR 1,500,000; it is a wrong period, a bonus or a typo |
| Midpoint | Average of the bounds; with one bound, flag it |
| Confidence | High: 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 APIs | Salary surveys | Arcadium Data /v1/pay/bands | |
|---|---|---|---|
| What it measures | Posted pay of open jobs | Pay of people | Posted pay of open jobs |
| Freshness | Today | Yearly | Weekly |
| Currency and period | Raw, you normalise | Normalised | Annual EUR plus local |
| Coverage | Boards you find | Sample | Documented panel of boards |
| Sample size shown | You count | Often hidden | n and employers per cell |
| Key | None | Often paid | None 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.
Related
- EU Pay Transparency Directive data
- Job board APIs: Greenhouse, Lever, Ashby
- Workday career site JSON
- Pay transparency tracker
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.