# Ats Job Scraper (`excellent_mustang/ats-job-scraper`) Actor

- **URL**: https://apify.com/excellent\_mustang/ats-job-scraper.md
- **Developed by:** [Gorav Agarwal](https://apify.com/excellent_mustang) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

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 Job Scraper — Greenhouse, Lever, Ashby & Workday

Scrape public job postings **straight from company career pages** — the applicant tracking system the employer actually posts into — and get one clean, normalised schema across every board. Give it a company domain and it works out which ATS that company uses on its own.

No API key. No login. No cookies. In testing, **2,580 jobs from 18 companies in 17 seconds**, with country resolved on 99% of rows.

### Why scrape the ATS instead of a job aggregator

Job boards and aggregators are a lossy, delayed copy. The ATS is the source: postings appear there first, the data is structured, and nothing has been de-duplicated, re-worded or dropped along the way. Scraping Greenhouse, Lever, Ashby and Workday directly gives you the employer's own record of the role — including requisition IDs and internal department names that aggregators strip out.

The catch has always been that every ATS models a job differently. This Actor does that normalisation for you.

### Auto-detection: you don't need to know the ATS

Most ATS scrapers make you supply the board token yourself — you have to already know that Monzo is `greenhouse:monzo` and Ramp is `ashby:ramp`. This one resolves it, using three strategies in ascending order of cost:

1. **Parse** — if you paste an ATS URL, the tenant is read straight out of it.
2. **Probe and verify** — slug candidates derived from the domain or company name are tested against each board API.
3. **Crawl** — if neither works, the company's own site and careers pages (including `careers.` and `jobs.` subdomains) are searched for an embedded board link.

Nothing is ever reported on the strength of a pattern match alone. **Every candidate is confirmed by calling the board API and requiring a non-empty job list**, so a guess that 404s or returns an empty board is discarded. In a 12-company benchmark, auto-detection resolved 10 — in 5.9 seconds.

You can mix input styles freely in one run:

```
monzo.com
Datadog
https://jobs.ashbyhq.com/ramp
https://sanofi.wd3.myworkdayjobs.com/en-US/SanofiCareers
lever:spotify
```

### Supported ATS platforms

| ATS | Coverage | Salary | Req ID | Notes |
|---|---|---|---|---|
| **Greenhouse** | Full board, one request | From description text | Yes | No employment-type field on the public API |
| **Lever** | Full board, one request | From description text | No | Publishes workplace type and commitment |
| **Ashby** | Full board, one request | **Structured** | No | Richest source: real compensation components and postal address |
| **Workday** | Paginated, 20/request | Detail fetch only | Yes | See the honest note below |

#### An honest note on Workday

Workday works, and it is reliable **when the tenant is known** — 4/4 real careers URLs scraped correctly in testing. What is *not* reliable is guessing a Workday tenant from a company name. Unilever's site is `Unilever_Experienced_Professionals`, not `Unilever_Careers`; site names follow no dependable convention.

So: **pass the Workday careers URL directly**, or let the page crawl find it (it discovered Unilever's real tenant unaided). Blind tenant probing exists behind the `enableWorkdayDiscovery` flag and is **off by default** because it costs ~24 requests per company and usually fails.

Two further Workday quirks this Actor handles that naive scrapers do not: the CXS endpoint **silently returns an empty array for any page size above 20** (it does not error — it just looks like an empty board), and its list response omits country and description. Enable `workdayJobDetails` to fill those in; in testing that took country resolution from 5/15 to **15/15**.

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `companies` | array | 3 demo boards | Careers URLs, domains, company names, or `ats:tenant` tokens |
| `maxItems` | integer | `500` | Total jobs across all boards |
| `maxJobsPerBoard` | integer | `0` | Per-company cap so one big employer can't dominate |
| `includeDescription` | boolean | `false` | Full description as plain text |
| `titleKeywords` / `excludeTitleKeywords` | array | `[]` | Title filters |
| `countries` | array | `[]` | ISO-3166 alpha-2 codes, e.g. `["GB","DE","IE"]` |
| `remoteOnly` | boolean | `false` | Remote roles only (hybrid excluded) |
| `withSalaryOnly` | boolean | `false` | Only postings with an extractable salary |
| `postedWithinDays` | integer | `0` | Recency window |
| `trackHistory` | boolean | `true` | Persist first-seen / last-seen across runs |
| `newJobsOnly` | boolean | `false` | Return only postings never seen before |

#### Example input

```json
{
  "companies": ["monzo.com", "wise.com", "n26.com", "gocardless.com", "pleo.io"],
  "countries": ["GB", "DE", "IE", "NL", "PL"],
  "titleKeywords": ["engineer", "data"],
  "postedWithinDays": 14,
  "maxItems": 1000
}
```

### Output example

One dataset item per posting. Real output:

```json
{
  "position": 1,
  "jobKey": "greenhouse:monzo:7115379",
  "company": "Monzo",
  "companyDomain": "monzo.com",
  "ats": "greenhouse",
  "atsTenant": "monzo",
  "jobId": "7115379",
  "requisitionId": "2311",
  "title": "Android Engineer",
  "department": "Engineering",
  "team": null,
  "employmentType": null,
  "location": {
    "raw": "Cardiff, London or Remote (UK)",
    "city": "Cardiff",
    "region": null,
    "country": "United Kingdom",
    "countryCode": "GB",
    "isRemote": true,
    "workplaceType": "Remote"
  },
  "additionalLocations": [],
  "isRemote": true,
  "countryCode": "GB",
  "salary": {
    "min": 85000.0,
    "max": 110000.0,
    "currency": "GBP",
    "period": "YEAR",
    "raw": "£85,000 - £110,000",
    "source": "text"
  },
  "hasSalary": true,
  "url": "https://job-boards.greenhouse.io/monzo/jobs/7115379",
  "applyUrl": "https://job-boards.greenhouse.io/monzo/jobs/7115379",
  "postedAt": "2026-03-09T13:32:35Z",
  "updatedAt": "2026-09-18T11:54:21Z",
  "firstSeenAt": "2026-09-14T06:02:11Z",
  "lastSeenAt": "2026-09-21T06:00:04Z",
  "isNew": false,
  "scrapedAt": "2026-09-21T06:00:04Z"
}
```

#### Parsed location and salary

`location` is parsed, not just passed through: `"Cardiff, London or Remote (UK)"` becomes a city, a country code and a remote flag. Country was resolved on **99% of 2,580 jobs** in the benchmark sweep.

`salary` comes from structured compensation fields where the ATS publishes them (Ashby) and from careful text extraction otherwise. The text parser only fires when the surrounding words actually concern pay, so `"this role runs 2024 - 2025"` and `"we hired 20 - 30 engineers"` are correctly ignored. Across the sweep, 914 of 2,580 jobs carried a salary — 814 structured, 100 from text. **Most employers simply do not publish pay**, and this Actor reports that honestly rather than inventing numbers.

#### First-seen / last-seen tracking

With `trackHistory` on, the Actor remembers every job key in your own key-value store. Each posting then carries `firstSeenAt`, `lastSeenAt` and `isNew` — so `isNew` means *absent from the previous run*, not merely "the ATS says it was posted recently". Schedule the Actor daily and set `newJobsOnly` to get a clean feed of genuinely new roles. A run where nothing is new finishes successfully and says so.

### Pricing

**Pay per result** — you are charged per job posting written to the dataset, at **$10 per 1,000 jobs**, and nothing else. Jobs are de-duplicated across boards before pushing, so a company found on two ATSs is never billed twice.

### Use cases

**Recruitment intelligence and market mapping.** Track who your competitors are hiring, in which countries, and at what level. Department and req ID come straight from the employer's own system.

**Job board and aggregator content.** Build a vertical job board with first-party postings, and use `newJobsOnly` to ingest only deltas.

**Compensation benchmarking.** Ashby boards publish structured salary bands; combined with the text parser this yields a real, sourced pay dataset with currency and period normalised.

**Hiring-signal research for sales and investors.** A company that opens twelve sales roles in Germany is telling you something. Scheduled runs plus `isNew` turn that into a time series.

**Talent sourcing ops.** Feed normalised, deduplicated postings into an ATS, CRM or spreadsheet without writing a parser per board.

### What this Actor does not do

**No personal data.** Job postings sometimes embed a recruiter's name, direct email or mobile number. All of it is stripped from every field before the record is written — emails, phone numbers, social profile links and labelled recruiter lines — whether or not descriptions are enabled. A scan over 3.3 MB of real descriptions from 400 postings found zero leaks. The output is job data, not contact data, and there is no option to turn that off.

**No candidate data, no applications.** This reads public postings only. It never authenticates, never submits anything, and never touches applicant information.

### FAQ & Support

**Do I need an API key for Greenhouse, Lever or Ashby?** No. All three expose public, unauthenticated job board APIs, and this Actor uses only those. Workday's CXS endpoint is likewise public.

**How do I scrape Greenhouse jobs for a specific company?** Pass the domain (`monzo.com`), the token (`greenhouse:monzo`), or the board URL. All three work.

**Why did my company return nothing?** Most likely it does not use one of the four supported systems — plenty of large employers run SmartRecruiters, SuccessFactors, Taleo or a bespoke site. The run log names every seed that failed to resolve. For Workday specifically, pass the careers URL rather than the company name.

**Why is `employmentType` empty for Greenhouse jobs?** Greenhouse's public board API does not expose it. Rather than guess from the title, the field is left null.

**Why do so few jobs have a salary?** Because few employers publish one. Ashby boards are the exception. Use `withSalaryOnly` to keep just the ones that do, but expect it to remove most rows.

**Can it scrape every company on an ATS?** No — these APIs are per-tenant, so you supply the companies. There is no global index to enumerate, and this Actor does not pretend otherwise.

**Does it need a proxy?** No. These APIs are public and answer datacenter IPs. Apify Proxy is available in the input for very large runs and is off by default.

**Is this legal?** It reads public job postings from public endpoints — no login, no personal data, no candidate information. You are responsible for how you use the output.

**Something looks wrong.** Open an issue on the Issues tab with your input JSON and the run ID.

# Actor input Schema

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

One entry per company. Accepts a careers URL (https://boards.greenhouse.io/monzo, https://jobs.ashbyhq.com/ramp, https://sanofi.wd3.myworkdayjobs.com/en-US/SanofiCareers), a plain domain (monzo.com), a company name (Datadog), or an explicit token (greenhouse:monzo, lever:spotify, ashby:ramp, workday:sanofi:wd3:SanofiCareers). Domains and names are auto-detected and verified against the live board before scraping.

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

Total number of job postings to return across all boards.

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

Cap the jobs taken from any single company, so one very large employer cannot consume the whole run. 0 means no per-board cap.

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

Add the complete job description as plain text. Descriptions are large, so this materially increases dataset size. Recruiter names, emails and phone numbers are always stripped, whether or not this is on.

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

Keep only jobs whose title contains at least one of these (case-insensitive), e.g. engineer, data, product.

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

Drop jobs whose title contains any of these, e.g. intern, contract.

## `countries` (type: `array`):

Keep only jobs in these countries, as ISO-3166 alpha-2 codes (GB, DE, FR, IE, NL, ES, PL, US...). Leave empty for all countries.

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

Keep only postings flagged as remote. Hybrid roles are not counted as remote.

## `withSalaryOnly` (type: `boolean`):

Keep only postings where a salary range could be extracted. Note that most employers do not publish pay, so this filter removes a large share of results.

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

Keep only jobs posted in this window. 0 disables the filter.

## `trackHistory` (type: `boolean`):

Remember which jobs were seen on previous runs so each posting carries firstSeenAt, lastSeenAt and isNew. History is stored in your own key-value store and persists between runs of this Actor.

## `newJobsOnly` (type: `boolean`):

Push only postings never seen on a previous run. Requires history tracking. On the very first run everything is new, so every job is returned.

## `enableWorkdayDiscovery` (type: `boolean`):

Workday site names are not derivable from a company domain, so finding one means probing up to ~24 host and site combinations per company. Off by default because it is slow and often fruitless. For Workday, passing the careers URL directly is faster and always works.

## `workdayJobDetails` (type: `boolean`):

Workday's list endpoint omits country and description. Enabling this fetches each Workday job individually to fill them in, at one extra request per job.

## `maxConcurrency` (type: `integer`):

Parallel HTTP requests. Raise it for large multi-company runs; lower it if a board starts rate-limiting.

## `proxyConfiguration` (type: `object`):

Optional. These ATS APIs are public and reachable without a proxy, so this is off by default. Enable Apify Proxy for very large runs.

## Actor input object example

```json
{
  "companies": [
    "monzo.com",
    "lever:spotify",
    "https://jobs.ashbyhq.com/ramp"
  ],
  "maxItems": 500,
  "maxJobsPerBoard": 0,
  "includeDescription": false,
  "titleKeywords": [],
  "excludeTitleKeywords": [],
  "countries": [],
  "remoteOnly": false,
  "withSalaryOnly": false,
  "postedWithinDays": 0,
  "trackHistory": true,
  "newJobsOnly": false,
  "enableWorkdayDiscovery": false,
  "workdayJobDetails": false,
  "maxConcurrency": 10,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

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

All job postings collected by this run, normalised across every supported ATS.

## `jobsOverview` (type: `string`):

The same postings trimmed to company, title, location, salary and URL.

# 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": [
        "monzo.com",
        "lever:spotify",
        "https://jobs.ashbyhq.com/ramp"
    ],
    "maxItems": 500
};

// Run the Actor and wait for it to finish
const run = await client.actor("excellent_mustang/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 = {
    "companies": [
        "monzo.com",
        "lever:spotify",
        "https://jobs.ashbyhq.com/ramp",
    ],
    "maxItems": 500,
}

# Run the Actor and wait for it to finish
run = client.actor("excellent_mustang/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 '{
  "companies": [
    "monzo.com",
    "lever:spotify",
    "https://jobs.ashbyhq.com/ramp"
  ],
  "maxItems": 500
}' |
apify call excellent_mustang/ats-job-scraper --silent --output-dataset

```

## MCP server setup

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