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:
- Ashby: Public Job Posting API
- Lever: Postings API on GitHub
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
workplaceTypeisOnSite,RemoteorHybrid, andisRemoteistruefor both Remote and Hybrid. For fully remote roles, useworkplaceType, notisRemote. On Notion's board, 52 roles had no workplace type at all (null).employmentTypeis one ofFullTime,PartTime,Intern,ContractorTemporary.- An unknown board name returns
404with the bodyNot 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=jsonasks for JSON. The same endpoint can also return HTML or an iframe snippet for embedding.skipandlimitpage through the board. Withoutlimityou 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,departmentandlevelfilter on the server side. Forlocation,commitment,teamanddepartment, 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:
idtext(the title)categories, withteam,department,location,allLocationsandcommitmentworkplaceType,countryandcreatedAthostedUrlandapplyUrl- several description fields:
description,descriptionPlain,lists,additionaland their plain versions
Two things to know:
createdAtis a Unix timestamp in milliseconds, not an ISO date.salaryRangeis 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 readsper-year-salaryorper-hour-wagerather 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_atis 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 (
FullTimevsFull-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/...orjobs.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 fromsummaryComponentsand dropisListed: false. - Lever:
GET api.lever.co/v0/postings/{site}?mode=json&skip=0&limit=100. Page through and wait a second between requests. Tryapi.eu.lever.cobefore 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.