← blog  /  scrapers

Greenhouse, Lever, Ashby and Workday job board APIs

7 min read  ·  scrapers

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 requestDatePay
GreenhouseNoAllExactExtra request per job
LeverNoAll, or pagesExactOptional, often empty
AshbyNot askedAllExactA flag in the address
WorkdayUndocumented20"4 Days Ago"No field
Where the jobs aretext
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}/jobs

The {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#

One Greenhouse board, every open jobbash
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": 19200000 is $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#

One Lever site, every open postingbash
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:

  • createdAt is milliseconds since 1970, not a date string.
  • salaryRange exists, but Lever calls it optional. None of those 399 postings had it.
  • An unknown company name answers 404. The robots.txt of api.lever.co asks for one second between requests (Crawl-delay: 1), so leave that gap.

Ashby#

One Ashby board, with pay where it is publishedbash
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: false means 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.

One page of a Workday careers sitebash
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. limit 20 works and limit 50 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 total on the first page only. Later pages of the same search mostly said total: 0, so read it once and keep it.
  • The sitemap does not list everything. The site's siteMap.xml held 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.

Read four job boards into one row shapepython
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 out
Run itpython
rows = 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:

One rowjson
{
  "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.

before you run anything

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