# Greenhouse, Lever and Ashby job APIs: one schema for all three, salary ranges included (JS + Python)

> Source: <https://dev.to/neorhapsody/greenhouse-lever-and-ashby-job-apis-one-schema-for-all-three-salary-ranges-included-js-python-2fbp>
> Published: 2026-10-24 06:05:07+00:00

*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.
