# ATS Jobs API - Workday, Greenhouse, Lever, Ashby job scraper (`beguiling_equator/ats-jobs-api`) Actor

Extract every public job posting from company career pages. Auto-detects Workday, Greenhouse, Lever, Ashby, SmartRecruiters, Workable and Recruitee, normalizes jobs to one schema (salary, location, remote, Markdown description) and flags new / updated / closed jobs between runs.

- **URL**: https://apify.com/beguiling_equator/ats-jobs-api.md
- **Developed by:** [WEIGUO HAN](https://apify.com/beguiling_equator) (community)
- **Stats:** 2 total users, 1 monthly users, 60.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.20 / 1,000 job postings

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

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

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

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## ATS Jobs API - Workday, Greenhouse, Lever, Ashby, SmartRecruiters, Workable & Recruitee job scraper (no API key)

Extract **every public job posting from any company's careers page** in one run. Give the Actor a careers URL, a company domain or an `ats:slug` pair; it **auto-detects the applicant tracking system (ATS)**, calls the vendor's **official public JSON endpoint** (no HTML scraping, no login, no API key), and returns clean, normalized jobs: title, locations, remote flag, department, employment type, posted/updated dates, **salary range when published**, and the full description as **Markdown and plain text**.

Run it on a schedule and it also tells you **what changed since last time**: every job is labelled `new`, `updated` or `unchanged`, and jobs that disappeared are emitted as `closed`. That makes it a drop-in **hiring-signal feed** for sales prospecting, recruiting intelligence, job boards, salary research and AI agents.

**Pricing: $1.20 per 1,000 jobs** (pay per event, see below) - across all 7 ATS platforms, including Workday.

### Why this Actor

| | This Actor | Typical single-ATS scrapers |
|---|---|---|
| ATS coverage | **7**: Workday, Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee | 1-3, usually without Workday |
| Input | Careers URL, company domain **or** `greenhouse:gitlab` style slugs - ATS is auto-detected | You must know the board token |
| Change detection | `new` / `updated` / `closed` out of the box (fingerprints in a key-value store) | None |
| Salary | Normalized `salaryMin/Max/Currency/Period` from Ashby, Lever, Recruitee and Greenhouse pay ranges | Raw or missing |
| Description | Markdown **and** plain text | HTML |
| Data source | Vendor-documented public job-board APIs + Workday's own career-site JSON | Rendered pages / pre-scraped caches |
| Politeness | <= 2 requests/s per host, exponential backoff, `Retry-After`, robots.txt | Varies |

### Supported ATS platforms and the endpoints used

All sources are public, read-only, unauthenticated endpoints that the vendors provide for displaying jobs on third-party sites.

| ATS | Public endpoint used | Pagination | Vendor documentation |
|---|---|---|---|
| **Greenhouse** | `GET https://boards-api.greenhouse.io/v1/boards/{board_token}/jobs?content=true` (EU: `boards-api.eu.greenhouse.io`) | single response (`page` fallback when `meta.total` is larger) | [Greenhouse Job Board API](https://developers.greenhouse.io/job-board.html) - "Job Board data is publicly available, so authentication is not required for any GET endpoints" |
| **Lever** | `GET https://api.lever.co/v0/postings/{site}?mode=json&skip={n}&limit=100` (EU: `api.eu.lever.co`) | `skip` / `limit` | [Lever Postings API](https://github.com/lever/postings-api) |
| **Ashby** | `GET https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=true` | single response | [Ashby Public Job Posting API](https://developers.ashbyhq.com/docs/public-job-posting-api) |
| **SmartRecruiters** | `GET https://api.smartrecruiters.com/v1/companies/{company}/postings?limit=100&offset={n}` + `GET .../postings/{id}` for the description | `offset` / `totalFound` | [SmartRecruiters Posting API](https://developers.smartrecruiters.com/docs/posting-api) |
| **Workable** | `GET https://apply.workable.com/api/v1/widget/accounts/{subdomain}?details=true` | single response | [Workable job widget / JSON feed](https://help.workable.com/hc/en-us/articles/115012771647) |
| **Recruitee** | `GET https://{company}.recruitee.com/api/offers/` | single response | [Recruitee Careers Site API](https://support.recruitee.com/en/articles/1066282) - token required from 2027-02-10 (optional input provided) |
| **Workday** | `POST https://{tenant}.wd{N}.myworkdayjobs.com/wday/cxs/{tenant}/{site}/jobs` (`{"appliedFacets":{},"limit":20,"offset":n,"searchText":""}`) + `GET .../wday/cxs/{tenant}/{site}{externalPath}` for the description | `offset` / `total`, 20 per page | Workday does not document this endpoint; it is the employer career site's own anonymous JSON API used by every Workday careers page ([community write-up](https://dev.to/udaninn/workday-job-boards-have-a-json-api-too-its-just-better-hidden-23fl)). The employer host's robots.txt is honoured. |

### How to scrape jobs from a careers page (tutorial)

1. Open the Actor, paste one or more entries into **Companies**. Any of these work:
   - a careers URL: `https://boards.greenhouse.io/gitlab`, `https://jobs.lever.co/palantir`, `https://jobs.ashbyhq.com/ashby`, `https://jobs.smartrecruiters.com/BoschGroup`, `https://apply.workable.com/blueground`, `https://bunq.recruitee.com`, `https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite`
   - an explicit pair: `greenhouse:gitlab`, `lever:palantir`, `ashby:ashby`, `smartrecruiters:BoschGroup`, `workable:blueground`, `recruitee:bunq`
   - a bare domain or name: `gitlab.com` - the Actor scans the company's `/careers` and `/jobs` pages for ATS links and probes each vendor API with the domain label as a slug. Successful detections are cached for 7 days.
2. Optionally set **Max jobs per company**, **Title keywords**, **Locations**, **Remote only** or **Posted after**.
3. Keep **Detect changes** on if you will run the Actor repeatedly (daily schedule, webhook, integration).
4. Click **Start**. Results land in the dataset (table / JSON / CSV / Excel) and a per-company summary is stored under the `SUMMARY` key of the run's key-value store.

#### Input example

```json
{
  "companies": [
    "https://boards.greenhouse.io/gitlab",
    "lever:palantir",
    "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite",
    "gitlab.com"
  ],
  "includeDescription": true,
  "keywords": ["engineer", "data"],
  "remoteOnly": false,
  "detectChanges": true,
  "emitUnchanged": false,
  "maxJobsPerCompany": 0
}
```

#### Output example (one dataset item)

```json
{
  "id": "greenhouse:gitlab:8860302002",
  "jobId": "8860302002",
  "title": "Account Executive - France",
  "company": "GitLab",
  "companySlug": "gitlab",
  "ats": "greenhouse",
  "url": "https://job-boards.greenhouse.io/gitlab/jobs/8860302002",
  "applyUrl": "https://job-boards.greenhouse.io/gitlab/jobs/8860302002",
  "location": "Remote, France",
  "locations": ["Remote, France", "France"],
  "remote": true,
  "workplaceType": "remote",
  "department": "EMEA - Commercial",
  "team": null,
  "employmentType": null,
  "postedAt": "2026-10-02T15:31:50Z",
  "updatedAt": "2026-10-02T15:31:50Z",
  "descriptionMarkdown": "GitLab is the intelligent orchestration platform...",
  "descriptionText": "GitLab is the intelligent orchestration platform...",
  "salaryMin": null,
  "salaryMax": null,
  "salaryCurrency": null,
  "salaryPeriod": null,
  "language": "en",
  "change": "new",
  "firstSeenAt": "2026-10-07T05:30:12Z",
  "scrapedAt": "2026-10-07T05:30:12Z",
  "fingerprint": "6c4d2b1e9a0f7c3d5e8b",
  "raw": { "...": "untouched vendor fields (disable with includeRaw=false)" }
}
```

An Ashby job with published compensation carries `"salaryMin": 110000, "salaryMax": 185000, "salaryCurrency": "EUR", "salaryPeriod": "year"`.

A closed job (present in the previous run, gone now) is a small free record:

```json
{ "id": "lever:palantir:6ed76ce8-...", "title": "Administrative Business Partner", "company": "palantir", "ats": "lever", "url": "https://jobs.lever.co/palantir/6ed76ce8-...", "change": "closed", "firstSeenAt": "2026-09-01T08:00:00Z", "lastSeenAt": "2026-10-06T08:00:00Z", "closedAt": "2026-10-07T08:00:00Z" }
```

### Change detection (new / updated / closed jobs)

With `detectChanges` on, the Actor keeps a fingerprint (title, locations, department, employment type, salary, vendor `updatedAt`, description hash) of every job per board in the named key-value store `stateStoreName` (default `ats-jobs-api-state`). On the next run each job is labelled:

- `new` - not seen before (`firstSeenAt` is set now)
- `updated` - fingerprint changed
- `unchanged` - identical (set `emitUnchanged: false` to skip and not pay for these)
- `closed` - present last time, missing now (free, `emitClosed`)

The description hash is part of the fingerprint, so toggling `includeDescription` between runs reports every job as `updated` once. `closed` is only computed when the whole board was listed in both runs; if a run is truncated by `maxJobsPerCompany`, filters, a rate limit or a vendor error, the state is merged instead of replaced and no false "closed" events are produced. Use different `stateStoreName` values for independent monitors.

### Pricing (pay per event)

| Event | What it means | Price |
|---|---|---|
| `job` | One job record written to the dataset (`new`, `updated` or `unchanged`) | **$0.0012** ($1.20 / 1,000 jobs) |
| `company-detected` | ATS auto-detected from a bare domain or company name (careers-page scan + API probing). Not charged for ATS URLs or `ats:slug` inputs | **$0.01** per company |
| Actor start | Apify's standard synthetic start event at the platform default | platform default |

Closed-job records, filtered-out jobs and failed boards are never charged. Proposed volume discounts for higher Apify plans: `job` $0.0010 (Silver) / $0.0008 (Gold); `company-detected` $0.008 / $0.006.

**Cost examples**

- Monitor 50 companies via their careers URLs, ~2,000 open jobs, daily run with `emitUnchanged: false`: ~40 new/updated jobs a day -> about **$0.05 per day** (plus the start event).
- One-off export of 10 Greenhouse/Lever/Workday boards with 5,000 jobs including descriptions -> **$6.00**.
- Lead list of 200 bare domains, auto-detected once (then cached 7 days), 8,000 jobs -> $2.00 detection + $9.60 jobs = **$11.60**.

### Use cases

- **Sales & lead generation** - "who is hiring for X right now" signals for outbound (export to CSV, Google Sheets, HubSpot via Zapier/Make/n8n).
- **Recruiting & talent intelligence** - track competitor headcount plans, new offices, remote policy shifts.
- **Job boards & aggregators** - one normalized feed instead of seven integrations.
- **Salary research** - Ashby, Lever, Recruitee and Greenhouse pay-transparency ranges in one schema.
- **AI agents / MCP** - "What is NVIDIA hiring for in Munich?" becomes one Actor call; Markdown descriptions are ready for RAG.

### Limitations and good-to-know

- **Workday**: the endpoint is undocumented by Workday (stable for years, but it can change). Descriptions need one request per job; at the polite 2 req/s limit a 2,000-job tenant takes ~17 minutes - set `includeDescription: false` for fast monitoring. Workday boards must be given as a careers URL (tenant + site cannot be guessed from a domain).
- **SmartRecruiters** descriptions also need one request per job. Its API host's robots.txt disallows all generic crawlers while the vendor's Posting API documentation permits anonymous reads; the default `robotsPolicy: vendor-api` follows the API terms, `strict` disables SmartRecruiters.
- **Recruitee** announced an `X-Careers-Sites-Token` requirement from 2027-02-10; paste your token into `recruiteeCareersSiteToken` once it is enforced.
- **Auto-detection from a domain** works when the company's `/careers` or `/jobs` page links to its ATS or when its slug equals the domain label (`gitlab.com` -> `gitlab`). JavaScript-only careers pages with a non-obvious slug cannot be detected - pass the careers URL instead.
- `remote` combines vendor flags with a keyword heuristic on location/title; `employmentType` keeps the vendor's wording (e.g. `Full-time`, `Full time`, `FullTime` is mapped).
- Salary is only present when the employer publishes it through the ATS.
- No personal data is collected: the Actor reads job advertisements only (recruiter e-mails in Recruitee's `mailbox_email` are kept in `raw` only if you leave `includeRaw` on).
- The Actor stops a board on persistent `429`, `401/403` or robots.txt disallow and reports it in the run summary instead of hammering the host.
- **One board never fails the run.** Each company has its own time budget (`maxSecondsPerCompany`, default 180 s) and error isolation: a slow, blocked or broken board is cut off with a warning in `SUMMARY`, its jobs fetched so far are kept, and the other companies finish normally. The run only fails when every board failed and nothing was written.
- Change-detection state lives in the named key-value store `stateStoreName` (default `ats-jobs-api-state`). If that store cannot be opened the Actor falls back to the run's own store and says so in the log / `SUMMARY.stateStore` instead of failing.

### FAQ

**Do I need API keys or accounts?** No. Every source is a public, unauthenticated endpoint.

**Is scraping these job boards allowed?** Greenhouse, Lever, Ashby, SmartRecruiters, Workable and Recruitee publish these endpoints precisely so third parties can display the jobs. Workday career sites expose the same JSON their own page uses; the Actor honours each employer host's robots.txt and sends at most 2 requests/second. You remain responsible for how you use the data.

**How do I get only new jobs?** Keep `detectChanges: true` and set `emitUnchanged: false`; schedule the Actor daily and connect a webhook or integration.

**Can I pass a list of company names?** Yes (`"Acme"` -> tries `acme.com`), but detection is best-effort; careers URLs are always reliable and never charged for detection.

**Why are there fewer jobs than the careers page shows?** Ashby only exposes *listed* jobs; Greenhouse boards may hide internal postings; filters and `maxJobsPerCompany` reduce the count - check `SUMMARY` in the key-value store for `totalReported` per company.

**Can it run locally?** Yes: `apify run` with the Apify CLI, or `python -m src` inside the venv. Unit tests: `pytest`.

### Changelog

- **1.0.1** (2026-10-09) - per-company time budget (`maxSecondsPerCompany`) and error isolation; named state store falls back to the run store instead of crashing; default example boards are now GitLab (Greenhouse), Palantir (Lever) and Ashby (Ashby) so the 20-job sample finishes in well under a minute; partial failures are summarised in the status message.
- **1.0.0** - initial release: 7 ATS adapters, auto-detection, change detection, pay-per-event charging.

# Actor input Schema

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

One entry per company. Accepted forms: a careers URL on a supported ATS (https://boards.greenhouse.io/gitlab, https://jobs.lever.co/palantir, https://jobs.ashbyhq.com/ashby, https://jobs.smartrecruiters.com/BoschGroup, https://apply.workable.com/blueground, https://bunq.recruitee.com, https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite); an explicit pair like greenhouse:gitlab or lever:palantir; or a bare company domain such as gitlab.com (auto-detected by scanning the careers page and probing the vendor APIs - this is the only form that triggers the company-detected charge).

## `ats` (type: `string`):

Force one ATS for every entry (useful when you pass bare slugs). Leave on Auto to detect per company.

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

Stop after this many jobs per company (0 = all). Applies before filters. When a board is truncated, 'closed' detection is skipped for it in that run.

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

Fetch the full description (Markdown + plain text). For Workday and SmartRecruiters this costs one extra request per job; turn it off for fast title/location-only monitoring.

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

Attach the untouched vendor JSON under `raw` (bigger dataset items).

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

Keep only jobs whose title, department or team contains any of these words (case-insensitive). Example: engineer, data, sales.

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

Keep only jobs whose location contains any of these strings (case-insensitive), e.g. London, Remote, United States.

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

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

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

Keep only jobs posted on or after this date (YYYY-MM-DD). Jobs with an unknown posting date are kept.

## `detectChanges` (type: `boolean`):

Compare with the previous run (fingerprints stored in a named key-value store) and label every job new / updated / unchanged, plus emit closed jobs that disappeared.

## `emitUnchanged` (type: `boolean`):

When off, only new and updated jobs are written (and charged). Great for daily monitors.

## `emitClosed` (type: `boolean`):

Write a small record with change = closed for every job that was present last run and is gone now (never charged).

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

Name of the key-value store holding fingerprints and the detection cache. Use different names for independent monitors.

## `detectionCacheDays` (type: `integer`):

How long an auto-detected ATS mapping for a domain is reused before probing again (0 = always re-detect).

## `maxConcurrencyPerHost` (type: `integer`):

Parallel requests to a single host (1-4).

## `requestsPerSecondPerHost` (type: `integer`):

Upper bound 2 (hard cap). Lower it for very sensitive hosts.

## `maxCompaniesInParallel` (type: `integer`):

How many companies are fetched at the same time (1-10). Per-host limits still apply.

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

Retries for 429/5xx/network errors with exponential backoff (0-5). After that the board is skipped with a warning.

## `maxSecondsPerCompany` (type: `integer`):

Hard cap on how long one company board may take (10-3000). A board that is still running when the budget ends is cut off with a warning; the jobs fetched until then are kept and the other companies continue. Raise it for very large Workday / SmartRecruiters boards with descriptions on (one request per job).

## `robotsPolicy` (type: `string`):

vendor-api (default): robots.txt is enforced for company websites and Workday hosts; the documented public JSON APIs of Greenhouse, Lever, Ashby, SmartRecruiters, Workable and Recruitee follow their vendor API terms instead. strict: enforce robots.txt on every host (this disables SmartRecruiters, whose API host disallows all crawlers).

## `recruiteeCareersSiteToken` (type: `string`):

Recruitee announced that from 2027-02-10 the offers endpoint requires an X-Careers-Sites-Token. Paste yours here when that happens; not needed today.

## Actor input object example

```json
{
  "companies": [
    "https://boards.greenhouse.io/gitlab",
    "https://jobs.lever.co/palantir",
    "https://jobs.ashbyhq.com/ashby"
  ],
  "ats": "auto",
  "maxJobsPerCompany": 20,
  "includeDescription": true,
  "includeRaw": true,
  "remoteOnly": false,
  "detectChanges": true,
  "emitUnchanged": true,
  "emitClosed": true,
  "stateStoreName": "ats-jobs-api-state",
  "detectionCacheDays": 7,
  "maxConcurrencyPerHost": 2,
  "requestsPerSecondPerHost": 2,
  "maxCompaniesInParallel": 3,
  "maxRetries": 3,
  "maxSecondsPerCompany": 180,
  "robotsPolicy": "vendor-api"
}
```

# Actor output Schema

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

No description

## `summary` (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": [
        "https://boards.greenhouse.io/gitlab",
        "https://jobs.lever.co/palantir",
        "https://jobs.ashbyhq.com/ashby"
    ],
    "maxJobsPerCompany": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("beguiling_equator/ats-jobs-api").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "companies": [
        "https://boards.greenhouse.io/gitlab",
        "https://jobs.lever.co/palantir",
        "https://jobs.ashbyhq.com/ashby",
    ],
    "maxJobsPerCompany": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("beguiling_equator/ats-jobs-api").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "companies": [
    "https://boards.greenhouse.io/gitlab",
    "https://jobs.lever.co/palantir",
    "https://jobs.ashbyhq.com/ashby"
  ],
  "maxJobsPerCompany": 20
}' |
apify call beguiling_equator/ats-jobs-api --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,beguiling_equator/ats-jobs-api"
        }
    }
}
```

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/Hz3xPfy0EYpNddYCk/builds/Lhf3lvvDRBtVzR1cj/openapi.json
