Job postings are one of the most underrated public data sources on the internet. Recruiters use them to spot placement opportunities, B2B teams read them as buying signals (a new Head of Data means data-tooling budget), and job seekers want to apply on day one — not when a posting finally reaches the aggregators.
The usual instinct is to scrape career pages. Don't. Most tech companies host their careers page on one of a handful of Applicant Tracking Systems (ATS), and the four biggest ones — Greenhouse, Lever, Ashby and SmartRecruiters — all expose public, documented JSON APIs. No auth. No proxies. No brittle HTML selectors. The career page itself loads the same JSON you're about to fetch.
In this tutorial we'll build a single-file Python tool that:
- fetches every open job for a company from any of the four ATS,
- auto-detects which ATS a company uses,
- normalizes everything into one clean schema,
- monitors changes — run it on a schedule and get only new / removed / changed postings.
The four endpoints
| ATS | Endpoint |
|---|---|
| Greenhouse | GET https://boards-api.greenhouse.io/v1/boards/{slug}/jobs?content=true |
| Lever | GET https://api.lever.co/v0/postings/{slug}?mode=json |
| Ashby | GET https://api.ashbyhq.com/posting-api/job-board/{slug} |
| SmartRecruiters |
GET https://api.smartrecruiters.com/v1/companies/{slug}/postings (paginated) |
The {slug} is the company identifier you see in career-page URLs: boards.greenhouse.io/stripe → stripe, jobs.lever.co/spotify → spotify, jobs.ashbyhq.com/linear → linear, careers.smartrecruiters.com/Visa → Visa.
Try one right now — no API key needed:
curl -s "https://api.ashbyhq.com/posting-api/job-board/linear" | head -c 400
Step 1 — fetchers, one per ATS
Each API returns a different shape, so we normalize as we fetch. Here are all four (Python 3, only requests):
import requests
UA = {"User-Agent": "ats-jobs-tutorial/1.0"}
def get_json(url, params=None):
r = requests.get(url, params=params, headers=UA, timeout=30)
r.raise_for_status()
return r.json()
def fetch_greenhouse(slug):
d = get_json(f"https://boards-api.greenhouse.io/v1/boards/{slug}/jobs",
{"content": "true"})
return [{
"job_id": str(j["id"]),
"title": j.get("title"),
"location": (j.get("location") or {}).get("name"),
"url": j.get("absolute_url"),
"published_at": j.get("first_published"),
} for j in d.get("jobs", [])]
def fetch_lever(slug):
d = get_json(f"https://api.lever.co/v0/postings/{slug}", {"mode": "json"})
return [{
"job_id": str(j["id"]),
"title": j.get("text"),
"location": (j.get("categories") or {}).get("location"),
"url": j.get("hostedUrl"),
"published_at": j.get("createdAt"), # milliseconds since epoch!
} for j in d]
def fetch_ashby(slug):
d = get_json(f"https://api.ashbyhq.com/posting-api/job-board/{slug}")
return [{
"job_id": str(j["id"]),
"title": j.get("title"),
"location": j.get("location"),
"url": j.get("jobUrl") or j.get("applyUrl"),
"published_at": j.get("publishedAt"),
} for j in d.get("jobs", []) if j.get("isListed") is not False]
def fetch_smartrecruiters(slug):
out, offset = [], 0
while True:
d = get_json(f"https://api.smartrecruiters.com/v1/companies/{slug}/postings",
{"limit": 100, "offset": offset})
items = d.get("content", [])
for j in items:
loc = j.get("location") or {}
out.append({
"job_id": str(j["id"]),
"title": j.get("name"),
"location": ", ".join(filter(None, [loc.get("city"), loc.get("country")])) or None,
"url": f"https://jobs.smartrecruiters.com/{slug}/{j['id']}",
"published_at": j.get("releasedDate"),
})
offset += len(items)
if not items or offset >= d.get("totalFound", 0):
break
return out
FETCHERS = {
"greenhouse": fetch_greenhouse,
"lever": fetch_lever,
"ashby": fetch_ashby,
"smartrecruiters": fetch_smartrecruiters,
}
Three real-world quirks worth knowing (each cost me a debugging session):
-
Lever returns timestamps in milliseconds (
createdAt: 1721900000000), not ISO strings. Divide by 1000 beforedatetime.fromtimestamp. -
Ashby includes unlisted postings — filter out
isListed: falseor you'll "discover" jobs the company never published. -
SmartRecruiters answers
200 OKwith an empty list for any slug, even one that doesn't exist. An empty SmartRecruiters response is not proof the company uses SmartRecruiters.
Step 2 — auto-detect the ATS
You usually don't know (or care) which ATS a company uses. Probe all four in order and keep the first that answers with jobs:
def detect_and_fetch(slug):
for ats, fetch in FETCHERS.items():
try:
jobs = fetch(slug)
except Exception:
continue
# SmartRecruiters gives 200 + [] for any slug — an empty board
# during detection means "not found", not "no openings".
if ats == "smartrecruiters" and not jobs:
continue
return ats, jobs
return None, []
>>> detect_and_fetch("linear")
('ashby', [{'job_id': '...', 'title': 'Senior / Staff Fullstack Engineer', ...}])
In production you'd cache the detected ATS per company so you don't re-probe on every run — four HTTP calls when one is enough.
Step 3 — monitor changes
Pulling all jobs is a one-liner now. The genuinely useful part is knowing what changed: which postings appeared today, which quietly disappeared, which got edited. That's a diff against the previous state.
Fingerprint each job, store {job_id: fingerprint} between runs, compare:
import hashlib, json, pathlib
STATE = pathlib.Path("state.json")
def fingerprint(job):
src = "|".join(str(job.get(f, "")) for f in ("title", "location", "url"))
return hashlib.sha1(src.encode()).hexdigest()[:16]
def diff(prev, jobs):
cur = {j["job_id"]: j for j in jobs}
changes = []
for jid, job in cur.items():
if jid not in prev:
changes.append({"change": "new", **job})
elif prev[jid]["fp"] != fingerprint(job):
changes.append({"change": "changed", **job})
for jid, snap in prev.items():
if jid not in cur:
changes.append({"change": "removed", "job_id": jid, "title": snap["title"]})
return changes
def monitor(companies):
state = json.loads(STATE.read_text()) if STATE.exists() else {}
for slug in companies:
ats, jobs = detect_and_fetch(slug)
if not ats:
print(f"{slug}: no supported board found")
continue
prev = state.get(slug)
if prev is None:
print(f"{slug} [{ats}]: baseline saved, {len(jobs)} jobs")
else:
for c in diff(prev, jobs):
print(f"{slug}: {c['change'].upper()} — {c.get('title')}")
state[slug] = {j["job_id"]: {"fp": fingerprint(j), "title": j.get("title")}
for j in jobs}
STATE.write_text(json.dumps(state))
monitor(["stripe", "linear", "spotify"])
First run saves a baseline. Every later run prints only the delta:
stripe: NEW — Backend Engineer, Payments
linear: REMOVED — Account Executive, Growth
Put it on cron (or GitHub Actions on a schedule) and pipe the output into Slack, a spreadsheet, or an n8n/Make webhook — you'll know a company is hiring the day the posting goes live.
What it takes to run this seriously
The 100-line version above works. Running it reliably for a real watchlist grows the usual operational tail: state storage that survives machines, retry/backoff when an API hiccups, distinguishing "board is gone" from "request failed" (so you don't fire 200 false REMOVED alerts), department/salary/remote fields where each ATS hides them differently, caching ATS detection, and a scheduler that doesn't silently die.
All of that is maintenance, not insight. If you'd rather not own it, I packaged this exact pipeline as an Apify Actor — ATS Jobs Scraper & Change Monitor:
- paste slugs or career-page URLs, get the normalized dataset (department, salary where exposed, remote flags included),
- monitor mode with hosted state and per-change pricing — watching 100 companies daily costs a few dollars a month,
- native @apify scheduling, dataset exports (CSV/JSON), webhooks, and it's callable by AI agents via Apify MCP.
The DIY script above gets you 80% of the way for $0 — start there (full runnable version on GitHub). When babysitting it stops being fun, the Actor is the same logic with the ops solved.
Questions about a specific ATS or an edge case? Drop a comment — I've probably hit it.
Top comments (0)