# ATS Jobs Scraper - Greenhouse, Lever & Ashby in One Schema (`kasimba/ats-jobs-scraper`) Actor

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.

- **URL**: https://apify.com/kasimba/ats-jobs-scraper.md
- **Developed by:** [John J. K. Omolo](https://apify.com/kasimba) (community)
- **Categories:** Jobs
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.05 / 1,000 job scrapeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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 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`**, not `title`, and nests location, department, team and commitment under `categories`.
- **Greenhouse returns HTML-*encoded* descriptions** (`&lt;p&gt;`) 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: 1754006400000` becomes `"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 nested `compensationTiers`, SmartRecruiters three levels deep inside `jobAd.sections`, and not at all in Greenhouse's API. One set of `salaryMin` / `salaryMax` / `salaryCurrency` fields 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: true` to 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.** `jobAd` only exists on their detail endpoint, confirmed against the OpenAPI spec — set `fetchSmartRecruitersDescriptions: true` to fetch it via a second request per job.
- **`department` will 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 in a structured field.** Greenhouse's API doesn't expose compensation at all, and at least one employer is confirmed to disclose salary as prose in the description instead of in Lever's structured field — this Actor doesn't parse free text for numbers.

### For maintainers

Greenhouse, Lever and Ashby have live confirmation behind them — real bugs (a location field leaking a dict instead of a string, epoch-millisecond timestamps failing dataset validation) were found and fixed against real company data during this build. Workable, Recruitee and SmartRecruiters were built from published API documentation only, with no network path to those APIs from the build environment — field names for these three are the published or third-party-documented shape, not a captured live payload. Every field on every source still 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.

**The first live run of each new source is the real test.** If fields come back null, check `RUN_STATS.noJobsFound` first — it names which ATS were tried and what each returned.

# Actor input Schema

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

One per line. A bare slug (`stripe`) or a full board URL. For explicit control use objects: `{"slug": "stripe", "ats": "greenhouse"}`.

## `maxJobsPerCompany` (type: `integer`):

Caps what you are billed for per company. Default 500.

## `fetchSmartRecruitersDescriptions` (type: `boolean`):

SmartRecruiters only exposes job descriptions on a per-job detail endpoint, not the list endpoint this Actor normally uses - confirmed against their own API spec. Turning this on adds one extra request per SmartRecruiters job to fetch it. Off by default since it changes run cost for SmartRecruiters companies specifically; the other five sources already include descriptions with no extra requests.

## Actor input object example

```json
{
  "companies": [
    "stripe",
    "palantir",
    "openai"
  ],
  "maxJobsPerCompany": 500,
  "fetchSmartRecruitersDescriptions": false
}
```

# Actor output Schema

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

One record per open job: title, location, department, team, employment type, salary range where published, description in both HTML and plain text, and apply URL.

## `runStats` (type: `string`):

Per-company breakdown: which ATS resolved, how many jobs, and for companies with no results, which ATS were tried and what each said.

# 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": [
        "stripe",
        "palantir",
        "openai"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("kasimba/ats-jobs-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": [
        "stripe",
        "palantir",
        "openai",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("kasimba/ats-jobs-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": [
    "stripe",
    "palantir",
    "openai"
  ]
}' |
apify call kasimba/ats-jobs-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,kasimba/ats-jobs-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/OdGRK1sxfcjzTIdWn/builds/cwumInaVtNal4W3jD/openapi.json
