Greenhouse, Lever, Ashby and Workday job board APIs
Four hiring systems publish their open jobs as JSON you can read without a key. What each answer holds, the catch in each one, and about forty lines of Python that put all four into one shape.
Most careers pages are not built by the company. They are a page of a hiring system (an ATS), and the big ones publish the open jobs as JSON. Three of the four below document it. The fourth does not, but its careers page reads from a JSON feed of its own, and that feed is what everyone uses.
We read all four on 25 October 2026. The field names, limits and sizes below come from those answers, not from memory.
The short version#
| Key? | Jobs per request | Date | Pay | |
|---|---|---|---|---|
| Greenhouse | No | All | Exact | Extra request per job |
| Lever | No | All, or pages | Exact | Optional, often empty |
| Ashby | Not asked | All | Exact | A flag in the address |
| Workday | Undocumented | 20 | "4 Days Ago" | No field |
Greenhouse GET https://boards-api.greenhouse.io/v1/boards/{board}/jobs
Lever GET https://api.lever.co/v0/postings/{company}?mode=json
Ashby GET https://api.ashbyhq.com/posting-api/job-board/{board}
Workday POST https://{site host}/wday/cxs/{tenant}/{site}/jobsThe {board} or {company} part is the name in the company's careers link: job-boards.greenhouse.io/discord, jobs.lever.co/spotify, jobs.ashbyhq.com/linear.
Greenhouse#
curl "https://boards-api.greenhouse.io/v1/boards/discord/jobs?content=true"Greenhouse says it plainly: board data is public, so no GET request needs a key. The answer is the whole board at once, with meta.total next to the list. Without content=true you get the title, location, first_published, updated_at and the link. With it you also get the department, the offices and the description. That made 46 jobs weigh 422 KB.
Two catches:
- The description is escaped twice. It starts
<div class="content-intro">, so you unescape it, strip the tags, and unescape again. - Pay is not in the list. It comes from the single-job address with
?pay_transparency=true, in cents:"min_cents": 19200000is $192,000 a year. That is one request per job, and only for jobs where the company filled the range in.
There is no remote flag. The location is a free-text name such as "San Francisco Bay Area".
Lever#
curl "https://api.lever.co/v0/postings/spotify?mode=json"The answer is a plain list: 90 postings and 860 KB for the first site we read. skip and limit give you pages if you want them. Each posting has text (the title), categories (location, team, department, commitment), workplaceType (on 399 postings from two sites: 239 hybrid, 131 onsite, 29 remote), hostedUrl, applyUrl and the description as both HTML and plain text.
The catches:
createdAtis milliseconds since 1970, not a date string.salaryRangeexists, but Lever calls it optional. None of those 399 postings had it.- An unknown company name answers 404. The robots.txt of
api.lever.coasks for one second between requests (Crawl-delay: 1), so leave that gap.
Ashby#
curl "https://api.ashbyhq.com/posting-api/job-board/linear?includeCompensation=true"The answer is again the whole board, and it is the friendliest of the four. descriptionPlain sits next to descriptionHtml (every one of 34 jobs on the board we read had it), and there are isRemote, workplaceType, employmentType and publishedAt. With includeCompensation=true you get a compensation block: a summary such as "$250K – $445K • Offers Equity" plus the tiers behind it, or empty fields where the company publishes no pay.
The catches:
- Big boards are big. One answer held 794 jobs in 13.9 MB, because every description comes twice.
isListed: falsemeans the job is only meant to be reached by its direct link.
Workday#
Workday has no documented public job API. Its careers pages (*.myworkdayjobs.com) load their list from a JSON address under /wday/cxs/, and the site we read does not close that path in its robots.txt.
curl -X POST "https://nvidia.wd5.myworkdayjobs.com/wday/cxs/nvidia/NVIDIAExternalCareerSite/jobs" \
-H "Content-Type: application/json" \
-d '{"appliedFacets": {}, "limit": 20, "offset": 0, "searchText": ""}'This is the one that costs you requests:
- 20 per request.
limit20 works andlimit50 answers HTTP 400. - The list row is thin. It has the title, the path, the location text, a request id and
postedOn, which is a phrase such as "Posted Today" or "Posted 12 Days Ago". The exact date (startDate), the country, the time type and the description are on the job's own address. So 20 jobs with real dates cost 21 requests. - Trust
totalon the first page only. Later pages of the same search mostly saidtotal: 0, so read it once and keep it. - The sitemap does not list everything. The site's
siteMap.xmlheld 100 job links, so it is not a way around the paging.
One shape for all four#
The four answers disagree on almost everything: the name of the title field, how a date is written, whether "remote" is a flag or a word in the location. This puts them into one row shape. It uses only the Python standard library and ran as printed on 25 October 2026.
import html, json, re, urllib.request
from datetime import datetime, timezone
UA = {"User-Agent": "jobs-reader/1.0", "Content-Type": "application/json"}
def get(url, body=None):
data = json.dumps(body).encode() if body is not None else None
with urllib.request.urlopen(urllib.request.Request(url, data, UA), timeout=30) as r:
return json.load(r)
def text(h): # Greenhouse escapes its HTML once more: unescape, strip tags, unescape again
return " ".join(html.unescape(re.sub(r"<[^>]+>", " ", html.unescape(h or ""))).split())
def row(ats, company, title, location, posted, url, remote=None):
return dict(ats=ats, company=company, title=title, location=location,
remote=remote, posted=posted, url=url)
def greenhouse(board):
jobs = get(f"https://boards-api.greenhouse.io/v1/boards/{board}/jobs")["jobs"]
return [row("greenhouse", board, j["title"], j["location"]["name"],
j["first_published"][:10], j["absolute_url"]) for j in jobs]
def lever(company):
jobs = get(f"https://api.lever.co/v0/postings/{company}?mode=json")
return [row("lever", company, j["text"], j["categories"].get("location"),
datetime.fromtimestamp(j["createdAt"] / 1000, timezone.utc).date().isoformat(),
j["hostedUrl"], j.get("workplaceType") == "remote") for j in jobs]
def ashby(board):
jobs = get(f"https://api.ashbyhq.com/posting-api/job-board/{board}")["jobs"]
return [row("ashby", board, j["title"], j["location"], j["publishedAt"][:10],
j["jobUrl"], j["isRemote"]) for j in jobs]
def workday(site, pages=2): # site = https://<tenant>.wdN.myworkdayjobs.com/<SiteName>
host, name = site.rstrip("/").rsplit("/", 1)
tenant = host.split("//")[1].split(".")[0]
api = f"{host}/wday/cxs/{tenant}/{name}"
out = []
for page in range(pages): # 20 a page is the most it accepts
found = get(f"{api}/jobs", {"appliedFacets": {}, "limit": 20, "offset": page * 20, "searchText": ""})
for j in found["jobPostings"]:
info = get(api + j["externalPath"])["jobPostingInfo"] # the list only says "Posted 4 Days Ago"
out.append(row("workday", tenant, j["title"], j.get("locationsText"),
info.get("startDate"), info.get("externalUrl")))
return outrows = greenhouse("discord") + lever("spotify") + ashby("linear") + workday(
"https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite", pages=1)That run gave 46, 90, 34 and 20 rows. One Workday row:
{
"ats": "workday",
"company": "nvidia",
"title": "Mechanical Engineer",
"location": "Israel, Yokneam",
"remote": null,
"posted": "2026-10-25",
"url": "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite/job/Israel-Yokneam/Mechanical-Engineer_JR2018381"
}What it leaves out on purpose: pay (an extra request per job on Greenhouse, a flag on Ashby, mostly absent on Lever, only in the text on Workday), descriptions as clean text, filters, retries and polite pacing, and the other systems careers pages run on (Workable, Recruitee, Personio, BambooHR).
When to stop writing it yourself#
For a handful of companies, the code above is enough. Keep it, and check it when a board changes.
For a few hundred companies across all these systems, every morning, with pay and plain-text descriptions, that is the job of our Greenhouse, Lever, Workday and Ashby jobs scraper on Apify. Paste careers page links (up to 200 a run) and it works out the system: Greenhouse, Lever, Workday, Workable, Ashby, Recruitee, Personio or BambooHR. It filters by title, location, remote and date, and gives one row per job in one schema. For a list of Workday sites only there is the Workday jobs scraper. Both are billed per result. Scripts that run the first one from Python, Node.js or curl and save a CSV are on GitHub.
These are public job postings that the companies publish so that people find them. Read them at a gentle pace, keep the link to the original posting, and do not use the apply endpoints for anything but real applications.
written
- 3 min read
robots.txt readers disagree, and the built-in one is the odd one out
Python's built-in reader takes the first matching rule and ignores wildcards. The standard takes the longest match. We ran both on 12,000 random files and listed where they split.
- 2 min read
The price is a string: reading money off a web page
Shops print the same number a dozen ways. A careful parser reads what is clear, leaves the rest empty and never fills a gap with a plausible guess.
- 3 min read
A timestamp without a zone is a guess
Scraped dates arrive as "2026-03-08 02:30" with nothing else. Some of those times never happened, some happened twice, and the gap between two cities moves during the year.