DEV Community

Scott Williams
Scott Williams

Posted on

Which job posts actually publish pay? Reading salary ranges from Greenhouse, Lever and Ashby

Pay transparency laws mean a lot of job postings now include a salary range. If you want those ranges as data (to compare offers, to research a market, or to check which employers publish pay at all) the good news is that three common applicant tracking systems all expose a pay range in their public job board APIs.

The bad news is that each one stores it differently, plenty of companies never fill it in, and some write the range into the job description instead. I wrote a small Python tool that reads all three, falls back to ranges written in the text, and labels every job with where its number came from. This post walks through how each API stores pay and what came back when I ran it against three real boards.

The code is in my repo, free and MIT licensed: fecklesspleb/job-pay-ranges. Standard library only, no API keys.

The three endpoints

All three are public, read-only, and return every open job on a board in one request.

Greenhouse

https://boards-api.greenhouse.io/v1/boards/{board}/jobs?content=true&pay_transparency=true
Enter fullscreen mode Exit fullscreen mode

The pay field is pay_input_ranges, and it is only in the response when you add pay_transparency=true. Each range has min_cents, max_cents, currency_type, a title and a blurb. Two details matter:

  • The amounts are in cents. min_cents: 12000000 is $120,000.
  • There is no pay period field. The period, if it is anywhere, is in the range's title ("Canada Annual Pay Range") or its blurb.
for r in job.get("pay_input_ranges") or []:
    if r.get("min_cents") is None or r.get("max_cents") is None:
        continue
    # Greenhouse has no period field. The range title ("Canada Annual Pay Range")
    # or the blurb next to it usually says it; otherwise it stays unknown.
    interval = interval_from_words(r.get("title")) or interval_from_words(html_to_text(r.get("blurb")))
    ranges.append({"min": r["min_cents"] / 100, "max": r["max_cents"] / 100,
                   "currency": r.get("currency_type"), "interval": interval,
                   "text": r.get("title")})
Enter fullscreen mode Exit fullscreen mode

One more Greenhouse quirk: the job content HTML comes back escaped a second time (&lt;p&gt; instead of <p>), so it has to be unescaped twice before you search it for a written range.

Lever

https://api.lever.co/v0/postings/{company}?mode=json
Enter fullscreen mode Exit fullscreen mode

Lever has the cleanest field: salaryRange with min, max, currency and an explicit interval. The amounts are plain numbers. Lever's docs don't list the interval values; the only ones I've seen in real data are per-year-salary and per-hour-wage. The tool maps those (plus a few untested guesses), and any value it doesn't recognise is passed through as-is in pay_text, with the period left empty:

LEVER_INTERVALS = {
    "per-year-salary": "year", "per-month-salary": "month", "per-week-salary": "week",
    "per-day-wage": "day", "per-hour-wage": "hour",
}
Enter fullscreen mode Exit fullscreen mode

Ashby

https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=true
Enter fullscreen mode Exit fullscreen mode

Without includeCompensation=true you get no structured pay data (the descriptions may still contain written ranges). With it, each job has compensation.compensationTiers, and each tier has a list of components. The catch is that base salary sits in that list next to equity, commission and bonus, so you have to filter by compensationType. Ashby also has a per-job flag, shouldDisplayCompensationOnJobPostings, which the tool respects. It also skips jobs with isListed: false, which Ashby's docs say should only be available via direct link:

for tier in comp.get("compensationTiers") or []:
    for c in tier.get("components") or []:
        if c.get("compensationType") != "Salary" or c.get("minValue") is None or c.get("maxValue") is None:
            continue
        ranges.append({"min": float(c["minValue"]), "max": float(c["maxValue"]),
                       "currency": c.get("currencyCode"),
                       "interval": ASHBY_INTERVALS.get(c.get("interval")),
                       "text": c.get("summary")})
Enter fullscreen mode Exit fullscreen mode

When the range is only in the description

If the structured field is empty, the tool searches the description for a currency symbol or code followed by a range: $120,000 - $150,000, $120-150K, €60.000 – €75.000, SEK 760,000 - SEK 1,240,000, or 140,000 - 190,000 USD. It then looks at the 80 characters before the range and the 40 after it for the period:

