# Greenhouse & Lever Jobs API — ATS Job Aggregator (`farside/ats-job-aggregator`) Actor

Job scraper API for Greenhouse & Lever ATS: structured JSON, deduplicated results, parsed salary ranges, first\_published dates. No LinkedIn, no bans. Pay-per-event: $0.002 per result.

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

## Pricing

from $2.00 / 1,000 job posting results

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/actors/running/actors-in-store.md#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 Aggregator (Greenhouse & Lever)

**ATS Job Aggregator** pulls **structured job postings** straight from company Applicant Tracking Systems — **Greenhouse** and **Lever** — through their **public JSON APIs**. No LinkedIn, no scraping of rendered pages, no account bans, no duplicate jobs. Every record comes out as clean JSON with the fields LinkedIn-based scrapers usually can't give you: **first\_published** timestamps, **parsed salary ranges**, and the **company → ATS mapping**.

### Why use ATS Job Aggregator?

LinkedIn scrapers have three chronic problems: jobs duplicated across scrapes, accounts getting banned, and missing salary data. Because this Actor reads the companies' own ATS APIs, none of those apply — you get the **same data the company publishes**, in **structured JSON**, with **no anti-bot risk**. The APIs return the job's true posting date and (where published) its salary range, which is exactly what you need to detect *new* jobs and compare pay across companies.

#### Who is this for?

- **Recruiting agencies / headhunters** — batch-monitor all your client companies' open roles. Point the Actor at a list of Greenhouse board tokens and Lever slugs, run it on a schedule, and diff `first_published` between runs to see what's new this week.
- **Job seekers with a target company list** — keep a personal feed of every opening at your 20 dream companies, with salary ranges and apply links, without browsing 20 career pages daily.
- **Market researchers / analysts** — count open roles per company, per department, per location; track hiring velocity over time; compare salary bands across the industry (USD/EUR/SGD/MYR/etc. parsed automatically).

#### Why this beats the LinkedIn-based scrapers

| | LinkedIn scrapers | This Actor |
|---|---|---|
| Data source | Rendered LinkedIn pages | Company ATS public APIs |
| Duplicate jobs within a run | Common | Deduplicated via stable `dedupe_key` |
| Account bans | Frequent | No accounts involved — nothing to ban |
| `first_published` | Not reliably available | Direct from Greenhouse / Lever |
| Structured salary | Missing or free-text | `salary_min` / `salary_max` / `salary_currency` / `salary_period` parsed |
| Run on schedule via Apify | Yes | Yes, with API access and integrations |

### How it works

The Actor takes two input lists and fetches everything:

1. **Greenhouse** — `GET https://boards-api.greenhouse.io/v1/boards/{token}/jobs` lists every open job; the Actor then fetches each job's detail endpoint (skippable) to capture `first_published`, department, and the compensation block.
2. **Lever** — `GET https://api.lever.co/v0/postings/{slug}?mode=json` returns the full posting list including `createdAt` and salary text, in a single request.

Salary text is parsed with a currency-aware regex engine — `"$60,000 - $97,000/year"`, `"€2,400 - €5,600/month"`, `"RM 3,000 - RM 5,000"`, `"110,000 - 200,000/year SGD"` all become structured `salary_min` / `salary_max` / `salary_currency` plus a `salary_period` (`year` / `month` / `hour` / `week`) so hourly rates never masquerade as annual pay. When a range can't be parsed, the raw text is kept in `salary_text` and the numbers are set to `null` — nothing is guessed.

Failures are isolated per company (60s timeout, 2 retries). One dead token never kills the run: the failure is reported in a clearly-flagged record and in the run log.

### How much does it cost?

Pay-per-event pricing: **$0.002 per job result** (the `result-item` event). No per-run or startup fees unless you enable additional synthetic events in Apify Console.

- 1,000 job records ≈ **$2**
- A 100-company monitor producing 5,000 records ≈ **$10**

Compare: Amazon product data on Apify ≈ $3/1K, Google Maps ≈ $1.5–4/1K. You're paying for structured, deduplicated, salary-enriched data — not proxy traffic.

### How to run it

1. **Collect your company list.** Greenhouse: open the company's jobs board at `boards.greenhouse.io/{token}` — the `{token}` in the URL is what you paste (e.g. `airtable`, `anthropic`). Lever: open `jobs.lever.co/{slug}` — the `{slug}` is what you paste (e.g. `palantir`).
2. Fill in the input (see below) — either or both lists, empty ones are fine.
3. Hit **Start**. Data lands in the dataset — download as JSON, CSV, or Excel, or pull it through the API.

### Input example

```json
{
    "greenhouse_tokens": ["airtable", "anthropic", "datadog"],
    "lever_slugs": ["palantir", "canva"],
    "enrich_details": true,
    "max_results": 0
}
```

- `greenhouse_tokens` — Greenhouse board tokens (optional, default `[]`)
- `lever_slugs` — Lever company slugs (optional, default `[]`)
- `enrich_details` — `true` (default) fetches every Greenhouse job's detail endpoint for `first_published`, department and salary; `false` skips detail requests and only returns list-level fields (faster, but those three fields stay `null` for Greenhouse jobs). Lever data is always complete — its single request includes everything.
- `max_results` — stop after N job records across all companies; `0` = unlimited

### Output example

