Article type: How-to guide (one task: collect the posted salary ranges for one role across several employers' job boards into a CSV)
Prerequisites: Python 3 with
requests(pip install requests). No API key or account: Greenhouse, Ashby and Lever all publish public job-board endpoints. An Apify account only if you use the hosted version at the end.Tested on: 14 November 2026
You want to know what a role pays across a set of companies: for a hiring plan, an offer, a compensation review or a job-market report. Many employers now publish a pay range on each job post, and if they use Greenhouse, Ashby or Lever, that range is in a structured field on the public job board. No scraping of web pages is needed.
All the data in this post comes from the employers' own public job boards, through each applicant tracking system's documented endpoint:
| ATS | Endpoint | Where the pay is |
|---|---|---|
| Greenhouse | https://boards-api.greenhouse.io/v1/boards/{token}/jobs?pay_transparency=true | pay_input_ranges: one or more ranges, each with min_cents, max_cents, currency_type and a title |
| Ashby | https://api.ashbyhq.com/posting-api/job-board/{token}?includeCompensation=true | compensation.summaryComponents: the Salary component has minValue, maxValue, currencyCode and interval |
| Lever | https://api.lever.co/v0/postings/{token}?mode=json | salaryRange: min, max, currency and interval (optional) |
The {token} is the company's slug in its job-board URL: job-boards.greenhouse.io/{token}, jobs.ashbyhq.com/{token} or jobs.lever.co/{token}. If a company's careers page is on its own domain, the job links or the page source usually show which board it uses.
How do I collect the ranges for one role?
This script reads eight boards, keeps jobs whose title contains "software engineer" or "software engineering" as whole words, and writes one CSV row per posted range. Save it as careers_salary.py:
import csv import re import time import requests ROLE = re.compile(r"\bsoftware engineer(ing)?\b", re.I) # whole words, case-insensitive HEADERS = {"User-Agent": "salary-range-script"} BOARDS = [ # (company, ATS, board token from the careers URL) ("Airbnb", "greenhouse", "airbnb"), ("Databricks", "greenhouse", "databricks"), ("Coinbase", "greenhouse", "coinbase"), ("OpenAI", "ashby", "openai"), ("Ramp", "ashby", "ramp"), ("Snowflake", "ashby", "snowflake"), ("Zoox", "lever", "zoox"), ("Match Group", "lever", "matchgroup"), ] def get(url, **params): r = requests.get(url, params=params, headers=HEADERS, timeout=60) r.raise_for_status() time.sleep(1) return r.json() def period(label): """Greenhouse and Lever give the period as text; turn it into one word.""" t = (label or "").lower() if "bi-week" in t: return "two weeks" for word, p in (("hour", "hour"), ("week", "week"), ("month", "month"), ("year", "year"), ("annual", "year")): if word in t: return p return "" # not stated: leave it blank rather than guess def greenhouse(token): jobs = get(f"https://boards-api.greenhouse.io/v1/boards/{token}/jobs", pay_transparency="true")["jobs"] rows = [] for j in jobs: for p in j.get("pay_input_ranges") or [{}]: # one row per posted range (zones, countries) rows.append({"title": j["title"], "location": j["location"]["name"], "range_label": p.get("title", ""), "currency": p.get("currency_type", ""), "min": p["min_cents"] / 100 if p.get("min_cents") else "", "max": p["max_cents"] / 100 if p.get("max_cents") else "", "period": period(p.get("title")) if p else "", "url": j["absolute_url"], "posted": (j.get("first_published") or "")[:10]}) return len(jobs), rows def ashby(token): jobs = get(f"https://api.ashbyhq.com/posting-api/job-board/{token}", includeCompensation="true")["jobs"] rows = [] for j in jobs: comp = j.get("compensation") or {} shown = j.get("shouldDisplayCompensationOnJobPostings") is not False sal = [c for c in comp.get("summaryComponents") or [] if c.get("compensationType") == "Salary"] if shown else [] for s in sal or [{}]: rows.append({"title": j["title"], "location": j.get("location", ""), "range_label": "Salary" if s else "", "currency": s.get("currencyCode", ""), "min": s.get("minValue") or "", "max": s.get("maxValue") or "", "period": period(s.get("interval")) if s else "", "url": j["jobUrl"], "posted": (j.get("publishedAt") or "")[:10]}) return len(jobs), rows def lever(token): jobs = get(f"https://api.lever.co/v0/postings/{token}", mode="json") rows = [] for j in jobs: s = j.get("salaryRange") or {} rows.append({"title": j["text"], "location": j.get("categories", {}).get("location", ""), "range_label": s.get("interval", ""), "currency": s.get("currency", ""), "min": s.get("min") or "", "max": s.get("max") or "", "period": period(s.get("interval")), "url": j["hostedUrl"], "posted": time.strftime("%Y-%m-%d", time.gmtime(j["createdAt"] / 1000))}) return len(jobs), rows READERS = {"greenhouse": greenhouse, "ashby": ashby, "lever": lever} def check(r): """Flag rows a person should look at before they go into a comparison.""" if not (r["min"] or r["max"]): return "" if not r["period"]: return "period not stated" if r["min"] and r["max"] and r["max"] > 3 * r["min"]: return "max is over 3x min" if r["period"] == "two weeks" and (r["max"] or r["min"]) > 50_000: return "label says two weeks, amount looks yearly" return "" out_rows = [] print(f"{'Company':<12} {'ATS':<10} {'board':>5} {'role':>5} {'w/ pay':>6} USD per year (lowest min - highest max)") for company, ats, token in BOARDS: total, rows = READERS[ats](token) role_rows = [r for r in rows if ROLE.search(r["title"])] jobs_with_role = {r["url"] for r in role_rows} with_pay = {r["url"] for r in role_rows if r["min"] or r["max"]} for r in role_rows: r["check"] = check(r) usd_year = [r for r in role_rows if r["currency"] == "USD" and r["period"] == "year" and r["min"] and r["max"] and not r["check"]] span = f'${min(r["min"] for r in usd_year):,.0f} - ${max(r["max"] for r in usd_year):,.0f}' if usd_year else "-" print(f"{company:<12} {ats:<10} {total:>5} {len(jobs_with_role):>5} {len(with_pay):>6} {span}") out_rows += [{"company": company, "ats": ats, **r} for r in role_rows] with open("salary_ranges.csv", "w", newline="") as f: w = csv.DictWriter(f, fieldnames=["company", "ats", "title", "location", "range_label", "currency", "min", "max", "period", "check", "posted", "url"]) w.writeheader() w.writerows(out_rows) print(f"\n{len(out_rows)} rows written to salary_ranges.csv") for reason in ("period not stated", "max is over 3x min", "label says two weeks, amount looks yearly"): print(f'{sum(r["check"] == reason for r in out_rows):>4} rows flagged: {reason}') Output on 14 November 2026:
Company ATS board role w/ pay USD per year (lowest min - highest max) Airbnb greenhouse 214 28 20 - Databricks greenhouse 896 169 92 - Coinbase greenhouse 265 67 66 $152,405 - $298,700 OpenAI ashby 839 157 153 $145,800 - $536,000 Ramp ashby 180 26 26 $168,000 - $400,000 Snowflake ashby 376 84 66 $157,000 - $437,000 Zoox lever 241 52 48 $137,000 - $493,000 Match Group lever 69 2 1 $125,000 - $130,000 594 rows written to salary_ranges.csv 118 rows flagged: period not stated 1 rows flagged: max is over 3x min 1 rows flagged: label says two weeks, amount looks yearly "board" is every open job on the board, "role" is jobs with a matching title, and "w/ pay" is matching jobs with at least one posted range. The last column only uses rows in US dollars, per year, that passed the checks. Some rows from salary_ranges.csv:
company,ats,title,range_label,currency,min,max,period,check Ramp,ashby,"Software Engineer, Growth Platform",Salary,USD,168000,275100,year, Zoox,lever,Embedded Software Engineer,per-year-salary,USD,137000,207000,year, Coinbase,greenhouse,Senior Software Engineer,Annual base salary range (excluding equity and bonus):,USD,186065.0,218900.0,year, Coinbase,greenhouse,Senior Software Engineer,Annual base salary range (excluding equity and bonus):,BRL,443460.0,466800.0,year, Databricks,greenhouse,Software Engineer - New Grad (2027 Start),Zone 1 Pay Range,USD,145000.0,155000.0,,period not stated Databricks,greenhouse,Software Engineer - New Grad (2027 Start),Zone 2 Pay Range,USD,137750.0,147450.0,,period not stated Databricks,greenhouse,Software Engineer - New Grad (2027 Start),Canada Pay Range,USD,125000.0,130000.0,,period not stated Airbnb,greenhouse,"Senior Software Engineer, Storage",Pay Range,USD,196000.0,230000.0,,period not stated Why it's built this way:
- One row per range, not per job. A Greenhouse job can carry several ranges: Databricks' new-grad post above has Zone 1, Zone 2 and Canada ranges. Collapsing them into one min and max hides where each number applies. Keep the label and decide later.
- Whole-word title match.
\bsoftware engineer(ing)?\bmatches "Senior Software Engineer" and "Software Engineering Intern", but not a title that merely contains "engineer". - The period comes from the board's own label, or stays blank. Greenhouse has no period field, only the range's title. "Annual base salary range" says year; "Pay Range" says nothing, so the script leaves it empty instead of guessing.
- Every row with pay gets a check. See below for what it caught.
- One request per board and a pause between boards. Each endpoint returns the whole board in one response.
What should I check before I compare the numbers?
- Greenhouse leaves pay out unless you ask. Without
pay_transparency=true, none of Airbnb's 214 jobs had apay_input_rangesfield; with it, 175 had a range. This is the most common reason a Greenhouse pull "has no salaries". - Some employers don't use the structured field. Stripe had 786 open jobs and none had
pay_input_ranges; 19 had a dollar amount only in the description text (content=true). If a board shows no ranges, the pay may still be in the text, and parsing that is a different, messier job. - The period isn't always stated. 118 matching rows had a range title with no period in it ("Pay Range", "Zone 1 Pay Range"). 117 are six-figure amounts that read as yearly; the other, an intern "Canada Pay Range", is 40 to 52, which can only be hourly. Other intern rows in the same pull were labelled hourly ($51.50 to $60) or monthly ($12,500), so check before you assume a period.
- Labels can contradict the numbers. One Lever job's interval said
bi-week-salarywith a range of $290,000 to $322,000, which reads as yearly. One Greenhouse range had a minimum of $152,405 and a maximum of $179,300,152, which is a typo. The script flags both instead of averaging them in. - Read the currency field, not the label. The matching rows used USD, CAD, INR, BRL, GBP and SGD. One "Canada Pay Range" above is in USD.
- Know what the range covers. Coinbase's label says "excluding equity and bonus"; Databricks' text says its ranges are on-target earnings for commissionable roles; some Ashby boards add a separate
Commissionor equity component. Compare base with base. - Boards change daily. Jobs open and close, so date your CSV and re-run it rather than reusing an old pull.
The hosted version (Disclosure: we built this)
We built an Apify Actor, Careers Page Scraper: Greenhouse Jobs, Lever & Workday Jobs, that reads these boards for a list of companies. Give it domains, careers page URLs or ats:token values; it finds the board across eleven systems (Greenhouse, Lever, Ashby, Workday, Workable, SmartRecruiters, Recruitee, Personio, Teamtailor, softgarden and d.vinci) and reads it live on every run. It returns one row per job with salaryMin, salaryMax, salaryCurrency, salaryPeriod and salaryText, filled only when the employer posts pay. Where a job has several ranges, salaryMin and salaryMax span them and salaryText lists each one, so the zone detail stays in the text rather than one row per range. Workday and Teamtailor rows have no salary. Title, location, remote and posted-date filters run before anything is returned or charged, every job row carries companyTotalJobs (the board's open-job count), and companies it can't find get a free not_found row. For example (not run for this post):
{ "companies": ["greenhouse:airbnb", "greenhouse:databricks", "greenhouse:coinbase", "ashby:openai", "ashby:ramp", "ashby:snowflake", "lever:zoox", "lever:matchgroup"], "titleKeywords": ["software engineer", "software engineering"] } It's pay per event: $0.002 per job returned, $0.002 per company whose board is found and read, and Apify's $0.00005 Actor start per run (per GB of memory; a default run counts once). These are base prices; higher Apify plans pay less. Companies it can't find are free. Every matching job is charged, with or without a posted range. At the counts above (585 matching jobs on 8 boards), that would be 593 × $0.002 + $0.00005 = $1.18605. The script above is all you need if you'd rather run it yourself.
Data: each employer's public job board on Greenhouse, Ashby and Lever, read 14 November 2026. Pay ranges are as posted by the employers. This post and the Actor are not affiliated with or endorsed by Greenhouse, Ashby, Lever or any employer named.
Your Evenings
![Embedded Software Engineering — Ch.10: OTA Firmware Update [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm6nak2soahu0o1ww35hy.png)
![Embedded Software Engineering — Ch.11: Embedded Security [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8lj1zci0ucpfvn91aqd5.png)


![Embedded Software Engineering — Ch.12: Embedded Case Studies [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fth5r2cczjsot6ae55lq5.png)