{"slug": "greenhouse-lever-and-ashby-job-apis-one-schema-for-all-three-salary-ranges-js", "title": "Greenhouse, Lever and Ashby job APIs: one schema for all three, salary ranges included (JS + Python)", "summary": "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.", "body_md": "*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.*\n\nMany 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.\n\nEach 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.\n\n**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.\n\n**What you need:** Node 18+ or Python 3.9+. Neither snippet needs dependencies.\n\nThis post covers:\n\n`{\"ok\": false}`, and rate limits\nEach 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`).\n\n| ATS | List endpoint | Flag to add | \n|---|---|---|\n| Greenhouse | `https://boards-api.greenhouse.io/v1/boards/{slug}/jobs` | `pay_transparency=true` | \n| Lever | `https://api.lever.co/v0/postings/{slug}?mode=json` | (salary is included when the company sets it) | \n| Ashby | `https://api.ashbyhq.com/posting-api/job-board/{slug}` | `includeCompensation=true` | \n\nDon'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.\n\nWhere the salary lives in each response:\n\n|  | Field | Units | Interval | \n|---|---|---|---|\n| Greenhouse | `pay_input_ranges[]` →`min_cents` ,`max_cents` ,`currency_type` ,`title` | **cents** | none (only the free-text `title` , e.g. \"Mexico Monthly Pay Range\") | \n| Lever | `salaryRange` →`min` ,`max` ,`currency` ,`interval` | whole units | e.g. `\"per-year-salary\"` | \n| Ashby | `compensation.summaryComponents[]` where`compensationType == \"Salary\"` →`minValue` ,`maxValue` ,`currencyCode` ,`interval` | whole units | e.g. `\"1 YEAR\"` ,`\"1 MONTH\"` | \n\nAshby's `summaryComponents` also holds non-salary items such as `EquityPercentage`, often with null values. Filter on `compensationType` instead of taking the first item.\n\nThe 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:\n\n```\n// ats.mjs: Node 18+ (uses the built-in fetch). Run: node ats.mjs greenhouse airbnb\nconst ENDPOINTS = {\n  greenhouse: (slug) => `https://boards-api.greenhouse.io/v1/boards/${slug}/jobs?pay_transparency=true`,\n  lever: (slug) => `https://api.lever.co/v0/postings/${slug}?mode=json`,\n  ashby: (slug) => `https://api.ashbyhq.com/posting-api/job-board/${slug}?includeCompensation=true`,\n};\n\nasync function getJson(url, tries = 4) {\n  for (let attempt = 1; ; attempt++) {\n    const res = await fetch(url, {\n      headers: { accept: 'application/json', 'user-agent': 'my-job-sync/1.0 (you@example.com)' },\n      signal: AbortSignal.timeout(30_000), // don't hang forever on a stalled connection\n    });\n    if (res.status === 404) return null; // unknown company slug\n    if ((res.status === 429 || res.status >= 500) && attempt < tries) {\n      const wait = Number(res.headers.get('retry-after')) * 1000 || 1000 * 2 ** attempt;\n      await new Promise((r) => setTimeout(r, wait));\n      continue;\n    }\n    if (!res.ok) throw new Error(`${res.status} from ${url}`);\n    return res.json();\n  }\n}\n\n// \"per-year-salary\", \"1 YEAR\", \"Hourly Pay Range\" -> \"year\" / \"hour\" / ...\nconst interval = (s) =>\n  /hour/i.test(s) ? 'hour' : /week/i.test(s) ? 'week' : /month/i.test(s) ? 'month'\n  : /year|annual/i.test(s) ? 'year' : null;\n\n// Greenhouse amounts are in cents; keep a missing bound as null instead of 0.\nconst fromCents = (c) => (c == null ? null : c / 100);\n\n// Map each ATS's shape onto one record.\nconst normalize = {\n  greenhouse: (j) => {\n    const r = (j.pay_input_ranges || []).find((p) => p.min_cents || p.max_cents);\n    // Greenhouse has no interval field. Use the range title (\"Mexico Monthly Pay Range\"),\n    // else guess from magnitude: a top of 20,000+ is almost certainly yearly.\n    const per = r && (interval(r.title) ?? (fromCents(r.max_cents) >= 20000 ? 'year' : null));\n    return {\n      id: String(j.id), title: j.title.trim(), company: j.company_name ?? null,\n      location: j.location?.name ?? null, workplace: null,\n      postedAt: j.first_published ?? j.updated_at, url: j.absolute_url,\n      salary: r ? { min: fromCents(r.min_cents), max: fromCents(r.max_cents), currency: r.currency_type, interval: per } : null,\n    };\n  },\n  lever: (j) => ({\n    id: j.id, title: j.text.trim(), company: null,\n    location: j.categories?.location ?? null, workplace: j.workplaceType ?? null,\n    postedAt: new Date(j.createdAt).toISOString(), url: j.hostedUrl,\n    salary: j.salaryRange\n      ? { min: j.salaryRange.min, max: j.salaryRange.max, currency: j.salaryRange.currency, interval: interval(j.salaryRange.interval) }\n      : null,\n  }),\n  ashby: (j) => {\n    const s = (j.compensation?.summaryComponents || []).find((c) => c.compensationType === 'Salary');\n    return {\n      id: j.id, title: j.title.trim(), company: null,\n      location: j.location ?? null, workplace: j.workplaceType ?? (j.isRemote ? 'Remote' : null),\n      postedAt: j.publishedAt, url: j.jobUrl,\n      salary: s ? { min: s.minValue, max: s.maxValue, currency: s.currencyCode, interval: interval(s.interval) } : null,\n    };\n  },\n};\n\nexport async function listJobs(platform, slug) {\n  const data = await getJson(ENDPOINTS[platform](encodeURIComponent(slug)));\n  if (!data || data.ok === false) return null; // Lever says {ok:false} for unknown slugs\n  const raw = platform === 'lever' ? data : data.jobs.filter((j) => j.isListed !== false);\n  return raw.map(normalize[platform]);\n}\n\nif (import.meta.url === `file://${process.argv[1]}`) {\n  const [platform = 'greenhouse', slug = 'airbnb'] = process.argv.slice(2);\n  const jobs = await listJobs(platform, slug);\n  if (!jobs) { console.log(`No ${platform} board called \"${slug}\"`); process.exit(1); }\n  const withPay = jobs.filter((j) => j.salary);\n  console.log(`${jobs.length} jobs, ${withPay.length} with a salary range`);\n  console.log(JSON.stringify(withPay[0] ?? jobs[0], null, 2));\n}\nnode ats.mjs greenhouse airbnb\n```\n\nOutput from my run:\n\n```\n191 jobs, 155 with a salary range\n{\n  \"id\": \"8231416\",\n  \"title\": \"Associate Principal, Procurement Innovation Supplier Programs\",\n  \"company\": \"Airbnb\",\n  \"location\": \"United States \",\n  \"workplace\": null,\n  \"postedAt\": \"2026-09-24T18:17:39-04:00\",\n  \"url\": \"https://careers.airbnb.com/positions/8231416?gh_jid=8231416\",\n  \"salary\": {\n    \"min\": 102000,\n    \"max\": 120000,\n    \"currency\": \"USD\",\n    \"interval\": \"year\"\n  }\n}\n```\n\n`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\".\n\nThe code handles these platform differences:\n\n`-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.\nThis 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`.\n\n``` python\n# ats.py: Python 3.9+, standard library only. Run: python3 ats.py\nimport json, time, urllib.error, urllib.parse, urllib.request\nfrom concurrent.futures import ThreadPoolExecutor\nfrom datetime import datetime, timezone\n\nENDPOINTS = {\n    \"greenhouse\": \"https://boards-api.greenhouse.io/v1/boards/{}/jobs?pay_transparency=true\",\n    \"lever\": \"https://api.lever.co/v0/postings/{}?mode=json\",\n    \"ashby\": \"https://api.ashbyhq.com/posting-api/job-board/{}?includeCompensation=true\",\n}\n\ndef get_json(url, tries=4):\n    for attempt in range(1, tries + 1):\n        try:\n            req = urllib.request.Request(url, headers={\n                \"Accept\": \"application/json\",\n                # Ashby answers 403 to the default \"Python-urllib/3.x\" User-Agent; name your client instead.\n                \"User-Agent\": \"my-job-sync/1.0 (you@example.com)\",\n            })\n            with urllib.request.urlopen(req, timeout=30) as r:\n                return json.load(r)\n        except urllib.error.HTTPError as e:\n            if e.code == 404:\n                return None  # unknown company slug\n            if (e.code == 429 or e.code >= 500) and attempt < tries:\n                time.sleep(float(e.headers.get(\"Retry-After\") or 2 ** attempt))\n                continue\n            raise\n\ndef interval(s):\n    s = (s or \"\").lower()\n    for word, out in ((\"hour\", \"hour\"), (\"week\", \"week\"), (\"month\", \"month\"), (\"year\", \"year\"), (\"annual\", \"year\")):\n        if word in s:\n            return out\n    return None\n\ndef cents(c):\n    return None if c is None else c / 100  # Greenhouse amounts are in cents\n\ndef norm_greenhouse(j):\n    r = next((p for p in j.get(\"pay_input_ranges\") or [] if p.get(\"min_cents\") or p.get(\"max_cents\")), None)\n    # No interval field on Greenhouse: read the range title, else guess yearly for a top of 20,000+.\n    per = r and (interval(r.get(\"title\")) or (\"year\" if (cents(r.get(\"max_cents\")) or 0) >= 20000 else None))\n    return {\n        \"id\": str(j[\"id\"]), \"title\": j[\"title\"].strip(), \"location\": (j.get(\"location\") or {}).get(\"name\"),\n        \"posted_at\": j.get(\"first_published\") or j.get(\"updated_at\"), \"url\": j[\"absolute_url\"],\n        \"salary\": r and {\"min\": cents(r.get(\"min_cents\")), \"max\": cents(r.get(\"max_cents\")),\n                         \"currency\": r[\"currency_type\"], \"interval\": per},\n    }\n\ndef norm_lever(j):\n    s = j.get(\"salaryRange\")\n    return {\n        \"id\": j[\"id\"], \"title\": j[\"text\"].strip(), \"location\": j.get(\"categories\", {}).get(\"location\"),\n        \"posted_at\": datetime.fromtimestamp(j[\"createdAt\"] / 1000, timezone.utc).isoformat(), \"url\": j[\"hostedUrl\"],\n        \"salary\": s and {\"min\": s.get(\"min\"), \"max\": s.get(\"max\"), \"currency\": s.get(\"currency\"),\n                         \"interval\": interval(s.get(\"interval\"))},\n    }\n\ndef norm_ashby(j):\n    s = next((c for c in (j.get(\"compensation\") or {}).get(\"summaryComponents\") or []\n              if c.get(\"compensationType\") == \"Salary\"), None)\n    return {\n        \"id\": j[\"id\"], \"title\": j[\"title\"].strip(), \"location\": j.get(\"location\"),\n        \"posted_at\": j.get(\"publishedAt\"), \"url\": j[\"jobUrl\"],\n        \"salary\": s and {\"min\": s.get(\"minValue\"), \"max\": s.get(\"maxValue\"), \"currency\": s.get(\"currencyCode\"),\n                         \"interval\": interval(s.get(\"interval\"))},\n    }\n\nNORMALIZE = {\"greenhouse\": norm_greenhouse, \"lever\": norm_lever, \"ashby\": norm_ashby}\n\ndef list_jobs(platform, slug):\n    data = get_json(ENDPOINTS[platform].format(urllib.parse.quote(slug)))\n    if not data or (isinstance(data, dict) and data.get(\"ok\") is False):\n        return None\n    raw = data if platform == \"lever\" else [j for j in data[\"jobs\"] if j.get(\"isListed\", True)]\n    return [dict(NORMALIZE[platform](j), company=slug, platform=platform) for j in raw]\n\nif __name__ == \"__main__\":\n    boards = [(\"greenhouse\", \"airbnb\"), (\"greenhouse\", \"stripe\"), (\"lever\", \"outreach\"),\n              (\"ashby\", \"ramp\"), (\"ashby\", \"notion\")]\n    with ThreadPoolExecutor(max_workers=3) as pool:  # be polite: a few boards at a time\n        results = list(pool.map(lambda b: list_jobs(*b), boards))\n    jobs = [j for r in results if r for j in r]\n    for (platform, slug), r in zip(boards, results):\n        print(f\"{platform:<10} {slug:<10} {'not found' if r is None else len(r)}\")\n    paid = [j for j in jobs if j[\"salary\"] and j[\"salary\"][\"interval\"] == \"year\" and j[\"salary\"][\"currency\"] == \"USD\"]\n    print(f\"{len(jobs)} jobs total, {len(paid)} with a yearly USD range\")\n    top = max(paid, key=lambda j: j[\"salary\"][\"max\"] or 0, default=None)\n    if top:\n        print(top[\"company\"], \"|\", top[\"title\"], \"|\", top[\"salary\"])\ngreenhouse airbnb     191\ngreenhouse stripe     720\nlever      outreach   23\nashby      ramp       167\nashby      notion     140\n1241 jobs total, 256 with a yearly USD range\nramp | Manager, Account Executive | Enterprise | {'min': 377000, 'max': 450000, 'currency': 'USD', 'interval': 'year'}\n```\n\n**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.\n\n**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.\n\n**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.\n\n**Boards can be big, especially with descriptions.** I measured these response sizes on 24 Oct 2026:\n\n| Request | Size | \n|---|---|\n| Greenhouse `stripe/jobs` | 449,398 bytes | \n| Greenhouse `stripe/jobs?pay_transparency=true` | 465,238 bytes | \n| Greenhouse `stripe/jobs?content=true` (full descriptions) | 5,594,715 bytes | \n| Lever `spotify?mode=json` (descriptions always included) | 860,720 bytes | \n| Ashby `ramp?includeCompensation=true` (descriptions always included) | 3,038,462 bytes | \n\nOn 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.\n\n**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.\n\n**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.\n\nThe 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.\n\nI built a hosted version of this as a side project, in case you'd rather not maintain it yourself. Both are my own products:\n\nThe 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.\n\nIf you've run into other quirks with these APIs, please share them in the comments.", "url": "https://wpnews.pro/news/greenhouse-lever-and-ashby-job-apis-one-schema-for-all-three-salary-ranges-js", "canonical_source": "https://dev.to/neorhapsody/greenhouse-lever-and-ashby-job-apis-one-schema-for-all-three-salary-ranges-included-js-python-2fbp", "published_at": "2026-10-24 06:05:07+00:00", "updated_at": "2026-10-24 06:11:56.608196+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Greenhouse", "Lever", "Ashby", "Airbnb", "Node.js", "Python"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/greenhouse-lever-and-ashby-job-apis-one-schema-for-all-three-salary-ranges-js", "markdown": "https://wpnews.pro/news/greenhouse-lever-and-ashby-job-apis-one-schema-for-all-three-salary-ranges-js.md", "text": "https://wpnews.pro/news/greenhouse-lever-and-ashby-job-apis-one-schema-for-all-three-salary-ranges-js.txt", "jsonld": "https://wpnews.pro/news/greenhouse-lever-and-ashby-job-apis-one-schema-for-all-three-salary-ranges-js.jsonld"}}