Ashby and Lever job posting APIs: pull open roles as JSON

Both applicant tracking systems publish a public jobs API for their careers pages. Endpoints, pay fields, Lever's EU host and one Python script that merges both into one table.

Requests and scripts tested on 26 October 2026.

Ashby and Lever both publish public read endpoints for the jobs a company shows on its careers page. You need no API key, no login and no HTML scraping: you ask a jobs API for a company's board and get JSON back. This guide covers both endpoints, the fields worth keeping and the differences that trip people up. It ends with a short Python script (standard library only) that puts both into one table.

We checked everything below with live requests on 26 October 2026, against the official docs:

These are not the private admin APIs. Ashby's main API and Lever's Data API need a key from the company and expose candidates. The posting APIs only return what a company already publishes for job seekers. (Lever's Postings API also has an apply endpoint, which does need a key; this guide only uses the read endpoints.)

The Ashby API for job postings

One request returns the whole board:

curl -A "YourApp/1.0 (+https://example.com/contact)" \
  "https://api.ashbyhq.com/posting-api/job-board/linear?includeCompensation=true"

The last part of the path is the job board name. It is the same name that appears in the company's careers URL, jobs.ashbyhq.com/linear. The response is an object with apiVersion (currently "1") and a jobs array. There is no pagination, so large boards return large files.

Each job carries id, title, department, team, location, secondaryLocations, isRemote, workplaceType, employmentType, publishedAt, jobUrl, applyUrl, the description as descriptionHtml and descriptionPlain, and an address object.

Ashby's docs define publishedAt as when the job was last published, so a job that was taken down and published again shows the later date, not its first posting.

includeCompensation

Without includeCompensation=true, the jobs have no compensation key at all. With it, each job gets a compensation object. The easiest part to read is summaryComponents, a list of typed pieces:

{
  "compensationType": "Salary",
  "interval": "1 YEAR",
  "currencyCode": "USD",
  "minValue": 211400,
  "maxValue": 290600
}

That example is from Ramp's board. Other components can be equity or bonus, often with null amounts, so filter on compensationType == "Salary". There is also a ready-made text summary (compensationTierSummary) and the full compensationTiers list for roles with several pay bands.

Compensation is only filled in when the employer publishes it. On the day we checked, Ramp had a salary range on 160 of its 167 roles. Linear and Notion returned the compensation object with empty lists for every role. Each job also has shouldDisplayCompensationOnJobPostings, which tells you whether the employer shows pay on its own page.

isListed

Ashby's docs describe isListed as the switch that decides whether a job appears in the board's list. An unlisted job is meant to be reachable by direct link only. Every job on the three boards we checked had isListed: true, but the field is in the schema for a reason. Treat isListed: false as "the employer does not want this in a list" and drop it, which is what the script below does.

Other details

  • workplaceType is OnSite, Remote or Hybrid, and isRemote is true for both Remote and Hybrid. For fully remote roles, use workplaceType, not isRemote. On Notion's board, 52 roles had no workplace type at all (null).
  • employmentType is one of FullTime, PartTime, Intern, Contract or Temporary.
  • An unknown board name returns 404 with the body Not Found.

The Lever postings API

Lever's list endpoint returns a plain JSON array:

curl -A "YourApp/1.0 (+https://example.com/contact)" \
  "https://api.lever.co/v0/postings/zoox?mode=json&skip=0&limit=100"

The part of the path after postings/ is the site name from the careers URL (jobs.lever.co/zoox). These parameters are the useful ones, all from Lever's docs:

  • mode=json asks for JSON. The same endpoint can also return HTML or an iframe snippet for embedding.
  • skip and limit page through the board. Without limit you get everything in one response. Palantir's board of 309 postings came back as about 6 MB in one go, so paging is kinder to both sides.
  • location, commitment, team, department and level filter on the server side. For location, commitment, team and department, repeat the parameter to match any of several values.

Only postings in the published state appear. Lever's docs say all other jobs are completely hidden from this API.

The fields

Each posting has these fields:

  • id
  • text (the title)
  • categories, with team, department, location, allLocations and commitment
  • workplaceType, country and createdAt
  • hostedUrl and applyUrl
  • several description fields: description, descriptionPlain, lists, additional and their plain versions

Two things to know:

  • createdAt is a Unix timestamp in milliseconds, not an ISO date.
  • salaryRange is optional: {"min": 123000, "max": 168000, "currency": "USD", "interval": "per-year-salary"}. Zoox publishes it on most postings, and Palantir published it on none of its 309. The interval reads per-year-salary or per-hour-wage rather than a plain unit.

The docs list workplaceType values as unspecified, on-site, remote and hybrid. The live responses we saw used onsite without a hyphen, so normalise both.

The EU host

Lever runs a separate EU instance. Companies hosted there have careers pages on jobs.eu.lever.co and their postings on:

https://api.eu.lever.co/v0/postings/{site}?mode=json

The two instances do not share data. A site that exists on one host returns 404 with {"ok":false,"error":"Document not found"} on the other. We confirmed this for Spotify and Zoox, which are on the global host. So if you only have a company name, try the global host, then the EU host, and only call the board missing when both say 404. A 200 with an empty array means the site answered but lists no open roles, which is not the same as missing.

Both Lever API hosts publish a robots.txt with Crawl-delay: 1, so wait a second between requests.

Ashby vs Lever at a glance

Ashby Lever
Endpoint api.ashbyhq.com/posting-api/job-board/{name} api.lever.co/v0/postings/{site}?mode=json (EU: api.eu.lever.co)
Response { apiVersion, jobs: [...] } [...]
Paging none, whole board skip and limit
Server-side filters none location, commitment, team, department, level
Title field title text
Date field publishedAt (ISO 8601, last publication) createdAt (milliseconds)
Pay compensation, only with includeCompensation=true salaryRange when published
Interval wording 1 YEAR per-year-salary
Workplace values OnSite, Remote, Hybrid onsite, remote, hybrid
Hidden jobs isListed: false not returned at all
Unknown board 404 Not Found 404 JSON error

One script for both: open roles in one schema

The script below fetches one Ashby board and one Lever site, maps both into the same fields and writes open_roles.json. It uses only the Python standard library. Set your own User-Agent with a contact URL so the API owners can reach you.

import json
import time
import urllib.error
import urllib.request

UA = "YourApp/1.0 (+https://example.com/contact)"  # use your own name and contact URL


def get_json(url):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.load(resp)


def ashby_jobs(board):
    url = f"https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=true"
    data = get_json(url)
    # Unlisted jobs are meant to be reachable by direct link only: skip them.
    return [j for j in data["jobs"] if j.get("isListed", True)]


def lever_jobs(site, eu=False, page_size=100):
    host = "api.eu.lever.co" if eu else "api.lever.co"
    jobs, skip = [], 0
    while True:
        url = f"https://{host}/v0/postings/{site}?mode=json&skip={skip}&limit={page_size}"
        page = get_json(url)
        jobs.extend(page)
        if len(page) < page_size:
            return jobs
        skip += page_size
        time.sleep(1)  # api.lever.co/robots.txt asks for Crawl-delay: 1


def lever_jobs_any_region(site):
    """Try Lever's global instance first, then the EU instance."""
    try:
        return lever_jobs(site), "global"
    except urllib.error.HTTPError as err:
        if err.code != 404:
            raise
    time.sleep(1)
    return lever_jobs(site, eu=True), "eu"


def ashby_salary(job):
    for c in (job.get("compensation") or {}).get("summaryComponents", []):
        if c["compensationType"] == "Salary":
            return c["minValue"], c["maxValue"], c["currencyCode"], c["interval"]
    return None, None, None, None


def interval(value):
    # Ashby says "1 YEAR", Lever says "per-year-salary": keep only the unit.
    for unit in ("year", "month", "week", "day", "hour"):
        if value and unit in value.lower():
            return unit
    return None


def workplace(value):
    # Ashby: OnSite, Remote, Hybrid. Lever: onsite (documented as on-site), remote, hybrid.
    v = (value or "").lower().replace("-", "")
    return v if v in ("onsite", "remote", "hybrid") else None


def from_ashby(board, j):
    lo, hi, cur, period = ashby_salary(j)
    return {
        "source": "ashby", "board": board, "job_id": j["id"], "title": j["title"].strip(),
        "department": j.get("department"), "team": j.get("team"),
        "location": j.get("location"), "workplace_type": workplace(j.get("workplaceType")),
        "employment_type": j.get("employmentType"),
        "salary_min": lo, "salary_max": hi, "salary_currency": cur, "salary_interval": interval(period),
        "posted_at": j.get("publishedAt"), "job_url": j.get("jobUrl"),
    }


def from_lever(site, j):
    cat = j.get("categories") or {}
    pay = j.get("salaryRange") or {}
    created = j.get("createdAt")  # milliseconds since 1970
    return {
        "source": "lever", "board": site, "job_id": j["id"], "title": j["text"].strip(),
        "department": cat.get("department"), "team": cat.get("team"),
        "location": cat.get("location"), "workplace_type": workplace(j.get("workplaceType")),
        "employment_type": cat.get("commitment"),
        "salary_min": pay.get("min"), "salary_max": pay.get("max"),
        "salary_currency": pay.get("currency"), "salary_interval": interval(pay.get("interval")),
        "posted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(created / 1000)) if created else None,
        "job_url": j.get("hostedUrl"),
    }


if __name__ == "__main__":
    rows = [from_ashby("ramp", j) for j in ashby_jobs("ramp")]
    time.sleep(1)
    lever, region = lever_jobs_any_region("zoox")
    rows += [from_lever("zoox", j) for j in lever]
    with open("open_roles.json", "w", encoding="utf-8") as f:
        json.dump(rows, f, indent=2)
    print(len(rows), "rows")

A Lever row from that run looks like this:

{
  "source": "lever",
  "board": "zoox",
  "job_id": "e8a190f0-7a89-4c11-a6a0-452ff6234749",
  "title": "Administrative Business Partner, Floater",
  "department": "Executive Office",
  "team": "Executive Office",
  "location": "Foster City, CA",
  "workplace_type": "hybrid",
  "employment_type": "Full-time",
  "salary_min": 123000,
  "salary_max": 168000,
  "salary_currency": "USD",
  "salary_interval": "year",
  "posted_at": "2026-10-06T17:27:44Z",
  "job_url": "https://jobs.lever.co/zoox/e8a190f0-7a89-4c11-a6a0-452ff6234749"
}

The script leaves a few things to you:

  • Descriptions. Both APIs return the full description, and descriptions sometimes name recruiters or hiring managers. If you store or republish text, decide first how you handle personal data. The script keeps only structured fields.
  • Dates. posted_at is Ashby's last publication date and Lever's creation date, so the two are close but not identical in meaning.
  • Employment type wording still differs (FullTime vs Full-time). Map it to your own list if you filter on it.
  • Finding the board name. Open the company's careers page and copy the name from the jobs.ashbyhq.com/... or jobs.lever.co/... link. Guessing from the company name often works, but not always.
  • Greenhouse. If some companies on your list use Greenhouse, its Job Board API is public too (boards-api.greenhouse.io/v1/boards/{token}/jobs), and the same mapping pattern applies.

If you would rather not maintain it: our Actor

We built Greenhouse, Lever & Ashby Jobs API + Job Monitor on the Apify Store for when your list of companies is long and you want the result as one table. It is an independent tool, not made or endorsed by Greenhouse, Lever, Ashby, Recruitee or Teamtailor, and it reads the same public endpoints shown above, plus the public job feeds of Greenhouse, Recruitee and Teamtailor. Here is what it does:

  • It takes 1 to 1,000 company names, domains or board URLs and detects which of the five systems each one uses: Greenhouse, Lever, Ashby, Recruitee or Teamtailor. That includes Lever's EU instance when you give the board URL. Name matching is a guess, so it reports which board each company matched, and companies it cannot find are free.
  • It maps all five into one schema: title, department, team, locations, remote flag, workplace type, employment type, published pay range (min, max, currency, interval), dates and links.
  • It drops Ashby jobs marked isListed: false, pages through Lever and waits a second between Lever requests.
  • In changes mode, it compares each run with the last one for the same watchlist and saves only new, updated and closed jobs, so a scheduled run tells you what changed.
  • It leaves descriptions out by default and screens text fields for email addresses and phone numbers.

Pricing is per company checked and per job row saved, and the current prices are on the Store page. If you only need one or two boards, the script above is free and does the job.

Summary

  • Ashby: GET api.ashbyhq.com/posting-api/job-board/{name}?includeCompensation=true. You get the whole board in one response. Read pay from summaryComponents and drop isListed: false.
  • Lever: GET api.lever.co/v0/postings/{site}?mode=json&skip=0&limit=100. Page through and wait a second between requests. Try api.eu.lever.co before you call a board missing.
  • Normalise titles, dates, workplace values and pay intervals before you merge the two. Keep each posting's own link so readers reach the employer's page.

Sources