# Ats Job Scraper Change Tracker (`crawlninja/ats-job-scraper-change-tracker`) Actor

- **URL**: https://apify.com/crawlninja/ats-job-scraper-change-tracker.md
- **Developed by:** [Maximilian Waibel](https://apify.com/crawlninja) (community)
- **Categories:**
- **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?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## ATS Job Scraper with Change Tracking

**Scrape company job boards on Workday, Greenhouse, Lever, Ashby, SmartRecruiters
and Personio — then run it on a schedule and get only what moved: roles opened,
edited, closed, or re-listed.**

Every closed role tells you **how many days it was open**. That is time-to-fill,
per company, per team — and it is a number no plain job scraper can give you,
because it only exists if something remembered yesterday.

***

### Why not just re-scrape the board every day?

You can. Every other ATS job scraper in the store will hand you all 4,000 roles
every morning for about $6, and leave you to work out which two of them changed.

This one does the diff, and charges accordingly:

|  | This Actor | Plain ATS scraper |
|---|---|---|
| 20 companies, first run | **$2.11** | ~$6.00 |
| 20 companies, every day after | **$0.41** | ~$6.00 |
| Per month, on a daily schedule | **~$12** | **~$180** |
| Rows you have to read | ~30/day | 4,000/day |
| Roles that closed |  reported, with tenure | not reported |
| What changed in an edited role | named, with before/after | work it out yourself |

Watching is cheap because watching is cheap. Reading a board that has not moved
costs half a cent, and unchanged roles are never billed at all.

***

### Quick start

**1. Type company names.** One per line, into *Companies to watch*. Up to 100.

```
stripe
linear
Bosch Group
```

You do not need to find their job board — the Actor probes Greenhouse, Lever,
Ashby, SmartRecruiters and Personio, works out which one hosts them, and
remembers the answer so it never looks again.

**2. Workday is the exception.** Paste the careers page URL:

```
https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite
```

A Workday board is identified by a tenant, a shard number (`wd5`) and a site name
that the company chose. None of the three can be guessed from "nvidia", so there
is nothing to probe. Every other provider also accepts `greenhouse:stripe` if you
already know the board.

**3. Leave *Only report movement* on** and run it.

The **first run on each company returns every open role**, because nothing is
known yet. That is the baseline, and it is priced like the plain list it is —
$0.50 per 1,000 roles, below the going rate in this store. Every run after that
returns only what changed.

**4. Put it on a schedule.** Daily is the sweet spot for hiring. In Apify
Console: *Actor → Schedules → Create*. That is the whole product — a scheduled
run that stays quiet until something happens.

***

### What you get back

One row per role that moved. `_change` tells you what happened:

| `_change` | Means |
|---|---|
| `new` | the role was posted since your last run |
| `changed` | title, location, team, salary or another detail was edited |
| `closed` | the role is gone from the board — filled or pulled |
| `reopened` | a role that had disappeared is listed again |
| `unchanged` | only on full-dump runs, where every open role is returned |

An edited role names what moved, so you do not have to diff it yourself:

```json
{
  "_change": "changed",
  "_changed_fields": "location, title",
  "_changed": {
    "title":    { "from": "Senior Backend Engineer", "to": "Staff Backend Engineer" },
    "location": { "from": "Berlin, Germany",         "to": "Munich, Germany" }
  },
  "title": "Staff Backend Engineer",
  "company": "acme",
  "url": "https://boards.greenhouse.io/acme/jobs/4001"
}
```

A closed role names itself and says how long it took:

```json
{
  "_change": "closed",
  "title": "Director, Compounding Operations",
  "company": "ro",
  "ats": "lever",
  "location": "Romeoville, IL",
  "department": "Pharmacy",
  "url": "https://jobs.lever.co/ro/c7f3d544-0899-4f8f-a319-c645e3928d08",
  "posted_at": "2026-08-13T22:35:07.762000+00:00",
  "_days_open": 17.3,
  "id": "lever:ro:c7f3d544-0899-4f8f-a319-c645e3928d08"
}
```

Every row carries `title`, `company`, `ats`, `location`, `department`,
`employment_type`, `remote`, `url`, `apply_url` and `posted_at`. Where the
provider publishes them you also get `salary_min` / `salary_max` /
`salary_currency`, `hybrid`, `seniority` and `reference`. Turn on *Include full
posting text* to add `description`.

`posted_at` is a full UTC timestamp wherever the provider gives one. On Workday
roles older than 30 days it is empty: the board only says "30+ Days Ago", which
names no date, and a guess would be worse than nothing.

***

### What people use it for

**Competitive hiring intelligence.** A rival's role closes after 9 days — they
hired fast, in that function. Six engineering roles open in a week — they raised,
or they are scaling. Watch 20 competitors for the price of a coffee a month and
read the pattern.

**Recruiting and sourcing.** Know within a day when a target company opens a role
you can fill, instead of finding it in week three.

**Your own funnel.** Your posting has been open 60 days while comparable roles
elsewhere close in 20. That is your process, not the market.

**Market research.** Track remote policies, salary bands and location mix across
a sector over months, without re-downloading the whole sector every night.

***

### Cost

You pay per event, not per month:

| | |
|---|---|
| **$0.01** | per run |
| **$0.005** | per company watched — an unreachable board is free |
| **$0.0005** | per role on that company's **first** run, when there is nothing to compare against yet |
| **$0.01** | per change delivered — only roles that actually moved |

Worked examples, using real board sizes:

| | Roles | Cost |
|---|---|---|
| First run, the three prefilled companies | 130 | **$0.09** once |
| A quiet day on those three | 0 moved | **$0.025** |
| A normal day, a few roles move | ~4 moved | **$0.065** |
| First run, 20 companies | ~4,000 | **$2.11** once |
| A normal day across 20 companies | ~30 moved | **$0.41** |
| Adding a 21st company later | ~200 | **$0.105** once |

Two things worth knowing about that table:

**The first run is cheap on purpose.** It is the run you judge this Actor on
before you trust it, so it is priced below every plain job scraper in the store
rather than above them. Stripe alone is 575 roles — about $0.30 to take a
complete snapshot of.

**Growing your watchlist costs one company.** Adding a company pays that
company's baseline and nothing else; the twenty you were already watching keep
their history and are not re-reported. The same is true of editing a filter, or
reordering the list.

**Want a hard ceiling?** Set a maximum cost per run when you start it. The run
stops delivering at your cap instead of quietly working for free, records in
`RUN_SUMMARY` how many rows it withheld, and reports those rows on the next run
rather than losing them — still at the baseline price if that is what they were.
Setting a limit delays data; it never makes it cost more.

***

### Getting the data out

Every run writes to a dataset you can export as JSON, CSV or Excel from the
Console, or pull through the API:

```
https://api.apify.com/v2/datasets/<DATASET_ID>/items?format=csv
```

On a schedule you usually want the **last run** endpoint, which always points at
the newest results:

```
https://api.apify.com/v2/acts/<USERNAME>~ats-job-scraper-change-tracker/runs/last/dataset/items?token=<TOKEN>
```

For alerting, add a webhook under *Actor → Integrations* on **Run succeeded** and
point it at Slack, Make, Zapier or your own endpoint. Because quiet runs deliver
nothing, a webhook that arrives with rows in it means something actually
happened — no filtering on your side.

The dataset ships two table views: **What moved** for the daily read, and **Time
to fill** for the closures, sorted by how long each role was open.

***

### Use it from an AI agent

The same Actor runs as an **MCP server**, so Claude, Cursor or Codex can query it
directly. Enable Standby in the Actor's settings and point your client at its
Standby URL. Three tools:

| Tool | |
|---|---|
| `watch_feed` | what moved since your last call |
| `read_feed` | every open role right now |
| `feed_status` | how many roles are tracked and still open |

Then ask things like *"has anyone on my watchlist opened a staff engineer role
this week?"* and the agent polls it itself.

***

### Settings worth knowing

**Only report movement** — on by default, and the reason to use this Actor. Turn
it off to get every open role on every run, billed at the cheaper baseline rate.

**Include full posting text** — off by default. Adds the `description` field and
makes results much larger. On Workday it costs one extra request per role, so
runs there get noticeably slower, and it stops after the first 300 roles of a
board. Changing it changes what a role's record contains, so it starts a fresh
comparison on purpose.

**Location filter / Title filter** — deliver only roles whose location or title
contains your text, case-insensitive. The board is still read and compared in
full, so **closures are still reported** and changing a filter does not reset
your history — you are simply billed for fewer rows.

***

### Limits, stated plainly

- **The first run on each company returns everything.** Nothing is known yet.
- **A company name is found by probing**, so a company whose board is empty on
  one provider and live on another resolves to the live one — but a company on
  none of the five is reported as unresolvable rather than guessed at. Paste the
  URL if you know it. Two consequences worth knowing: a name resolves only when
  all five providers actually answered, so a rate limit fails that name for the
  run rather than settling it wrongly and remembering it; and a SmartRecruiters
  board with nothing open at all cannot be found by name, because their API
  answers the same way for a company with no roles as for a name that was never
  a company. Watch that one as `smartrecruiters:token` or by URL.
- **Workday needs the full URL**, and caps a board at 2,000 roles. Bigger boards
  are read up to that ceiling and reported, but closures are not reported for
  them, because the full set was never visible. The run says so in its notes.
- **An unreachable board is skipped**, noted, and not charged for. Transient
  failures (429, 503, timeouts) are retried with backoff first. The rest of your
  watchlist runs normally, and no closure is invented for the roles on the board
  that failed — a board that times out is not a board where everyone got hired.
- **A very large watchlist has a ceiling.** Tracking state is checkpointed into a
  single platform record with an 8 MB limit; compressed, that holds roughly
  150,000 tracked roles. Past it, closed roles are dropped first and the run
  tells you in the dataset if it still does not fit. Roles that are still open
  are never dropped to make room.

### Where the data comes from

Only the public job-board endpoints these six providers publish for third-party
use. No authentication, no login-walled data, no personal data. Recruiter names
are never requested. Descriptions are employer-authored posting text and are
truncated rather than republished in full. Unlisted Ashby postings are excluded.

***

### Troubleshooting

**"No public job board found for X"** — the company is on Workday (paste the
careers URL), on an ATS this Actor does not read, or spells its board slug
unusually. Open the careers page in a browser and paste the URL you land on.

**"Board not found"** — the token in the URL is wrong, or the company moved
boards. Open the careers page in a browser: if it redirects somewhere else, use
the URL you land on.

**Everything came back as `new` on the second run** — check whether you changed
*Include full posting text* between runs; that changes what a role's record
contains, so it starts a fresh comparison on purpose. Changing the *companies*
or the *filters* does not. If neither applies, open `RUN_SUMMARY` and check
`state_durable` — a failed checkpoint is reported there and as a `warning` row in
the dataset.

**A run returned nothing at all** — that is the normal result on a quiet day, and
it costs a couple of cents. Open `RUN_SUMMARY` in the run's key-value store:
`unchanged` tells you how many roles were checked and found untouched.

**Closures never appear** — they cannot on a company's first run, and they will
not appear for a Workday board over 2,000 roles. They *do* appear on filtered
runs, unlike most scrapers.

**A department or salary field is empty** — not every provider publishes every
field, and not every employer fills in the ones that exist. Salary bands come
from Lever and Ashby where the company entered them; `hybrid`, `seniority` and
`reference` come from SmartRecruiters; `seniority` also from Personio.

***

Working on the code? See \[DEVELOPMENT.md].

# Actor input Schema

## `boards` (type: `array`):

One company per line. A name is enough — 'stripe', 'Bosch Group' — and the job board is found for you across Greenhouse, Lever, Ashby, SmartRecruiters and Personio. The answer is remembered, so a company is looked up once and never again. Workday is the exception: paste the careers page URL (e.g. https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite), because the shard and site name cannot be guessed from a company name. 'greenhouse:stripe' also works if you already know the board. Each company's first run returns everything it has open, priced as a plain list at $0.50 per 1,000 roles; from the second run on you pay only for what moved. Adding a company later costs that one company's first run, not the whole watchlist's.

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

On, and the reason to use this Actor. Each run reports roles that opened, were edited, closed, or were re-listed since the last run, and nothing else — so a daily schedule stays small and cheap. Turn it off to get every open role on every run, billed at the cheaper baseline rate.

## `include_description` (type: `boolean`):

Off by default. Descriptions make results much larger and runs slower. On Workday this costs one extra request per posting, so it also makes runs considerably longer there. Changing this changes what a role's record contains, so it starts a fresh comparison on purpose.

## `location_contains` (type: `string`):

Deliver only roles whose location contains this text, case-insensitive. Leave empty for all. Filtering happens after the comparison, not before it, so closures are still reported and you can change this at any time without losing history — you are simply billed for fewer rows.

## `title_contains` (type: `string`):

Deliver only roles whose title contains this text, case-insensitive. Leave empty for all. As with the location filter, closures are still reported and changing it does not reset your history.

## `source` (type: `string`):

Internal adapter key. This Actor ships exactly one adapter, so leave it as it is unless support tells you otherwise.

## Actor input object example

```json
{
  "boards": [
    "discord",
    "linear",
    "ro"
  ],
  "onlyChanges": true,
  "include_description": false,
  "location_contains": "",
  "title_contains": "",
  "source": "ats-jobs"
}
```

# Actor output Schema

## `movement` (type: `string`):

The roles that opened, were edited, closed or came back since the previous run, each saying which fields changed and - for a closed role - how many days it was open. This is the product: on a quiet day it is deliberately empty. The first run for a company has no history to compare against, so everything on its board appears here as new.

## `time_to_fill` (type: `string`):

Only the roles that closed in this run, with the date each was first seen, the date it disappeared, and the days between - the tenure of a filled requisition. Empty until a watched board actually loses a role.

## `all_rows` (type: `string`):

The same rows with no view applied: salary, description, apply URL, department, seniority and the rest of the normalised schema, identical in shape across all six ATS providers.

## `run_summary` (type: `string`):

Counts for this run - fetched, new, changed, closed, unchanged, boards reached - plus what was charged, any board that could not be read, and state\_durable: whether the comparison state was saved. If state\_durable is false the next run cannot tell what moved and will report the whole watchlist again.

# 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 = {
    "boards": [
        "discord",
        "linear",
        "ro"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("crawlninja/ats-job-scraper-change-tracker").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 = { "boards": [
        "discord",
        "linear",
        "ro",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("crawlninja/ats-job-scraper-change-tracker").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 '{
  "boards": [
    "discord",
    "linear",
    "ro"
  ]
}' |
apify call crawlninja/ats-job-scraper-change-tracker --silent --output-dataset

```

## MCP server setup

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

```

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/oLIcmO8X3vMYjDYUb/builds/yfZGlvVmiFfXMJLwx/openapi.json
