# ATS Job Scraper — Greenhouse, Lever & Ashby Jobs API (`flash_scraper/ats-job-scraper`) Actor

ATS job scraper for Greenhouse, Lever & Ashby: turn company names into live job openings straight from each company's own board via the public keyless APIs - title, location, remote flag, department, salary (annualized), posting date, canonical URL. No API key, no proxy, no browser.

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

## Pricing

from $1.00 / 1,000 job 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/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 Scraper — Greenhouse, Lever & Ashby Jobs API

Turn company names into their live job openings. Give the actor a list of companies ("stripe", "openai", "figma"); for each one it finds the company's public job board on **Greenhouse**, **Lever** or **Ashby** — the three ATSes behind most tech-company careers pages — and returns every live posting as a clean, uniform row.

It reads the ATSes' own public job-board APIs directly. **No API key, no proxy, no headless browser** — just the same JSON the company's careers page loads. That makes it fast (a whole 500-job board is one HTTP call), reliable, and safe to run on a schedule.

**What you get per job:** company, title, location, remote flag, employment type, department/team, **salary range** (min/max/currency, annualized), canonical apply URL, posting date, a ~300-character description snippet — and the **full description** when you switch `includeDescription` on.

**Built-in monitoring:** turn on `onlyNewJobs` and schedule the actor — the first run is the baseline, every later run delivers (and bills) **only postings it has never delivered before**. A run where nothing changed delivers 0 rows and charges $0. No downstream diffing needed.

**Good for:**

- **Job alerts & company watchlists** — `onlyNewJobs` + a daily schedule = only the new openings, ever (see the walkthrough below).
- **Recruiting & sourcing** — see exactly who is hiring for what, straight from the source.
- **Sales signals** — a company hiring 12 SDRs or opening a Berlin office is telling you something.
- **Job boards & aggregators** — fresh first-party postings with canonical apply links.

### Example output

A real row from a live run (bare default input, 2026-08-15):

```json
{
  "company": "datadog",
  "title": "AI Research Scientist - Datadog AI Research (DAIR)",
  "location": "New York, New York, USA",
  "remote": null,
  "employment_type": "full-time",
  "department": "Dev Eng",
  "salary_min": 320000,
  "salary_max": 400000,
  "salary_currency": "USD",
  "salary_interval": "yearly",
  "url": "https://careers.datadoghq.com/detail/6572669/?gh_jid=6572669",
  "source_ats": "greenhouse",
  "posted_at": "2026-08-12",
  "description_snippet": "As a Research Scientist on our team, you will partner with Research Engineers, working on fundamental research problems and collaborating…"
}
```

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `atsCompanies` | array of strings | `["stripe", "openai", "cloudflare", "datadog", "figma"]` | Company names or board slugs. Each is checked on Greenhouse, then Lever, then Ashby — the first board with live jobs wins. Your own list **replaces** the sample entirely. |
| `keyword` | string | `""` (off) | Only keep jobs whose **title** contains this text (case-insensitive), e.g. `engineer`. |
| `locationContains` | string | `""` (off) | Only keep jobs whose location contains this text (case-insensitive), e.g. `remote`, `London`. |
| `onlyRemote` | boolean | `false` | Only keep jobs the board itself marks remote. No guessing: a job without remote evidence is dropped, not claimed. |
| `onlyNewJobs` | boolean | `false` | Deliver (and bill) only postings not delivered by earlier runs of this same watch. First run = baseline. A no-news run delivers 0 rows and charges $0. |
| `includeDescription` | boolean | `false` | Add a `description` column with the complete HTML-stripped text. Off by default to keep rows light; the snippet is always included. |
| `maxItems` | integer | `100` (max 1000) | Total cap **across all companies**, split round-robin so one giant board cannot crowd out the others. |

Filters run **before billing** — a filtered job is never charged. Running with completely empty input works out of the box and returns live jobs from the sample companies.

**Finding a slug:** it is the last path segment of the careers page URL — `job-boards.greenhouse.io/<slug>`, `jobs.lever.co/<slug>`, or `jobs.ashbyhq.com/<slug>`. A plain lowercase company name usually just works ("Stripe Inc." is tried as `stripeinc`). The run's status message names every company that resolved (and on which ATS) and every company that was not found, so a bad slug is never silent.

### Output columns

