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.