如何透過公開 JSON 端點讀取 Greenhouse、Lever 和 Ashby 職位列表
Reading Greenhouse, Lever and Ashby job boards from their public JSON endpoints
作者以 Python 讀取 Greenhouse、Lever 和 Ashby 的公開職位 JSON 端點,並將三者資料整理成統一格式。端點分別為 https://boards-api.greenhouse.io/v1/boards/{board}/jobs?
AI disclosure: this post was written with AI assistance (drafting and editing). I ran every request and code sample below myself on October 28, 2026 and checked the output. Select "AI-Assisted" in the DEV editor before publishing.
If you want a list of open jobs at a set of companies, you usually do not need a headless browser. Most tech companies host their career page on an applicant tracking system (ATS), and three of the most common ones, Greenhouse, Lever and Ashby, expose the same postings their career pages render as plain JSON. No API key, no login.
This post covers the three endpoints, the fields worth keeping, and a small reader that turns all three into one shape.
1. Find the board name
The board name is in the career page URL:
| ATS | Career page looks like | Board name |
|---|---|---|
| Greenhouse |
boards.greenhouse.io/airbnb or job-boards.greenhouse.io/airbnb
|
airbnb |
| Lever | jobs.lever.co/spotify |
spotify |
| Ashby | jobs.ashbyhq.com/ramp |
ramp |
If a company uses its own domain, view the page source and search for greenhouse, lever.co or ashbyhq. The embed script or the apply links usually give it away.
2. The endpoints
Greenhouse (Job Board API, docs):
GET https://boards-api.greenhouse.io/v1/boards/{board}/jobs?content=true
Returns {"jobs": [...], "meta": {"total": N}}. Each job has id, title, location.name, absolute_url, updated_at, first_published and, with content=true, an HTML-escaped description in content. Drop content=true if you only need titles; the response gets much smaller.
Lever (Postings API, docs):
GET https://api.lever.co/v0/postings/{site}?mode=json
Returns a bare JSON array. Useful fields: id, text (the title), categories.location, categories.team, categories.commitment, workplaceType, hostedUrl, createdAt (epoch milliseconds). Companies on Lever's EU instance use api.eu.lever.co instead. An unknown site returns {"ok": false, "error": "Document not found"} with a 404, so check for a list before iterating.
Ashby (public job posting API, docs):
GET https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=true
Returns {"jobs": [...]}. Fields: id, title, department, team, location, secondaryLocations, isRemote, workplaceType, employmentType, publishedAt, jobUrl, applyUrl. With includeCompensation=true, many jobs carry compensation.compensationTierSummary, a ready-to-display string such as $211.4K – $290.6K • Offers Equity. Salary data is the main reason I reach for Ashby boards first.
3. One reader for all three
Standard library only:
import json
import urllib.request
UA = {"User-Agent": "job-board-reader/1.0 (+you@example.com)"}
def get_json(url):
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=20) as r:
return json.load(r)
def greenhouse(board):
data = get_json(f"https://boards-api.greenhouse.io/v1/boards/{board}/jobs?content=true")
for j in data["jobs"]:
yield {
"source": "greenhouse", "id": str(j["id"]), "title": j["title"],
"location": (j.get("location") or {}).get("name"),
"posted": j.get("first_published") or j.get("updated_at"),
"url": j["absolute_url"],
}
def lever(site):
for j in get_json(f"https://api.lever.co/v0/postings/{site}?mode=json"):
cats = j.get("categories") or {}
yield {
"source": "lever", "id": j["id"], "title": j["text"],
"location": cats.get("location"),
"posted": j.get("createdAt"), # epoch milliseconds
"url": j["hostedUrl"],
}
def ashby(board):
data = get_json(f"https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=true")
for j in data["jobs"]:
yield {
"source": "ashby", "id": j["id"], "title": j["title"],
"location": j.get("location"),
"posted": j.get("publishedAt"),
"url": j["jobUrl"],
}
if __name__ == "__main__":
jobs = [*greenhouse("airbnb"), *lever("spotify"), *ashby("ramp")]
print(len(jobs), "jobs")
When I ran it, it printed 456 jobs (195 Greenhouse, 89 Lever, 172 Ashby), in under a second end to end.
4. Things that bite
- Dates come in three formats. Greenhouse uses ISO 8601 with a local offset, Ashby uses ISO 8601 in UTC, and Lever uses epoch milliseconds. Normalize before you sort or filter by "posted in the last N days".
-
Greenhouse
updated_atis not the posting date. It changes when a recruiter edits the job. Preferfirst_publishedwhen it is present. -
Locations are free text. "Remote - USA", "United States" and "New York, NY (HQ)" all appear. Ashby's
isRemoteand Lever'sworkplaceTypeare more reliable than parsing strings. -
IDs are only unique per ATS. Prefix them (
ashby:<id>) before you dedupe across sources. - Descriptions are HTML. Greenhouse's is HTML-escaped HTML, so unescape first, then strip tags.
- Detecting new jobs is just a set difference: store the prefixed IDs you have seen, and on the next run keep only IDs you have not seen.
5. Be a polite client
These endpoints exist so career pages can render, and that is the reason they are open. Treat them that way:
- Send a descriptive
User-Agentwith a contact address. - Check robots.txt. On the day I wrote this,
api.lever.co/robots.txtallowed everything withCrawl-delay: 1, andboards-api.greenhouse.io/robots.txtdisallowed only/embed/. - One request per board is enough. Each endpoint returns the whole board, so polling once or twice a day covers almost every use case.
- Only collect what the company has already published. Do not try to reach application data or anything behind a login.
Workday career sites are the big platform missing from this list. They also serve JSON, but through a per-tenant POST endpoint with pagination and facets, which deserves a post of its own.
This article was created with the help of AI. The endpoints, numbers and code were tested by me before publishing.
來源:DEV Community · dev.to