Most people who need job-posting data reach for a headless browser. They point Playwright at a careers page, hope the React app has hydrated, and write selectors that will break the next time marketing ships a redesign.
You usually don't need any of that.
The three ATS platforms that most startups use — Greenhouse, Ashby and Lever — already serve every job board as plain JSON. They have to. That's how the "embed our jobs on your own website" widget works.
These endpoints are public. No login, no API key, no browser. Here are all three, with real numbers I pulled while writing this.
The three endpoints
| Provider | Endpoint | Shape |
|---|---|---|
| Greenhouse | https://boards-api.greenhouse.io/v1/boards/{slug}/jobs |
{"jobs": [...]} |
| Ashby | https://api.ashbyhq.com/posting-api/job-board/{slug} |
{"jobs": [...]} |
| Lever | https://api.lever.co/v0/postings/{slug}?mode=json |
bare [...]
|
Two of them wrap the array; Lever returns a naked one. That alone will make you write three normalizers, so plan for it.
Live counts, pulled on 2026-10-12:
Greenhouse stripe → 727 jobs
Greenhouse gitlab → 221 jobs
Ashby openai → 811 jobs
Ashby ramp → 165 jobs
Lever leverdemo → 10 jobs
That's 1,934 postings from five requests. No browser, no proxies, well under a second each.
Working out which ATS a company uses
Don't guess — read the careers URL. All three vendors use predictable hosts:
job-boards.greenhouse.io/stripe → greenhouse, slug "stripe"
boards.greenhouse.io/gitlab → greenhouse, slug "gitlab"
jobs.ashbyhq.com/openai → ashby, slug "openai"
jobs.lever.co/leverdemo → lever, slug "leverdemo"
The slug is whatever sits between the host and the next slash. On a company's own domain the path is usually similar — /careers redirects, or the embed URL leaks the slug in a query parameter.
The smallest thing that works
slug=stripe
curl -s "https://boards-api.greenhouse.io/v1/boards/$slug/jobs" \
| jq -r '.jobs[] | [.title, .location.name, .absolute_url] | @tsv' \
| head -20
And in Python, normalizing all three into one shape:
import httpx
def fetch(provider: str, slug: str) -> list[dict]:
if provider == "greenhouse":
url = f"https://boards-api.greenhouse.io/v1/boards/{slug}/jobs"
raw = httpx.get(url, timeout=20).json()["jobs"]
return [{"title": j["title"], "url": j["absolute_url"],
"location": (j.get("location") or {}).get("name"),
"updatedAt": j.get("updated_at")} for j in raw]
if provider == "ashby":
url = f"https://api.ashbyhq.com/posting-api/job-board/{slug}"
raw = httpx.get(url, timeout=20).json()["jobs"]
return [{"title": j["title"], "url": j["jobUrl"],
"location": j.get("location"),
"updatedAt": j.get("publishedAt")} for j in raw]
if provider == "lever":
url = f"https://api.lever.co/v0/postings/{slug}?mode=json"
raw = httpx.get(url, timeout=20).json() # bare array
return [{"title": j["text"], "url": j["hostedUrl"],
"location": j.get("categories", {}).get("location"),
"updatedAt": j.get("createdAt")} for j in raw]
raise ValueError(provider)
The traps nobody mentions
The slug is not the company name. gitlab works. So does a lot of near-misses that don't — which is why you need to distinguish "wrong slug" from "no openings right now". A board that exists but has zero jobs still returns a valid empty result. Test for a 200 with an empty array, not a 404. Otherwise you'll spend an afternoon renaming a company that was never hiring.
Timestamps are three different formats. Greenhouse gives you ISO-8601 strings, Ashby gives ISO-8601, Lever gives epoch milliseconds as an integer. If you sort a mixed array without normalizing, you get silent nonsense.
Descriptions can blow up your payload. Greenhouse's ?content=true and Ashby's ?includeCompensation=true pull the full HTML job description — routinely 10–20 KB per posting. On a board with 800 openings that's tens of megabytes for data most pipelines throw away. Fetch titles first, then enrich only the rows you actually need.
Be a good citizen. These are public endpoints, but they're not yours. Space your requests, don't run a hundred boards in parallel, and cache. I use a 250 ms delay between boards and four-way concurrency, which is enough to feel instant and rude to nobody.
Check the terms before you redistribute. The endpoints are public; the postings aren't necessarily yours to republish. Personal data and contact details inside postings deserve particular care, especially if you're in a jurisdiction with data-protection law.
If you'd rather not maintain this
All of the above is maybe 120 lines of code. I wrote it out and put it here — standard library only, no dependencies, MIT:
github.com/yff124/apify-actors/blob/main/ats_normalizers.py
python3 ats_normalizers.py stripe # bare slug, auto-detects the provider
python3 ats_normalizers.py https://jobs.ashbyhq.com/openai # or hand it a careers URL
It accepts a company name, an explicit provider:slug, or a full careers URL, auto-detects which of the three platforms answers, and returns one schema — the same three public endpoints described above, with the slug guessing, the timestamp normalizing and the politeness handled for you.
Run 5 companies or 500. If you only ever need one company, the curl command at the top of this post is genuinely all you need — I'd rather you knew how it works than treated it as magic.
Written with AI assistance and verified by hand — every endpoint and job count in this post was pulled live on 2026-10-12.
Top comments (1)
Nice write-up — the three endpoints and the Lever-vs-wrapped-array gotcha are exactly right.
One more that bit me after the "it works" stage: Greenhouse omits job descriptions unless you add
?content=true. Without that flag you get title/location/url only and wonder where the body went (and with the flag, Stripe's board jumps from ~0.45 MB to ~5.6 MB, so strip HTML before you store it).Also useful: a bad slug is a hard 404 on all three APIs (
{"status":404,...}/"Document not found"/ plainNot Found), not an emptyjobsarray — so treat 404 as "wrong board", not "not hiring".