I spent an afternoon trying to scrape a careers page with a headless browser before I noticed the page itself was calling a JSON endpoint. The company runs Greenhouse. Greenhouse serves the whole board — every open role, full descriptions — over one unauthenticated GET. No Playwright, no proxy, no waiting for React to hydrate. I deleted the browser code and never looked back.
That's the thing nobody tells you when you're building a job aggregator or a "who's hiring" tracker. Stripe, Spotify, Ramp — thousands of companies post through four ATS platforms, and each one exposes a per-company feed that's already public. You just have to know the URL shape.
Here are the four I use, with real slugs so you can curl along.
The endpoints
Every platform keys off a slug — the token buried in the careers URL.
Greenhouse
https://boards-api.greenhouse.io/v1/boards/{slug}/jobs?content=true
?content=true gives you the full HTML description inline. Hit stripe and you get 500+ jobs in a single response — no pagination at all. The slug is whatever sits in boards.greenhouse.io/stripe.
const res = await gotScraping({
url: 'https://boards-api.greenhouse.io/v1/boards/stripe/jobs?content=true',
responseType: 'json',
});
// res.body.jobs -> [{ id, title, location: {name}, content, absolute_url, departments, offices, first_published, updated_at }]
One trap cost me twenty minutes: Greenhouse double-encodes the content field. You get <p> where you expected <p>. Run it through an HTML-entity decode once before you touch it, or you'll render literal tags in your UI and blame your own template.
Lever
https://api.lever.co/v0/postings/{slug}?mode=json
Try spotify or ro. You get a flat array back, and the useful fields (team, location, commitment) hide one level down under categories:
// [{ id, text, categories: { team, location, commitment }, description, descriptionPlain, hostedUrl, applyUrl, createdAt }]
A 404 here is not an error. It just means the company isn't on Lever, which is exactly what you want to hear when you're guessing which platform a slug belongs to.
Ashby
https://api.ashbyhq.com/posting-api/job-board/{slug}?includeCompensation=true
Try ramp. Ashby is the one I wish everyone used. Structured location, secondaryLocations, a real isRemote boolean, employmentType, and both descriptionHtml and descriptionPlain so you don't have to strip tags yourself. When the company fills in comp bands, you get those too.
SmartRecruiters
https://api.smartrecruiters.com/v1/companies/{slug}/postings?limit=100&offset=0
Try Visa. This is the odd one out — it paginates. You get a totalFound and you loop limit/offset until you've pulled everything. And here's the catch that bit me: the list endpoint returns metadata only. No description body. If you want the actual job text you have to call a separate per-posting detail endpoint, one request each. So the "free and fast" story falls apart a bit for SmartRecruiters — you're paying N+1 requests for full text.
Turning four shapes into one
The endpoints are the easy part. The value is a single record shape so the rest of your code never has to know or care which ATS a company runs. A thin per-provider normalizer does it:
function normalizeGreenhouse(job) {
return {
source: 'greenhouse',
id: String(job.id),
title: job.title,
location: job.location?.name ?? null,
department: (job.departments ?? []).map((d) => d.name).join(', ') || null,
remote: /remote/i.test(job.location?.name ?? ''),
url: job.absolute_url,
descriptionHtml: decodeEntities(job.content),
postedAt: job.first_published ?? null,
};
}
function normalizeLever(job) {
const c = job.categories ?? {};
return {
source: 'lever',
id: String(job.id),
title: job.text,
location: c.location ?? null,
department: c.team ?? c.department ?? null,
remote: /remote/i.test(job.workplaceType ?? c.location ?? ''),
url: job.hostedUrl,
descriptionHtml: job.description,
postedAt: job.createdAt ? new Date(job.createdAt).toISOString() : null,
};
}
// ...ashby, smartRecruiters similarly
Notice remote is a regex guess on Greenhouse and Lever because neither gives you a clean flag. Ashby actually hands you isRemote, so use it there instead of guessing. That inconsistency is the whole reason the normalizer exists.
Guessing the platform when you only have a name
Say a user types "Ramp" and you have no idea which ATS they're on. You could look it up. Or you could throw the slug at all four at once and keep whatever answers:
async function findJobs(slug) {
const results = await Promise.allSettled([
fetchGreenhouse(slug),
fetchLever(slug),
fetchAshby(slug),
fetchSmartRecruiters(slug),
]);
return results
.filter((r) => r.status === 'fulfilled' && r.value.length)
.flatMap((r) => r.value);
}
The 404s from the three wrong platforms come back in a couple hundred milliseconds and cost nothing. In a real build you'd resolve the actual slug by parsing the careers-page embed — the ATS board is almost always iframed or linked from there — but bare-slug guessing covers more companies than you'd expect: company names and slugs match more often than not.
1,199 roles in one pass
To sanity-check that this actually holds up, I ran it across 8 real companies spanning all four platforms and pulled 1,199 open roles in a single sweep. Proxyless, roughly 200ms per company on the single-request platforms. The stable part is what sells it: these are the same feeds the companies' own job widgets call, so a marketing redesign doesn't touch them. Scrape the rendered page and you're one CSS class rename away from a broken parser.
Where it stops working
Not every ATS plays along, and I'd rather tell you now than have you burn an afternoon.
-
SmartRecruiters' list feed has no descriptions. Covered above — plan for the N+1 or ship jobs without body text. And
log()the pagination totals loudly: a truncatedlimit/offsetloop will quietly return page one and let you think you got the whole board. - Workable is auth-gated now. The old open feed wants a token. Same story with Recruitee. A year ago both were open; that window closed.
So this isn't a universal skeleton key. It's four platforms that happen to still be open, and they happen to cover a large slice of tech hiring. Good enough that I stopped writing careers-page scrapers entirely.
The ready-made version
I packed all four platforms, the unified schema, and the auto-detect into an Apify actor — ATS Job Feed Scraper. But the endpoints above really are most of it. Build your own; it genuinely is a fun afternoon, and you'll understand every field.
If you know another ATS that still serves an open feed, drop it in the comments and I'll add notes. I'm especially curious whether anyone has a live Workable path that doesn't need a token.
Top comments (0)