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:
- The three endpoints and the query flags that matter
- A normaliser in JavaScript that maps all three onto one record
- The same normaliser in Python, fetching several boards concurrently
- The pitfalls: payload size, a User-Agent gotcha, 404 vs
{"ok": false}, and rate limits
1. The three endpoints and their salary fields
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.
2. Normalise all three in JavaScript
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:
- Greenhouse amounts are in cents. Divide by 100, or you'll be showing $10,200,000 salaries.
- Greenhouse has no interval field. The code reads the range title first. Of the 155 Airbnb ranges the code used, 57 had a title such as "Canada Annual Pay Range" or "Mexico Monthly Pay Range" (10 were monthly: 6 Mexico, 4 Brazil). The other 98 were titled just "Pay Range". For those, the code guesses from magnitude and treats a top of 20,000+ as yearly. That guess covers most of Airbnb's ranges, so mark it as inferred if accuracy matters.
- Greenhouse can return several ranges per job, for example one per pay zone (a location-based pay band). On Airbnb, 154 jobs had one range and 1 had two. The code takes the first. If you show ranges to users, you may want all of them.
- Timestamps keep each platform's format. Greenhouse returns a local offset (
-04:00), Lever an epoch value that the code converts to UTC, and Ashby+00:00. To sort across platforms, convert each withnew Date(x).toISOString(). - Lever's
createdAtis epoch milliseconds, while the other two give ISO strings. - Ashby can return unlisted postings. Its docs say a job with
isListed: falseshould only be reachable by direct link. Ramp and Notion had none on the day I tested, but the code filters them out anyway. - Titles can have stray whitespace. One Ramp title came back as
" Security Engineer, Cloud", so the code trims every title.
3. Fetch several boards concurrently in Python
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.
# 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"Source:https://dev.to/neorhapsody/greenhouse-lever-and-ashby-job-apis-one-schema-for-all-three-salary-ranges-included-js-python-2fbp