# ATS Jobs Scraper – Greenhouse, Lever, Ashby & Workday (`herazur/ats-jobs-scraper`) Actor

Search jobs posted on company career pages (Greenhouse, Lever, Ashby and Workday) by role, keyword, location and remote status. Deduplicates listings and exports descriptions, company details and direct apply URLs.

- **URL**: https://apify.com/herazur/ats-jobs-scraper.md
- **Developed by:** [Furkan Toluç](https://apify.com/herazur) (community)
- **Categories:** Jobs, Lead generation, Open source
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.10 / 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/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 & Workday

Search jobs posted on company career pages (Greenhouse, Lever, Ashby and Workday) by role, keyword, location and remote status. The Actor deduplicates listings and exports descriptions, company details and direct apply URLs in one normalized schema, with optional persistent new-job monitoring.

Jobs come only from these four applicant tracking systems (ATS). The Actor does not scrape LinkedIn, Indeed, Google Jobs or other job aggregators.

### Quick start

The Store input form is prefilled with a small trial: `roles: ["Engineer"]`, the Greenhouse and Ashby sources, two starter-catalog boards, up to 25 processed jobs per board and 10 results. Click **Start**, then export the dataset as JSON, CSV or Excel. `RUN_REPORT` in the default key-value store lists coverage and source errors. An empty dataset can be a legitimate outcome for narrow filters; inspect the report before treating it as “no jobs exist.”

To search specific employers, add their hosted ATS board or job URLs to `careerUrls`:

```json
{
  "roles": ["Software Engineer"],
  "locations": ["Europe"],
  "remote": true,
  "careerUrls": [
    "https://job-boards.greenhouse.io/stripe",
    "https://jobs.lever.co/ciandt",
    "https://jobs.ashbyhq.com/ashby",
    "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite"
  ],
  "maxResults": 100
}
```

A narrower search with a date window:

```json
{
  "roles": ["AI Automation Specialist", "QA Automation Engineer"],
  "locations": ["Turkey", "Europe"],
  "remote": true,
  "postedWithinDays": 7,
  "maxResults": 1000
}
```

For a broader first trial, use `roles: ["Engineer"]` and omit the date/location filters.

### Where the boards come from

Job records always come from the ATS feeds themselves, never from search snippets. The career boards to scan are taken, in this priority order, from:

1. **Your `careerUrls`** — hosted Greenhouse, Lever, Ashby or Workday board/job URLs. Custom company career domains are not resolved.
2. **Optional web-search discovery** — finds additional hosted ATS boards. No search key is bundled with the Actor: supply your own Brave Search API key (`braveSearchApiKey`) or SerpAPI key (`serpApiKey`). Both are secret input fields. Without a key, discovery is skipped and the run continues.
3. **A starter catalog** (`useCatalog`, on by default) of four employers: Stripe, CI\&T, Ashby and NVIDIA. This is not a substitute for broad discovery.

`maxBoards` caps the total. For local runs, the `BRAVE_SEARCH_API_KEY` or `SERPAPI_API_KEY` environment variables can be used instead of the input fields. Never commit actual credentials.

### Architecture

```text
Input validation → provider-independent company discovery
                    Brave → SerpAPI failover
                            ↓
              Optional URLs + discovered ATS boards + catalog
                            ↓
           Greenhouse / Lever / Ashby / Workday connectors
                            ↓
          Normalization → filters → deduplication → monitoring
                            ↓
                 Dataset / JSONL + coverage report
```

`src/discovery.ts` defines the small `SearchProvider` interface. A provider implements `search(query): Promise<SearchHit[]>`; connector code has no search-vendor dependency. Add another adapter and register it in `providersFor` to support another vendor.

`discovery: "auto"` (the default) tries Brave first when a key is available, then SerpAPI on a failed request or no usable ATS hits. A failing provider is disabled for the remainder of the run. Discovery failure never discards supplied URLs or the catalog. The catalog also supplements successful discovery. Explicit URLs take priority, followed by discovered boards, then the catalog, subject to `maxBoards`.

Discovery extracts ATS board URLs from indexed job/board search results. This MVP does not crawl arbitrary company domains to find embedded career links. `careerUrls` accepts hosted board or job URLs; unsupported custom domains are reported and skipped. Search results are used to locate boards; job records come from the ATS feeds, not search snippets.

### Search behavior

| Input | Behavior |
| --- | --- |
| `roles` | OR between roles; all normalized words of a role must occur in the title. No semantic synonym expansion. |
| `keywords` | OR between phrases in title/description; combined with roles using AND. |
| `locations` | OR between locations, matched against listed primary/secondary locations and country fields. |
| `remote: true` | Explicit remote metadata or remote wording in the location is required. |
| `remote: false` | Explicit non-remote or hybrid records; unknown status is excluded. Omit for all workplace types. |
| `postedWithinDays` | Rolling window on the source date, never on `firstSeenAt`; future dates excluded. |
| `unknownDatePolicy` | `exclude` by default when a date filter is active; `include` opts in to unknown dates. |
| `maxResults` | Delivered unique jobs after filtering and monitoring; default 1,000. |
| `maxDiscoveryQueries` | Default 8 logical queries. Failover can call both providers; HTTP retries add requests. |
| `maxBoards` | Default 20 selected boards. Omitted board count appears in the report. |
| `maxJobsPerBoard` | Default 250. Lever/Workday: listing scan cap. Greenhouse/Ashby: title-matched processing cap after the full feed download. |
| `enrich` | Adds a small deterministic skills dictionary and title-based seniority extraction. No LLM requests. |

Europe and Turkey matching uses a limited country/city alias dictionary in `src/filters.ts`. It is not a geocoder or a work-authorization check. City-only locations can be ambiguous. Generic “Remote” or “Worldwide” does not bypass a location constraint. “EMEA” is not treated as synonymous with Europe. Unknown remote status is `null`, not `false`.

Workday uses the public career site's CXS endpoints. They are site implementation details, not a guaranteed public developer API contract. Listing scans are bounded; detail requests are made only for title-matching jobs. A changed endpoint, blocked request, partial page, failed detail, or scan cap appears in the report. There is no login, CAPTCHA solver, proxy rotation or application submission.

### Output contract

Every row includes the proposed common fields plus provenance and salary units:

```json
{
  "title": "Senior QA Automation Engineer",
  "company": "Example",
  "location": "Berlin, Germany",
  "locations": ["Berlin, Germany"],
  "countries": ["DE"],
  "remote": true,
  "workplaceType": "remote",
  "employmentType": "Full-time",
  "salaryMin": 60000,
  "salaryMax": 80000,
  "currency": "EUR",
  "salaryInterval": "year",
  "description": "Build automated quality checks with Python and Playwright.",
  "skills": ["Python", "Playwright"],
  "experienceLevel": "Senior",
  "postedAt": "2026-09-10T10:00:00.000Z",
  "postedAtKind": "lastPublished",
  "updatedAt": null,
  "source": "ashby",
  "sourceBoard": "https://jobs.ashbyhq.com/example",
  "applyUrl": "https://jobs.ashbyhq.com/example/example-job/application",
  "jobId": "example-job",
  "requisitionId": null,
  "firstSeenAt": "2026-09-12T12:00:00.000Z",
  "isNew": true,
  "uniqueId": "stable-sha256-identifier",
  "sources": [{
    "source": "ashby",
    "board": "https://jobs.ashbyhq.com/example",
    "jobId": "example-job",
    "applyUrl": "https://jobs.ashbyhq.com/example/example-job/application"
  }]
}
```

The example is synthetic. Missing salary, seniority or dates remain `null`; basic-mode skills are `[]`. Salaries are taken from structured Lever/Ashby compensation fields, retain their original interval and currency, and are not inferred from prose or converted to annual pay. Greenhouse/Workday salary extraction and external company enrichment are not implemented. For discovered boards without a supplied organization name, `company` falls back to the ATS board slug; Greenhouse can supply its actual company name.

Date provenance is explicit: Greenhouse `first_published` and Workday `startDate` map to `published`; Lever `createdAt` maps to `created` (not guaranteed publication time); Ashby `publishedAt` maps to `lastPublished`. Greenhouse `updated_at` is only `updatedAt`. Relative Workday labels such as “30+ days ago” are never turned into invented exact dates.

### Deduplication and monitoring

Default `deduplication: "strict"` merges identical ATS board/job identities or canonical application URLs, stripping known tracking parameters and keeping job query identifiers. Distinct jobs with the same title remain separate. `crossSource` also merges exact normalized company/title/location/description matches across ATS sources, provided the description is sufficiently long and the match is not ambiguous within one source. It is conservative and cannot guarantee every duplicate is recognized. The `sources` array retains all merged provenance.

```json
{
  "roles": ["Software Engineer"],
  "locations": ["Europe"],
  "remote": true,
  "monitor": true,
  "onlyNewJobs": true,
  "monitorId": "europe-software-jobs"
}
```

Schedule this input daily with an Apify Schedule; the Actor does not create schedules automatically.

The first monitoring run returns a baseline of matching jobs. Subsequent runs suppress delivered identities and preserve their first delivery timestamp. `isNew` means **not previously delivered in this monitor scope**, not “published since yesterday.” Use the date filter separately when a publication/creation window is needed. This distinction handles boards with unknown dates and older jobs discovered for the first time.

History uses the named Apify key-value store `all-jobs-search-history-v1`, which persists across independent runs. The scope includes `monitorId` and normalized search filters; changing filters starts a separate history. Limits, discovery vendor, URL order and `onlyNewJobs` do not reset it. Without `monitor`, each invocation has no persistent history and all returned rows are new observations for that invocation.

Only successfully delivered records are marked seen. Filtered records, result-limit overflow and budget-rejected records remain eligible. Matching aliases of previously seen duplicates are also remembered. Delivery and history writes are separate operations: a crash between them may redeliver a record. Consumers needing crash-safe idempotency should use `uniqueId` and source identities. Run **one cloud run per monitor scope at a time**; Apify KV storage does not provide transactional locking here. The local CLI uses an exclusive scope lock; after a killed process, remove its stale `.lock` only after confirming the process is stopped. History has no automatic retention expiry; changing `monitorId` creates a fresh scope.

### Output, API and pricing

Output rows go to the default dataset; `RUN_REPORT` in the default key-value store contains source errors, truncation, discovery fallback, delivered counts and run status. All boards failing makes the Actor fail; partial successes remain usable.

Use the Apify API to automate runs: `POST https://api.apify.com/v2/acts/herazur~ats-jobs-scraper/runs` with the input JSON, wait for completion, then retrieve the default dataset. Apify Schedules and webhooks work as with any Actor.

Pricing is pay per event: one `job-scraped` event per unique job saved to the dataset, charged after deduplication and monitoring. Duplicates and jobs suppressed by `onlyNewJobs` are not charged. On the Apify Free plan this costs **$0.003 per job ($3 per 1,000)**. Paid Apify plans get lower prices: $0.0027 (Bronze), $0.0024 (Silver), and $0.0021 (Gold and higher). Apify's standard Actor start event ($0.00005 per GB of run memory) also applies. Platform usage is included. Use `maxResults` and the run's maximum charge to cap spending; rows rejected by the budget are not marked seen in monitoring history.

### Local development and extension

Requires Node.js 22 or later.

```sh
npm ci
npm run build
npm test
npm run search -- examples/input.json output/search
```

Local CLI results: `output/search/jobs.jsonl`. Coverage and failures: `output/search/run-report.json`. `.env` files are not loaded automatically; Node's `--env-file` or your shell can supply search keys.

```sh
npm run check
npm test
npm run build
npm run smoke
```

`npm test` uses synthetic fixtures for source contracts, discovery failover, filters, pagination, retry behavior, deduplication and monitoring durability. `npm run smoke` explicitly contacts one live board on each ATS, processes two sample listings, and saves `output/smoke-report.json`. The low cap intentionally produces `partial`/`truncated` reports. It is not a load test or proof of all tenants working.

Add connector implementations in `src/connectors.ts` and source mappings in `src/normalize.ts`; extend source types, discovery operators, board parsing, input schemas and fixture tests together. SmartRecruiters, Recruitee and Personio are planned extensions; LinkedIn, Indeed and Google Jobs collectors are not included.

Primary integration references: [Greenhouse Job Board API](https://docs.greenhouse.io/job-board.html), [Lever Postings API](https://github.com/lever/postings-api), [Ashby Job Postings API](https://developers.ashbyhq.com/docs/public-job-posting-api), [Brave Web Search API](https://api-dashboard.search.brave.com/api-reference/web/search/get), [SerpAPI Google Search API](https://serpapi.com/search-api), [Apify Actor SDK](https://docs.apify.com/sdk/js/reference/class/Actor).

# Actor input Schema

## `roles` (type: `array`):

OR between roles; every word in a role must appear in the job title.

## `keywords` (type: `array`):

OR between phrases, searched in title and description. Combined with the role filter using AND.

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

OR between locations. Europe and Turkey have a limited alias dictionary. Remote jobs still need to match the requested region.

## `remote` (type: `boolean`):

True: explicitly remote. False: explicitly non-remote/hybrid. Omit this field in JSON to include all workplace types.

## `postedWithinDays` (type: `integer`):

Omit to disable date filtering. Uses ATS date and postedAtKind provenance, never firstSeenAt.

## `maxResults` (type: `integer`):

Maximum delivered results after filters, deduplication and monitoring.

## `enrich` (type: `boolean`):

Deterministic keyword extraction; no paid LLM calls or invented salary values.

## `monitor` (type: `boolean`):

Preserve first delivered observation across scheduled runs. Run only one instance of a monitor scope at a time.

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

Requires monitor. First run returns a baseline; later runs suppress previously delivered jobs.

## `monitorId` (type: `string`):

Optional namespace. Search filters also form part of the history scope.

## `discovery` (type: `string`):

Optional discovery of additional hosted ATS boards through web search. Requires your own Brave Search or SerpAPI key below. Auto tries Brave first, then SerpAPI on failure or no supported hits. None scans only your career URLs and the starter catalog.

## `braveSearchApiKey` (type: `string`):

Optional. Your Brave Search API key, used only to discover additional hosted ATS boards.

## `serpApiKey` (type: `string`):

Optional. Your SerpAPI key, used only to discover additional hosted ATS boards.

## `maxDiscoveryQueries` (type: `integer`):

Logical queries. Auto failover may make up to two provider calls per query, plus HTTP retries.

## `careerUrls` (type: `array`):

Optional hosted Greenhouse, Lever, Ashby or Workday board/job URLs. Custom company domains are not resolved in this MVP.

## `useCatalog` (type: `boolean`):

Include a small starter catalog of four employers (Stripe, CI\&T, Ashby and NVIDIA). This is not whole-web coverage.

## `sources` (type: `array`):

Enabled connectors.

## `maxBoards` (type: `integer`):

User URLs, discovered boards, then catalog. See the run report for omitted boards.

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

Lever/Workday listing scan cap; Greenhouse/Ashby title-matched processing cap after the full feed download. Truncation is reported.

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

HTTP requests are also throttled by host.

## `unknownDatePolicy` (type: `string`):

Only applies when postedWithinDays is set. Exclude is the strict default.

## `deduplication` (type: `string`):

Strict: same ATS identity or application URL. CrossSource additionally requires exact normalized company, title, locations and description across different ATS sources.

## Actor input object example

```json
{
  "roles": [
    "Engineer"
  ],
  "keywords": [],
  "locations": [],
  "maxResults": 10,
  "enrich": false,
  "monitor": false,
  "onlyNewJobs": false,
  "discovery": "none",
  "maxDiscoveryQueries": 8,
  "careerUrls": [],
  "useCatalog": true,
  "sources": [
    "greenhouse",
    "ashby"
  ],
  "maxBoards": 2,
  "maxJobsPerBoard": 25,
  "concurrency": 3,
  "unknownDatePolicy": "exclude",
  "deduplication": "strict"
}
```

# Actor output Schema

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

Normalized, deduplicated jobs saved in the default dataset.

## `report` (type: `string`):

Search coverage, emitted results and warnings.

# 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 = {
    "roles": [
        "Engineer"
    ],
    "maxResults": 10,
    "discovery": "none",
    "sources": [
        "greenhouse",
        "ashby"
    ],
    "maxBoards": 2,
    "maxJobsPerBoard": 25
};

// Run the Actor and wait for it to finish
const run = await client.actor("herazur/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 = {
    "roles": ["Engineer"],
    "maxResults": 10,
    "discovery": "none",
    "sources": [
        "greenhouse",
        "ashby",
    ],
    "maxBoards": 2,
    "maxJobsPerBoard": 25,
}

# Run the Actor and wait for it to finish
run = client.actor("herazur/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 '{
  "roles": [
    "Engineer"
  ],
  "maxResults": 10,
  "discovery": "none",
  "sources": [
    "greenhouse",
    "ashby"
  ],
  "maxBoards": 2,
  "maxJobsPerBoard": 25
}' |
apify call herazur/ats-jobs-scraper --silent --output-dataset

```

## MCP server setup

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