# ATS Job Scraper — Workday, Oracle, Greenhouse, Lever & 10 More (`sukhdipp/ats-job-scraper`) Actor

Scrape job postings from 14 ATS boards — Workday, Oracle, Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee, Personio, Breezy, Rippling, Eightfold, Pinpoint, Teamtailor. Unified JSON, salary in USD, tech tags, ghost-job signals, monitor mode. Duplicates & filtered jobs never charged.

- **URL**: https://apify.com/sukhdipp/ats-job-scraper.md
- **Developed by:** [Sukhdip Singh](https://apify.com/sukhdipp) (community)
- **Categories:** Jobs, Lead generation, Automation
- **Stats:** 1 total users, 0 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: 5.00 out of 5 stars

## Pricing

$1.50 / 1,000 job results

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

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## ATS Job Scraper — Workday, Oracle, Greenhouse, Lever & 10 more in one API

Extract **job postings from 14 applicant tracking systems** — **Workday, Oracle HCM, Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee, Personio, Breezy, Rippling, Eightfold, Pinpoint and Teamtailor** — in a single run, and get back **one unified, deduplicated JSON schema** with salary data, technology tags, seniority and ghost-job signals.

No cookies. No proxies. No LinkedIn. Every source is a **public, sanctioned job-board endpoint**, so runs are fast, stable and cheap, and the data is public corporate information (no personal data).

### Why this scraper instead of fourteen separate ones

- **One schema for all fourteen ATSes.** `title`, `company`, `location`, `isRemote`, `employmentType`, `compensation`, `publishedAt`, `url`, `applyUrl` — identical field names whether the job lives on Workday or Ashby. Plus derived fields (`seniority`, `employmentTypeNorm`, `workplaceTypeNorm`) so you never post-process fourteen vocabularies.
- **Enterprise boards included.** Workday and Oracle HCM are where most large-company jobs actually live, and most scrapers skip both. Boards are walked to the end, not to the first two pages.
- **Monitor mode.** Schedule the actor and get **only the jobs that are new since the last run** — plus records for the ones that disappeared. Already-seen jobs are neither re-emitted nor re-billed.
- **Ghost-job signals.** Every job carries `daysOpen` and `isStale`; monitor mode adds `firstSeenAt` and `repostedCount` — the role that keeps being reposted every two weeks is flagged, not hidden.
- **Paste a careers URL and it just works.** `https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite`, `https://boards.greenhouse.io/stripe`, `https://acme.breezy.hr` — the right adapter is picked from the hostname.
- **Salary you can filter on — and compare.** Lever, Ashby, Recruitee, Breezy and Pinpoint pay ranges map to one `compensation` object, plus `compensationAnnualUsd` (approximate annual USD equivalent) so a €6,000/month posting and a $150k/year posting are finally comparable. `hasSalary` and `salaryMin` filter *before* you are charged. Only employer-published pay — never estimates.
- **Technology tags.** `technologies` extracts a curated dictionary of ~280 languages, frameworks, clouds and tools from each posting (`technologyFilter` to match). No LLM, no external calls — deterministic and free.
- **Full-text filters that don't lie.** `descriptionQuery` / `descriptionExclude` search the posting body with documented boolean semantics (comma = OR, space = AND) — and when you filter on descriptions without wanting them in the dataset, they are fetched for the filter and stripped from the output.
- **ATS auto-detection.** Don't know the ATS? Pass the bare slug (`stripe`) and the actor probes all twelve slug-addressable boards in parallel.

### What you pay for — and what you don't

Pay per job returned — a fraction of what typical job-data APIs charge per record. Five things are **never charged**:

- **Duplicates.** Deduplication runs *before* billing, not after.
- **Jobs your filters rejected.** Every filter — title, location, remote, date, salary, seniority, staleness — is applied before the meter.
- **Jobs you already received.** In monitor mode, previously delivered jobs are not re-emitted and not re-billed. Removal records are free.
- **Failed boards.** A board that errors returns no rows, so it costs nothing — and the run tells you which board failed and why.
- **Anything past your cap.** `maxTotalJobs` is a hard spending cap per run; `maxJobsPerCompany` stops pagination early rather than fetching and discarding.

No silent truncation: the `SUMMARY` record reports per board how many jobs were found, kept, filtered and deduplicated — and whether a cap was hit.

### Monitor mode: new and removed jobs, on a schedule

Set `monitorMode` and run the actor on a schedule (hourly, daily — your call):

- **`new-only`** — the dataset contains only jobs that appeared since the last run. First run seeds the state; every later run is pure signal.
- **`annotate`** — every job is emitted, enriched with `isNew`, `firstSeenAt` and `repostedCount`.
- **`emitRemoved: true`** — jobs that vanished from a board are emitted as `{ status: "removed", ... }` records (never billed). A removal is your close-date signal.
- **`repostedCount` and `repostedAt`** — how many times this exact role (company + title + location) has been taken down and reposted, and when it last came back. The classic ghost-job tell.
- Removal records carry both `removedAt` and `closedAt` (same value, both names) — a close-date feed you can build history on.

State is kept per `monitorId` in a named key-value store, so different schedules don't interfere. A failed board never marks its jobs as removed. **Keep the input stable between monitor runs**: changing filters or caps mid-stream makes jobs legitimately enter/leave your feed and shows up as new/removed churn.

### What you get

```json
{
  "id": "1f0e5b2a9c4d7e88",
  "source": "ashby",
  "company": "openai",
  "companyName": "OpenAI",
  "title": "Machine Learning Engineer",
  "department": "Research",
  "team": "Training",
  "location": "San Francisco",
  "locations": ["San Francisco", "New York"],
  "isRemote": false,
  "workplaceType": "Hybrid",
  "workplaceTypeNorm": "hybrid",
  "employmentType": "FullTime",
  "employmentTypeNorm": "fulltime",
  "seniority": null,
  "compensation": { "min": 200000, "max": 300000, "currency": "USD", "interval": "1 YEAR", "summary": "$200K – $300K" },
  "url": "https://jobs.ashbyhq.com/openai/...",
  "applyUrl": "https://jobs.ashbyhq.com/openai/.../application",
  "publishedAt": "2026-08-10T00:00:00.000Z",
  "updatedAt": null,
  "daysOpen": 17,
  "isStale": false,
  "countryCode": "US",
  "language": "en",
  "technologies": ["AWS", "PyTorch", "Python"],
  "compensationAnnualUsd": { "min": 200000, "max": 300000 }
}
```

Monitor mode adds `isNew`, `firstSeenAt`, `repostedCount`. Toggle `includeDescription` for the full posting text, `includeRaw` for the untouched ATS payload. Derived fields are honest: `seniority` is `null` when the title gives no signal — never guessed.

### Input in 10 seconds

```json
{
  "companies": [
    "greenhouse:stripe",
    "ashby:openai",
    "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite",
    "acme-corp"
  ],
  "query": "engineer",
  "excludeQuery": "internship, staffing",
  "descriptionQuery": "kubernetes, terraform",
  "technologyFilter": "Python, Go",
  "seniorityFilter": "senior, staff, principal",
  "countryFilter": "US, NL",
  "remoteOnly": true,
  "maxDaysOpen": 30,
  "salaryMin": 150000,
  "maxTotalJobs": 500
}
```

Three ways to name a board, mix them freely:

| Form | Example | When |
|---|---|---|
| `ats:slug` | `greenhouse:stripe` | You know the ATS |
| careers URL | `https://acme.breezy.hr` | You have the link |
| bare slug | `stripe` | You know neither — all twelve slug-based boards are probed |

**Filters** — all applied before billing: `query` / `excludeQuery` (comma = OR, space = AND), `descriptionQuery` / `descriptionExclude`, `technologyFilter`, `seniorityFilter`, `locationFilter`, `countryFilter`, `languageFilter`, `remoteOnly`, `postedAfter`, `maxDaysOpen`, `hasSalary`, `salaryMin` (annual USD), `maxJobsPerCompany`, `maxTotalJobs`.

### Supported ATS platforms

| ATS | Target form | Salary | Notes |
|---|---|---|---|
| **Workday** | careers URL | – | Enterprise. Tenant IDs aren't guessable, so paste the board URL |
| **Oracle HCM** | careers URL | – | Oracle Recruiting Cloud. Paste the `/sites/{SITE}/` URL |
| **Greenhouse** | slug / URL | – | |
| **Lever** | slug / URL | ✅ | |
| **Ashby** | slug / URL | ✅ | Richest compensation data |
| **Workable** | slug / URL | – | |
| **SmartRecruiters** | slug / URL | – | Paginated |
| **Recruitee** | slug / URL | ✅ | |
| **Personio** | slug / URL | – | XML feed, strong in Europe |
| **Breezy HR** | slug / URL | ✅ | Free-text ranges parsed |
| **Rippling** | slug / URL | – | List view carries no dates |
| **Eightfold** | slug / URL | – | Enterprise talent platform |
| **Pinpoint** | slug / URL | ✅ | No posting dates in the public feed |
| **Teamtailor** | slug / URL | – | RSS feed; 13,000+ companies, strong in Europe |

### Integrations

The dataset is plain JSON on the Apify platform, so everything downstream just works: **n8n**, **Make**, **Zapier** (Apify app → dataset items), a built-in **`webhookUrl`** that POSTs the run's new/removed jobs straight to your endpoint when the run finishes (a webhook failure never fails the run), CSV/Excel export, and the **Apify MCP server** — AI agents can call this actor as a tool and query hiring data mid-conversation. Stable field names are designed for LLM pipelines.

### Common uses

- **Job boards & aggregators** — fresh postings straight from the source, with stale roles filtered out and closed roles detected by monitor mode.
- **Recruiting & sourcing** — monitor competitors' hiring by team, title and seniority; a new posting is a buying signal.
- **Sales intelligence** — a company hiring "Head of Payments" is about to buy payments tooling. Schedule monitor mode and act on `isNew` the hour it appears.
- **Salary benchmarking** — collect published ranges at scale from the five ATSes that expose them, filtered by floor.
- **Labor-market research** — lifecycle data (`firstSeenAt`, removal records, `repostedCount`) instead of raw snapshots.

### When a company returns zero jobs

Every run writes a **`BOARD-STATUS`** record (JSON + CSV) to the key-value store: one row per company with found/kept/filtered/deduplicated counts, the error if the board failed, and a **hint** — a wrong slug tells you the URL forms to check, a fully-filtered board tells you which filter ate the jobs. No guessing, no "why is my dataset empty" mystery: the answer is in the run.

### Coverage & freshness

Data comes from each company's live job board at run time — if it's on the board, you get it, and closed roles are gone the moment the company closes them. All fourteen adapters are verified against live boards.

Honest caveats:

- Workday's list view dates jobs as "Posted 3 Days Ago", so `publishedAt` is approximated unless you enable `includeDescription`, which fetches the exact date per job.
- Rippling and Pinpoint publish no posting dates on their public feeds, so `daysOpen` is `null` there (monitor mode's `firstSeenAt` still tracks them).
- `isStale` is a **heuristic**, not a verdict: it means "open at least 60 days" (configurable via `staleAfterDays`). Some long-open roles are genuinely open.
- `seniority` is inferred from the title only, and is `null` when there is no explicit signal.
- `countryCode` and `language` are best-effort and honest: `null` when the location or text is ambiguous ("London" alone names no country), never guessed. Language detection needs the description text.
- `compensationAnnualUsd` uses approximate exchange rates updated at release time — comparison-grade, not payroll-grade.

### FAQ

**How do I find a company's board slug?** It's in the careers-page URL: `boards.greenhouse.io/{slug}`, `jobs.lever.co/{slug}`, `jobs.ashbyhq.com/{slug}`, `apply.workable.com/{slug}`, `careers.smartrecruiters.com/{Company}`, `{slug}.recruitee.com`, `{slug}.jobs.personio.de`, `{slug}.breezy.hr`, `{slug}.eightfold.ai`, `{slug}.pinpointhq.com`, `{slug}.teamtailor.com`. Or just paste the whole URL — or the bare company name and let auto-detection try all twelve.

**How do I scrape a Workday or Oracle board?** Paste the careers URL, e.g. `https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite` or `https://tenant.fa.us2.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX_1/requisitions`. Their tenant IDs can't be derived from a company name, which is why a slug alone won't do.

**How do I get only new jobs every day?** Set `monitorMode: "new-only"`, schedule the actor daily, and every run's dataset contains exactly the postings that appeared since the previous run. Add `emitRemoved: true` to also get close signals.

**How do I filter out ghost jobs?** Set `maxDaysOpen` (e.g. `30`), and in monitor mode watch `repostedCount` — a role that keeps being reposted is the classic ghost pattern. Every job also carries `daysOpen` and `isStale` so you can decide for yourself.

**Why do I get fewer jobs than the careers site shows?** The `SUMMARY` and `BOARD-STATUS` records account for every job: found vs kept vs filtered vs deduplicated, per board. If a number surprises you, the breakdown is already in your run — most often it's your own filters or the dedup doing exactly what they promise.

**Does this scrape LinkedIn or Indeed?** No — deliberately. Those platforms block and litigate, and scraping them risks your account. This actor uses only the ATS boards companies publish on purpose. That's why it needs no proxies and doesn't break.

**What happens when an ATS changes its API?** Each adapter is isolated and contract-tested against fixtures captured from live boards; fixes ship without touching the rest. Open an issue on the actor page and it gets picked up.

# Actor input Schema

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

One entry per company, in any of three forms — mix them freely.

1. **Careers URL** — paste the link, the ATS is read from the hostname: `https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite`, `https://boards.greenhouse.io/stripe`, `https://acme.breezy.hr`. **Workday and Oracle boards must use this form** (tenant IDs can't be guessed from a company name).
2. **`ats:slug`** — `greenhouse:stripe`, `lever:leverdemo`, `ashby:openai`, `workable:blueground`, `smartrecruiters:acme`, `recruitee:acme`, `personio:acme`, `breezy:acme`, `rippling:acme`, `eightfold:bayer`, `pinpoint:acme`, `teamtailor:acme`.
3. **Bare slug** — `stripe` probes all twelve slug-addressable boards and keeps every hit.

## `query` (type: `string`):

Only keep jobs whose **title** matches these keywords.

Syntax: **comma = OR**, **space = AND** inside one alternative, **case-insensitive**, **substring** match. `senior engineer, staff engineer` keeps a title containing both "senior" and "engineer", or both "staff" and "engineer". Substring means `intern` also matches "Internal Tools Engineer" — use a longer word when that matters. Leave empty to keep every title.

## `excludeQuery` (type: `string`):

**Drop** jobs whose title matches these keywords. Same syntax as **Title keywords** (comma = OR, space = AND, case-insensitive, substring): `intern, staffing agency` drops titles containing "intern" **or** containing both "staffing" and "agency".

Applied after **Title keywords**, so the two combine: keep the engineers, minus the internships. Remember the substring rule — `intern` also drops "Internal Tools Engineer"; write `internship` if that is not what you want.

## `descriptionQuery` (type: `string`):

Only keep jobs whose **full description text** matches these keywords. Same syntax as **Title keywords**: **comma = OR**, **space = AND** inside one alternative, **case-insensitive**, **substring** match. `kubernetes, terraform` keeps postings whose text mentions either.

**This costs requests.** The description has to be fetched to be searched, so switching this on makes the run do the same work as **Include full description** — on Workday that is **one extra detail request per job**, and on Greenhouse/Workable a heavier payload. The descriptions are *not* added to the output unless you also switch on **Include full description**: the filter runs, the records stay small.

Boards that expose no description cannot match, so their jobs are dropped by this filter.

## `descriptionExclude` (type: `string`):

**Drop** jobs whose full description text matches these keywords (comma = OR, space = AND, case-insensitive, substring). Useful for the things a title never says: `security clearance, must relocate, unpaid`.

Same request cost as **Description keywords**. Jobs with no description available are **kept** — no text means nothing matched, so there is nothing to exclude.

## `seniorityFilter` (type: `string`):

Only keep jobs whose **inferred** seniority is in this comma-separated list. Levels: `intern`, `junior`, `mid`, `senior`, `staff`, `principal`, `lead`, `manager`, `director`, `vp`, `cxo`. Example: `senior, staff, principal`.

Seniority is derived from the job title (no ATS publishes it), and it is `null` whenever the title gives no signal — e.g. plain "Software Engineer". **Jobs with no inferred seniority are dropped by this filter**, on the assumption that filtering by level means wanting explicit matches. Leave it empty to keep them and read the `seniority` field yourself. Unknown words in the list are ignored.

## `technologyFilter` (type: `string`):

Only keep jobs that use at least one of these technologies (comma-separated, case-insensitive). Example: `python, go, kubernetes`.

Technologies are **detected** from the job title and — when it was fetched — the description, against a curated dictionary of ~280 languages, frameworks, databases, cloud and infra tools, and practices. Every job carries the result in `technologies`. Detection from a title alone is thin: pair this with **Description keywords** or **Include full description** to search the actual requirements text.

**Jobs where nothing was detected are dropped by this filter**, on the assumption that filtering by stack means wanting explicit matches. Leave it empty to keep them and read the `technologies` field yourself.

## `locationFilter` (type: `string`):

Only keep jobs whose location contains this text (case-insensitive). Example: `London` or `Remote`.

## `countryFilter` (type: `string`):

Only keep jobs in these countries, as comma-separated **ISO-2 codes** (case-insensitive). Example: `de, at, ch` or `us`.

No ATS publishes a country field, so every job carries a `countryCode` **derived** from its location text: country names in English and in the common native spellings (`Deutschland`, `Nederland`, `Sverige`, `Polska`, `Brasil`), bare ISO-2/ISO-3 codes where a country can actually be meant, and `City, MO`-style US state abbreviations as a last resort. It is `null` whenever the string does not say — `London` is a city in three countries, `Remote` is nowhere, and guessing would be worse than admitting it.

**Only jobs whose country could be determined pass this filter**, so remote-anywhere postings are dropped by it. Leave it empty to keep them and read the `countryCode` field yourself.

## `languageFilter` (type: `string`):

Only keep jobs written in these languages, as comma-separated **ISO 639-1 codes** (case-insensitive). Supported: `en`, `de`, `fr`, `es`, `it`, `nl`, `pt`, `pl`, `sv`, `da`. Example: `en, de`.

The language is **detected** from the description text by counting each language's most frequent function words — never from the title, which says nothing ("Senior Product Manager" is the same string in Berlin and in Boston). It is `null` for postings under ~30 words and for the bilingual ones plenty of EU boards publish.

**This filter needs the description**, so switch on **Include full description** (or use a description filter) — otherwise every job has `language: null`, nothing matches and the run returns nothing. **Jobs whose language could not be determined are dropped by this filter.**

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

Keep only jobs flagged remote by the ATS or whose location says remote.

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

ISO date (YYYY-MM-DD). Only keep jobs published/updated after this date. Boards that expose no date keep the job (better safe than silently empty).

## `hasSalary` (type: `boolean`):

Keep only jobs where the ATS publishes pay. Lever, Ashby, Recruitee, Breezy and Pinpoint expose it; the others do not.

## `salaryMin` (type: `integer`):

Keep only jobs whose top-of-range pay is at least this much, as an **annual USD equivalent**: converted from the published currency and interval with approximate rates, so a `6000 EUR / month` posting is compared as ~83,000 USD instead of failing a 70,000 floor. The converted figure is published on every job as `compensationAnnualUsd`, next to the untouched raw `compensation`.

Jobs are dropped when they cannot be compared: no published pay, a currency outside the ~25 majors in the rate table, or a number whose interval the ATS never stated and that is too small to be a plausible annual salary.

## `maxDaysOpen` (type: `integer`):

Keep only jobs posted within this many days. The classic ghost-job filter: `30` drops anything that has been sitting on the board for a month. Jobs with no date are kept.

## `staleAfterDays` (type: `integer`):

How many days open before a job is flagged `isStale: true`. Does not drop anything — it only labels. Default 60.

## `monitorMode` (type: `string`):

Turns this actor into a job **monitor** for scheduled runs. The actor remembers every posting it has already returned, so a daily run reports what actually changed instead of the same board over and over.

- **Off** — every run returns the full board (default; identical to previous versions).
- **Annotate only** — return everything, but stamp each job with `isNew`, `firstSeenAt`, `repostedCount` and `repostedAt` (the date a posting came back after having been taken down — `null` for everything else).
- **New jobs only** — return *only* postings not seen in an earlier run. Jobs you already received are **not re-emitted and not charged again**; they only refresh their `lastSeenAt` internally.

Use the same **Monitor ID** on every run of a schedule, or the actor has nothing to compare against. Also switch on **Report removed jobs** to be told when a posting disappears: those records carry the closing date under both names in use, `removedAt` and `closedAt`.

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

Name of the memory this run reads and writes. Keep it identical across the runs of one schedule; give a different ID to each schedule so two monitors watching different companies never diff against each other. Changing it starts from a blank slate (every job is new again).

## `emitRemoved` (type: `boolean`):

Also output one record per posting that has disappeared from a board since the previous run, marked `status: "removed"` with `id`, `title`, `url`, `firstSeenAt` and the closing date under both names in use, `removedAt` and `closedAt`. Removal records are **never charged**. Boards that failed this run produce no removals, so a network blip is never reported as a closed job. Requires a monitor mode other than Off.

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

Include descriptionHtml and descriptionText in every record. Off by default to keep items small and runs fast. On Workday this also fetches each job's exact posting date (one extra request per job).

This switch controls the **output** only. **Description keywords** and **Technologies** fetch the description on their own when they need it, and leaving this off keeps the text out of the records while the filters still run on it.

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

0 = no limit. The form pre-fills 50 so a trial run stays cheap — clear it for complete boards.

## `maxTotalJobs` (type: `integer`):

Hard cap on billed results per run — 0 = no cap (default). Across **all** boards, not per company: once this many jobs have been returned the run stops keeping results and finishes. Use it to make the cost of a run predictable before you start it. The cap is always reported in the run's status message and in the SUMMARY record, so a short result set is never a silent truncation. With a monitor mode on, the cap counts the jobs actually returned (in *New jobs only*, the new ones), and jobs it held back are **not** recorded as seen — they come back on the next run instead of being lost.

## `dedupe` (type: `boolean`):

Drop postings with identical company + title + location (duplicates across pages, duplicate boards, or double-published roles).

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

How many boards to fetch in parallel.

## `webhookUrl` (type: `string`):

An **https** URL to POST the result to when the run finishes. Useful for a scheduled monitor: your endpoint is told what changed instead of your having to poll the dataset.

The body is JSON: `event` (`"run.finished"`), `summary` (the same object as the SUMMARY record — per-board counts, failures and hints), `newJobs` (up to the first 200 jobs this run returned; in **New jobs only** mode those are exactly the new ones), `removed` (up to the first 200 removal records, when **Report removed jobs** is on), `datasetId` and `runId` so you can fetch the rest.

Sent once, 15-second timeout, one retry. **A webhook failure never fails the run** — the results are already saved and charged, so a dead endpoint is logged as a warning and nothing more. Plain `http://` URLs are refused, because the body carries the whole result set.

## `includeRaw` (type: `boolean`):

Attach the original ATS object under `raw` for fields the unified schema does not carry.

## Actor input object example

```json
{
  "companies": [
    "greenhouse:stripe",
    "ashby:openai",
    "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite"
  ],
  "remoteOnly": false,
  "hasSalary": false,
  "staleAfterDays": 60,
  "monitorMode": "off",
  "monitorId": "default",
  "emitRemoved": false,
  "includeDescription": false,
  "maxJobsPerCompany": 50,
  "maxTotalJobs": 0,
  "dedupe": true,
  "concurrency": 10,
  "includeRaw": false
}
```

# Actor output Schema

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

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

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

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "companies": [
        "greenhouse:stripe",
        "ashby:openai",
        "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite"
    ],
    "maxJobsPerCompany": 50
};

// Run the Actor and wait for it to finish
const run = await client.actor("sukhdipp/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": [
        "greenhouse:stripe",
        "ashby:openai",
        "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite",
    ],
    "maxJobsPerCompany": 50,
}

# Run the Actor and wait for it to finish
run = client.actor("sukhdipp/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": [
    "greenhouse:stripe",
    "ashby:openai",
    "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite"
  ],
  "maxJobsPerCompany": 50
}' |
apify call sukhdipp/ats-job-scraper --silent --output-dataset

```

## MCP server setup

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