# ATS Jobs Scraper: Workday, Greenhouse, Lever, Ashby (`dgn-lab/ats-jobs-scraper`) Actor

Type a company name, get its open jobs. Auto-detects the career site on 13 ATS (Workday, Greenhouse, Lever, Ashby, SmartRecruiters...) and returns one clean format: salary in USD, skills, seniority, visa sponsorship. New and closed job alerts. Pay per job delivered.

- **URL**: https://apify.com/dgn-lab/ats-jobs-scraper.md
- **Developed by:** [Sinan Dogan](https://apify.com/dgn-lab) (community)
- **Categories:** Jobs, Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 1,000 job delivereds

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

### What does ATS Jobs Scraper do?

**Type a company name, get its open jobs.** **ATS Jobs Scraper** finds a company's career site on **13 applicant tracking systems (ATS)** ([Workday](https://www.workday.com/), [Greenhouse](https://www.greenhouse.com/), Lever, Ashby, SmartRecruiters, Workable, Recruitee, Teamtailor, Personio, Breezy HR, Pinpoint, BambooHR and Rippling) and returns every open job in **one clean, comparable format**. It works as a **Workday, Greenhouse and Lever jobs API**: no ATS account, no API key, no browser.

- 🔎 **No URL hunting:** enter `Stripe`, `nvidia.com` or a job-board URL. The Actor detects the ATS itself, follows "Careers" links on the company website, and tells you how sure it is (`matchConfidence`).
- 💰 **Salaries you can compare:** published pay ranges, plus the ones written in the description, annualised and converted to **USD**. Filter on "at least 100,000 USD a year" across hourly, monthly and yearly offers.
- 🧠 **Every job enriched, free:** about 180 **skills** detected, **seniority**, **visa sponsorship**, **years of experience**, remote/hybrid/on-site, contract type, company domain and LinkedIn page.
- 📊 **Hiring signals per company:** one row per company with open jobs by department, location and seniority, top skills and remote share. Built for Clay, CRMs and account scoring.
- 🔔 **New and closed job alerts:** schedule it and get a Slack, Discord or Telegram message (or a webhook) the day a company opens, changes or closes a role.
- 💲 **Pay only for the jobs you get:** **$3 per 1,000 jobs delivered**. Jobs dropped by your filters cost nothing, and a monitoring run where nothing changed costs nothing per job.

**Who uses it:**

| You are                              | You use it to                                                                                                                                                  |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sales and growth teams**           | Spot companies hiring for the roles your product serves (a new "Head of Security" means a security budget) and push the signal to Clay, HubSpot or Salesforce. |
| **Recruiters and staffing agencies** | Watch target accounts and be first on every new opening, with the salary and the skills already extracted.                                                     |
| **Job boards and HR tech**           | Aggregate fresh jobs straight from the source ATS, deduplicated (`jobKey`) and in one schema.                                                                  |
| **Investors and analysts**           | Track hiring velocity, departments and locations of portfolio and competitor companies over time.                                                              |

### How to scrape Workday, Greenhouse and Lever jobs

1. Keep the two example **Companies**, or enter your own company names or career URLs.
2. Keep **Max jobs per company** at 20 for a small first run.
3. Click **Start**, then inspect the dataset, the **Run summary** and the Markdown **Report** outputs.
4. Download the dataset as JSON, HTML, CSV or Excel. Some sources do not publish every field; missing values remain null.

```json
{
    "companies": ["Stripe", "https://jobs.lever.co/spotify"],
    "maxJobsPerCompany": 20,
    "includeDescription": true
}
```

### Why use this jobs scraper?

- **Thirteen ATS, one format:** compare roles without normalizing thirteen different exports. Workday, Greenhouse, Lever, Ashby, Workable, Recruitee, SmartRecruiters, Teamtailor, Personio, Breezy HR, Pinpoint, BambooHR (`https://<company>.bamboohr.com/careers`) and Rippling (`https://ats.rippling.com/<company>/jobs`) are read through the public feeds their own career pages use.
- **Workday career sites:** paste a Workday URL such as `https://acme.wd5.myworkdayjobs.com/en-US/External` or `https://wd3.myworkdaysite.com/recruiting/acme/careers` (locale segments and job links work too), or a company website that links to it. Every job is paginated; sites with more than 2,000 jobs are split by job category, because Workday searches stop at 2,000. Descriptions, contract type, country, all locations and the exact posting date are fetched job by job (5 at a time), only for saved jobs or when description filters (keywords, required skills, visa sponsorship, experience) need them.
- **Careers pages followed:** for a company website, the Actor checks the page you gave and the homepage, then follows up to two same-company links such as "Careers", "Jobs", "Join us", "Work with us", "Emploi", "Recrutement", "Karriere" or "Carreras", then up to two such links on those careers pages (large companies often link their Workday site from a job search page), including redirects to the ATS, and finally tries `/careers` and `/jobs`.
- **Company names reach Workday:** for a plain name, the Actor also looks for the company homepage at `<name>.com`, `.fr`, `.io`, `.ai`, `.de` and `.co`, and accepts one only when its site name or title names the company (a parked `stripe.de` or a placeholder `revolut.io` is rejected). It then follows that website's careers links like above, so "Nvidia" finds `nvidia.wd5.myworkdayjobs.com`. A board linked from the website wins over a board guessed from the name. No third-party lookup service is called. Sites behind bot protection (for example Airbus or Revolut) cannot be read: paste their job-board URL.
- **Flexible company input:** use names for convenience or exact job-board URLs for precise targeting. Website and name discovery are heuristics, so every company gets a `matchConfidence` in the summary: `certain` (job-board URL), `high` (linked from the company website you gave or found from the name, or name confirmed by the ATS), `medium` (board name only) or `ambiguous` (namesakes found on several ATS, listed so you can pick the right URL).
- **Filters that match how people search:** workplace type (remote, hybrid, on-site), contract type (full-time, part-time, contract, internship, temporary), minimum salary, jobs with a published salary only, title keywords and exclusions, location, department, publication age, seniority level, required skills, visa sponsorship and maximum years of experience. Many companies do not publish contract or workplace types; the filters let you keep or drop those jobs explicitly instead of hiding them.
- **More salaries:** structured pay ranges from the ATS, plus ranges written in the job description (e.g. "$120,000 - $150,000") when the ATS fields are empty. `salarySource` tells you which. `salaryMin`, `salaryMax`, `salaryCurrency` and `salaryPeriod` keep the published amounts, currency and period; the annualised and USD fields below make them comparable.
- **Comparable salaries:** `salaryAnnualMin` and `salaryAnnualMax` give the pay per year in the published currency (hourly x 2,080, daily x 260, weekly x 52, monthly x 12, yearly x 1). `salaryAnnualUsdMin` and `salaryAnnualUsdMax` convert them to US dollars with a **static exchange-rate table of 2026-10-29** (European Central Bank reference rates, 30 currencies). These conversions are approximate: rates move every day. Annual amounts are null when the period is not published; USD amounts are null for currencies outside the table.
- **Minimum annual salary in your currency:** **Minimum annual salary** compares each job's highest annualised salary, converted to **Minimum salary currency** (default USD), so a $60/hour contract passes a 100,000 USD filter and a 100,000 EUR job is compared with its dollar value. A salary without a published period is compared as published, as a yearly amount.
- **Skills and seniority, free:** every job gets `detectedSkills` (about 180 tools, languages and certifications such as Python, Kubernetes, Salesforce, SAP or IFRS, matched as whole words in the title and description) and a `seniority` level read from the title (intern, junior, mid, senior, staff, principal, lead, manager, director, executive). No AI call and no extra request.
- **Visa sponsorship, experience and recruiting contact, free:** `visaSponsorship` is `true` when the description offers sponsorship ("visa sponsorship available", "we sponsor H-1B"), `false` when it rules it out ("unable to sponsor", "without sponsorship", "requiring sponsorship will not be considered") and null when it says nothing. `experienceYearsMin` reads "5+ years of experience", "at least 3 years" or "3-5 years" (3), ignoring company-age phrases such as "founded 20 years ago". `contactEmails` lists only generic recruiting mailboxes written in the description (jobs@, careers@, recruiting@, talent@, hr@, rh@, recrutement@, hiring@...); addresses of individual people are never extracted. All three are read from the description with rules (English, plus some French and German for experience), with no AI call. They need descriptions: with **Include job description** off, they are only filled when a description filter (keywords, required skills, visa sponsorship, experience) downloads them.
- **Company hiring summaries:** set **Output** to "Company summaries" (or "Both") to get one row per company with `totalOpenJobs`, `departmentBreakdown`, `locationBreakdown`, `seniorityBreakdown`, `topSkills`, `remoteShare` and, with change tracking, `newJobsCount` and `closedJobsCount`. Summaries count every job that matches your filters, regardless of **Max jobs per company**. In "Company summaries" mode, SmartRecruiters and Workday descriptions are not downloaded job by job, so their `topSkills` come from job titles only.
- **Sort order before the cap:** **Sort jobs by** orders each company's matching jobs before **Max jobs per company** keeps the first ones, so the jobs whose details are downloaded and saved are the ones you asked for. "Newest first" (default) sorts by `publishedAt`, then `updatedAt`, jobs without a date last. BambooHR and Rippling job lists have no dates, so with "Newest first" their jobs keep the career site order, unless a salary or publication-age filter downloads their details first. "Highest salary first" sorts by `salaryAnnualUsdMax` (else `salaryAnnualUsdMin`), jobs without a USD salary last; SmartRecruiters, Workday, BambooHR and Rippling often publish salaries only in the job details, which are downloaded after the sort, so their jobs mostly keep the career site order. "Career site order" keeps the order of the source.
- **Markdown report:** every run also stores a `REPORT` record (Markdown) in the key-value store: one table of the companies found (company, ATS, domain, LinkedIn, open jobs, matching jobs, top 5 skills, remote share, USD salary range), the new jobs since the previous run when change tracking is on (up to 50, with links), and the companies not found with how to fix them. Paste it into a doc, a ticket or a chat message.
- **Description keywords:** keep only jobs whose description mentions at least one of your keywords, e.g. "Kubernetes" or "HubSpot". Whole words, case-insensitive.
- **Seniority, skills, visa and experience filters:** these run on the fields above, before any job is saved.
  - **Seniority levels** keeps the selected levels. A title without a level ("Software Engineer") has an unknown seniority and is dropped unless you also select **Unknown**; select it when you look for mid-level roles, which often have no qualifier.
  - **Required skills** keeps jobs whose `detectedSkills` include **any** of your skills (default) or **all** of them (**Required skills match**). Names are matched case-insensitively against the skill dictionary, and common aliases work ("golang" for Go, "k8s" for Kubernetes, "postgres" for PostgreSQL). Unknown names are ignored with a warning; if none is known, the run stops with an error rather than returning unfiltered jobs. Use **Description keywords** for free text.
  - **Only jobs offering visa sponsorship** keeps jobs whose `visaSponsorship` is `true`; jobs that do not mention it are dropped.
  - **Maximum years of experience required** keeps jobs whose `experienceYearsMin` is at most your value, **and jobs whose description states no requirement** (null), since most descriptions do not.
  - Skills, visa and experience filters download descriptions (job by job on SmartRecruiters, Workday, BambooHR and Rippling, only for jobs that pass the other filters), even with **Include job description** off.
- **Company domain for enrichment:** `companyDomain` holds the company website domain and `companyDomainSource` says where it comes from: `input` (a website you entered), `ats` (a company URL published by the ATS) or `resolved` (the homepage found from a plain name and checked against it, as described above). The domain kept is the one tried when the site only redirects to a country version (`philips.com`), and the landing domain otherwise (`datadoghq.com`). It is null when none is found.
- **Company LinkedIn page and description, free:** when the Actor downloads the company homepage or careers page to find the job board (website input, or a website found from a name), it also reads `companyLinkedinUrl` (a `linkedin.com/company/...` link on the page; personal `/in/` profiles are never taken) and `companyDescription` (the page's meta description, else `og:description`, trimmed to 300 characters). No extra request: both are null for job-board URL inputs, and when the page has none. Monitors remember them with the resolved board.
- **Hiring changes:** identify new, updated, unchanged and closed roles between scheduled runs. Updated jobs list their `changedFields` (title, location, salary, description...). Capped changes stay pending for a later run. `jobKey` is a stable identifier to deduplicate across runs.

### What data do you get?

| Fields                                                     | Meaning                                                                   |
| ---------------------------------------------------------- | ------------------------------------------------------------------------- |
| `title`, `url`, `applyUrl`                                 | Role and application links                                                |
| `companyName`, `companySlug`, `ats`                        | Company and source                                                        |
| `companyDomain`, `companyDomainSource`                     | Company website domain, and whether it came from input, ATS or resolution |
| `companyLinkedinUrl`, `companyDescription`                 | LinkedIn company page and meta description from the company website       |
| `recordType`                                               | `job`, or `companySummary` for company summary rows                       |
| `seniority`                                                | Level read from the title, or null                                        |
| `visaSponsorship`                                          | Sponsorship offered (true), ruled out (false) or not stated (null)        |
| `experienceYearsMin`                                       | Minimum years of experience required, or null                             |
| `contactEmails`                                            | Generic recruiting mailboxes from the description (never personal ones)   |
| `detectedSkills`                                           | Tools and skills named in the title or description                        |
| `location`, `locations`, `country`                         | Published locations                                                       |
| `workplaceType`, `isRemote`                                | Source work arrangement or a remote keyword signal                        |
| `employmentType`, `employmentCategory`                     | Contract type as published, and normalized                                |
| `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryPeriod` | Published pay, with unknown parts left null                               |
| `salaryText`, `salarySource`                               | Salary as published, and whether it comes from the ATS or the description |
| `salaryAnnualMin`, `salaryAnnualMax`                       | Salary per year in the published currency                                 |
| `salaryAnnualUsdMin`, `salaryAnnualUsdMax`                 | Annual salary in USD, static FX table of 2026-10-29 (approximate)         |
| `publishedAt`, `updatedAt`                                 | Dates supplied by the ATS (approximate for Workday jobs without details)  |
| `descriptionText`, `descriptionHtml`                       | Optional full description                                                 |
| `changeStatus`, `changedFields`                            | Change relative to the saved monitoring snapshot, and what changed        |
| `jobKey`                                                   | Stable identifier: ats:company:jobId                                      |

Company summary rows (output modes "Company summaries" and "Both") have `recordType: "companySummary"`, `companyName`, `companyInput`, `ats`, `companySlug`, `companyDomain`, `companyDomainSource`, `companyLinkedinUrl`, `companyDescription`, `totalOpenJobs`, `departmentBreakdown`, `locationBreakdown`, `seniorityBreakdown`, `topSkills`, `remoteShare` (0 to 1), `newJobsCount` and `closedJobsCount` (null without change tracking; on a company's first monitoring run every job counts as new). The dataset's **Company summaries** view shows them; in "Both" mode, filter on `recordType` to separate them from jobs.

```json
[
    {
        "recordType": "companySummary",
        "companyName": "Example Company",
        "ats": "workday",
        "companySlug": "examplecompany/External",
        "companyDomain": "example.com",
        "companyLinkedinUrl": "https://www.linkedin.com/company/example-company",
        "companyDescription": "Example Company builds payment tools for online businesses.",
        "totalOpenJobs": 42,
        "departmentBreakdown": { "Unknown": 42 },
        "locationBreakdown": { "Paris, France": 15, "Remote": 10, "Other": 17 },
        "remoteShare": 0.24,
        "topSkills": { "Python": 12, "AWS": 8 },
        "seniorityBreakdown": { "senior": 14, "unspecified": 20, "junior": 8 },
        "newJobsCount": null,
        "closedJobsCount": null
    }
]
```

### Monitor new and closed jobs

Enable **Track changes between runs** and **Only output changes**, then save a task and schedule it. Give each independent monitor a different **Snapshot store name**. The first run reports current matching jobs as new; later runs compare with the saved state. A closed job means it disappeared from the fetched board.

```json
{
    "companies": ["Stripe"],
    "maxJobsPerCompany": 20,
    "monitorChanges": true,
    "onlyChanges": true,
    "stateStoreName": "my-hiring-monitor"
}
```

#### Slack, Discord and Telegram alerts

Add a **Notification webhook URL** to a monitor and each run that finds changes posts a short summary with links to the jobs:

- **Slack:** an incoming webhook URL (`https://hooks.slack.com/services/...`).
- **Discord:** a channel webhook URL (`https://discord.com/api/webhooks/...`). Messages are kept under Discord's 2,000-character limit.
- **Telegram:** `https://api.telegram.org/bot<token>/sendMessage?chat_id=<chat id>`.
- **Anything else** (n8n, Make, Zapier, your API) receives JSON: `event`, `text`, `counts`, `datasetUrl` and up to 100 `jobs` with `changeStatus`, `title`, `companyName`, `location` and `url`.

The URL is stored encrypted and never written to the log. A company's first monitoring run sets the baseline and sends nothing. A failed delivery is logged as a warning and never fails the run.

Every run remembers which job board each company name or website resolved to, so later runs on the same companies skip detection (about 30 requests per company). Monitors keep this in their state store; other runs keep it in a small key-value store named `ats-jobs-scraper-cache` in your account. A board that disappears or fails is detected again (without change tracking, so is a board left with no jobs), and name and website matches are re-checked every 30 days; `resolvedFromCache` in the run summary shows which companies used it. Job board URLs need no detection and are not cached. To force a fresh detection, delete the `ats-jobs-scraper-cache` store or paste the job board URL.

Keep company inputs and filters stable for a monitor. A publication-age filter excludes jobs without a published date; a remote flag does not mean the employer accepts applicants from every country. Consult the location and job description before using a result.

### Example output

This is an illustrative excerpt with anonymized companies. Real runs contain the full schema and source values.

```json
[
    {
        "ats": "greenhouse",
        "companyName": "Example Company",
        "companySlug": "examplecompany",
        "jobId": "1234567",
        "title": "Backend Engineer",
        "url": "https://job-boards.greenhouse.io/examplecompany/jobs/1234567",
        "location": "Remote, France",
        "isRemote": true,
        "salaryMin": null,
        "salaryMax": null,
        "salaryCurrency": null,
        "salaryPeriod": null,
        "descriptionText": "Build and maintain backend services.",
        "changeStatus": "new"
    },
    {
        "ats": "lever",
        "companyName": "Example Company Two",
        "companySlug": "examplecompanytwo",
        "jobId": "abcd1234",
        "title": "Product Designer",
        "url": "https://jobs.lever.co/examplecompanytwo/abcd1234",
        "location": "London, United Kingdom",
        "workplaceType": "hybrid",
        "isRemote": false,
        "salaryMin": 60000,
        "salaryMax": 80000,
        "salaryCurrency": "GBP",
        "salaryPeriod": "year",
        "salaryAnnualMin": 60000,
        "salaryAnnualMax": 80000,
        "salaryAnnualUsdMin": 79901,
        "salaryAnnualUsdMax": 106535,
        "seniority": "senior",
        "detectedSkills": ["Figma", "Jira"],
        "companyDomain": "example.com",
        "companyDomainSource": "resolved",
        "changeStatus": "new"
    }
]
```

### How much does it cost to scrape company jobs?

**$3 per 1,000 jobs delivered ($0.003 per job), every feature included.** You pay for the jobs that land in your dataset, plus a fraction of a cent per run. Check the **Pricing** tab for the price that applies to your run.

**Run start:** each run also has a small start fee of $0.001 per GB of memory, charged automatically by Apify: **$0.004 per run** at the default 4 GB. It covers starting the container, so a run that finds nothing costs you less than half a cent.

| What happens                                                                                              | Charged?                                  |
| --------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| A job is saved to your dataset (new, updated, unchanged or closed)                                        | Yes, one `job`                            |
| A job is dropped by your filters (title, location, salary, skills, seniority, visa, age...)               | **No**                                    |
| A company is not found, or its board is entered twice                                                     | **No**                                    |
| Company detection, salary normalisation, skills, seniority, visa and experience detection                 | **No**, included                          |
| Company summaries saved with the jobs, the **Report**, the **Run summary**, Slack/Discord/Telegram alerts | **No**, included                          |
| "Company summaries" output only (no job rows)                                                             | One `job` per job counted in each summary |

**What it costs in practice:**

- 20 jobs from each of 50 target companies: 1,000 jobs = **$3**.
- A 700-job board filtered down to the 12 remote senior roles you want: 12 jobs = **$0.036**.
- A daily monitor of 100 companies with **Only output changes**: you pay only for the new, updated and closed jobs of the day.
- Apify's free plan includes $5 of monthly credit: about **1,600 jobs a month for free**.
- Each run adds the $0.004 start fee: 1,000 jobs in one run = **$3.004**.

**Spending limit:** set a maximum cost per run in the run options. The Actor saves the jobs the remaining budget pays for (the first ones in your sort order), then stops cleanly: the run still succeeds, with `spendingLimitReached: true` and `jobsCharged` in the **Run summary**. **Max jobs per company** caps both the output and the cost per company.

No browser or proxy is required. Company discovery can make several requests, and sources that need individual description requests take longer. Start with two companies and 20 jobs each to see the results; turn off descriptions when you only need hiring signals, it makes runs faster.

The Actor supports the thirteen listed ATS. Breezy HR does not publish descriptions in its public feed, so description keywords drop its jobs, its skills come from the title only, and the visa sponsorship filter drops its jobs. It does not cover Taleo, iCIMS or career sites that load their jobs with JavaScript from their own domain.

**Workday specifics.** A Workday address combines a tenant, a data center (`wd1`, `wd5`...) and a site name that cannot be guessed, so a company name reaches Workday only through the company website found from it, when its careers pages link the Workday site within two clicks. Many large companies put their own career site in front of Workday (Phenom, Radancy, Eightfold...) without linking it: paste the Workday URL then. The Workday job list only says "Posted Today", "Posted 3 Days Ago" or "Posted 30+ Days Ago": without descriptions, `publishedAt` is that day at midnight UTC, and "30+ days" is dated 30 days ago, so it can be older. With descriptions, the exact posting date replaces it. Contract type, country and extra locations also come with descriptions; filters run on the list data, so a contract-type filter keeps Workday jobs only when "Unknown" is selected, unless descriptions are on. Workday does not publish departments per job, so department filters and breakdowns see them as unknown. Without filters, change tracking or summaries, **Max jobs per company** stops the download early, with "Newest first" or "Career site order" only: Workday lists jobs newest first, while the highest salaries can be anywhere on the site. Large sites (several thousand jobs) take one request per 20 jobs, plus one per job for descriptions. An ambiguous name, an unsupported source or a blocked endpoint is reported in the summary. A result cap limits saved jobs, not necessarily the number of job records the source API returns.

**BambooHR and Rippling specifics.** Their job lists have titles, locations, departments and workplace types; descriptions, salaries, publication dates and (Rippling) contract types come from one request per job, made for saved jobs when **Include job description** is on, and for every job that passes the other filters when a salary or publication-age filter is set. With descriptions off and no such filter, those fields stay null. BambooHR publishes pay as free text (`salaryText`); a range or single amount with a currency is parsed into numbers.

### Troubleshooting

**Company not found?** Paste its exact supported job-board URL. For Workday, open the company's job search and copy the address that contains `myworkdayjobs.com` or `myworkdaysite.com`. Check the detected ATS, selected company and alternatives in the summary.

**Missing salary or date?** The source may not publish it. Salaries written in descriptions are only read when **Include job description** is on. Null is intentional and should not be interpreted as zero.

**No new results?** Check the filters and monitoring state. With changes-only output, a successful run can legitimately return no rows. A run where no company is found, or no job matches, still succeeds: the status message, the **Run summary** and the **Report** say which company was not found and why.

### Automate with n8n, Make, Zapier, Clay and AI agents (MCP)

Every run can be started from another tool, and its results read back as JSON, CSV or Excel. The Actor ID is `dgn-lab/ats-jobs-scraper` (`dgn-lab~ats-jobs-scraper` in API URLs). Get an API token in [Apify Console > Settings > API & Integrations](https://console.apify.com/settings/integrations) and keep it in your tool's credential store: never paste it into shared workflows, public files or prompts.

#### Apify API: run and get the results in one request

```bash
curl -X POST "https://api.apify.com/v2/actors/dgn-lab~ats-jobs-scraper/run-sync-get-dataset-items?token=<YOUR_APIFY_TOKEN>&format=json&maxTotalChargeUsd=5" \
  -H "Content-Type: application/json" \
  -d '{"companies": ["Stripe", "https://jobs.lever.co/spotify"], "maxJobsPerCompany": 20}'
```

The request body is the Actor input (same fields as the **Input** tab) and the response is the dataset items. Use `format=csv` or `format=xlsx` for a file, and `maxTotalChargeUsd` to cap what one run can cost. This [synchronous endpoint](https://docs.apify.com/api/v2/actor-run-sync-get-dataset-items-post) answers with HTTP 408 when the run lasts more than 300 seconds. Company discovery and job-by-job descriptions (Workday, SmartRecruiters, BambooHR, Rippling) take longer, so keep synchronous calls to a few companies, or turn off **Include job description**. For longer runs, start the run with `POST https://api.apify.com/v2/actors/dgn-lab~ats-jobs-scraper/runs` and read `GET https://api.apify.com/v2/datasets/<defaultDatasetId>/items` once it has finished, or let a webhook tell you (below). The **API** tab shows the same calls in JavaScript, Python and cURL.

#### n8n

1. Install the official Apify node: on n8n Cloud, search **Apify** in the nodes panel; self-hosted, go to **Settings > Community Nodes** and install `@apify/n8n-nodes-apify`.
2. Add the node with the operation **Run an Actor and get dataset**, select `dgn-lab/ats-jobs-scraper` and paste your input JSON in **Custom input**.
3. Send the rows to Google Sheets, your ATS or CRM, or a Slack channel. For hiring signals, set `"outputMode": "companySummary"` to get one row per company.

To process scheduled runs instead, start the workflow with the **On new Apify Event** trigger on this Actor (or on your saved task). In an **AI Agent** workflow, add the **Apify Tool** so the agent can run the Actor itself. Guide: [Apify n8n integration](https://docs.apify.com/integrations/n8n).

#### Make

Add the Apify app, module **Run an Actor** with **Run synchronously** set to Yes, then **Get Dataset Items** with the run's default dataset ID, then for example Google Sheets **Bulk Add Rows**. Make limits how long a synchronous module can wait; for longer runs use the **Watch Actor Runs** trigger and start the Actor from a schedule. Guide: [Apify Make integration](https://docs.apify.com/integrations/make).

#### Zapier

Zapier waits at most 30 seconds for a synchronous **Run Actor** action, so for this Actor use the trigger **Finished Actor run** (or **Finished task run** for a saved task), then the search **Fetch dataset items**, then your next app (Google Sheets, a CRM, Slack, Gmail). Start the runs from an Apify schedule or from another Zap with **Run Actor** (asynchronous). Guide: [Apify Zapier integration](https://docs.apify.com/integrations/zapier).

#### Clay

There is no Apify app inside Clay; use Clay's own **HTTP API** integration with the synchronous endpoint above (method POST, token in the URL from your Clay secrets). For account enrichment, send one company per row, for example `{"companies": ["{{Company domain}}"], "outputMode": "companySummary", "includeDescription": false}` with your column inserted in place of `{{Company domain}}`, then map `totalOpenJobs`, `departmentBreakdown`, `topSkills`, `remoteShare` and `companyLinkedinUrl` to new columns.

#### AI agents and MCP clients

Add the [Apify MCP server](https://docs.apify.com/integrations/mcp) to Claude, ChatGPT, Cursor, VS Code or any MCP client with this URL, which loads only this Actor as a tool:

```text
https://mcp.apify.com?tools=dgn-lab/ats-jobs-scraper
```

On the first connection your browser opens to sign in to Apify (OAuth), so no token is written in the client configuration. The agent can then run the Actor and read its dataset (`get-dataset-items`). With the Apify CLI, `apify mcp install <client>` writes the configuration for you.

#### Schedules and webhooks

Save your input as a **task**, then create a schedule in [Apify Console > Schedules](https://console.apify.com/schedules) with a cron expression and time zone. To push results to your own system, open the Actor's or task's **Integrations** tab and add a webhook on the `ACTOR.RUN.SUCCEEDED` event: Apify sends a POST request with the run details, including `defaultDatasetId`, to your URL. See [webhooks](https://docs.apify.com/integrations/webhooks).

### FAQ, disclaimer and support

#### Can I scrape Workday jobs from a company name?

Often, but not always. A Workday address cannot be guessed, so a name reaches Workday only when the company website found from it links the Workday site within two clicks. Otherwise paste the Workday URL (it contains `myworkdayjobs.com` or `myworkdaysite.com`). See **Workday specifics** above.

#### Do I need an ATS account or API key?

No. The Actor reads the public job-board feeds that the career pages themselves use.

#### Which career sites are not supported?

Taleo, iCIMS and career sites that load their jobs with JavaScript from their own domain. Sites behind bot protection cannot be read either: paste their job-board URL when it is on a supported ATS.

#### How do I find which ATS a company uses?

Enter the company name or website: the Actor tries the 13 supported ATS, follows the careers links of the website, and returns the board it found with `ats`, `matchConfidence` and any alternatives in the **Run summary**. It is also a quick way to map the ATS used by a list of accounts.

#### Is there a Greenhouse, Lever or Workday jobs API?

Greenhouse, Lever and Ashby publish public job-board feeds, and Workday career sites load their jobs from an internal endpoint. This Actor reads all of them for you and returns one schema, so you call one API (the Apify API, below) instead of thirteen.

#### How do I get alerts when a company posts a new job?

Turn on **Track changes between runs** and **Only output changes**, add a Slack, Discord or Telegram **Notification webhook URL**, save the input as a task and schedule it daily. See [Monitor new and closed jobs](#monitor-new-and-closed-jobs).

#### How much does it cost to scrape 10,000 jobs?

$30 at $3 per 1,000 jobs delivered, everything included. Jobs your filters drop are free, so a narrow search costs only what it returns.

#### Is it legal to scrape job postings?

Our Actors are ethical and only read what companies publish on their public career sites. This Actor saves generic recruiting mailboxes written in job descriptions (jobs@, careers@...) but never personal email addresses or personal LinkedIn profiles. Results can still contain personal data, such as a hiring manager named in a description. Personal data is protected by the GDPR in the European Union and by other regulations around the world. You should not scrape personal data unless you have a legitimate reason to do so. If you're unsure whether your reason is legitimate, consult your lawyers.

#### Support

Use the **API** tab for code examples. For a bug or a missing ATS, open the **Issues** tab with the input and run ID. Remove credentials from anything you share.

# Actor input Schema

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

Company names (e.g. "Stripe"), websites (e.g. "notion.so"), career pages or job board URLs (e.g. https://jobs.lever.co/spotify or https://acme.wd5.myworkdayjobs.com/External). The Actor detects which ATS each company uses and follows careers links from a website. For a plain name it also looks for the company homepage (<name>.com, .fr, .io, .ai, .de, .co, accepted only when its title matches the name) and follows its careers links, which is how a name can reach a Workday site.

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

Maximum number of jobs saved per company (0 = no limit). You pay per job saved, so this cap also caps the cost: 20 = at most 20 jobs charged per company, even on a board of 700.

## `sortBy` (type: `string`):

Order of each company's matching jobs, applied before "Max jobs per company" so the limit keeps the first jobs in this order. "Newest first" sorts by publication date, then update date, jobs without a date last; BambooHR and Rippling list no dates, so their jobs keep the career site order unless a salary or date filter downloads their details. "Highest salary first" sorts by the maximum annual salary in USD (else the minimum), jobs without a salary last; SmartRecruiters, Workday, BambooHR and Rippling often show salaries only in the job details. "Career site order" keeps the order of the career site.

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

Add the full job description as HTML and plain text. Turn off for lighter, faster results.

## `outputMode` (type: `string`):

"Jobs" saves one item per job. "Company summaries" saves one item per company instead: open jobs, department, location, seniority and skill breakdowns, remote share and, with change tracking, new and closed job counts. "Both" saves the jobs and the summaries. Summaries count every job that matches the filters, regardless of "Max jobs per company". Saved with the jobs, summaries are free; saved alone, each job a summary counts is charged as one job.

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

Keep only jobs whose title contains at least one of these keywords (case-insensitive). Leave empty to keep all.

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

Drop jobs whose title contains any of these keywords, e.g. "Senior" or "Intern".

## `locationKeywords` (type: `array`):

Keep only jobs whose location contains at least one of these keywords, e.g. "Paris", "Germany" or "Remote".

## `departmentKeywords` (type: `array`):

Keep only jobs whose department or team contains at least one of these keywords, e.g. "Engineering".

## `descriptionKeywords` (type: `array`):

Keep only jobs whose description contains at least one of these keywords (case-insensitive), e.g. "Kubernetes" or "Salesforce". Descriptions are downloaded for matching even when "Include job description" is off. Breezy HR publishes no descriptions, so its jobs are dropped when this is set. On SmartRecruiters and Workday, descriptions are downloaded job by job, which makes matching slower.

## `requiredSkills` (type: `array`):

Keep only jobs whose detected skills (detectedSkills, read from the title and description) include these skills, e.g. "Python", "Kubernetes" or "Salesforce". Names are matched case-insensitively against the built-in skill dictionary; common aliases work ("golang" for Go, "k8s" for Kubernetes, "postgres" for PostgreSQL). Unknown names are ignored with a warning in the log; if none is known, the run stops with an error. Use "Description keywords" for free text. Descriptions are downloaded for matching even when "Include job description" is off. Leave empty to keep all.

## `requiredSkillsMode` (type: `string`):

Whether a job needs at least one of the required skills, or all of them.

## `visaSponsorshipOnly` (type: `boolean`):

Keep only jobs whose description says visa sponsorship is offered (visaSponsorship true). Jobs that do not mention it are dropped. Detection reads English phrasing only.

## `maxExperienceYears` (type: `integer`):

Keep only jobs whose description asks for at most this many years of experience (experienceYearsMin). Jobs whose description states no requirement are kept, since most descriptions do not state one. 0 = no filter.

## `workplaceTypes` (type: `array`):

Keep only jobs with these workplace types. Jobs whose workplace type is not published are kept only if "Unknown" is selected. Leave empty to keep all.

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

Keep only remote jobs (declared remote by the ATS or mentioning remote work in the title or location). Shortcut for Workplace types = Remote that also catches undeclared remote jobs.

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

Keep only these contract types. Many companies do not publish the contract type: select "Unknown" to keep those jobs too. Leave empty to keep all.

## `seniorityLevels` (type: `array`):

Keep only jobs at these levels, read from the job title (see the seniority output field). Titles that state no level ("Software Engineer") have an unknown seniority and are dropped unless you also select "Unknown": select it when looking for mid-level roles, which often have no qualifier in the title. Leave empty to keep all.

## `minSalary` (type: `integer`):

Keep only jobs whose highest salary, as an annual amount, is at least this much in the minimum salary currency. Hourly, daily, weekly and monthly pay is annualised (x 2,080, 260, 52, 12); other currencies are converted with a static, approximate exchange-rate table (ECB rates of 2026-10-29). A salary without a published period is compared as a yearly amount. Jobs without a known salary, or in a currency outside the table, are dropped when this is set (0 = no filter).

## `minSalaryCurrency` (type: `string`):

Currency of the minimum annual salary. Salaries in other currencies are converted with a static, approximate exchange-rate table (ECB reference rates of 2026-10-29).

## `onlyWithSalary` (type: `boolean`):

Keep only jobs that publish a salary range.

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

Keep only jobs published in the last N days (0 = any date). Jobs without a publication date are dropped when this is set.

## `monitorChanges` (type: `boolean`):

Compare each company with the previous run and label jobs as new, updated, unchanged or closed. Schedule the Actor to get hiring alerts.

## `onlyChanges` (type: `boolean`):

With change tracking on, skip unchanged jobs and save only new, updated and closed ones. Only the new, updated and closed jobs saved are charged: a run where nothing changed costs nothing per job.

## `stateStoreName` (type: `string`):

Name of the key-value store holding company snapshots. Use different names to run independent monitors.

## `notificationWebhookUrl` (type: `string`):

Optional. With change tracking on, post a short summary of new, updated and closed jobs to this URL after each run that finds changes. Slack incoming webhooks (hooks.slack.com), Discord webhooks (discord.com/api/webhooks/...) and Telegram bots (https://api.telegram.org/bot<token>/sendMessage?chat_id=<id>) get a chat message; any other URL receives JSON. Nothing is sent on a company's first monitoring run. A failed delivery is logged and never fails the run.

## Actor input object example

```json
{
  "companies": [
    "Stripe",
    "https://jobs.lever.co/spotify"
  ],
  "maxJobsPerCompany": 20,
  "sortBy": "newest",
  "includeDescription": true,
  "outputMode": "jobs",
  "titleKeywords": [],
  "excludeTitleKeywords": [],
  "locationKeywords": [],
  "departmentKeywords": [],
  "descriptionKeywords": [],
  "requiredSkills": [],
  "requiredSkillsMode": "any",
  "visaSponsorshipOnly": false,
  "maxExperienceYears": 0,
  "workplaceTypes": [],
  "remoteOnly": false,
  "employmentTypes": [],
  "seniorityLevels": [],
  "minSalary": 0,
  "minSalaryCurrency": "USD",
  "onlyWithSalary": false,
  "postedWithinDays": 0,
  "monitorChanges": false,
  "onlyChanges": false,
  "stateStoreName": "ats-jobs-monitor"
}
```

# Actor output Schema

## `dataset` (type: `string`):

Dataset containing one item per job

## `companySummaries` (type: `string`):

Company summary items (output modes "Company summaries" and "Both"), in the company summaries view

## `files` (type: `string`):

Key-value store with the SUMMARY and REPORT records

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

Collection coverage, job offers analysed, jobs charged (the billed unit), result counts and errors for this run.

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

Markdown report: companies found with their key figures, new jobs when tracking changes, and companies not found.

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

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

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

```

## MCP server setup

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