| Column | Notes |
|---|---|
| `company` | The name you supplied. |
| `title` | Job title as posted. |
| `location` | As the board publishes it (city, "Remote", multi-office strings…). |
| `remote` | `true` when the board explicitly marks it remote; `null` when there is no evidence either way — never guessed. |
| `employment_type` | Normalized: `full-time`, `part-time`, `contract`, `internship`, `temporary`. From Ashby's `employmentType`, Lever's commitment, Greenhouse "Time Type" metadata. Measured fill: **53%** of the 2,216 live jobs across the five sample companies (2026-08-15). |
| `department` | Department/team when the board provides one. |
| `salary_min` / `salary_max` | Published pay range. Hourly/weekly/monthly figures are **annualized to yearly** (40h×52wk arithmetic) with a magnitude sanity guard — a suspicious conversion ships the raw numbers instead. Measured fill: **47%** of the 2,216 live jobs across the five sample companies (2026-08-15); varies hard by company — Ashby boards with published compensation hit 85%, companies that publish no pay (e.g. Stripe) are ~0%. |
| `salary_currency` | ISO code (`USD`, `EUR`, `GBP`…) when the board states one. |
| `salary_interval` | `yearly` after annualization; the original interval when a conversion would not be safe. |
| `url` | Canonical posting URL on the company's own board (deduplicated on this). |
| `source_ats` | `greenhouse`, `lever` or `ashby`. |
| `posted_at` | ISO date when the board provides one (Greenhouse exposes last-updated, Lever/Ashby creation/publish dates). |
| `description_snippet` | First ~300 characters of the description, HTML stripped. Always present. |
| `description` | The complete HTML-stripped description — only when `includeDescription: true`. 100% filled on every board tested. |

**Where the salary numbers come from** (structured sources first, text parsing only as fallback): Greenhouse pay-transparency metadata (`currency_range`) then a salary-context window of the description; Lever's structured `salaryRange` then `salaryDescription`; Ashby's `compensationTierSummary`. Equity percentages, funding amounts and 401(k) mentions are explicitly excluded by the parser.

### Use it from your code (API)

**JavaScript:**

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

const client = new ApifyClient({ token: 'YOUR_APIFY_TOKEN' });

