# ATS Jobs Scraper - Greenhouse, Lever, Ashby & Change Tracking (`cedarfetch/ats-jobs-scraper`) Actor

Open job postings from company applicant-tracking systems (Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee, Personio) via their official public job-board APIs. One clean schema, salary where published, and change tracking: new, updated and removed jobs between runs.

- **URL**: https://apify.com/cedarfetch/ats-jobs-scraper.md
- **Developed by:** [Cedarfetch Labs](https://apify.com/cedarfetch) (community)
- **Features:** Limited permissions
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 1,000 jobs

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 Scraper - Greenhouse, Lever, Ashby & Change Tracking

Get every open job posting straight from a company's applicant-tracking system: Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee and Personio. The data comes from each vendor's **official public job-board API**, the same feed that powers the company's own careers page, and every ATS is normalised to **one schema**: title, department, locations, remote/hybrid/on-site, employment type, salary where the company publishes it, description, dates and links.

Turn on **change tracking** and schedule the Actor: every row then says whether the job is `new`, `updated` (with the fields that changed), `unchanged`, or `removed` since the last run. With "Output only changes" a daily run returns just the new and closed postings.

No browser, no proxies, no login. Runs are fast and cheap: most boards are a single request.

### What you can use it for

- **Recruiters and sourcers**: watch target companies' boards and get new roles the day they open.
- **Job boards and aggregators**: clean, deduplicated, normalised postings from hundreds of companies, with closed jobs flagged so you can expire them.
- **Sales and investors**: hiring as a buying or growth signal ("which accounts opened data-engineering roles this week?").
- **Labour-market analysts**: salary ranges, remote share, department mix across companies.
- **AI agents and pipelines**: one stable JSON shape, whatever ATS the company uses.

### Why this one

- **Seven ATS, one schema.** Same fields, same types, same enums for every vendor. `employmentType` and `workplaceType` are normalised; the ATS's own labels are kept alongside.
- **Change tracking built in.** A compact snapshot per board is kept in your account. Rows are labelled new / updated / unchanged / removed. "Updated" compares meaningful fields only (title, locations, department, team, workplace, employment type, salary, description), never timestamps that tick on their own.
- **Removed jobs are reported safely.** A job is only labelled `removed` after a complete, error-free read of the board. A partial or failed fetch never claims a job closed.
- **Flexible input.** Paste board URLs, single-job URLs, embedded `?gh_jid=` links, `ats:token`, or bare company names with auto-detection.
- **Honest failures.** Unknown boards, blocked boards, empty boards and typos are reported per company in plain words, never as a silent empty dataset. If no job at all could be extracted, the run fails and says why.
- **Polite by design.** Official endpoints only, an honest User-Agent, a pause between requests, backoff on rate limits. Nothing is bypassed.

### Supported ATS

| ATS | Paste | Company name | Salary | postedAt | updatedAt | Requisition ID | Description | Requests |
|---|---|---|---|---|---|---|---|---|
| **Greenhouse** | `job-boards.greenhouse.io/stripe`, `boards.greenhouse.io/stripe`, `...?gh_jid=123` | yes | opt-in `fetchGreenhousePay` (+1 request per job), when published | first published | yes | yes | yes | 1 per board |
| **Lever** | `jobs.lever.co/palantir`, `jobs.eu.lever.co/acme` | no | when published | created | no | no | yes | 1 per 100 jobs |
| **Ashby** | `jobs.ashbyhq.com/ramp` | no | when published | published | no | no | yes | 1 per board |
| **SmartRecruiters** | `jobs.smartrecruiters.com/BoschGroup` | yes | no | released | no | yes (ref number) | +1 request per job | 1 per 100 jobs |
| **Workable** | `apply.workable.com/huggingface` | yes | no | date only | no | when set | yes | 1 per board |
| **Recruitee** | `bunq.recruitee.com` | yes | when published | published | yes | no | yes | 1 per board |
| **Personio** | `acme.jobs.personio.de` | yes (legal entity) | when published | created | no | no | yes | 1 per board |

Endpoints used: Greenhouse Job Board API, Lever Postings API (global and EU instances), Ashby public Job Posting API, SmartRecruiters Posting API, Workable's public jobs widget API, Recruitee Careers Site API, Personio XML job feed. Fields an ATS does not expose are `null`, never guessed.

Not supported: Workday, iCIMS, Taleo, SuccessFactors and other systems without a public, unauthenticated job feed, and custom career sites. If a company embeds Greenhouse or Ashby on its own domain, paste the link that contains `gh_jid=` / `ashby_jid=`, or use `greenhouse:<board>`.

### Input

Only `companies` is required. One entry per line, in any of these forms:

| You paste | Read as |
|---|---|
| `https://job-boards.greenhouse.io/stripe` (or a link to one of its jobs) | Greenhouse board `stripe` |
| `https://stripe.com/jobs/search?gh_jid=8172487` | Greenhouse board guessed from the domain (`stripe`); a warning is added if that job is not on it |
| `greenhouse:stripe`, `lever:palantir`, `lever-eu:acme`, `ashby:ramp`, `smartrecruiters:BoschGroup`, `workable:huggingface`, `recruitee:bunq`, `personio:acme` | that board (aliases: `gh:`, `sr:`) |
| `stripe` with **Default ATS** = Greenhouse | Greenhouse board `stripe` |
| `stripe` with **Auto-detect** on | every ATS that has an open board named `stripe` |

A link to a single job reads the company's whole board. Duplicate entries for the same board are merged.

#### Example: three companies, engineering roles only

```json
{
    "companies": ["https://job-boards.greenhouse.io/stripe", "lever:palantir", "ashby:ramp"],
    "titleKeywords": ["engineer*"],
    "excludeTitleKeywords": ["intern"],
    "includeDescription": false
}
```

#### Example: remote jobs posted this week, with salary

```json
{
    "companies": ["ashby:ramp", "ashby:notion", "lever:leverdemo", "greenhouse:anthropic"],
    "remoteOnly": true,
    "postedAfter": "7 days",
    "fetchGreenhousePay": true
}
```

#### Example: find which ATS a company uses

```json
{
    "companies": ["ramp", "palantir", "notion"],
    "autoDetect": true,
    "maxJobsPerCompany": 5
}
```

#### All options

| Field | Default | What it does |
|---|---|---|
| `companies` | (required) | Boards to read, in any form above. Up to 2,000 per run. |
| `maxJobsPerCompany` | 0 (all) | Stop after this many matching jobs per company. |
| `includeDescription` | true | Add `descriptionText` and `descriptionHtml`. On SmartRecruiters this is one extra request per job. |
| `defaultAts` | none | ATS for bare tokens. |
| `autoDetect` | false | Try every ATS for bare tokens (see caveats). |
| `titleKeywords` | none | Keep titles containing these words. |
| `titleKeywordsMode` | `any` | `any` or `all` of the keywords. |
| `excludeTitleKeywords` | none | Drop titles containing any of these words. |
| `locations` | none | Keep jobs whose locations or country match any of these words. |
| `departments` | none | Keep jobs whose department or team matches. |
| `remoteOnly` | false | Keep only `isRemote: true`. |
| `postedAfter` | none | ISO date (`2026-11-01`) or relative (`7 days`, `24 hours`, `2 weeks`). |
| `employmentTypes` | none | Any of `full_time`, `part_time`, `contract`, `temporary`, `internship`, `apprenticeship`, `volunteer`, `other`. |
| `trackChanges` | false | Compare with the previous run and label every row. |
| `changesOnly` | false | With tracking on, deliver only the selected change types. |
| `changeTypes` | `new`, `updated`, `removed` | Which changes `changesOnly` delivers. |
| `snapshotStoreName` | `ats-jobs-scraper-snapshots` | Named key-value store for snapshots. Use different names for independent feeds. |
| `fetchGreenhousePay` | false | One extra request per matching Greenhouse job to read its published pay range. |
| `concurrency` | 2 | Parallel requests per company. |
| `requestDelayMs` | 250 | Pause after each request to the same board. |
| `maxConcurrentCompanies` | 4 | Companies read at the same time. |
| `requestTimeoutSecs` | 60 | Timeout per request (Lever pages can take 20+ seconds). |
| `maxRetries` | 4 | Retries for 429, 5xx and network errors. |

**Keyword matching** (titles, locations, departments) is case- and accent-insensitive and works on whole words: `engineer` matches "Senior Engineer" but not "Engineering"; `engineer*` matches both; `US` matches "Remote (US)" but not "Austin"; phrases such as `data scientist` work. Filters run before `maxJobsPerCompany`, so the cap counts matching jobs.

### Output

One row per job in the default dataset. Download as JSON, CSV, Excel or XML from the Output tab or the API. Views: **Jobs**, **Changes**, **Job details**, **Companies that could not be read**.

```json
{
    "recordType": "job",
    "ats": "ashby",
    "companyToken": "ramp",
    "companyName": null,
    "jobId": "34413f8d-26bf-4bbc-8ade-eb309a0e2245",
    "requisitionId": null,
    "title": "Security Engineer, Cloud",
    "department": "Engineering",
    "team": "Backend",
    "location": "New York, NY (HQ)",
    "locations": ["New York, NY (HQ)", "Remote (Canada)", "Remote (US)", "Miami, FL"],
    "country": "USA",
    "isRemote": true,
    "workplaceType": "hybrid",
    "employmentType": "full_time",
    "employmentTypeRaw": "FullTime",
    "compensation": { "min": 211400, "max": 290600, "currency": "USD", "interval": "year", "text": "$211.4K – $290.6K • Offers Equity" },
    "descriptionText": "ABOUT RAMP\n\nRamp is building the smart infrastructure for finance teams...",
    "descriptionHtml": "<h1><strong>About Ramp</strong></h1>...",
    "postedAt": "2026-04-07T17:12:35.753Z",
    "updatedAt": null,
    "jobUrl": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245",
    "applyUrl": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245/application",
    "changeType": null,
    "changedFields": null,
    "previous": null,
    "firstSeenAt": null,
    "removedAt": null,
    "matchedBy": "url",
    "input": "https://jobs.ashbyhq.com/ramp",
    "source": "api",
    "scrapedAt": "2026-11-11T18:49:33.028Z"
}
```

(Real row, description shortened.) Dates are ISO 8601 in UTC; numbers are numbers; `jobId` is always a string. `compensation` is `null` unless the company publishes pay through the API; `interval` is `year`, `month`, `week`, `day` or `hour`. `country` is passed through as the ATS gives it (ISO code or name).

#### isRemote and workplaceType

- `workplaceType` (`remote`, `hybrid`, `onsite`, `null`) comes from the ATS's own structured field when it has one: Lever and Ashby `workplaceType`, SmartRecruiters remote/hybrid flags, Recruitee remote/hybrid/on-site flags, Workable `telecommuting`, Greenhouse custom "Location Type" / "Workplace" fields where the company set one. Otherwise it is read from the location names (`Remote`, `Anywhere`, `Hybrid`), and is `null` when nothing says.
- `isRemote` is `true` when the job is remote, or any listed location says Remote / Anywhere / Work from home (so a hybrid job that also lists "Remote (US)" is `true`, with `workplaceType: "hybrid"`); `false` when the ATS states on-site or hybrid and no location mentions remote; `null` when there is nothing to go on.
- Ashby's own `isRemote` flag is not used: Ashby sets it on many hybrid jobs. SmartRecruiters and Workable "remote = false" alone is treated as unknown, because many postings leave it unset.

#### Companies that could not be read

A free `company_status` row is added for every company that could not be read, with a `status` and a plain-language `message`:

| status | Meaning |
|---|---|
| `not_found` | The ATS has no public board with that name (typo, or the company uses another ATS). |
| `no_jobs` | The board exists but has no open jobs. On SmartRecruiters this can also mean an unknown identifier, because it answers both the same way. |
| `not_detected` | Auto-detect found no ATS with open jobs for that name. |
| `blocked` | The ATS refused the request (401/403) or answered with a bot-protection challenge. Never worked around. |
| `rate_limited` | Still rate-limited after polite retries. Try later or lower `concurrency`. |
| `unreachable` / `error` | Network failure or an unexpected answer. |
| `invalid_input` | The entry is not a recognised URL, `ats:token` or token. |
| `partial` | (in OUTPUT) Some jobs were read, then the run hit an error or the charge limit. |

If a run finds jobs but delivers none (all filtered out, or nothing changed in changes-only mode), one free `company_status` row per company explains it.

#### Run summary

The default key-value store record `OUTPUT` has, per company: the ATS matched, how the input was resolved, status, jobs found / matched / delivered, removed jobs reported, requests, retries, duration, whether the board was read completely, filter counts, change counts and warnings. Use it to monitor scheduled runs.

### Change tracking

Turn on **Track changes between runs** and schedule the Actor. Each run compares every matching job with the snapshot from the previous run of the same board and filters, and adds:

| Field | Meaning |
|---|---|
| `changeType` | `new`, `updated`, `unchanged` or `removed`. |
| `changedFields` | For `updated`: e.g. `["title", "compensation"]`. Tracked: title, department, team, locations, workplaceType, isRemote, employmentType, compensation, description. |
| `previous` | For `updated`: the previous values of the changed fields (for description only the change is detected; old text is not stored). |
| `firstSeenAt` | When the job first appeared in this feed. |
| `removedAt` | For `removed` rows: when the removal was detected. Removed rows carry the last known title, location, department, dates and links. |

Rules you can rely on:

- **The first tracked run labels every job `new`.** It sets the baseline.
- **`removed` needs a complete read.** If the board could not be read, or a paginated board (Lever, SmartRecruiters) was cut short by `maxJobsPerCompany` or the charge limit, nothing is labelled removed; the next complete run reports it.
- **Nothing is lost on a budget stop.** The snapshot only records jobs that were delivered, so a change that did not fit in this run is reported in the next one.
- **Filters define the feed.** A job that stops matching your filters (for example it ages out of `postedAfter: "7 days"`) is not "removed"; only jobs that disappear from the board are. Each combination of board and filters has its own snapshot, so changing filters starts a new baseline.
- **Descriptions** are compared by hash when both runs read them. On six of the seven ATS the description comes with the list, so this works even with `includeDescription` off (it is read for the comparison, just not output). On SmartRecruiters descriptions need a per-job request, so they are compared only with `includeDescription` on, and not for already-known jobs in changes-only mode (skipped to save cost).
- Snapshots live in the named key-value store `snapshotStoreName` in your Apify account (compressed, one record per board and filter set). Delete it, or pick a new name, to start over.

#### Recipe: daily new-jobs feed

1. Create a task with your companies and filters, plus:

```json
{
    "companies": ["greenhouse:stripe", "ashby:ramp", "lever:palantir"],
    "titleKeywords": ["engineer*", "data"],
    "trackChanges": true,
    "changesOnly": true,
    "changeTypes": ["new"],
    "includeDescription": false
}
```

2. Run it once to set the baseline (every job is `new` on that run).
3. Schedule it daily. From then on each run delivers only the jobs that opened since the previous run, and you pay only for those. Add `"removed"` to `changeTypes` to also get closed jobs, for expiring listings.
4. Connect the dataset to Slack, email, a Google Sheet or a webhook through Apify integrations.

### Pricing

Pay per event: a small fee per run start, plus one `job` event per job row delivered to the dataset (including `removed` rows). See the Pricing tab for the current price.

- `company_status` rows are free.
- With changes-only output, unchanged jobs are not delivered and not charged.
- Set **Maximum cost per run** in the run options to cap spending. The Actor stops cleanly at the cap, keeps everything delivered so far, and notes it in `OUTPUT`.

### Use from an AI agent or via API

Ready-to-run Python, Node.js and cURL examples: [github.com/cedarfetch/ats-jobs-scraper-examples](https://github.com/cedarfetch/ats-jobs-scraper-examples).

Run synchronously and get the rows back in one HTTP call:

```bash
curl -X POST "https://api.apify.com/v2/acts/<username>~ats-jobs-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"companies":["ashby:ramp"],"titleKeywords":["engineer*"],"maxJobsPerCompany":50,"includeDescription":false}'
```

JavaScript:

```js
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('<username>/ats-jobs-scraper').call({ companies: ['greenhouse:stripe'], trackChanges: true, changesOnly: true });
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

Python:

```python
from apify_client import ApifyClient
client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("<username>/ats-jobs-scraper").call(run_input={"companies": ["lever:palantir"]})
items = client.dataset(run["defaultDatasetId"]).list_items().items
```

The Actor is also available to agents through the Apify MCP server, which exposes Store Actors as tools. Tips for agents: use `ats:token` or URLs (not auto-detect) when you know the ATS; keep `maxJobsPerCompany` small and `includeDescription` off for exploratory calls; filter on `recordType == "job"`; read `OUTPUT` for per-company status.

### Limits and honest caveats

- **Public postings only.** It reads what the company publishes on its careers page. Internal, confidential and unlisted postings are out of reach by design. Nothing about applicants or employees is collected; recruiter or hiring-manager names that appear inside a description are not extracted into fields.
- **Auto-detect can match the wrong company.** Board names are first-come slugs: `mercury` on one ATS may be a different Mercury than the one you mean, and companies often leave a stale board behind after switching ATS. Each row says which ATS matched (`ats`, `matchedBy: "autodetect"`), the summary lists every ATS tried, and a warning is added when one slug matches on several ATS. Check `companyName` and `jobUrl`, and prefer URLs or `ats:token` when you know the ATS. Only boards with open jobs count as a match; auto-detect costs up to 7 requests per name.
- **SmartRecruiters** answers an unknown company identifier exactly like a company with no jobs (an empty list), so a typo shows up as `no_jobs`. Identifiers are often not the plain company name (`BoschGroup`, `Ubisoft2`). Its descriptions need one request per job.
- **Recruitee** has announced that its Careers Site API will require a company-issued token from **10 February 2027**. Until then it answers plain requests; after that, Recruitee boards will be reported as `blocked` unless Recruitee changes course.
- **Lever** does not expose the company name, a last-updated date or requisition codes; its `postedAt` is the posting's creation time. Large Lever boards can take 20+ seconds per page of 100.
- **Workable** gives the publish date without a time, and no hybrid/on-site field (only a remote flag).
- **Greenhouse** lists jobs without pay; `fetchGreenhousePay` reads the pay range per job where the company publishes one for pay-transparency laws. Greenhouse's `requisition_id` is passed through as published (some companies put placeholder text there).
- **Personio** feeds come in the company's default language; `companyName` is the hiring legal entity, which can differ per job.
- **Language and location formats** are as each company writes them. Locations are not geocoded.

### FAQ

**How do I find a company's board name?** Open its careers page and click a job: the URL usually shows the ATS and the name (`jobs.lever.co/<name>`, `job-boards.greenhouse.io/<name>`, `jobs.ashbyhq.com/<name>`). Paste that URL. Or try the company name with auto-detect.

**Can I read many companies in one run?** Yes, up to 2,000. Four are read at a time by default; most boards take one or two seconds.

**Why does a company show fewer jobs than its website?** Some companies list jobs from several boards or several ATS; you get the boards you pass. Check `OUTPUT` for filters and caps.

**Do I need proxies?** No. These APIs are meant to be read by anyone. Boards that refuse plain requests are reported, not worked around.

**What happens to a job that is reposted?** Most ATS give a reposted job a new id, so it appears as `new` and the old one as `removed`.

**Is this legal?** The Actor reads job postings that companies publish through public APIs intended for that purpose, and collects no personal data about applicants or employees. You are responsible for how you use the data and for each source's terms. If in doubt, ask a lawyer.

### Support

Found a board that behaves oddly? Open an issue on the Issues tab with the company and the run ID; the `OUTPUT` record usually shows what happened.

### More Actors from Cedarfetch Labs

Built the same way: public data only, typed output, per-source error reporting, change tracking for scheduled runs.

- [Greenhouse Jobs Scraper](https://apify.com/cedarfetch/greenhouse-jobs-scraper): only need Greenhouse boards? The same engine with a simpler input.
- [Lever Jobs Scraper](https://apify.com/cedarfetch/lever-jobs-scraper): open postings from any Lever-hosted careers page, with new and removed jobs flagged.
- [Ashby Jobs Scraper](https://apify.com/cedarfetch/ashby-jobs-scraper): open jobs from any Ashby job board, with new and removed jobs flagged.
- [Shopify Store Scraper](https://apify.com/cedarfetch/shopify-store-scraper): full product catalogs from any Shopify store, with price and stock change tracking.
- [Remote Jobs Scraper](https://apify.com/cedarfetch/remote-jobs-scraper): remote jobs from Remote OK, We Work Remotely and five more boards in one deduplicated feed.
- [Substack Scraper](https://apify.com/cedarfetch/substack-scraper): public posts from any Substack newsletter as Markdown, with a new-posts feed.
- [Google Trending Searches](https://apify.com/cedarfetch/google-trending-searches): what is trending on Google right now, per country, with alerts on new trends.

# Changelog

This Actor's version history is a separate document: https://apify.com/cedarfetch/ats-jobs-scraper/changelog.md

# Actor input Schema

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

One per line. Accepts job-board URLs (https://job-boards.greenhouse.io/stripe, https://jobs.lever.co/palantir, https://jobs.ashbyhq.com/ramp, https://jobs.smartrecruiters.com/BoschGroup, https://apply.workable.com/huggingface, https://bunq.recruitee.com, https://acme.jobs.personio.de), a link to a single job on any of those (reads the whole board), embedded links with ?gh_jid= or ?ashby_jid=, the form ats:token (greenhouse:stripe, lever:palantir, lever-eu:acme, ashby:ramp, smartrecruiters:BoschGroup, workable:huggingface, recruitee:bunq, personio:acme), or a bare token together with "Default ATS" or "Auto-detect".

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

Stop after this many matching jobs per company. 0 means all. Keeps test runs and samples cheap; a capped run never reports removed jobs.

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

Add descriptionText (plain text) and descriptionHtml. Turn off for smaller rows. On SmartRecruiters this costs one extra request per job; on the other ATS the description comes with the list at no extra cost.

## `defaultAts` (type: `string`):

Which ATS a bare token (for example "stripe") refers to. Leave empty and use the ats:token form, a URL, or Auto-detect instead.

## `autoDetect` (type: `boolean`):

Try each supported ATS for every bare token and read every board that exists. Rows show which ATS matched. Caution: board names can collide across unrelated companies (a "mercury" board on one ATS may belong to a different Mercury), so check companyName and jobUrl, and prefer URLs or ats:token when you know the ATS. A board only counts as a match when it has open jobs (empty boards are often left behind after a company switches ATS, and SmartRecruiters answers unknown names with an empty list). Auto-detect costs up to 7 extra requests per token.

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

Keep jobs whose title contains these words (case- and accent-insensitive, whole words). Add \* for a prefix: "engineer\*" also matches "Engineering". Phrases work: "data scientist".

## `titleKeywordsMode` (type: `string`):

"any": at least one keyword in the title. "all": every keyword in the title.

## `excludeTitleKeywords` (type: `array`):

Drop jobs whose title contains any of these words (same matching rules), for example "intern" or "senior".

## `locations` (type: `array`):

Keep jobs where any listed location (or the country code / name the ATS gives) matches one of these words, for example "London", "Germany", "US" or "Remote". Whole-word matching, so "US" does not match "Austin".

## `departments` (type: `array`):

Keep jobs whose department or team matches one of these words, for example "Engineering" or "Sales".

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

Keep only jobs with isRemote = true (see the README for how remote is determined per ATS).

## `postedAfter` (type: `string`):

Keep jobs first published on or after this moment: an ISO date (2026-11-01 or 2026-11-01T12:00:00Z) or a relative value such as "7 days", "24 hours" or "2 weeks". Jobs without a publish date are dropped by this filter.

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

Keep only these normalised employment types. Jobs whose ATS does not state a type have employmentType null and are dropped by this filter.

## `trackChanges` (type: `boolean`):

Compare every job with the previous run and add changeType (new, updated, unchanged, removed), the changed fields and their previous values, and firstSeenAt. Jobs that disappeared from the board are reported as rows with changeType "removed" and removedAt. The snapshot is kept in a named key-value store in your account, so it survives between runs. Schedule the Actor daily for a jobs feed.

## `changesOnly` (type: `boolean`):

Requires change tracking. Deliver (and pay for) only the change types selected below; unchanged jobs are skipped but the snapshot is still refreshed. The first tracked run labels every job "new".

## `changeTypes` (type: `array`):

With "Output only changes": which change types to deliver. A daily new-jobs feed uses \["new"]; new plus closed jobs uses \["new", "removed"].

## `snapshotStoreName` (type: `string`):

Name of the key-value store that holds the change-tracking snapshots. Use a different name to run independent tracking jobs on the same companies (for example one per client). Lowercase letters, digits and hyphens.

## `fetchGreenhousePay` (type: `boolean`):

Greenhouse lists jobs without pay. With this on, one extra request per matching Greenhouse job reads the pay range the company publishes for pay-transparency laws (fills compensation; empty when the company publishes none).

## `concurrency` (type: `integer`):

How many requests run at once against one company's board (matters for paginated boards and per-job detail requests).

## `requestDelayMs` (type: `integer`):

Pause after each request to the same company's board, with small random jitter.

## `maxConcurrentCompanies` (type: `integer`):

How many different companies are read at the same time.

## `requestTimeoutSecs` (type: `integer`):

Give up on a single request after this many seconds (it is then retried). Lever can take 20+ seconds for a page of a large board.

## `maxRetries` (type: `integer`):

Retries for rate-limit (429), server-error (5xx) and network failures, with jittered exponential backoff that honours Retry-After. 401/403 and bot-protection answers are never retried.

## Actor input object example

```json
{
  "companies": [
    "https://job-boards.greenhouse.io/stripe",
    "lever:palantir",
    "ashby:ramp",
    "smartrecruiters:BoschGroup"
  ],
  "maxJobsPerCompany": 200,
  "includeDescription": true,
  "autoDetect": false,
  "titleKeywords": [
    "engineer*",
    "product manager"
  ],
  "titleKeywordsMode": "any",
  "excludeTitleKeywords": [
    "intern",
    "principal"
  ],
  "locations": [
    "New York",
    "Remote"
  ],
  "departments": [
    "Engineering"
  ],
  "remoteOnly": false,
  "postedAfter": "7 days",
  "trackChanges": false,
  "changesOnly": false,
  "changeTypes": [
    "new",
    "updated",
    "removed"
  ],
  "snapshotStoreName": "ats-jobs-scraper-snapshots",
  "fetchGreenhousePay": false,
  "concurrency": 2,
  "requestDelayMs": 250,
  "maxConcurrentCompanies": 4,
  "requestTimeoutSecs": 60,
  "maxRetries": 4
}
```

# Actor output Schema

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

All job rows (including removed rows with change tracking), plus company_status rows for companies that could not be read.

## `changes` (type: `string`):

The same rows shown with the change-tracking columns.

## `summary` (type: `string`):

Per-company ATS matched, status (ok, partial, no_jobs, not_found, not_detected, blocked, rate_limited, unreachable...), job counts, requests, duration and change counts.

# 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": [
        "https://jobs.lever.co/leverdemo"
    ],
    "maxJobsPerCompany": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("cedarfetch/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": ["https://jobs.lever.co/leverdemo"],
    "maxJobsPerCompany": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("cedarfetch/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": [
    "https://jobs.lever.co/leverdemo"
  ],
  "maxJobsPerCompany": 20
}' |
apify call cedarfetch/ats-jobs-scraper --silent --output-dataset

```

## MCP server setup

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