ATS Jobs Scraper - Greenhouse, Lever & Ashby in One Schema
Pricing
from $1.05 / 1,000 job scrapeds
ATS Jobs Scraper - Greenhouse, Lever & Ashby in One Schema
Pull open jobs from six applicant tracking systems — Greenhouse, Lever, Ashby, Workable, Recruitee, SmartRecruiters — into one normalised schema. Auto-detects which ATS a company uses. Official public APIs, no browser, no proxy, no blocking.
Pricing
from $1.05 / 1,000 job scrapeds
Rating
0.0
(0)
Developer
John J. K. Omolo
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
14 days ago
Last modified
Categories
Share
ATS Jobs Scraper — 6 Sources, One Schema
Give it a list of company board slugs. Get back every open job, from any of six applicant tracking systems, in one consistent schema.
Why this exists
Almost every tech company's job board runs on one of a handful of platforms — Greenhouse, Lever, Ashby, Workable, Recruitee, or SmartRecruiters — and every one of them exposes a public, documented JSON API. So this was never a scraping problem. It's a normalisation problem: six different response shapes, board-slug discovery, HTML-encoded descriptions in one source and raw HTML in another, epoch timestamps in milliseconds in some and ISO strings in others, and salary data structured differently everywhere it exists at all.
That's the work this does.
No browser. No proxy. No anti-bot. These are the platforms' own public endpoints, which means nothing here breaks when a page layout changes, and runs are fast and cheap.
The ATS is auto-detected
None of these platforms publishes a directory of which companies use it. Without detection you'd need to know each company's ATS in advance — so this simply tries each one and uses whichever responds. Set ats explicitly only if you want to skip the extra lookups, since detection can now mean up to five extra requests per company rather than two.
What normalisation actually means here
Concrete examples of what you don't have to handle yourself:
- Lever calls the job title
text, nottitle, and nests location, department, team and commitment undercategories. - Greenhouse returns HTML-encoded descriptions (
<p>) while Lever and Ashby return real HTML. You get real HTML from every source, plus a clean plain-text version. - Lever's dates are epoch milliseconds, confirmed live —
createdAt: 1754006400000becomes"2026-08-01T00:00:00+00:00"in the output, not a raw number sitting in a field that expects a string. Every date field across every source runs through the same conversion, whatever shape it arrives in. - Salary lives in a different place for each source that has it — Lever's
salaryRange, Ashby's nestedcompensationTiers, SmartRecruiters three levels deep insidejobAd.sections, and not at all in Greenhouse's API. One set ofsalaryMin/salaryMax/salaryCurrencyfields regardless of source. When the structured field is empty, prose is checked too — confirmed necessary on Lever, where at least one major employer discloses salary as a sentence in the description ("$60,000 - $97,000/year") rather than in the structured field. This never overrides a structured value that's actually present; it only fills a genuine gap. - SmartRecruiters descriptions need an opt-in second request per job. Confirmed against their own API spec: the description field only exists on the single-job detail endpoint, never the list endpoint this Actor normally uses. Set
fetchSmartRecruitersDescriptions: trueto fetch it — off by default, since it changes the request count (and therefore run time) for SmartRecruiters companies specifically. The other five sources already include descriptions with no extra requests.
Input
| Field | Required | Description |
|---|---|---|
companies | yes | Board slugs, one per line. A full board URL works too — the slug is taken from it. For explicit control: {"slug": "stripe", "ats": "greenhouse"} |
maxJobsPerCompany | no | Caps billable results per company. Default 500 |
The slug is the identifier in the board URL, not always the company name: boards.greenhouse.io/stripe, jobs.lever.co/palantir, jobs.ashbyhq.com/openai.
Output
One record per job:
ats · companySlug · jobId · title · location · isRemote · department · team · employmentType · descriptionHtml · descriptionText · salaryMin · salaryMax · salaryCurrency · salaryInterval · applyUrl · jobUrl · postedAt · updatedAt
Every key is always present, even when null — a field that only sometimes exists is awkward to consume programmatically.
RUN_STATS records which ATS each company resolved to, and for any company that returned nothing, which platforms were tried and what each one said — so an empty result tells you whether the board is private, empty, or on an unsupported ATS.
Pricing
Billed per job returned. A company that returns no jobs costs you nothing, and a run that finds nothing at all is never billed.
Limits, honestly
- Confidence isn't equal across all six sources. Greenhouse, Lever, Ashby, and now Workable and Recruitee have run against live company data, in this build, with real bugs found and fixed against real responses. SmartRecruiters has too, though one of its gaps (see below) turned out to be the source's own data, not this Actor's code.
- SmartRecruiters descriptions are null by default, on purpose.
jobAdonly exists on their detail endpoint, confirmed against the OpenAPI spec — setfetchSmartRecruitersDescriptions: trueto fetch it via a second request per job. departmentwill be null for some SmartRecruiters companies, and that's the source's data, not a parsing gap. Confirmed against three separate official examples that this Actor's field path is correct; a company that simply doesn't fill in the department field on its postings will show null regardless.- SmartRecruiters' public feed can be switched off per customer. Unlike the other five, a 404 from SmartRecruiters doesn't only mean "wrong slug" — it can mean the company's plan tier doesn't expose the public Postings API at all. There's no way to tell these apart from outside; both look identical.
- You need the slug. No platform offers a directory of its customers, so there's no way to enumerate companies. This turns slugs into jobs; it doesn't discover slugs.
- Only published, public jobs. Anything behind a private board or a vanity careers domain isn't visible to these endpoints.
- Salary appears only where the employer published it at all. Greenhouse's API doesn't expose compensation in any form. Where a source has a structured field — Lever's
salaryRange, Ashby'scompensationTiers— that's used first. Where it's empty, the description text is checked for a disclosed range, which never overrides a structured value and only fills a genuine gap. That prose check is deliberately conservative: it requires a range separator immediately after the amount, so a revenue figure or a funding total mentioned earlier in a company blurb won't be mistaken for a salary. An employer who published no compensation anywhere will still show null, because there is nothing to find.
For maintainers
All six sources have run against live company data. A 93-row run across Stripe (Greenhouse), Palantir (Lever), OpenAI (Ashby), HuggingFace (Workable), AnywhereWorks (Recruitee) and Bosch (SmartRecruiters) returned title, location and postedAt non-null on every source, with every remaining null traced to a confirmed cause rather than left as a mystery.
Real bugs were found and fixed against real responses along the way: a location field leaking a dict instead of a string, and epoch-millisecond timestamps failing dataset validation. The second of those was found latent in Lever — before it had ever fired — by generalising from Greenhouse evidence and checking every date-field call site across all three parsers.
Two features are built and unit-tested but not yet confirmed against a live payload: prose salary extraction, and the SmartRecruiters per-job description fetch. Both were built from confirmed real evidence — Lever's own documentation on epoch milliseconds, SmartRecruiters' OpenAPI spec on where jobAd exists — rather than from guesswork. But the first live run of each is still the real test, and that distinction is worth keeping visible rather than quietly collapsing into "done".
Every field on every source goes through the same tolerant lookup (pick, pick_list) that caught the Lever surprise, precisely because that gap is real and this is how it gets closed the next time it matters.
If fields come back null, check RUN_STATS.noJobsFound first — it names which ATS were tried and what each returned.