const run = await client.actor('flash_scraper/ats-job-scraper').call({
    atsCompanies: ['anthropic', 'vercel', 'ramp'],
    keyword: 'engineer',
    onlyRemote: true,
    maxItems: 200,
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items);
```

**Python:**

```python
from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")

run = client.actor("flash_scraper/ats-job-scraper").call(run_input={
    "atsCompanies": ["anthropic", "vercel", "ramp"],
    "keyword": "engineer",
    "onlyRemote": True,
    "maxItems": 200,
})

for job in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(job["company"], "-", job["title"], "-", job["url"])
```

**curl:**

```bash
curl -X POST "https://api.apify.com/v2/acts/flash_scraper~ats-job-scraper/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"atsCompanies": ["anthropic", "vercel"], "keyword": "engineer", "maxItems": 100}'
```

### Turn it into a job alert (onlyNewJobs + a schedule)

`onlyNewJobs: true` gives the actor a memory. It remembers every posting it has delivered for a given watch (same companies + same filters) in a small key-value store named `ats-job-monitor` **in your own Apify account**, and on the next run drops those rows **before billing** — you pay only for postings you have never seen.

**Setup, once:**

1. Open the actor → **Input** → set your companies and filters, switch **Only new jobs since the last run** on. Save the input.
2. Go to **Schedules** (Apify Console → Schedules → Create new) → pick a cron like `0 7 * * *` (every morning at 07:00) → add this actor as the scheduled task with that input.
3. Run it once manually. The first run is the **baseline**: it delivers everything currently live and its status message says so.

**From then on:**

- Each scheduled run delivers **only postings that were not delivered before**. New rows land in that run's dataset — pipe them anywhere (step 4 below).
- A morning where nothing changed ends **green** with the status message "Nothing new since your last run (…): all N live job(s) matching this watch were already delivered… You were not charged." Zero rows, **zero charge** — an empty alert costs nothing.
- Memory is per-watch: change the company list or filters and a new baseline starts (the old one is kept). Entries expire after 90 days, so a repost after a long gap counts as new.

**4. Get pinged** — any of these work on top of the schedule:

- **n8n**: community **Apify node** (or HTTP Request) → "Run actor and get dataset items" with your input → an IF node "items > 0" → Slack/email node. With `onlyNewJobs` on, no diff step is needed — every item IS news.
- **Make (Integromat)**: **Apify → Run an Actor** → **Get Dataset Items** → filter "bundle count > 0" → email/Sheets/Notion.
- **Zapier**: Schedule trigger → **Apify → Run Actor** → second Apify step fetches dataset items → Slack/Gmail.
- **Apify only**: Schedules + the actor's "Run succeeded" notification, and read the status message — it says how many new jobs arrived.

### Honest limits

- **Coverage is exactly Greenhouse + Lever + Ashby.** A company on Workday, SmartRecruiters, iCIMS, Recruitee, or a home-grown careers page will come back "not found" — that is a coverage boundary, not an error. The status message names every such company.
- **Slug guessing is simple.** Names are lowercased and stripped to letters/digits. Most companies match; some use a slug that differs from their brand name (check their careers URL and pass the slug directly).
- **`remote` is evidence-based.** Only an explicit remote flag or "remote" in the location sets it `true`; everything else is `null`. `onlyRemote` therefore *undercounts* rather than lies.
- **`posted_at` semantics differ per ATS.** Greenhouse exposes *last updated*, Lever *created at*, Ashby *published at*. It can be missing.
- **Salary coverage depends on the employer, not the actor.** Measured 2026-08-15 across the five sample companies (2,216 live jobs): 47% carry a parsed range overall — 85% on a compensation-publishing Ashby board, ~0% at companies that publish no pay anywhere. The columns are `null` when the company publishes nothing; numbers are never invented.
- **`onlyNewJobs` memory starts at your first run.** The baseline run delivers (and bills) everything currently live; "new" is defined against what *your* runs have delivered, not against a global database. Memory entries expire after 90 days.
- **Big boards are capped fairly.** `maxItems` is a global cap split round-robin; raise it (up to 1000) to pull an entire large board.

> Need LinkedIn, Indeed and Glassdoor too - merged and deduplicated across 12 boards with only-new-jobs monitoring? -> [Multi Job Board Scraper](https://apify.com/flash_scraper/multi-jobboard-scraper)

Related: [LinkedIn Jobs Scraper](https://apify.com/flash_scraper/linkedin-jobs-scraper) — search LinkedIn postings by keyword and location.

# Actor input Schema

## `atsCompanies` (type: `array`):

Company names or ATS board slugs, one per line, e.g. 'stripe', 'openai', 'figma'. Each company is checked on Greenhouse, then Lever, then Ashby — the first board with live jobs wins. Supplying your own list replaces the sample entirely.

## `keyword` (type: `string`):

Only keep jobs whose title contains this text (case-insensitive), e.g. 'engineer', 'designer', 'sales'. Leave empty for every opening.

## `locationContains` (type: `string`):

Only keep jobs whose location contains this text (case-insensitive), e.g. 'remote', 'New York', 'London'. Leave empty for every location.

## `onlyRemote` (type: `boolean`):

Only keep jobs the board itself marks as remote (explicit remote flag or 'remote' in the location). Jobs with no remote evidence are dropped, never guessed.

## `onlyNewJobs` (type: `boolean`):

The first run is the baseline (delivers everything and says so). Every later run with the same companies + filters delivers ONLY postings not delivered before. A run where nothing is new delivers 0 rows, bills nothing, and says so plainly. Memory is kept 90 days in a small key-value store named 'ats-job-monitor' in your account.

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

Maximum number of jobs to return ACROSS all companies. The cap is split round-robin, so one giant company cannot crowd out the others.

## `includeDescription` (type: `boolean`):

Add a 'description' column with the complete HTML-stripped job description. Off by default to keep rows light — the ~300-character description\_snippet is always included either way.

## Actor input object example

```json
{
  "atsCompanies": [
    "stripe",
    "openai",
    "cloudflare",
    "datadog",
    "figma"
  ],
  "keyword": "",
  "locationContains": "",
  "onlyRemote": false,
  "onlyNewJobs": false,
  "maxItems": 50,
  "includeDescription": false
}
```

# Actor output Schema

## `results` (type: `string`):

No description

# 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 = {
    "atsCompanies": [
        "stripe",
        "openai",
        "cloudflare",
        "datadog",
        "figma"
    ],
    "onlyNewJobs": false,
    "maxItems": 50
};

// Run the Actor and wait for it to finish
const run = await client.actor("flash_scraper/ats-job-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 = {
    "atsCompanies": [
        "stripe",
        "openai",
        "cloudflare",
        "datadog",
        "figma",
    ],
    "onlyNewJobs": False,
    "maxItems": 50,
}

# Run the Actor and wait for it to finish
run = client.actor("flash_scraper/ats-job-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 '{
  "atsCompanies": [
    "stripe",
    "openai",
    "cloudflare",
    "datadog",
    "figma"
  ],
  "onlyNewJobs": false,
  "maxItems": 50
}' |
apify call flash_scraper/ats-job-scraper --silent --output-dataset

```

## MCP server setup

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