window = text[max(0, m.start() - 80):m.end() + 40]
interval = interval_from_words(window)
if interval is None and lo >= 10000 and re.search(r"\bsalary\b", window, re.I):
    interval = "year"                        # "salary range is $120,000 - $150,000"
Enter fullscreen mode Exit fullscreen mode

If it still can't tell, the period stays empty. A wrong guess (treating an hourly rate as a salary, say) is worse than a blank.

Running it

python3 job_pay_ranges.py greenhouse:cloudflare lever:zoox ashby:ramp --csv pay.csv
Enter fullscreen mode Exit fullscreen mode

That prints a summary per board and writes one CSV row per job, with pay_source set to ats (the structured field), description (a range found in the text) or none. Amounts stay in their own currency, and annual_min / annual_max convert hourly, daily, weekly and monthly ranges to a year (hour × 2,080, day × 260, week × 52, month × 12).

What three real boards look like

I ran it on November 2, 2026 against Cloudflare (Greenhouse), Zoox (Lever) and Ramp (Ashby). These counts are from that one run; the boards change every day.

Board Jobs Structured field Range in description No range found
Cloudflare (Greenhouse) 466 0 207 259
Zoox (Lever) 226 210 1 15
Ramp (Ashby) 173 166 0 7

The three boards show the three situations you will run into.

Cloudflare publishes pay, but not in the field. None of its 466 jobs had pay_input_ranges, yet 207 had a range written in the description, in USD (150), EUR (56) and SEK (1). 86 of those jobs list more than one range, usually one per location, and one job had six. So for Greenhouse boards, the fallback is not optional. (Because no job in this run used the structured field, the Greenhouse pay_input_ranges path is covered by the repo's tests rather than by this run.)

The run also showed the fallback's limits. Two ranges had no period nearby, so their period was left blank. And at least five jobs say "Estimated annual salary" but were marked none, because the number itself has a typo or an unusual format the pattern doesn't match: €63, 000, $257 ,000 - $31 4,000, SEK 1,742, 000, 209, 000, and 418K-574K Swedish SEK. If you need every range, read the none rows that mention salary.

Zoox fills in Lever's field almost everywhere. 210 of 226 jobs used salaryRange: 206 yearly and 4 hourly. One more job had an hourly range of $37-43 only in its description. 14 of the 15 jobs with no range were contract student roles.

Ramp uses Ashby's tiers, with extras. 166 of 173 jobs had a salary component. Across those jobs, 129 also had an equity cash value component, 43 had commission, 28 an equity percentage and 1 a bonus, which is why filtering on compensationType == "Salary" matters: without it, you'd mix equity into base pay. 51 jobs had more than one salary range (location tiers). The other 7 jobs (go-to-market roles such as Account Executive and Business Development) had shouldDisplayCompensationOnJobPostings set to false and no compensation data.

Ramp also shows why the period matters. Five internship postings are listed at $12.5K per month, not per year. Ashby says so in the interval (1 MONTH), so the tool records them as monthly and annualizes them to $150,000. Read naively as yearly, they would have been a $12,500 "salary".

Limits

  • Ranges are kept in their own currency. There is no currency conversion.
  • When a job has several ranges, the CSV columns hold the first one; range_count and pay_text hold all of them.
  • The description fallback misses ranges with typos inside the numbers, ranges with no currency, and currency names written out in words.
  • Annualizing assumes full-time hours (2,080 a year). It's a comparison aid, not what someone will actually earn.

Be polite to the endpoints

These APIs exist to power each company's own careers page. The tool makes one request per board, sends a User-Agent naming itself, waits a second between boards and backs off on HTTP 429. Job lists rarely change more than a few times a day, so there's no reason to poll them faster than that.

The code, tests (25, all offline, against made-up board files) and full field notes are in my repo: fecklesspleb/job-pay-ranges.


AI disclosure: this article and the code were written with AI assistance. The numbers come from one real run on November 2, 2026, and I reviewed them before publishing. Not affiliated with Greenhouse, Lever, Ashby or the companies named.

Top comments (0)