# ATS Job API: Workday Jobs, Greenhouse Jobs, Ashby Jobs (`fei.yang/ats-job-board-aggregator`) Actor

Get jobs, job listings and openings from Greenhouse, Ashby, Lever and Workday. A job board scraper alternative: we read each vendor's official public job-board API instead of scraping, so your pipeline survives careers-page changes. One normalized dataset of hiring data. No login, no keys.

- **URL**: https://apify.com/fei.yang/ats-job-board-aggregator.md
- **Developed by:** [FEI Yang](https://apify.com/fei.yang) (community)
- **Categories:** Jobs, Lead generation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 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 Job Board Aggregator — Greenhouse + Ashby + Lever + Workday

Pull **live job postings** straight from the **official public job-board APIs** of the ATS platforms most companies use. Paste a company name, a board slug, or a full careers URL — get one normalized dataset back.

**No login. No API keys. No scraping. No browser automation.**

If you were about to write a **job board scraper** for Greenhouse, Ashby, Lever or Workday — this is the same data, read from the vendor's own JSON endpoint instead of the rendered page. Same fields, one input, and no HTML selectors to repair every time a careers page is redesigned.

| Provider | Endpoint used | Verified (2026-10-11) |
|---|---|---|
| **Greenhouse** | `boards-api.greenhouse.io/v1/boards/{slug}/jobs` | stripe → **729** jobs, gitlab → **221** |
| **Ashby** | `api.ashbyhq.com/posting-api/job-board/{slug}` | openai → **815**, ramp → **165** |
| **Lever** | `api.lever.co/v0/postings/{slug}?mode=json` | leverdemo → **10** |
| **Workday** | `{host}/wday/cxs/{tenant}/{site}/jobs` (POST) | nvidia → **2,000** |

These are the same endpoints those vendors publish so companies can embed their job board on their own website. They return clean JSON, so this Actor **does not break when a careers page is redesigned** — a failure mode that kills HTML scrapers.

Workday matters if you need enterprise hiring data: it is the ATS behind a large share of the Fortune 500, and a single tenant can carry 2,000+ open roles.

### Input

| Field | Type | Default | Notes |
|---|---|---|---|
| `companies` | array | `["stripe","openai","gitlab","ramp"]` | Three accepted formats — see below |
| `keywords` | array | `[]` | Match against title / department / team / location |
| `excludeKeywords` | array | `[]` | Inverted filter |
| `locationFilter` | string | `""` | Case-insensitive substring, e.g. `"Berlin"` |
| `remoteOnly` | bool | `false` | Keep remote postings only |
| `includeDescription` | bool | `false` | Adds up to 20k chars of plain-text description |
| `maxItems` | int | `2000` | Hard cap |
| `concurrency` | int | `4` | Boards fetched in parallel |
| `requestDelayMs` | int | `250` | Polite pause between boards |

#### The four ways to specify a company

```
stripe                                  → auto-detects which ATS it uses
ashby:openai                            → explicit provider
https://job-boards.greenhouse.io/gitlab → parsed straight from a careers URL
workday:nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite
https://nvidia.wd5.myworkdayjobs.com/en-US/NVIDIAExternalCareerSite
```

With **auto-detect**, the Actor tries Greenhouse → Ashby → Lever and keeps the first board that answers. A board that exists but currently has zero openings still counts as a hit — so you can tell "wrong slug" apart from "nothing open right now".

**Workday is the one exception:** it can't be auto-detected, because the tenant and site are defined by the customer and can't be derived from a company name. Give it either a `workday:HOST/SITE` target or the company's careers URL — just paste the URL from your browser and the Actor picks out the pieces.

### Output

One uniform record per posting, whichever ATS it came from:

```json
{
  "provider": "ashby",
  "company": "ramp",
  "companySlug": "ramp",
  "boardUrl": "https://jobs.ashbyhq.com/ramp",
  "jobId": "34413f8d-…",
  "title": "Security Engineer, Cloud",
  "department": "Engineering",
  "team": "Backend",
  "employmentType": "FullTime",
  "location": "New York, NY (HQ)",
  "allLocations": ["New York, NY (HQ)", "Remote (Canada)", "Remote (US)"],
  "isRemote": true,
  "url": "https://jobs.ashbyhq.com/ramp/…",
  "applyUrl": "https://jobs.ashbyhq.com/ramp/…",
  "postedAt": "2026-04-07T17:12:35.753Z",
  "updatedAt": null,
  "compensation": "USD 180000–240000 / year"
}
```

Results land in the default dataset; a `SUMMARY` key-value record reports which boards were found, how many jobs each returned, and which slugs failed.

### Use cases

- **Job boards & niche aggregators** — build a vertical board from the companies you care about.
- **Recruiting / sourcing** — watch a competitor's hiring signals ("they just opened 12 infra roles").
- **Sales & market signals** — headcount growth by department, market by market.
- **AI / research datasets** — normalized, multi-company, multi-ATS job corpus with dates and locations.

### Notes & limits

- **No HR email addresses.** These endpoints publish postings, not contact emails. Any Actor claiming otherwise is guessing.
- **About `includeDescription`.** Plain text, HTML stripped, capped at 20,000 characters per posting. Measured 2026-10-14 on live boards: Greenhouse `stripe` returns ~4.6k chars per posting, Ashby `openai` ~6.9k, Lever `leverdemo` ~1.4k. Two things worth knowing before you switch it on:
  - **Ashby and Lever have no "no description" mode.** Their public endpoints always return the text, so the toggle only decides whether it is written to your dataset. Only Greenhouse actually saves you bandwidth when it is off (`stripe`: 451 KB → 5.6 MB with content on).
  - **Descriptions are the biggest cost lever**, because pricing is per returned posting, not per byte — enabling it does not change what you pay, but it does change dataset size.
- **A wrong board slug is a hard 404, not an empty result.** Verified on Greenhouse: `boards-api.greenhouse.io/v1/boards/<nope>/jobs` → `404 {"status":404,"error":"Job not found"}`. The Actor reports it under `failures` in the `SUMMARY` record, so "wrong slug" never gets confused with "this company is not hiring". A board that answers with zero postings is still a successful hit.
- **`department` / `team` are populated for Ashby and Lever, but usually `null` for Greenhouse** — Greenhouse's board endpoint stopped returning per-job department objects. Verified, not assumed: 950 Greenhouse postings in a test run came back without departments while the Ashby/Lever ones all carried them.
- **Which ATS does a company use?** Not provided by any API; auto-detect answers it by probing. Two calls, at most, for the common cases.
- **Workday dates are approximate.** Its list endpoint returns relative strings ("Posted Today", "Posted 30+ Days Ago"), so `postedAt` is normalised to that day's midnight UTC and the raw string is kept in `postedText`. Enable *Include job description* to replace it with the exact `startDate` from the detail endpoint.
- **Workday locations can read "2 Locations".** The list endpoint collapses multi-site postings; the full list is only in the detail endpoint. `allLocations` therefore holds one entry for Workday.
- **Five other ATS platforms are deliberately not supported.** Workable (the widget endpoint returned an empty `jobs` array for six real slugs), SmartRecruiters (keys on internal company IDs — returned `totalFound: 0` for Visa, McDonald's and Ubisoft), Recruitee (401), BambooHR (302), Personio (XML only), Teamtailor (JSON Feed with no per-company endpoint). **Listing a source that silently returns nothing is worse than not listing it** — every provider here was verified against a live board first.
- Only public, documented endpoints. No authentication, no rate-limit circumvention, no anti-bot evasion.

### Tested (2026-10-11, direct connection, strict TLS)

| Case | Result |
|---|---|
| `stripe` (auto-detect) | greenhouse / 729 jobs |
| `gitlab` (auto-detect) | greenhouse / 221 jobs |
| `openai` (auto-detect) | ashby / 815 jobs |
| `ramp` (auto-detect) | ashby / 165 jobs |
| `lever:leverdemo` (explicit) | lever / 10 jobs |
| `https://job-boards.greenhouse.io/gitlab` (URL) | greenhouse / 221 jobs |
| `workday:nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite` | workday / **2,000 jobs** |
| `https://nvidia.wd5.myworkdayjobs.com/en-US/NVIDIAExternalCareerSite` (URL) | workday / **2,000 jobs** |
| `ashby:nonexistent-board-xyz` | clean error: `ashby:404` |
| `workday:this-does-not-exist.wd5.myworkdayjobs.com/NoSite` | clean error: `workday:422` |
| `zzz-not-a-real-company` | clean error, all three auto-detect providers probed |
| Full local regression, 8 boards | **8 ok / 0 empty / 3 expected errors** — 61 seconds wall clock |

**Workday pagination, measured:** the list endpoint caps at 20 postings per request and returns **0 rows (not an error) if you ask for more** — a silent truncation trap. We page by 20 and fetch pages concurrently, which brought a 2,000-posting board from **3m49s to 68s**.

**Descriptions, measured separately (2026-10-14):** the Greenhouse endpoint does **not** include the posting body by default — the bare response carries 14 fields and no `content` at all, and only `?content=true` adds it (`stripe`: 451,481 B → 5,630,572 B). That parameter is now always sent when the toggle is on, and the escaped HTML (`&lt;h2&gt;…`) is decoded before tag-stripping, so what you get is prose, not markup. Regression suite: `src/test-description.js`, **27/27 assertions**, covering the three providers plus the 404 semantics.

### Pricing

Pay-per-event, in two parts:

| Event | Price | What it covers |
|---|---|---|
| Actor start | **$0.00005** per run | Booting the container (platform default — Apify still covers the first 5s of compute) |
| Result returned | **$0.002** per posting | Each row pushed to the dataset |

That is **~$2 per 1,000 postings**, plus a nominal start fee. A 50-company run returning 1,000–3,000 postings costs **$2–6**.

For context: the most-installed Actor in this category charges **$0.012 per posting** — six times more for largely the same public endpoints. We're pricing for adoption first, and will revisit once there is usage data.

# Actor input Schema

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

One entry per company. Accepts a bare name or slug ("stripe" — auto-detects Greenhouse/Ashby/Lever), an explicit provider:slug ("ashby:openai"), a job-board URL ("https://jobs.ashbyhq.com/openai"), or a Workday target as "workday:HOST/SITE" ("workday:nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite") or its careers URL. Workday needs the host because tenant and site are tenant-defined — paste the careers URL if unsure.

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

Keep only postings matching at least one keyword. Leave empty for no filter.

## `excludeKeywords` (type: `array`):

Drop postings matching any of these.

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

Case-insensitive substring match against all listed locations, e.g. "Berlin" or "Remote".

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

Keep only postings the source marks (or that read) as remote.

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

Adds a plain-text description (up to 20k chars). Larger datasets, slower runs.

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

Hard cap on returned postings.

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

How many company boards to fetch in parallel.

## `requestDelayMs` (type: `integer`):

Polite pause per board. Keep it non-zero for large lists.

## Actor input object example

```json
{
  "companies": [
    "stripe",
    "openai",
    "gitlab",
    "ramp"
  ],
  "keywords": [],
  "excludeKeywords": [],
  "locationFilter": "",
  "remoteOnly": false,
  "includeDescription": false,
  "maxItems": 2000,
  "concurrency": 4,
  "requestDelayMs": 250
}
```

# Actor output Schema

## `postings` (type: `string`):

One row per posting, in the run's default dataset.

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

How many boards were found, how many postings were pushed, and which inputs failed.

# 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",
        "openai",
        "gitlab",
        "ramp"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("fei.yang/ats-job-board-aggregator").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",
        "openai",
        "gitlab",
        "ramp",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("fei.yang/ats-job-board-aggregator").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",
    "openai",
    "gitlab",
    "ramp"
  ]
}' |
apify call fei.yang/ats-job-board-aggregator --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,fei.yang/ats-job-board-aggregator"
        }
    }
}
```

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/wRsL7LBwa9jgEuOaH/builds/fJd8JvXKMW6gmCp1j/openapi.json
