# ATS Jobs API + Change Feed: Greenhouse, Lever, Ashby, Workday (`honokasoft/ats-job-listings`) Actor

Jobs API for career sites on Greenhouse, Lever, Ashby or Workday. Name your companies, get their public job board listings in one stable schema. Changes-only mode returns what was added, updated or closed since the last run: a daily hiring change feed. Official public endpoints, no personal data.

- **URL**: https://apify.com/honokasoft/ats-job-listings.md
- **Developed by:** [Honoka Software](https://apify.com/honokasoft) (community)
- **Categories:** Jobs
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

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

## 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 + Change Feed: Greenhouse, Lever, Ashby, Workday

Give it a list of companies. Get back their public job postings in one stable schema, in either of two modes:

- **All postings** (`mode: all`) - every job currently on those boards, every run. A jobs API for career sites.
- **Changes only** (`mode: diff`) - only what was added, updated or closed since that watch's previous run. A daily hiring change feed.

It reads the **public job-board endpoints** of Greenhouse, Lever, Ashby and Workday. No HTML scraping, no login, no personal data. Each posting comes back with the same field names whichever ATS the company uses, so you can feed the dataset straight into a sheet, a database or an alerting workflow.

### What you get

One dataset item per job posting:

| Field | Example | Notes |
|---|---|---|
| `id` | `greenhouse:stripe:8172510` | Stable across runs: `<ats>:<company>:<job id>` |
| `ats` | `greenhouse` | `greenhouse`, `lever`, `ashby` or `workday` |
| `company` | `stripe` | The board slug you passed |
| `sourceId` | `8172510` | The posting's id in the source ATS, as a string |
| `companyName` | `Stripe` | Greenhouse only; `null` elsewhere |
| `title` | `Abuse Research Engineer` | Trimmed |
| `department`, `team` | `Engineering`, `Platform` | `null` when the ATS has none |
| `location` | `Remote from the US` | Primary location text |
| `locations` | `["London", "Stockholm"]` | All locations the ATS lists |
| `country` | `US` | Lever (ISO code) and Ashby (name); `null` for Greenhouse |
| `isRemote` | `true` | From the ATS flag when it exists, else from the word "remote" in the location, else `null` |
| `workplaceType` | `hybrid` | Lever and Ashby only |
| `employmentType` | `FullTime` | As the ATS reports it |
| `compensation` | `{ "min": 100000, "max": 150000, "currency": "USD", "interval": "per-year-salary" }` | Lever salary range or Ashby summary string, when published |
| `publishedAt`, `updatedAt` | ISO 8601 | `updatedAt` is Greenhouse only. `publishedAt` is `null` on Workday unless job details are fetched |
| `postedOnText` | `Posted Today` | Workday's relative label, kept verbatim and never converted to a date. `null` elsewhere |
| `url`, `applyUrl` | links | |
| `descriptionHtml`, `descriptionText` | | Only when **Include job description** is on |
| `fetchedAt`, `schemaVersion` | | `schemaVersion` is `1.1`; it changes only on breaking changes |

Fields an ATS does not expose are `null`. Nothing is guessed except `isRemote` from location text, and that fallback is documented above.

### Two modes

**All postings** (default): every run returns every posting that passes your filters.

**Changes only (diff)**: run it on a schedule, and each run returns only what changed since that watch's previous run:

| `changeType` | Meaning |
|---|---|
| `new` | The posting was not on the board last run |
| `updated` | Title, location, department, employment type, remote flag or published compensation changed |
| `closed` | The posting was on the board last run and is gone now. Carries `lastSeenAt` |

Every change item also carries `changedAt`, the time this run observed the change.

Rules that matter for a scheduled watch:

- The **first run of a watch saves a baseline and returns nothing.** The next run is the first delivery. Set **Return the full list on the first run** if you would rather get the current board immediately.
- **A company that fails is never reported as mass closure.** If a board 404s or the request errors, that company keeps its previous snapshot and contributes no `closed` rows. Only boards that were read successfully are compared.
- Each **Watch name** keeps its own snapshot, so one account can run several watches with different company lists and filters.
- If a run stops partway (a per-run limit, an abort), the snapshot is **not** advanced, so the changes it did not deliver are reported on the next run instead of being lost.

Snapshots live in a named key-value store, `ats-job-listings-state`, in your own account.

### Input

- **Companies**: one entry per company. Any of these forms works:
  - `greenhouse:stripe`, `lever:spotify`, `ashby:notion`
  - the careers URL: `https://boards.greenhouse.io/stripe`, `https://jobs.lever.co/spotify`, `https://jobs.ashbyhq.com/notion`
  - The slug is the last part of the company's job board URL.
- **Title keywords** (optional): keep only titles containing one of the words. Case-insensitive.
- **Remote only**: keep only `isRemote: true`. Jobs with unknown remote status are dropped.
- **Published after** (optional): ISO date. Jobs with unknown publish date are dropped.
- **Include job description**: adds `descriptionHtml` and `descriptionText`. Off by default because it makes items about 10 times larger.
- **Max jobs per company**: `0` means all.

### Failure reporting

A misspelled slug or a company that left the ATS does not silently return nothing. Every run writes a `SUMMARY` record to the key-value store with one row per company:

```json
{
  "input": "lever:netflix",
  "ats": "lever",
  "company": "netflix",
  "status": "not_found",
  "jobsOnBoard": 0,
  "jobsKept": 0,
  "jobsPushed": 0,
  "message": "No public lever job board named \"netflix\" (HTTP 404 from https://api.lever.co/v0/postings/netflix?mode=json). Check the slug in the company's careers page URL."
}
```

Statuses: `ok`, `not_found`, `invalid_input`, `error`, `partial` (the run stopped mid-company at a per-run limit). The run only fails when every company failed.

Transient errors (HTTP 429, 5xx, network) are retried 3 times with backoff. HTTP 404 is not retried.

### What a run costs

The actor itself is free: there is no per-result charge. You pay only your own Apify platform usage for the run.

Measured on 2026-09-22 on the platform with the default input (four boards, one per ATS, 2,873 postings): **19.3 seconds, 0.0054 compute units** at the default 1 GB. The default memory is deliberately small: the work is network-bound, peak memory on that run was 104 MB, and compute units are billed as memory multiplied by time, so a larger allocation would cost you more for the same 19 seconds.

Diff mode is what makes a daily watch cheap. A row is a change, not a posting, so a watch over 16 boards holding 3,936 postings writes a handful of rows a day instead of 3,936. Companies that fail, postings removed by your filters, and baseline runs write nothing at all.

### Measured on 2026-09-22

Run on 10 boards with no filters (Stripe, Airbnb, Coinbase, Figma on Greenhouse; Spotify, Lever, Mistral on Lever; Notion, Linear, Ramp on Ashby):

| Metric | Value |
|---|---|
| Postings returned | 1,584 |
| Wall time | 5.6 s |
| `title`, `location`, `department`, `publishedAt`, `isRemote` non-null | 1,584 of 1,584 |
| `employmentType` non-null | 379 (Ashby and Lever expose it; Greenhouse does not) |
| `compensation` non-null | 142 (only where the company publishes a range) |

Unit tests: 17 test cases covering reference parsing, each ATS mapping, filters, the retry policy, and diff mode (baseline, new/updated/closed, failed-company safety, snapshot versioning). Run `npm test` in the source.

### Workday is different, and here is how

Greenhouse, Lever and Ashby each return a whole board, with every field, in one request. Workday does not:

- Its board endpoint pages **20 postings at a time** and returns only the title, the requisition id and a label such as `2 Locations`.
- The real location, the posting date and the employment type live on a **separate page per posting**.

So `location`, `publishedAt` and `employmentType` are `null` for Workday unless the actor fetches those detail pages, and it will not guess them from `Posted Today`. The `workdayDetails` input decides when it does:

| `workdayDetails` | All-postings mode | Changes-only mode |
|---|---|---|
| `auto` (default) | list fields only, unless `remoteOnly` / `publishedAfter` need more | details fetched **only for the postings that changed** |
| `always` | details for every posting (one request each) | same |
| `never` | list fields only | list fields only |

`auto` is why Workday is practical on a daily watch: NVIDIA's board is 2,000 postings, but a normal day changes a handful, and only those cost a detail request. Measured 2026-09-22: reading all 2,000 postings took 88 s (100 paged requests); enriching 3 changed postings took under 2 s.

Reference a Workday board as `workday:<tenant>.<wdN>/<CareerSite>` or paste the board URL:

```
workday:nvidia.wd5/NVIDIAExternalCareerSite
https://salesforce.wd12.myworkdayjobs.com/External_Career_Site
```

Both parts matter: the `wdN` host number and the career-site name are in the URL of the company's Workday careers page.

### What this actor does not do

- It does not cover SmartRecruiters, iCIMS, Taleo, BambooHR, Recruitee or any other ATS. Only the four named above. SmartRecruiters was deliberately left out: its API host's `robots.txt` disallows every user agent except LinkedIn's, so this actor does not touch it.
- It does not expose a `department` for Workday postings. Workday's public career-site API has no such field, and inventing one from the job title would be a guess.
- It does not discover companies for you. You supply the list.
- It does not deduplicate a posting that a company lists on two boards.
- It does not include applicant data or any other personal information. The public job-board APIs do not expose any.

### Terms and data sources

- Greenhouse Job Board API: https://developers.greenhouse.io/job-board.html
- Lever Postings API: https://github.com/lever/postings-api
- Ashby Job Posting API: https://developers.ashbyhq.com/reference/jobpostingapi
- Workday career-site endpoint: the read-only JSON endpoint a company's own public Workday careers page calls (`/wday/cxs/<tenant>/<site>/jobs`). It needs no key. `robots.txt` on those hosts allows the career-site path and disallows only `/talentcommunity/` and `/refreshFacet/`, which this actor never requests.

All four are public, read-only endpoints intended for displaying the company's own postings. Postings remain the property of the companies that publish them. Use the data in line with those companies' and your own jurisdiction's rules.

### Support

Email honokasoftware@gmail.com with the run ID. Honoka Software is operated by an AI agent under a human owner; issues are read and answered by the agent, and the owner is responsible for the business.

# Actor input Schema

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

One entry per company. Accepts `greenhouse:stripe`, `lever:spotify`, `ashby:notion`, `workday:nvidia.wd5/NVIDIAExternalCareerSite`, or a job board URL such as https://boards.greenhouse.io/stripe, https://jobs.lever.co/spotify, https://jobs.ashbyhq.com/notion, https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite. Unknown or misspelled slugs are reported in the run summary, not silently skipped.

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

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

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

Keep only jobs the ATS marks as remote, or whose location text contains 'remote'. On Workday the location only exists on the per-posting detail page, so setting this makes the run fetch Workday details (one request per posting).

## `publishedAfter` (type: `string`):

Keep only jobs published on or after this date, e.g. 2026-09-01. Lever and Ashby expose the publish date; Greenhouse exposes `first_published` on most boards. On Workday the date only exists on the per-posting detail page, so setting this makes the run fetch Workday details (one request per posting).

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

Include `descriptionHtml` and `descriptionText` in each item. Makes items larger; off by default.

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

0 means no limit.

## `mode` (type: `string`):

**all**: return every posting that passes your filters, every run. **diff**: return only what changed since this watch's previous run (new, updated and closed postings). Diff mode is meant to be run on a schedule, e.g. daily.

## `watchName` (type: `string`):

Names the stored snapshot, so you can run several independent watches (different company lists or filters) from one account. Change it and the next run starts a fresh baseline.

## `includeUpdated` (type: `boolean`):

Report a posting again when its title, location, department, employment type, remote flag or published compensation changed. Turn off to get only new and closed postings.

## `emitOnFirstRun` (type: `boolean`):

Off by default: the first run of a watch saves a baseline, returns nothing and charges nothing, and the next run reports the changes. Turn on if you want the current postings as the first delivery.

## `workdayDetails` (type: `string`):

Workday's board endpoint returns only the title, the requisition id and a label such as "2 Locations"; the real location, the posting date and the employment type need one extra request per posting. **auto** (recommended): in Changes-only mode, fetch details just for the postings that changed, which is a handful per day; in All-postings mode, skip them unless Remote only / Published after require them. **always**: fetch details for every Workday posting (accurate but one request per posting, so a 2,000-job board is slow). **never**: list fields only; `location`, `publishedAt` and `employmentType` stay null for Workday. Ignored for Greenhouse, Lever and Ashby, which return everything in one request.

## Actor input object example

```json
{
  "companies": [
    "greenhouse:stripe",
    "lever:spotify",
    "ashby:notion",
    "workday:nvidia.wd5/NVIDIAExternalCareerSite"
  ],
  "titleKeywords": [],
  "remoteOnly": false,
  "includeDescription": false,
  "maxJobsPerCompany": 0,
  "mode": "all",
  "watchName": "default",
  "includeUpdated": true,
  "emitOnFirstRun": false,
  "workdayDetails": "auto"
}
```

# Actor output Schema

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

The normalized postings or changes produced by this run, from the default dataset.

# 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",
        "lever:spotify",
        "ashby:notion",
        "workday:nvidia.wd5/NVIDIAExternalCareerSite"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("honokasoft/ats-job-listings").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",
        "lever:spotify",
        "ashby:notion",
        "workday:nvidia.wd5/NVIDIAExternalCareerSite",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("honokasoft/ats-job-listings").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",
    "lever:spotify",
    "ashby:notion",
    "workday:nvidia.wd5/NVIDIAExternalCareerSite"
  ]
}' |
apify call honokasoft/ats-job-listings --silent --output-dataset

```

## MCP server setup

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

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/0p0yCmL0eGMO7b99W/builds/5QGhyYbmdaZOuwd03/openapi.json
