跳到正文
DEV Community· Fikri Marhan·· 3 小時前AI 評分53

如何用 JavaScript 和 Python 統一 Greenhouse、Lever、Ashby 招聘 API 的職位及薪酬資料

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

AI 導讀

文章提供 JavaScript 和 Python 範例,將 Greenhouse、Lever、Ashby 三個招聘 API 的職位資料正規化為統一結構,並納入薪酬範圍。內容比較三者的薪酬欄位格式,並示範處理 Greenhouse 缺少薪酬週期欄位、Ashby 對 Python 預設 User-Agent 回傳 403,以及請求重試等情況。

正文

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:

  1. The three endpoints and the query flags that matter
  2. A normaliser in JavaScript that maps all three onto one record
  3. The same normaliser in Python, fetching several boards concurrently
  4. 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 with new Date(x).toISOString().
  • Lever's createdAt is epoch milliseconds, while the other two give ISO strings.
  • Ashby can return unlisted postings. Its docs say a job with 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.
  • 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") 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"])

Output from my run:

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.

4. Pitfalls: User-Agent, 404s, payload size and rate limits

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.

Summary

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.

Disclosure: my hosted version

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:

  • As a REST API on RapidAPI: ATS Jobs API covers Greenhouse, Lever, Ashby and SmartRecruiters with one normalised schema.
  • As an Apify actor: ATS Jobs Feed is for batch runs over many companies. It can also return only the jobs added or removed since the last run.

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.

來源:DEV Community · dev.to