If you're building a niche job board, a hiring-signal alert, or just tracking a list of target companies, you don't need to scrape HTML. Most tech companies post jobs through an applicant tracking system (ATS), and the big five all expose the same public JSON feed that powers the company's own careers page. No login, no API key, no headless browser.
I checked all five this morning (October 2026). Here's what each one returns, the gotchas, and a small Python script that merges them into one schema.
The five endpoints
| ATS | Careers page looks like | JSON endpoint |
|---|---|---|
| Greenhouse | boards.greenhouse.io/figma |
https://boards-api.greenhouse.io/v1/boards/{slug}/jobs |
| Lever | jobs.lever.co/spotify |
https://api.lever.co/v0/postings/{slug}?mode=json |
| Ashby | jobs.ashbyhq.com/ramp |
https://api.ashbyhq.com/posting-api/job-board/{slug}?includeCompensation=true |
| Workable | apply.workable.com/huggingface |
https://apply.workable.com/api/v1/widget/accounts/{slug} |
| SmartRecruiters | careers.smartrecruiters.com/Ubisoft2 |
https://api.smartrecruiters.com/v1/companies/{slug}/postings?limit=100&offset=0 |
The slug is just the last part of the careers-page URL.
Test run just now: Figma 141 jobs (0.26 s), Spotify 81 (1.1 s), Ramp 167 (0.16 s), Hugging Face 6 (0.17 s), Ubisoft 329 across 4 pages. That's 724 jobs from five companies in about 3.5 seconds.
Quirks worth knowing
-
Greenhouse: the list endpoint has
title,location.name,absolute_url,updated_atandfirst_published. Add?content=trueif you need the HTML description and departments in the same call (much bigger response). -
Lever: returns a bare JSON array.
createdAtis epoch milliseconds, not ISO. EU-hosted companies live onapi.eu.lever.co; the US host answers 404 for them, so try both. Plain-text descriptions come for free (descriptionPlain). -
Ashby:
includeCompensation=trueadds structured salary ranges when the company publishes them. You also getisRemoteandworkplaceTypedirectly, which is the cleanest remote flag of the five. -
Workable: the widget endpoint is light; pass
?details=trueto get descriptions. Remote is a boolean calledtelecommuting. -
SmartRecruiters: paginated with
limit(max 100) andoffset; stop whenoffset >= totalFound. The list has no description; fetch/postings/{id}per job if you need it, and be polite about rate.
One normalized feed in Python
import requests
UA = {"User-Agent": "job-feed-tutorial/1.0"}
def greenhouse(slug):
r = requests.get(f"https://boards-api.greenhouse.io/v1/boards/{slug}/jobs", headers=UA, timeout=30)
for j in r.json()["jobs"]:
yield {"title": j["title"], "location": j["location"]["name"],
"url": j["absolute_url"], "posted": j.get("first_published") or j["updated_at"]}
def lever(slug):
r = requests.get(f"https://api.lever.co/v0/postings/{slug}?mode=json", headers=UA, timeout=30)
if r.status_code == 404: # EU-hosted boards live on a different host
r = requests.get(f"https://api.eu.lever.co/v0/postings/{slug}?mode=json", headers=UA, timeout=30)
for j in r.json():
yield {"title": j["text"], "location": j["categories"].get("location"),
"url": j["hostedUrl"], "posted": j["createdAt"]} # createdAt is epoch ms
def ashby(slug):
r = requests.get(f"https://api.ashbyhq.com/posting-api/job-board/{slug}?includeCompensation=true", headers=UA, timeout=30)
for j in r.json()["jobs"]:
yield {"title": j["title"], "location": j["location"],
"url": j["jobUrl"], "posted": j["publishedAt"]}
def workable(slug):
r = requests.get(f"https://apply.workable.com/api/v1/widget/accounts/{slug}", headers=UA, timeout=30)
for j in r.json()["jobs"]:
loc = ", ".join(x for x in (j.get("city"), j.get("country")) if x)
yield {"title": j["title"], "location": loc,
"url": j["url"], "posted": j.get("published_on")}
def smartrecruiters(slug):
offset = 0
while True:
r = requests.get(f"https://api.smartrecruiters.com/v1/companies/{slug}/postings",
params={"limit": 100, "offset": offset}, headers=UA, timeout=30).json()
for j in r["content"]:
yield {"title": j["name"], "location": j["location"].get("fullLocation") or j["location"].get("city"),
"url": f"https://jobs.smartrecruiters.com/{slug}/{j['id']}", "posted": j["releasedDate"]}
offset += 100
if offset >= r["totalFound"]:
break
PLATFORMS = {"greenhouse": greenhouse, "lever": lever, "ashby": ashby,
"workable": workable, "smartrecruiters": smartrecruiters}
Use it like this:
companies = ["greenhouse:figma", "lever:spotify", "ashby:ramp",
"workable:huggingface", "smartrecruiters:Ubisoft2"]
feed = []
for c in companies:
platform, slug = c.split(":")
feed += [dict(j, company=slug, platform=platform) for j in PLATFORMS[platform](slug)]
print(len(feed)) # 724 this morning
Detecting new and closed jobs
The interesting signal is usually the change: a company that opens ten data-engineering roles in a week is hiring a team. Keep the last run's job URLs and diff:
import json, pathlib
state = pathlib.Path("seen.json")
before = set(json.loads(state.read_text())) if state.exists() else set()
now = {j["url"] for j in feed}
new_jobs = [j for j in feed if j["url"] not in before]
closed = before - now
state.write_text(json.dumps(sorted(now)))
print(f"{len(new_jobs)} new, {len(closed)} closed")
Run it on a cron and push new_jobs to Slack, a sheet, or your board.
Pitfalls
-
Finding the slug: companies with a custom careers domain often still embed
boards.greenhouse.io/embed/job_board?for=<slug>or ajobs.lever.co/<slug>link. View source and search for the ATS name. -
Bad slugs fail differently: Greenhouse, Lever, Ashby and Workable return 404, but SmartRecruiters returns 200 with an empty list (
totalFound: 0), so check the count too. Log per company instead of crashing the whole run. - Be a good citizen: these feeds are public, but one request per company per few hours is plenty. Set a real User-Agent.
- Postings only: none of these endpoints expose recruiter personal data, and you don't need it.
If you'd rather not maintain it
I packaged the same approach (all five platforms, one schema, title/location/remote/date filters, salary where published, and new/closed detection across scheduled runs) as a hosted tool: ATS Jobs Scraper on Apify. It's pay-per-result at $1 per 1,000 jobs, and Apify's free monthly credit covers small watch-lists. You can also call it as an MCP tool from an AI agent.
But honestly, for a handful of companies the script above is all you need. Happy building!
Top comments (0)