cd /news/developer-tools/greenhouse-lever-and-ashby-job-apis-… · home › topics › developer-tools › article
[ARTICLE · art-156600] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

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.

by read11 min views2 publishedOct 24, 2026

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[] wherecompensationType == "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 millisecondsisListed: 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.

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",
                "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)
    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.

── more in #developer-tools 4 stories · sorted by recency
── more on @greenhouse 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/greenhouse-lever-and…] indexed:0 read:11min 2026-10-24 · —