# ATS Job Board Feed Sync - Greenhouse, Lever, Ashby (`smiling_jubilance/ats-job-feed-sync`) Actor

One actor for every major ATS: Greenhouse, Lever, Ashby, Workday. Auto-detect the job board behind a company, pull all open roles as a jobs JSON feed with one normalized schema. Incremental mode bills only NEW, UPDATED and REMOVED jobs - unchanged are tracked but never billed.

- **URL**: https://apify.com/smiling\_jubilance/ats-job-feed-sync.md
- **Developed by:** [Lake Sky](https://apify.com/smiling_jubilance) (community)
- **Categories:** Jobs, Automation, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.60 / 1,000 job changes

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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 is ATS Job Feed Sync?

Every company runs its hiring on one of a handful of applicant tracking systems — Greenhouse, Lever,
Ashby, Workable, SmartRecruiters, Workday and others. Each one exposes a different JSON shape, with
different field names for the same concept.

**ATS Job Feed Sync** hides that. You give it a list of companies, it **auto-detects which board each
one runs on**, pulls every open role, and returns a **single normalized schema** regardless of the
source.

Set it to **incremental mode** and it keeps a fingerprint index between runs, so the second run
reports only what actually changed: **new**, **updated** and **removed** jobs. Unchanged roles are
still tracked in the index, but they are **never emitted and never billed** — which is what makes a
daily or hourly watch affordable.

#### Four guarantees about incremental billing

1. **The index survives between runs.** It is stored in a *named* key-value store
   (`ats-feed-index`). Apify keeps named storages indefinitely, while default/unnamed storages are
   created per run and expire — that is why this Actor does not use the default store for state.
2. **Shrinking your company list never creates phantom charges.** Jobs belonging to companies you did
   NOT request in a run are excluded from `removed` detection (`removedScopeSkipped` in the run
   summary), so changing 100 companies to 60 can never bill you for the 40 you dropped.
3. **A cold start says so.** If there is no usable index (first run, or you set `resetIndex: true`),
   every job counts as `new` and is billed in full — and the run status message says *"Cold start"*
   instead of quietly charging you the full-feed price while promising incremental rates.
4. **Changing your company list does not erase the baselines you already paid for.** Baselines are
   stored per company, and a run only refreshes the companies it successfully fetched. Running
   3 companies, then 1, then 3 again bills **0** on the third run — not 180. (Measured, see
   `research/apify/incremental-contract.cjs`, assertions B6/B8.)

Because it talks to plain JSON endpoints rather than rendering pages, throughput is roughly
**600,000 jobs per hour**.

### What can this Actor do?

- ✅ **Auto-detect the ATS** behind a company — you do not need to know which board they use
- ✅ **One normalized schema** across every supported board
- ✅ **Incremental diffing** — `new`, `updated` and `removed` change types with a persisted index
- ✅ **Opt-in Workday probing** during auto-detect (off by default because it is slow)
- ✅ **Optional full job descriptions** in the output
- ✅ **Run summary** counters written to the key-value store, so you can see what happened without
  reading every row
- ✅ Scheduling, webhooks, API access and storage come from the Apify platform, so wiring this into a
  nightly pipeline is a few clicks

### How to use it

1. Open the **Input** tab.
2. Add companies to `companies`. A `slug` is enough; add `ats` only if you already know the board and
   want to skip detection.
3. Choose `mode`: `full` for a one-off snapshot, or `incremental` for a recurring watch.
4. Click **Start**.
5. Switch the dataset to the **Changes only** view to see just `new` / `updated` / `removed` rows.

```json
{
  "companies": [
    { "slug": "stripe" },
    { "slug": "palantir", "ats": "lever" }
  ],
  "detectAts": true,
  "mode": "incremental"
}
```

### Pricing

This Actor is **pay-per-event**, billed on the **job change** event.

| Event | When it is charged | Price |
|---|---|---|
| **Job change** | One new, updated or removed job detected | **$5** per 1,000 changes ( = $0.005 each ) |
| **Actor start** | Once per run, per GB of memory | **$0.00005** per GB |

The important detail: in **incremental mode, unchanged jobs are tracked but never charged**. A company
with 400 open roles that added 3 and closed 1 produces **4 billable events**, not 400 — every single
run after the first. Compare that with scrapers that re-bill the full job list on every run.

Apify's BRONZE, SILVER, GOLD, PLATINUM and DIAMOND plans reduce the unit price automatically.

Rough budgeting:

| Job changes detected | Cost |
|---|---|
| 1,000 | $5 |
| 5,000 | 5 × $5 |
| 10,000 | 10 × $5 |

### Input

| Field | Type | Default | Notes |
|---|---|---|---|
| `companies` | array | two samples | Objects with `slug` and optional `ats` |
| `detectAts` | boolean | `true` | Auto-detect the board when `ats` is omitted |
| `includeWorkday` | boolean | `false` | Also probe Workday during auto-detect |
| `mode` | string | `full` | `full` or `incremental` |
| `includeDescription` | boolean | `true` | Include job description text |
| `maxConcurrency` | integer | `8` | Parallel company fetches |
| `requestTimeoutMs` | integer | `25000` | Per-request timeout |
| `retries` | integer | `1` | Retries per company |

### Output

One dataset item per detected change:

```json
{
  "changeType": "new",
  "title": "Senior Backend Engineer",
  "company": "Stripe",
  "department": "Engineering",
  "location": "Remote, US",
  "applyUrl": "https://boards.greenhouse.io/stripe/jobs/1234567"
}
```

The run also writes a **SUMMARY** record to the key-value store with aggregate counters (jobs seen,
changes emitted, ATS detected per company) plus the incremental index used by the next diff.

### Other Actors you may like

- **[Bulk Tech Stack Detector - BuiltWith/Wappalyzer Alternative](https://apify.com/store)** — find companies running
  a given stack, then track their hiring here.
- **[Bulk Email Verifier](https://apify.com/store)** — verify recruiting contacts before outreach.
- **[Bulk On-Page SEO Audit](https://apify.com/store)** — 26 on-page checks across a whole sitemap.

### FAQ

**Do I need to know which ATS a company uses?** No. Leave `ats` off and `detectAts` will find it.
Supply it explicitly only to skip detection.

**What counts as a change?** A job that appeared, disappeared, or whose normalized fingerprint
changed. Unchanged roles are indexed, not emitted.

**Is Workday supported?** Yes, but probing it is slow, so `includeWorkday` is off by default. Turn it
on when you expect a target to run Workday.

**What happens on the very first incremental run?** There is no previous index, so every open role is
`new` — one full bill. Every run after that bills only the delta.

# Actor input Schema

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

List of company board slugs. A slug is the ATS board identifier, e.g. 'gitlab' for boards.greenhouse.io/gitlab. Leave 'ats' empty to auto-detect across Greenhouse / Lever / Ashby / SmartRecruiters / Workday. Demo/QA-safe defaults: airtable (Greenhouse, 16 jobs) + matchgroup (Lever, 72 jobs); a full run with these takes ~60s vs ~407s for a large board like stripe (665 jobs).

## `detectAts` (type: `boolean`):

Try Greenhouse -> Lever -> Ashby -> SmartRecruiters and use the first board that returns jobs. Workday is only probed when 'includeWorkday' is on (it is slow to probe and rarely the answer).

## `includeWorkday` (type: `boolean`):

Workday has no universal endpoint: the actor must try 3 host variants per tenant, which costs up to 50s per company that is NOT on Workday. Leave off unless you know your targets use Workday.

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

'full' = output every job found and bill for all of them. 'incremental' = compare against the previous run and bill ONLY new / updated / removed jobs; unchanged jobs are stored but never billed. The change index lives in a NAMED key-value store ('ats-feed-index'), which Apify retains indefinitely - unnamed/default storages are not guaranteed to survive between runs. If no index exists yet (first run), every job counts as new and is billed; the run log says 'cold start' when that happens.

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

Include the full plaintext job description where the ATS exposes it. Note: salary data is NOT available from any of the supported public endpoints.

## `maxConcurrency` (type: `integer`):

Parallel companies to fetch. Measured: coverage at 8 and at 4 is identical (94% median over 18 supported companies, 3 rounds each) - misses come from slow single responses, not from parallelism, so lowering this does not help.

## `requestTimeoutMs` (type: `integer`):

Per-request timeout when calling an ATS endpoint. Ashby endpoints were measured at up to 14s, so values below 25s cause false "not found" results.

## `retries` (type: `integer`):

How many times to retry a timed-out or 5xx request for the same company before marking it unavailable. One retry measurably improves coverage.

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

In incremental mode, also output and bill jobs that disappeared from a board since the previous run (a hiring signal). Turn off if you only want new + updated jobs. Jobs of companies you did NOT request in this run are never judged as removed - shrinking your company list can never generate phantom 'removed' charges.

## `resetIndex` (type: `boolean`):

Discard the stored index and treat this run as a cold start (every job is new and billed). Useful after a long gap or if you suspect the index drifted. Leave off for normal daily monitoring.

## Actor input object example

```json
{
  "companies": [
    {
      "slug": "airtable",
      "ats": "greenhouse"
    },
    {
      "slug": "matchgroup",
      "ats": "lever"
    }
  ],
  "detectAts": true,
  "includeWorkday": false,
  "mode": "full",
  "includeDescription": false,
  "maxConcurrency": 8,
  "requestTimeoutMs": 25000,
  "retries": 1,
  "emitRemoved": true,
  "resetIndex": false
}
```

# Actor output Schema

## `results` (type: `string`):

Per-job record: changeType (new | updated | removed), title, company, department, location, applyUrl and the normalized job payload.

## `runSummary` (type: `string`):

Aggregated counters for the run (jobs seen, changes emitted, ATS detected per company) plus the incremental index used for the next diff.

# 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": [
        {
            "slug": "airtable",
            "ats": "greenhouse"
        },
        {
            "slug": "matchgroup",
            "ats": "lever"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("smiling_jubilance/ats-job-feed-sync").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": [
        {
            "slug": "airtable",
            "ats": "greenhouse",
        },
        {
            "slug": "matchgroup",
            "ats": "lever",
        },
    ] }

# Run the Actor and wait for it to finish
run = client.actor("smiling_jubilance/ats-job-feed-sync").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": [
    {
      "slug": "airtable",
      "ats": "greenhouse"
    },
    {
      "slug": "matchgroup",
      "ats": "lever"
    }
  ]
}' |
apify call smiling_jubilance/ats-job-feed-sync --silent --output-dataset

```

## MCP server setup

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

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/OaJhpP5q47Dd1dtTz/builds/Pw33LtIxX0nvjnlBZ/openapi.json
