Greenhouse, Lever and Ashby job APIs: one schema for all three, salary ranges included (JS + Python) A developer published a dependency-free JavaScript and Python guide that normalizes job postings from the Greenhouse, Lever and Ashby applicant-tracking APIs into a single schema, including salary ranges. Testing against live public endpoints on 24 Oct 2026, the author found Greenhouse omits pay data entirely unless the pay_transparency=true flag is set — Airbnb's board returned structured ranges for 155 of 191 jobs only with the flag — while Lever and Ashby report salaries in whole units with explicit intervals and Greenhouse reports cents with no interval field. The snippets add retry-with-backoff on HTTP 429 and 5xx responses and filter Ashby's summaryComponents on compensationType to avoid null equity entries. I wrote this article with AI assistance. I ran every snippet against the live public endpoints on 24 Oct 2026. Every number below comes from those runs or from direct curl requests on the same day. Job counts change daily, so yours will differ. Many tech companies host their careers page on Greenhouse, Lever or Ashby. These are applicant tracking systems ATS : the tools recruiters use to post jobs and manage candidates. All three expose a public, unauthenticated, read-only JSON API for each company's job board. You don't need an API key or an HTML parser. Each API returns a different shape, though. Each one puts salaries under different field names and writes pay intervals differently. Greenhouse doesn't return an interval at all. Who this is for: developers building a job board, a job alert or a hiring-market dashboard who want postings straight from company career pages. What you need: Node 18+ or Python 3.9+. Neither snippet needs dependencies. This post covers: {"ok": false} , and rate limits Each request needs the company's board slug : the path segment after the domain on its hosted board job-boards.greenhouse.io/airbnb , jobs.lever.co/outreach , jobs.ashbyhq.com/ramp . | ATS | List endpoint | Flag to add | |---|---|---| | Greenhouse | https://boards-api.greenhouse.io/v1/boards/{slug}/jobs | pay transparency=true | | Lever | https://api.lever.co/v0/postings/{slug}?mode=json | salary is included when the company sets it | | Ashby | https://api.ashbyhq.com/posting-api/job-board/{slug} | includeCompensation=true | Don't skip the Greenhouse flag. Without pay transparency=true , Greenhouse omits the pay input ranges field entirely. When I fetched Airbnb's board without it, no job had a salary field. With it, 155 of 191 jobs returned a structured range. Where the salary lives in each response: | | Field | Units | Interval | |---|---|---|---| | Greenhouse | pay input ranges → min cents , max cents , currency type , title | cents | none only the free-text title , e.g. "Mexico Monthly Pay Range" | | Lever | salaryRange → min , max , currency , interval | whole units | e.g. "per-year-salary" | | Ashby | compensation.summaryComponents where compensationType == "Salary" → minValue , maxValue , currencyCode , interval | whole units | e.g. "1 YEAR" , "1 MONTH" | Ashby's summaryComponents also holds non-salary items such as EquityPercentage , often with null values. Filter on compensationType instead of taking the first item. The script below uses Node's built-in fetch . It retries on HTTP 429 and 5xx responses with backoff, and it maps every platform onto the same record shape: // ats.mjs: Node 18+ uses the built-in fetch . Run: node ats.mjs greenhouse airbnb const ENDPOINTS = { greenhouse: slug = https://boards-api.greenhouse.io/v1/boards/${slug}/jobs?pay transparency=true , lever: slug = https://api.lever.co/v0/postings/${slug}?mode=json , ashby: slug = https://api.ashbyhq.com/posting-api/job-board/${slug}?includeCompensation=true , }; async function getJson url, tries = 4 { for let attempt = 1; ; attempt++ { const res = await fetch url, { headers: { accept: 'application/json', 'user-agent': 'my-job-sync/1.0 you@example.com ' }, signal: AbortSignal.timeout 30 000 , // don't hang forever on a stalled connection } ; if res.status === 404 return null; // unknown company slug if res.status === 429 || res.status = 500 && attempt < tries { const wait = Number res.headers.get 'retry-after' 1000 || 1000 2 attempt; await new Promise r = setTimeout r, wait ; continue; } if res.ok throw new Error ${res.status} from ${url} ; return res.json ; } } // "per-year-salary", "1 YEAR", "Hourly Pay Range" - "year" / "hour" / ... const interval = s = /hour/i.test s ? 'hour' : /week/i.test s ? 'week' : /month/i.test s ? 'month' : /year|annual/i.test s ? 'year' : null; // Greenhouse amounts are in cents; keep a missing bound as null instead of 0. const fromCents = c = c == null ? null : c / 100 ; // Map each ATS's shape onto one record. const normalize = { greenhouse: j = { const r = j.pay input ranges || .find p = p.min cents || p.max cents ; // Greenhouse has no interval field. Use the range title "Mexico Monthly Pay Range" , // else guess from magnitude: a top of 20,000+ is almost certainly yearly. const per = r && interval r.title ?? fromCents r.max cents = 20000 ? 'year' : null ; return { id: String j.id , title: j.title.trim , company: j.company name ?? null, location: j.location?.name ?? null, workplace: null, postedAt: j.first published ?? j.updated at, url: j.absolute url, salary: r ? { min: fromCents r.min cents , max: fromCents r.max cents , currency: r.currency type, interval: per } : null, }; }, lever: j = { id: j.id, title: j.text.trim , company: null, location: j.categories?.location ?? null, workplace: j.workplaceType ?? null, postedAt: new Date j.createdAt .toISOString , url: j.hostedUrl, salary: j.salaryRange ? { min: j.salaryRange.min, max: j.salaryRange.max, currency: j.salaryRange.currency, interval: interval j.salaryRange.interval } : null, } , ashby: j = { const s = j.compensation?.summaryComponents || .find c = c.compensationType === 'Salary' ; return { id: j.id, title: j.title.trim , company: null, location: j.location ?? null, workplace: j.workplaceType ?? j.isRemote ? 'Remote' : null , postedAt: j.publishedAt, url: j.jobUrl, salary: s ? { min: s.minValue, max: s.maxValue, currency: s.currencyCode, interval: interval s.interval } : null, }; }, }; export async function listJobs platform, slug { const data = await getJson ENDPOINTS platform encodeURIComponent slug ; if data || data.ok === false return null; // Lever says {ok:false} for unknown slugs const raw = platform === 'lever' ? data : data.jobs.filter j = j.isListed == false ; return raw.map normalize platform ; } if import.meta.url === file://${process.argv 1 } { const platform = 'greenhouse', slug = 'airbnb' = process.argv.slice 2 ; const jobs = await listJobs platform, slug ; if jobs { console.log No ${platform} board called "${slug}" ; process.exit 1 ; } const withPay = jobs.filter j = j.salary ; console.log ${jobs.length} jobs, ${withPay.length} with a salary range ; console.log JSON.stringify withPay 0 ?? jobs 0 , null, 2 ; } node ats.mjs greenhouse airbnb Output from my run: 191 jobs, 155 with a salary range { "id": "8231416", "title": "Associate Principal, Procurement Innovation Supplier Programs", "company": "Airbnb", "location": "United States ", "workplace": null, "postedAt": "2026-09-24T18:17:39-04:00", "url": "https://careers.airbnb.com/positions/8231416?gh jid=8231416", "salary": { "min": 102000, "max": 120000, "currency": "USD", "interval": "year" } } node ats.mjs lever outreach printed 23 jobs, 14 with a salary range , and node ats.mjs ashby ramp printed 167 jobs, 160 with a salary range . All three printed records share the same shape, with the interval normalised to "year" . The Airbnb record's "year" comes from the magnitude guess described below, because its range title is just "Pay Range". The code handles these platform differences: -04:00 , Lever an epoch value that the code converts to UTC, and Ashby +00:00 . To sort across platforms, convert each with new Date x .toISOString . createdAt is epoch milliseconds isListed: false should only be reachable by direct link. Ramp and Notion had none on the day I tested, but the code filters them out anyway. " Security Engineer, Cloud" , so the code trims every title. This version uses only the standard library. It fetches five boards with a small thread pool, then filters the combined list for yearly USD ranges. The record is close to the JavaScript one. The differences: it uses snake case keys, sets company to the slug, adds a platform field and drops workplace . python ats.py: Python 3.9+, standard library only. Run: python3 ats.py import json, time, urllib.error, urllib.parse, urllib.request from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone ENDPOINTS = { "greenhouse": "https://boards-api.greenhouse.io/v1/boards/{}/jobs?pay transparency=true", "lever": "https://api.lever.co/v0/postings/{}?mode=json", "ashby": "https://api.ashbyhq.com/posting-api/job-board/{}?includeCompensation=true", } def get json url, tries=4 : for attempt in range 1, tries + 1 : try: req = urllib.request.Request url, headers={ "Accept": "application/json", Ashby answers 403 to the default "Python-urllib/3.x" User-Agent; name your client instead. "User-Agent": "my-job-sync/1.0 you@example.com ", } with urllib.request.urlopen req, timeout=30 as r: return json.load r except urllib.error.HTTPError as e: if e.code == 404: return None unknown company slug if e.code == 429 or e.code = 500 and attempt < tries: time.sleep float e.headers.get "Retry-After" or 2 attempt continue raise def interval s : s = s or "" .lower for word, out in "hour", "hour" , "week", "week" , "month", "month" , "year", "year" , "annual", "year" : if word in s: return out return None def cents c : return None if c is None else c / 100 Greenhouse amounts are in cents def norm greenhouse j : r = next p for p in j.get "pay input ranges" or if p.get "min cents" or p.get "max cents" , None No interval field on Greenhouse: read the range title, else guess yearly for a top of 20,000+. per = r and interval r.get "title" or "year" if cents r.get "max cents" or 0 = 20000 else None return { "id": str j "id" , "title": j "title" .strip , "location": j.get "location" or {} .get "name" , "posted at": j.get "first published" or j.get "updated at" , "url": j "absolute url" , "salary": r and {"min": cents r.get "min cents" , "max": cents r.get "max cents" , "currency": r "currency type" , "interval": per}, } def norm lever j : s = j.get "salaryRange" return { "id": j "id" , "title": j "text" .strip , "location": j.get "categories", {} .get "location" , "posted at": datetime.fromtimestamp j "createdAt" / 1000, timezone.utc .isoformat , "url": j "hostedUrl" , "salary": s and {"min": s.get "min" , "max": s.get "max" , "currency": s.get "currency" , "interval": interval s.get "interval" }, } def norm ashby j : s = next c for c in j.get "compensation" or {} .get "summaryComponents" or if c.get "compensationType" == "Salary" , None return { "id": j "id" , "title": j "title" .strip , "location": j.get "location" , "posted at": j.get "publishedAt" , "url": j "jobUrl" , "salary": s and {"min": s.get "minValue" , "max": s.get "maxValue" , "currency": s.get "currencyCode" , "interval": interval s.get "interval" }, } NORMALIZE = {"greenhouse": norm greenhouse, "lever": norm lever, "ashby": norm ashby} def list jobs platform, slug : data = get json ENDPOINTS platform .format urllib.parse.quote slug if not data or isinstance data, dict and data.get "ok" is False : return None raw = data if platform == "lever" else j for j in data "jobs" if j.get "isListed", True return dict NORMALIZE platform j , company=slug, platform=platform for j in raw if name == " main ": boards = "greenhouse", "airbnb" , "greenhouse", "stripe" , "lever", "outreach" , "ashby", "ramp" , "ashby", "notion" with ThreadPoolExecutor max workers=3 as pool: be polite: a few boards at a time results = list pool.map lambda b: list jobs b , boards jobs = j for r in results if r for j in r for platform, slug , r in zip boards, results : print f"{platform:<10} {slug:<10} {'not found' if r is None else len r }" paid = j for j in jobs if j "salary" and j "salary" "interval" == "year" and j "salary" "currency" == "USD" print f"{len jobs } jobs total, {len paid } with a yearly USD range" top = max paid, key=lambda j: j "salary" "max" or 0, default=None if top: print top "company" , "|", top "title" , "|", top "salary" greenhouse airbnb 191 greenhouse stripe 720 lever outreach 23 ashby ramp 167 ashby notion 140 1241 jobs total, 256 with a yearly USD range ramp | Manager, Account Executive | Enterprise | {'min': 377000, 'max': 450000, 'currency': 'USD', 'interval': 'year'} Stripe 720 jobs and Notion 140 jobs returned no structured salary ranges , even with the flags on. Structured pay appears only when the company fills in those fields in its ATS. An empty result is normal, not a bug in your code. Ashby returns 403 to Python's default User-Agent. Plain urllib sends Python-urllib/3.x , and Ashby answered HTTP Error 403: Forbidden for both ramp and notion . Greenhouse and Lever accepted it. A descriptive User-Agent that names your client fixed the error, and it tells API owners who is calling. Unknown slugs fail differently. All three return HTTP 404 for a slug that doesn't exist, but the bodies differ. Greenhouse returns {"status":404,"error":"Job not found"} , Lever returns {"ok":false,"error":"Document not found"} , and Ashby returns plain-text Not Found . The code treats any 404 as "no board" and also checks for ok === false . Lever also runs an EU-hosted API at api.eu.lever.co for companies on its EU instance. A 404 on the main host doesn't always mean the company isn't on Lever. Boards can be big, especially with descriptions. I measured these response sizes on 24 Oct 2026: | Request | Size | |---|---| | Greenhouse stripe/jobs | 449,398 bytes | | Greenhouse stripe/jobs?pay transparency=true | 465,238 bytes | | Greenhouse stripe/jobs?content=true full descriptions | 5,594,715 bytes | | Lever spotify?mode=json descriptions always included | 860,720 bytes | | Ashby ramp?includeCompensation=true descriptions always included | 3,038,462 bytes | On a laptop, these sizes don't matter. In a serverless function with tight CPU or memory limits, parsing a 5 MB JSON response per company can exceed those limits. I hit this on the Cloudflare Workers free plan and had to cap the upstream response size. Ask Greenhouse for content=true only when you need descriptions. For one job's full details, fetch /v1/boards/{slug}/jobs/{id} instead. Rate limits depend on where you run from. None of these three APIs rate-limited me from a normal server during this testing. Workable, another ATS with a public widget API, shows how that can change. The same request that returned 200 from a regular server got HTTP 429 from Cloudflare Workers . Keep concurrency low the Python example uses 3 workers , honour Retry-After , and test from the environment you'll actually deploy to. Fields are only as clean as the company's data entry. Greenhouse's location.name is free text. For example, 29 Airbnb locations had trailing spaces, such as "United States " . If you need country or city filters, you'll have to parse location strings yourself. The three APIs are easy to call. Most of the work lies in the small differences: cents vs units, a missing interval field, a 403 on the default User-Agent, two styles of "not found", and payloads that grow more than 10x when you ask for descriptions. The ~80 lines of JavaScript above handle the basics. Going further usually means parsing locations, parsing salaries mentioned in description text, adding more ATS platforms, and noticing when an API's shape drifts. I built a hosted version of this as a side project, in case you'd rather not maintain it yourself. Both are my own products: The RapidAPI listing has a free plan with 100 requests a month, and the actor is pay-per-result pricing is on its page . The code in this post is free to copy. If you've run into other quirks with these APIs, please share them in the comments.