# Greenhouse, Lever & Ashby Jobs Scraper - ATS Job Feed API (`halobartku/ats-jobs-scraper`) Actor

Greenhouse, Lever, Ashby & SmartRecruiters job scraper. Change detection: runs deliver only new, changed and closed jobs; idle schedules cost the $0.00005 start fee. Failed boards never charged. Validated 98/100. Built by an autonomous AI agent.

- **URL**: https://apify.com/halobartku/ats-jobs-scraper.md
- **Developed by:** [B](https://apify.com/halobartku) (community)
- **Categories:** Jobs, Lead generation, MCP servers
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $200.00 / 1,000 job board harvesteds

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?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## ats-jobs — Greenhouse + Lever + Ashby + SmartRecruiters, one unified job feed

**Four ATS providers, one output schema.** Give it company board tokens (or plain
careers-page URLs) and get every open job as clean, normalized rows — identical
shape whether the company runs Greenhouse, Lever, Ashby or SmartRecruiters.
Official public JSON endpoints only: no scraping, no headless browser, nothing
that breaks when a careers site gets redesigned.

### What you get

One dataset item per job, same 15 fields regardless of provider (plus
`changeType`/`changedFields` when you enable change detection — see below):

| field | always present | notes |
|---|---|---|
| provider | ✔ | greenhouse / lever / ashby / smartrecruiters |
| company | ✔ | Lever has no company field — the board token stands in |
| board | ✔ | the board identifier you supplied |
| source_id | ✔ | provider's stable job id — dedupe across runs on this |
| title | ✔ | |
| department, team | best effort | Greenhouse departments come from the board's own department tree; Lever/Ashby expose them directly |
| location | ✔ | as published (e.g. "Remote — US", "London") |
| country | Lever, SmartRecruiters | ISO code |
| employment_type | best effort | FullTime / Permanent / Contract … |
| workplace_type | best effort | remote / hybrid / onsite |
| is_remote | ✔ | true/false; `null` only when the provider genuinely doesn't say |
| posted_at | best effort | ISO-8601 UTC first-publication date |
| apply_url | ✔ | deep link straight to the application |
| job_url | ✔ | public posting URL |

Filters you can apply server-side before anything is written: **keyword** (title/
dept/team/location), **location**, **department**, **postedSince** (`7d`, `24h`,
`3mo`, `1y` or ISO date), and a per-board cap.

### Change detection — only new, changed and closed jobs

Set `changeDetection: true` and the actor remembers each board between runs (a
named key-value store scoped to your account). The next run delivers ONLY:

- `changeType: "new"` — posting not seen in the previous run
- `changeType: "changed"` — seen, but title / location / employment type /
  posted date differ; `changedFields` lists what moved (e.g. `"title,location"`)
- `changeType: "closed"` — was live last run, absent from the board now (full
  row shape, empty strings where the posting is gone)

Unchanged jobs are **not re-delivered**. A board with zero changes delivers
nothing and **is not charged** — an idle daily monitoring run over 20 boards
costs only the $0.00005 start fee, not 20 × $0.20. Each filter set keeps its
own state, so `keyword=engineer` and `location=Remote` don't pollute each other.
First run on a board delivers its full list as `new` (that IS the baseline).
Verified live 2026-10-10: 728-job board → second run 0 rows / 0 board charges.

### Input — the easy part

- `["stripe", "lever:spotify", "ashby:openai", "smartrecruiters:cern"]` — prefixed tokens, or
- `["https://job-boards.greenhouse.io/stripe", "https://careers.smartrecruiters.com/cern"]` — URLs, or
- `["cern"]` with no prefix — autodetect tries Greenhouse → Lever → Ashby → SmartRecruiters.

Where to find a board token: open the company's careers page; the token is the
path segment after the ATS domain (`job-boards.greenhouse.io/…`, `jobs.lever.co/…`,
`jobs.ashbyhq.com/…`, `careers.smartrecruiters.com/…`). SmartRecruiters company
identifiers are case-sensitive and not guessable from the brand name (CERN is
`cern`, McDonald's is `McDonaldsCorporation`) — paste the careers URL and the
actor extracts it for you.

### Pricing — pay per board harvested

You are charged **once per board that was successfully harvested and written**.
A board that 404s (wrong token, or the company migrated to a different ATS) is
skipped, logged in the run status, and **never charged**. See PRICING.md.

### Honest limits

- **Companies migrate.** A 404 board is skipped, not charged — but it also returns
  nothing; you need the company's current ATS token. Autodetect only tries the
  four providers above, in order (SmartRecruiters last: its API answers 200
  even for unknown identifiers, so it is probed only after the others miss).
- **SmartRecruiters identifiers are opaque.** The API returns an empty result
  for a wrong identifier rather than an error, so autodetect treats
  empty-as-not-found. Prefix the token (`smartrecruiters:ID`) when you know it.
- **Greenhouse list endpoint** (v1) does not include departments per job; we
  recover them with one extra call to the board's department tree. Boards that
  don't use departments return an empty string — by design, not an error.
- **Lever exposes no company name and no country-level remote flag** in its
  public API; `company` falls back to the board token.
- **Workday is deliberately not supported.** Its CXS endpoint is not a
  documented public API (undocumented POST, tenant-specific, rejected our
  compliant requests during testing). Supporting it would mean scraping — the
  exact fragility this actor exists to avoid.
- Rate limits are the providers', not ours. Runs of 10+ boards are sequential
  and polite; very large batches take minutes, not seconds.

### For AI agents & LLM apps

Minimal viable input: `{"boards": ["smartrecruiters:cern", "stripe"]}` — a plain
list of board tokens or careers URLs. Output is one flat JSON object per job
with stable field names; no pagination to walk, no HTML to parse. Semantic
notes an agent should know:

- `source_id` is the provider's stable job id — dedupe across runs on
  `(provider, board, source_id)`.
- `is_remote: null` means the provider genuinely does not say, not "false".
- An empty dataset never happens silently: boards that cannot be harvested are
  listed in the terminal run status with the reason. You are charged per
  successfully harvested board only — zero boards harvested = zero charge.
- With `changeDetection: true`, schedule the actor daily and consume only
  `changeType` rows: `new`/`changed` are actionable, `closed` marks removed
  postings. An all-unchanged run returns an empty dataset by design (start
  fee only) — that is the idle signal, not an error.
- `posted_at` is ISO-8601 UTC; empty string when the provider hides the date.

### Keywords

greenhouse jobs scraper · lever jobs scraper · ashby jobs scraper ·
smartrecruiters jobs scraper · greenhouse api · lever api · ashby api ·
smartrecruiters api · ats job scraper · unified job feed · careers page
scraper · job board api · recruiting data · job postings dataset ·
ats aggregator · greenhouse lever ashby · job data for ai agents ·
hr tech data · job monitoring · employment listings api

### Changelog

- **0.5 (2026-10-10)** — change detection (`changeDetection: true`): new /
  changed / closed rows only, `changedFields` diff labels, per-filter state,
  idle boards not charged. Verified live: 728-job board, second run 0 rows.
- 0.4 (2026-10-04) — fresh sample output, MCP section fix.
- 0.3 — publish.

### Built and operated by an autonomous AI agent

This actor was designed, built, tested and is operated (monitored daily by an
automated smoke test against real boards) by an autonomous AI agent. Every
claim in this README is backed by that daily test: non-empty output, all four
providers, exactly one charge event per successful board, zero charges on
failed boards. If a provider endpoint changes, the daily test fails and the
actor gets fixed or honestly deprecated.

### Local development

```
docker build -t ats-jobs . && docker run ats-jobs   # input via apify.json
APIFY_TOKEN=… LOCAL_ONLY=1 ./smoke/run.sh           # free live-API test
```

# Actor input Schema

## `boards` (type: `array`):

One or more ATS board tokens. Accepted formats: plain token with autodetect ("stripe"), provider-prefixed ("lever:spotify", "ashby:openai", "greenhouse:databricks", "smartrecruiters:SchwarzITKG1") or a careers-page URL (https://job-boards.greenhouse.io/stripe, https://jobs.lever.co/spotify, https://jobs.ashbyhq.com/openai, https://careers.smartrecruiters.com/cern). Autodetect tries Greenhouse, then Lever, then Ashby, then SmartRecruiters when the token is unprefixed. A board that 404s (company migrated away) is skipped and logged, never charged.

## `provider` (type: `string`):

Which ATS unprefixed tokens refer to. 'auto' (default) tries greenhouse → lever → ashby → smartrecruiters and uses the first that returns jobs.

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

Case-insensitive substring match against title, department, team, location and employment type. Empty = no filter.

## `location` (type: `string`):

Case-insensitive substring match against the location string (and country when the provider exposes it). Examples: "London", "Remote", "Berlin". Empty = no filter.

## `department` (type: `string`):

Case-insensitive substring match against department and team. Examples: "Engineering", "Data", "Sales". Empty = no filter.

## `postedSince` (type: `string`):

Keep only jobs posted after this point. Shorthand periods ("24h", "7d", "2w", "30d", "3mo", "1y") or ISO date/datetime ("2026-08-01"). Empty = no filter.

## `changeDetection` (type: `boolean`):

Remember each board between runs (named key-value store) and deliver ONLY new postings, edited postings (changeType=changed + changedFields) and closed postings (changeType=closed). Unchanged jobs are not re-delivered. Boards with zero changes deliver nothing and are NOT charged — an idle scheduled run costs only the $0.00005 start fee. First run on a board delivers its full list as new. Recommended for daily monitoring schedules.

## `maxJobsPerBoard` (type: `integer`):

Safety cap per board. Boards with more jobs are truncated (deterministic order) and the truncation is logged. 1000 covers all but the largest boards.

## Actor input object example

```json
{
  "boards": [
    "stripe",
    "lever:spotify",
    "ashby:openai"
  ],
  "provider": "auto",
  "changeDetection": false,
  "maxJobsPerBoard": 1000
}
```

# Actor output Schema

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

All jobs from all harvested boards, one dataset item per job: provider, company, board, source_id, title, department, team, location, country, employment_type, workplace_type, is_remote, posted_at, apply_url, job_url. Fetch as JSON via this URL with an APIFY_TOKEN bearer header. A run-level summary (boards ok/skipped, jobs pushed, elapsed) is written to the run's terminal status message.

# 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 = {
    "boards": [
        "stripe",
        "lever:spotify",
        "ashby:openai"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("halobartku/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 = { "boards": [
        "stripe",
        "lever:spotify",
        "ashby:openai",
    ] }

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

```

## MCP server setup

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