ATS Job Boards Scraper - Greenhouse, Ashby, Lever
Pricing
from $0.70 / 1,000 job returneds
ATS Job Boards Scraper - Greenhouse, Ashby, Lever
Scrape open job postings from any company's Greenhouse, Ashby or Lever job board into one unified schema. Auto-detects the ATS from just a company slug. Every row carries the full decoded description, department, location, remote flag, employment type, salary band and apply URL.
Scrape open job postings straight from a company's own applicant tracking system. This ATS job scraper reads the official Greenhouse, Ashby and Lever job board APIs and returns every posting in one unified schema, so a Greenhouse row and a Lever row have the same column names, the same date format and the same employment-type values.
You give it a company slug. It works out which ATS that company uses, pulls the whole board, and returns clean structured data with the complete job description, department, location, remote flag, employment type, salary band where the employer publishes one, and the direct apply URL.
This is first-party hiring data. It comes from the same API the company's own careers page calls, so there are no ghost jobs, no aggregator staleness and no third-party middleman.
What you get
Every row carries the same 30 fields whichever ATS it came from:
| Field | What it holds |
|---|---|
title, jobId, jobUrl, applyUrl | The posting and where to apply |
companySlug, companyName, boardUrl | Which employer, and the board it came from |
atsPlatform | greenhouse, ashby or lever |
department, team | Org placement, with a Lever-aware fallback |
location, allLocations, countryCode | Primary and every additional location |
isRemote, workplaceType | Remote, hybrid or onsite, normalized across vendors |
employmentType | full-time, part-time, contract, temporary, intern, volunteer |
salaryMin, salaryMax, salaryCurrency, salaryPeriod, salaryRaw, salarySource | Pay band, plus how it was obtained |
postedAt, updatedAt | ISO-8601, always, on every vendor |
descriptionHtml, descriptionText | The full posting body, as HTML and as clean plain text |
requisitionId | The employer's internal req number, where published |
What this scraper does that others do not
These four things came out of measuring competing Apify actors against the same three boards, not from guesswork.
Descriptions arrive readable, not escaped. Greenhouse serves its job description as HTML
that has itself been entity-encoded. Actors that pass the field straight through hand you
<p>We are hiring</p> instead of a description. This scraper decodes it, and
also gives you a descriptionText field with the paragraph structure preserved, ready to
drop into a search index, an embedding or an LLM prompt.
Lever descriptions are complete. No single Lever field holds the whole posting. The body is split across an intro, a set of requirement lists and a closing section. Reading only the obvious field silently drops every requirement bullet. This scraper reassembles all three parts in order.
Salary bands are extracted, and labelled honestly. Where the ATS publishes a pay range as
a structured field, you get it with salarySource: "structured". Where the band only appears
in the posting text, you still get it, marked salarySource: "description" so you know to
spot-check it. Nothing is guessed: the parser only fires on an explicit two-ended range with
a currency marker.
Dates are one format. Lever publishes its posting date as epoch milliseconds while
Greenhouse and Ashby use ISO-8601. Every postedAt in your dataset is ISO-8601.
You are charged per job, not per row of padding. No per-company summary rows in the dataset. Postings removed by your filters and duplicates found across boards are dropped before billing. Unpublished Ashby drafts, which that API returns alongside live roles, are filtered out rather than sold to you as open jobs. The one non-job row that can ever appear is the single explanatory row a run emits when it finds nothing, which does count as one charged item.
Input
The only thing you need is a list of companies.
{"companies": ["gitlab", "ashby:linear", "lever:palantir"],"maxItems": 200,"includeDescriptions": true}
The slug is the last part of the company's public careers URL. For
job-boards.greenhouse.io/gitlab the slug is gitlab. A bare slug is enough and the ATS is
detected for you. Prefixing it (lever:palantir) skips detection and saves a request per
company.
You can paste full careers URLs into startUrls instead, which also skips detection:
{"startUrls": ["https://job-boards.greenhouse.io/gitlab","https://jobs.ashbyhq.com/linear","https://jobs.lever.co/palantir"]}
Filtering
| Input | Effect |
|---|---|
titleKeywords | Keep postings whose title contains any of these |
locationKeywords | Keep postings matching any location, primary or additional |
employmentTypes | Keep only these normalized employment types |
remoteOnly | Keep only postings confirmed remote |
maxItemsPerCompany | Stop one large employer using up the whole run |
maxItems | Hard cap on rows returned and on what you are charged |
includeDescriptions | Off makes Greenhouse boards about 20x smaller and faster, but nulls department, team and salary on Greenhouse |
ats | Assume one ATS for bare slugs instead of auto-detecting |
Two filters are worth understanding before you rely on them. remoteOnly excludes Greenhouse
postings whose location text does not say remote, because Greenhouse publishes no remote flag
at all. And employmentTypes excludes postings with no published employment type, which most
Greenhouse postings do not have.
Output
One row per posting:
{"jobId": "8503792002","atsPlatform": "greenhouse","companySlug": "gitlab","companyName": "GitLab","title": "Account Executive - Italy","department": "EMEA - Commercial","location": "Remote, Italy","allLocations": ["Remote, Italy"],"isRemote": true,"employmentType": null,"salaryMin": 139200,"salaryMax": 235200,"salaryCurrency": "USD","salaryPeriod": "yearly","salaryRaw": "$139,200-$235,200","salarySource": "description","postedAt": "2026-04-17T05:58:03-04:00","updatedAt": "2026-08-03T16:43:10-04:00","jobUrl": "https://job-boards.greenhouse.io/gitlab/jobs/8503792002","applyUrl": "https://job-boards.greenhouse.io/gitlab/jobs/8503792002","descriptionText": "GitLab is the intelligent orchestration platform for DevSecOps...","requisitionId": "6263","boardUrl": "https://job-boards.greenhouse.io/gitlab","scrapedAt": "2026-08-09T02:14:55.101Z"}
A run that finds nothing still returns one row, with noResults: true and a message
explaining whether the slug was wrong, the board is genuinely empty, or your filters removed
everything. An empty dataset never tells you which.
Some fields are null on some vendors because the vendor does not publish them, not because
the scrape missed them. companyName, updatedAt and requisitionId are Greenhouse-only.
workplaceType, employmentType and countryCode are not published by Greenhouse. The
dataset schema documents this per field.
Use cases
- Job boards and aggregators. Pull live roles from hundreds of company boards on a schedule. First-party data means no expired postings.
- Recruiting and sourcing research. See who is hiring for what, in which locations, at what pay.
- Sales and lead generation. Open roles are a hiring-intent signal. A company posting six RevOps roles is buying tooling.
- Labour market analysis. Normalized departments, locations and salary bands across employers make the data comparable.
- AI agents and RAG.
descriptionTextis clean plain text with paragraph breaks, so it chunks and embeds without an HTML-stripping step.
API and Python usage
Run it from the Apify API, or from Python with the Apify client:
from apify_client import ApifyClientclient = ApifyClient("YOUR_APIFY_TOKEN")run = client.actor("cirkit/ats-job-boards-scraper").call(run_input={"companies": ["gitlab", "ashby:linear", "lever:palantir"],"titleKeywords": ["engineer"],"maxItems": 500,})for job in client.dataset(run["defaultDatasetId"]).iterate_items():print(job["title"], job["location"], job["jobUrl"])
Notes on scale and cost
Greenhouse and Ashby return an entire board in a single response, so one company is one
request. Lever pages at 100 postings per request. Boards vary enormously: a small startup has
20 open roles, a large employer can have 800 or more, and the biggest Ashby boards return a
single 12 MB response. Set maxItems to match what you actually want.
Turning includeDescriptions off cuts the data transferred on Greenhouse boards by roughly
95% and does not change your per-job price. There is one tradeoff, and it applies to
Greenhouse only: that API serves department, offices and the pay range in the same payload
as the description, so switching descriptions off leaves department, team and the salary
fields null on Greenhouse rows. Measured on a 300-job Databricks run: department and salary
coverage go from 100% to 0%. Ashby and Lever return descriptions separately and lose nothing.
The actor logs a warning when you use this combination so it is visible in the run log.
No proxy is required. These are public APIs with no anti-bot protection and no rate limiting that a normal run would hit. The proxy input exists for people scraping many hundreds of boards who want the requests spread across addresses.