```json
{
    "company": "Airtable",
    "source_ats": "greenhouse",
    "job_id": "8403127002",
    "title": "Account Executive, Strategic Accounts",
    "location": "Remote - US",
    "department": "Sales",
    "salary_min": 272000,
    "salary_max": 354000,
    "salary_currency": "USD",
    "salary_text": "$272,000  —  $354,000",
    "salary_period": "year",
    "first_published": "2026-02-28T14:04:24.000Z",
    "last_updated": "2026-03-27T15:33:08-04:00",
    "apply_url": "https://job-boards.greenhouse.io/airtable/jobs/8403127002",
    "scraped_at": "2026-08-31T03:40:00.000Z",
    "dedupe_key": "5c23f0aeefa61d9a",
    "errors": []
}
```

#### Fields

| Field | Type | Description |
|---|---|---|
| `company` | string | Company name (Greenhouse) or slug (Lever). Falls back to the board token for Greenhouse when details are not fetched |
| `source_ats` | string | `greenhouse` or `lever` |
| `job_id` | string | ATS-internal job ID |
| `title` | string | Job title |
| `location` | string | Location as published |
| `department` | string | Department / team |
| `salary_min` / `salary_max` | number | Parsed range bounds (`null` when not published or unparseable) |
| `salary_currency` | string | ISO 4217 code (`USD`, `EUR`, `MYR`, `SGD`, ...) |
| `salary_text` | string | Raw salary fragment as published |
| `salary_period` | string | `year` / `month` / `hour` / `week` — compare only values sharing the same period |
| `first_published` | string | First posting date (Greenhouse `first_published`, Lever `createdAt`), normalized to UTC; `null` for Greenhouse when `enrich_details: false` |
| `last_updated` | string | Last modification in the ATS (Greenhouse only; always `null` for Lever) |
| `apply_url` | string | Direct posting/application URL |
| `scraped_at` | string | When this record was scraped (UTC) |
| `dedupe_key` | string | sha256 of `company identifier + normalized title + normalized location + job_id` (first 16 hex chars) — stable across runs, so you can diff two scrapes without re-fetching everything |
| `errors` | array | Always `[]` on normal records; a failed company produces one flagged record with the error message here |

All fields are nullable. You can download the dataset in JSON, HTML, CSV, or Excel format from the Storage tab.

### Tips

- **Diff two runs for new jobs**: `dedupe_key` is deterministic — join runs on it and flag records whose `first_published` is newer than your last scrape. (Each run pushes the full current posting list again; the stable key is what makes the diff cheap.)
- **Keep costs flat**: with `enrich_details: false`, Greenhouse runs make 1 request per company instead of 1 per job — use it when you only need the posting list.
- **Mix both ATSs freely**: companies don't all use the same ATS; combine `greenhouse_tokens` + `lever_slugs` in one run for a single unified dataset.
- **Schedule it**: use Apify's Scheduler with weekly runs and email/API delivery for a zero-touch monitoring pipeline.

### FAQ & support

**Will my IP get banned?** No. The Actor only calls public, undocumented-but-stable JSON endpoints of the ATS providers themselves — the same endpoints the companies' own career pages use. There is no account and no anti-bot wall to trip.

**Why is `salary_min` null sometimes?** Many companies simply don't publish pay ranges. If the range exists but is in an exotic format, the raw text stays available in `salary_text`.

**Do I need proxies or login credentials?** No. No proxies, no cookies, no credentials.

Our Actors only extract what companies have chosen to publish publicly. The data may include personal data (e.g. names inside job descriptions), which is protected by the GDPR in the EU and similar laws worldwide. Only scrape for legitimate purposes and consult your lawyers if unsure. Use the Issues tab for feedback and the API tab for programmatic access.

### Local development

```bash
pip install -r requirements.txt
## put your input in storage/key_value_stores/default/INPUT.json, then:
apify run
## unit tests:
python -m unittest discover -s tests
```

# Actor input Schema

## `greenhouse_tokens` (type: `array`):

List of Greenhouse board tokens (the {token} part of https://boards-api.greenhouse.io/v1/boards/{token}/jobs). Find them by opening a company's jobs page at boards.greenhouse.io/{token}. Leave empty if you don't need Greenhouse.

## `lever_slugs` (type: `array`):

List of Lever company slugs (the {slug} part of https://api.lever.co/v0/postings/{slug} or https://jobs.lever.co/{slug}). Leave empty if you don't need Lever.

## `enrich_details` (type: `boolean`):

When true (default), fetch the Greenhouse detail endpoint for every job to get first\_published, department, and salary range. When false, only list-level fields are returned (faster, fewer requests, but first\_published/salary stay empty for Greenhouse jobs). Lever data is always complete from its single request.

## `max_results` (type: `integer`):

Stop after pushing this many job records (across all companies). 0 = no limit.

## Actor input object example

```json
{
  "greenhouse_tokens": [
    "airtable",
    "anthropic"
  ],
  "lever_slugs": [
    "palantir"
  ],
  "enrich_details": true,
  "max_results": 0
}
```

# Actor output Schema

## `dataset` (type: `string`):

Dataset containing one record per job posting (plus one record per failed company, flagged via the errors field)

# 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 = {
    "greenhouse_tokens": [
        "airtable",
        "anthropic"
    ],
    "lever_slugs": [
        "palantir"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("farside/ats-job-aggregator").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 = {
    "greenhouse_tokens": [
        "airtable",
        "anthropic",
    ],
    "lever_slugs": ["palantir"],
}

# Run the Actor and wait for it to finish
run = client.actor("farside/ats-job-aggregator").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 '{
  "greenhouse_tokens": [
    "airtable",
    "anthropic"
  ],
  "lever_slugs": [
    "palantir"
  ]
}' |
apify call farside/ats-job-aggregator --silent --output-dataset

```

## MCP server setup

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

```

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/IHH6i3ym6r2VusdTg/builds/lcKeyNMZpQmN0rScy/openapi.json
