ATS Job Boards Scraper - Greenhouse, Ashby, Lever avatar

ATS Job Boards Scraper - Greenhouse, Ashby, Lever

Pricing

from $0.70 / 1,000 job returneds

Go to Apify Store
ATS Job Boards Scraper - Greenhouse, Ashby, Lever

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.

Pricing

from $0.70 / 1,000 job returneds

Rating

0.0

(0)

Developer

Crikit

Crikit

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

4 days ago

Last modified

Categories

Share

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:

FieldWhat it holds
title, jobId, jobUrl, applyUrlThe posting and where to apply
companySlug, companyName, boardUrlWhich employer, and the board it came from
atsPlatformgreenhouse, ashby or lever
department, teamOrg placement, with a Lever-aware fallback
location, allLocations, countryCodePrimary and every additional location
isRemote, workplaceTypeRemote, hybrid or onsite, normalized across vendors
employmentTypefull-time, part-time, contract, temporary, intern, volunteer
salaryMin, salaryMax, salaryCurrency, salaryPeriod, salaryRaw, salarySourcePay band, plus how it was obtained
postedAt, updatedAtISO-8601, always, on every vendor
descriptionHtml, descriptionTextThe full posting body, as HTML and as clean plain text
requisitionIdThe 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

InputEffect
titleKeywordsKeep postings whose title contains any of these
locationKeywordsKeep postings matching any location, primary or additional
employmentTypesKeep only these normalized employment types
remoteOnlyKeep only postings confirmed remote
maxItemsPerCompanyStop one large employer using up the whole run
maxItemsHard cap on rows returned and on what you are charged
includeDescriptionsOff makes Greenhouse boards about 20x smaller and faster, but nulls department, team and salary on Greenhouse
atsAssume 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. descriptionText is 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 ApifyClient
client = 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.