# ATS Job Boards Scraper - Greenhouse, Ashby, Lever (`cirkit/ats-job-boards-scraper`) Actor

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.

- **URL**: https://apify.com/cirkit/ats-job-boards-scraper.md
- **Developed by:** [Crikit](https://apify.com/cirkit) (community)
- **Categories:** Jobs
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.70 / 1,000 job returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## ATS Job Boards Scraper - Greenhouse, Ashby, Lever

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
`&lt;p&gt;We are hiring&lt;/p&gt;` 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.

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```python
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.

# Actor input Schema

## `companies` (type: `array`):

One company per line, using the slug from its careers URL. For job-boards.greenhouse.io/gitlab the slug is gitlab. The ATS is detected automatically, so a bare slug is enough. Prefix it to skip detection and save a request: greenhouse:gitlab, ashby:linear, lever:palantir.

## `startUrls` (type: `array`):

Paste full job board URLs instead of slugs, for example https://job-boards.greenhouse.io/gitlab, https://jobs.ashbyhq.com/linear or https://jobs.lever.co/palantir. The ATS is read from the hostname, so these never need a detection request.

## `ats` (type: `string`):

Which ATS to assume for slugs that are not prefixed. Leave on Auto-detect to try Greenhouse, then Ashby, then Lever. Choosing one saves up to two failed requests per company when you already know where the board lives.

## `titleKeywords` (type: `array`):

Keep only postings whose title contains one of these, case-insensitive. Leave empty to return every open role. For example engineer, designer.

## `locationKeywords` (type: `array`):

Keep only postings whose location contains one of these, case-insensitive. Matched against the primary location and every additional location. For example London, Remote, Germany.

## `employmentTypes` (type: `array`):

Keep only these employment types. Postings that do not publish an employment type are excluded when this filter is set, so leave it empty unless you need it. Greenhouse rarely publishes this field.

## `remoteOnly` (type: `boolean`):

Return only postings confirmed remote. Ashby and Lever publish a remote flag directly. Greenhouse publishes none, so its postings qualify only when the location text itself says remote, which means this filter can hide remote Greenhouse roles that do not say so.

## `includeDescriptions` (type: `boolean`):

On by default. Returns the complete posting body as both HTML and plain text. Turning it off makes Greenhouse boards about 20 times smaller and faster to fetch, and does not change what you are charged. Important tradeoff on Greenhouse only: that API returns department, team and pay range in the same payload as the description, so turning this off leaves those three fields null on Greenhouse rows. Ashby and Lever are unaffected and keep every field.

## `maxItemsPerCompany` (type: `integer`):

Cap on postings taken from any single board, applied before filters. Useful when one large employer would otherwise use up the whole run. Leave empty for no per-company cap.

## `maxItems` (type: `integer`):

Hard cap on postings returned across every board, and the cap on what you are charged. A single large employer can publish 800 or more open roles, so raise this when scraping big boards.

## `proxyConfiguration` (type: `object`):

Optional. These job board APIs are public and do not block datacenter IPs, so no proxy is needed. Enable one only if you are scraping hundreds of boards and want the requests spread across addresses.

## Actor input object example

```json
{
  "companies": [
    "gitlab",
    "ashby:linear",
    "lever:palantir"
  ],
  "startUrls": [],
  "ats": "auto",
  "titleKeywords": [],
  "locationKeywords": [],
  "employmentTypes": [],
  "remoteOnly": false,
  "includeDescriptions": true,
  "maxItems": 200,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `jobs` (type: `string`):

Every job posting collected in this run, as JSON.

## `jobsCsv` (type: `string`):

The same job postings as a CSV download.

## `jobsExcel` (type: `string`):

The same job postings as an Excel workbook.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "companies": [
        "gitlab",
        "ashby:linear",
        "lever:palantir"
    ],
    "ats": "auto",
    "remoteOnly": false,
    "includeDescriptions": true,
    "maxItems": 200,
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("cirkit/ats-job-boards-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "companies": [
        "gitlab",
        "ashby:linear",
        "lever:palantir",
    ],
    "ats": "auto",
    "remoteOnly": False,
    "includeDescriptions": True,
    "maxItems": 200,
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("cirkit/ats-job-boards-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "companies": [
    "gitlab",
    "ashby:linear",
    "lever:palantir"
  ],
  "ats": "auto",
  "remoteOnly": false,
  "includeDescriptions": true,
  "maxItems": 200,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call cirkit/ats-job-boards-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,cirkit/ats-job-boards-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/wSUGf9qzeI31jnUFc/builds/6ycXyqaExEWCEFAU0/openapi.json
