# Job Postings API & Change Tracker: Greenhouse, Lever, Ashby +4 (`fulox/ats-hiring-signals`) Actor

Hiring signals and job postings API for Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee and Personio. Paste company domains, get every open job, then only new, closed and edited ones each run, plus a surge or freeze signal per company. Slack alerts. No API key.

- **URL**: https://apify.com/fulox/ats-hiring-signals.md
- **Developed by:** [Fulox](https://apify.com/fulox) (community)
- **Categories:** Jobs, Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 62.5% runs succeeded, 1 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 job posting or company checks

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

## Job Postings API & Change Tracker: Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee, Personio

**What you get:** paste company domains (`acme.com`) and get every open job posting from **Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee and Personio** in one format: title, department, location, remote, posted salary range, link. On every later run you get only what changed: **new, closed and edited postings**, plus one **hiring-signal row per company** (`surge`, `freeze`, leadership hires, tools mentioned).

**Who it is for:** sales and RevOps teams that want lead signals from job postings, recruiters and agencies watching client and competitor boards, investors tracking portfolio companies, and researchers who count roles and posted salaries. It is a job change monitor and hiring signals tracker, not a one-off scraper.

No login, no API key, no personal data.

[![Next run, only what changed: new, closed and edited job postings across companies](https://raw.githubusercontent.com/FuloxDev/apify-assets/main/ats-hiring-signals/demo-v2.gif)](https://github.com/FuloxDev/apify-assets/blob/main/ats-hiring-signals/demo-v2.gif)

▶️ **[Watch the 18-second demo](https://github.com/FuloxDev/apify-assets/blob/main/ats-hiring-signals/demo-v2.gif)**: paste domains, the job board is found, every open job in one table, then only what changed, hiring signals and a Slack alert.

### ⚡ Try it in 30 seconds

Click **Start** with the default input. It reads three public boards (`ashby:linear`, `greenhouse:vercel`, `lever:spotify`) and lists the postings of the **last 7 days** (about 10 in October 2026) plus a row per company, for about **$0.02**. For every open posting (about **200**, roughly **$0.30** on the Free or Starter plan, less on higher plans, see Pricing), clear `postedWithinDays` before your first run; if you already ran the default input, also give a new `watchlistName`, because later runs of a watchlist list only changes. Then replace the boards with your own domains.

Run it again later, and you get only the changes.

### ✨ Key features

- 🌐 **Domains in, board found for you.** Paste `acme.com` or `https://acme.com/careers`. The job board is detected for free.
- 🧩 **7 applicant tracking systems** in one output format: Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee, Personio.
- 🔄 **Change tracking.** First run lists everything. Later runs deliver only **new**, **closed** and **changed** postings.
- ✏️ **Edits with old values.** A changed posting lists which fields changed (`changedFields`) and what they were before (`previous`): title, department, team, location, workplace type, employment type, salary.
- 📈 **Hiring signals per company.** `surge`, `freeze`, `expanding`, `contracting` or `steady`, with reasons. Plus open roles now vs 7 and 30 days ago, leadership hires (titles only), seniority mix, roles per function, functions hiring for the first time, reposted roles, remote share and tools mentioned (Kubernetes, Snowflake, dbt and more).
- 💰 **Salary ranges, also from the posting text.** Ashby and other feeds give the pay range directly (`salarySource: "structured"`). Greenhouse does not, so the range is read from the posting text by fixed rules, no AI (`salarySource: "description"`). On the 80 preset companies, 55% of the postings now carry a range (it was 18% from feed fields alone). These are **posted ranges, not actual pay**.
- 🆚 **Competitor hiring report.** With two or more companies you also get one free `report` row: open roles, 7 and 30 day change, function and seniority mix, remote share, median posted salary by function and top tools per company, each marked above or below your list's average.
- 🧰 **Skills and tools ranking.** The same row lists the 25 most mentioned tools across all postings in the run, with the functions asking for them, and which tools are **rising** since your last report.
- 🏢 **Ready-made company lists.** Add `presets` such as `ai-labs`, `dev-tools`, `fintech` or `b2b-saas`: 20 well-known companies each, boards found for you.
- 🗓️ **Fresh postings only.** `postedWithinDays` keeps postings from the last N days, so a first run lists and charges only what is recent.
- 📰 **Pick the rows you pay for.** `statuses` delivers only `new`, only `closed` (an expired-jobs feed) or any mix.
- 📝 **Description text, optional.** `includeDescription` adds a plain-text description; `descriptionIncludes` keeps postings that mention your keywords.
- 💶 **Volume discounts.** $1.50 per 1,000 results, down to $1.00 on Business and Enterprise plans.
- 🔔 **Alerts.** Optional Slack or webhook digest, sent only when something changed. Free.
- 🎯 **Filters and watchlists.** Filter by title, department, location or remote. Keep separate watchlists per list or per client.
- 🔑 **No login, no API key.** Reads public job board feeds.

### 👥 Who it is for

- 💼 **Sales and RevOps.** Watch your target accounts. A company with a `surge` signal, a first-ever `legal` or `security` posting, or a new "Head of" hire is a reason to reach out this week. Push `signal` and `summary` into your CRM or Clay.
- 🧑‍💼 **Recruiters and agencies.** Track client and competitor boards. Get new postings matching `titleIncludes: ["Data Engineer"]` in your locations every morning, without opening 40 career pages.
- 📊 **Investors and VCs.** Monitor portfolio and pipeline companies. A `freeze` (nothing new, many roles closed) or a `contracting` signal shows up in the data before it shows up in the news.
- 🔍 **Market researchers.** Count roles per function and tools mentioned across a list of companies, and track how that changes week by week.

### 🎯 Use cases

- **Sales hiring signals.** Put your target accounts in `boards` with `"emitBaseline": false` and schedule it weekly. Send company rows whose `signal` is `surge`, or that list `newFunctions` or `leadershipHires`, to Slack, your CRM or Clay.
- **Recruiting lead signals.** A company that opens its first `legal`, `security` or `sales` posting is hiring in a new area. Use `titleIncludes` and `locations` to get only the roles you place.
- **Job change monitor for competitors.** Track a rival's new, closed and edited postings (a changed title or location comes with the old value in `previous`) and the open-role trend in the report row.
- **Salary from job postings.** Run a `presets` list with `postedWithinDays: 30` and read `salaryMin`, `salaryMax`, `salaryCurrency` and `salarySource`. These are posted ranges, not actual pay.
- **Expired jobs feed.** Set `"statuses": ["closed"]` to get only postings that disappeared (see the job board feed recipe).

### 🚀 How to use it

1. Paste company **domains** (`acme.com`), career pages, or boards such as `greenhouse:vercel`, `ashby:linear`, `lever:spotify`, `smartrecruiters:Ubisoft2`, `workable:mindvalley`, `recruitee:bunq`, `personio:acme`.
2. Optional: add filters, a `watchlistName`, and a Slack webhook.
3. Click **Start**. The first run lists every open posting (turn `emitBaseline` off to only record the starting point).
4. Open **Schedules** and create a schedule, for example weekly on Monday at 07:00, with your saved input. Each scheduled run delivers only the changes.
5. For Slack alerts, paste an incoming webhook URL into `slackWebhookUrl`. A short message lists the companies that changed and up to 5 new titles each.
6. Download as JSON, CSV or Excel, or send the data to Google Sheets, Make, Zapier, n8n or Clay.

### 📥 Input

![Input form of the Job Postings API and Change Tracker with company domains and filters](https://raw.githubusercontent.com/FuloxDev/apify-assets/main/ats-hiring-signals/input.png)

Example input:

```json
{
  "boards": ["acme.com", "https://globex.com/careers", "greenhouse:vercel"],
  "titleIncludes": ["engineer", "data"],
  "remoteOnly": false,
  "watchlistName": "target-accounts",
  "emitBaseline": true,
  "presets": ["dev-tools"],
  "postedWithinDays": 30,
  "statuses": ["baseline", "new", "closed"],
  "slackWebhookUrl": "https://hooks.slack.com/services/XXX/YYY/ZZZ"
}
```

| Field | What it does | Default |
|---|---|---|
| `boards` | Up to 100 entries. A company domain or career page (the board is detected, free), or `<ats>:<slug>` / a board link. Invalid entries are skipped free, duplicates removed. | `ashby:linear`, `greenhouse:vercel`, `lever:spotify` |
| `presets` | Add ready-made lists of 20 companies each: `ai-labs`, `dev-tools`, `fintech`, `b2b-saas` (checked 2026-10-14). Added after your own entries, deduplicated, and counted in the 100-company limit. A run finds up to 20 new company domains, so use one or two presets per first run. | none |
| `emitBaseline` | On a company's first run, output every open posting (after your filters) as a `baseline` row; each row is charged. Off = record the starting point only. | on |
| `titleIncludes`, `titleExcludes` | Keep postings whose title contains any of these words / drop those that do (not case sensitive, up to 50 words). | none |
| `departments` | Keep postings whose department, team or function (engineering, product, design, data, sales, marketing, customer success, finance, legal, people, operations, security) contains any of these. | none |
| `locations`, `remoteOnly` | Keep postings in these places / marked remote. | none, off |
| `postedWithinDays` | 1 to 365. Keep postings first published (or, if the board gives no date, first seen) within this many days. Older postings are not listed or charged; company counts stay whole. | no limit |
| `statuses` | Which posting rows to deliver: `baseline`, `new`, `closed`, `changed`. `["closed"]` is an expired-jobs feed. The company read is still one result. | all |
| `includeDescription` | Add a plain-text `description` (HTML removed, up to 5,000 characters) to posting rows. Same price. | off |
| `descriptionIncludes` | Keep postings whose description contains any of these words (not case sensitive). Closed rows carry no text, so they are not listed while this is set. | none |
| `report` | Add the free competitor hiring report row (see Output). Default: on when two or more companies were read; `false` turns it off; `true` also works with one company. | automatic |
| `watchlistName` | Letters, digits, `-`, `_`. Each name keeps its own snapshots: several lists or several clients, without mixing their changes. | `default` |
| `slackWebhookUrl` | Slack incoming webhook. A digest is posted only when something changed. Free. | none |
| `webhookUrl` | An https URL that gets the same digest as JSON. Private and local addresses are refused. Free. | none |
| `resetState` | Forget the saved snapshot and saved board lookup of the listed companies; this run is their first run again. | off |

Filters, `postedWithinDays`, `statuses` and `descriptionIncludes` decide which posting rows you get (and pay for). The company row always counts the whole board (`openCount`); `matchingOpenCount` counts the postings that pass your filters. Changing a filter later never creates fake new or closed postings.

### 📤 Output

![Dataset table of job postings with status, title, department, location and link](https://raw.githubusercontent.com/FuloxDev/apify-assets/main/ats-hiring-signals/output-table.png)

A **posting row** (`type: "posting"`, status `baseline`, `new`, `closed` or `changed`):

```json
{
  "type": "posting", "status": "new", "ats": "lever", "board": "acme", "domain": "acme.com", "company": "Acme",
  "jobId": "223f9722-3579-4ff2-b123-e4cc7b0c1a64", "title": "Head of Legal",
  "department": "Legal", "team": null, "location": "Berlin", "workplaceType": "hybrid", "employmentType": "Permanent",
  "salaryMin": 150000, "salaryMax": 190000, "salaryCurrency": "EUR", "salaryInterval": "1 YEAR", "salarySource": "description", "salaryOte": false,
  "function": "legal", "seniority": "director", "remote": false, "techMentions": ["Snowflake"], "reposted": false,
  "postedAt": "2026-10-13T17:15:08.174Z", "url": "https://jobs.lever.co/acme/223f9722-3579-4ff2-b123-e4cc7b0c1a64",
  "applyUrl": "https://jobs.lever.co/acme/223f9722-3579-4ff2-b123-e4cc7b0c1a64/apply",
  "firstSeenAt": "2026-10-14T08:00:00.000Z", "closedAt": null, "daysOpen": null,
  "changedFields": [], "previous": null, "runAt": "2026-10-14T08:00:00.000Z"
}
```

With `includeDescription` on, posting rows also carry `"description": "..."` (plain text, at most 5,000 characters; `null` for closed rows and for Workable and SmartRecruiters, which publish no text).

For an edited posting (`status: "changed"`), `changedFields` and `previous` carry the edit, for example `"changedFields": ["location"]` and `"previous": {"location": "Berlin"}`.

![Hiring signals per company: surge, freeze, leadership hires and open roles in 7 and 30 days](https://raw.githubusercontent.com/FuloxDev/apify-assets/main/ats-hiring-signals/hiring-signals.png)

A **company / signals row** (`type: "board"`):

```json
{
  "type": "board", "ats": "lever", "board": "acme", "domain": "acme.com", "company": "Acme", "status": "ok", "baseline": false,
  "openCount": 31, "previousOpenCount": 28, "newCount": 3, "closedCount": 0, "changedCount": 1, "netChange": 3,
  "openCount7dAgo": 28, "openCount30dAgo": 29, "matchingOpenCount": 31,
  "byFunction": {"engineering": 14, "sales": 9, "legal": 1, "other": 7}, "newFunctions": ["legal"],
  "seniorityMix": {"intern": 0, "junior": 1, "mid": 2, "senior": 9, "lead": 3, "manager": 4, "director": 1, "exec": 0, "unknown": 11},
  "leadershipHires": {"count": 1, "titles": ["Head of Legal"]}, "remoteShare": 0.26,
  "signal": "surge", "signalReasons": ["3 new of 28 (11%)", "first legal posting"],
  "repostedCount": 0, "techStack": {"Kubernetes": 4, "Snowflake": 1}, "newTech": ["Snowflake"],
  "summary": "Acme: +3 new (2 engineering, 1 legal), first legal hire, 1 leadership hire, 31 open, +7% in 30 days",
  "fetchedAt": "2026-10-14T08:00:00.000Z", "runAt": "2026-10-14T08:00:00.000Z"
}
```

#### Salary: where the numbers come from

`salarySource` says it. **`structured`**: the feed publishes the range (Ashby, Lever, Recruitee when filled in). **`description`**: the feed has none, so the range is read from the posting text by fixed rules (no AI): `$150,000 - $200,000 USD per year`, `£60k–£75k`, `€55.000 - €70.000 brutto/Jahr`, `$45–$60/hr`, `OTE $200K`. The feed's own range always wins. `salaryInterval` is `1 YEAR`, `1 HOUR`, `1 MONTH`, `1 WEEK` or `1 DAY`; `salaryOte: true` marks on-target earnings (base plus variable pay).

Figures that are revenue, funding, bonus, equity, perks, phone numbers or years are ignored, and so are ranges with no currency or no sign that they are pay. A bare `$` is read as USD unless the words next to the figures name Canada, Australia, New Zealand or Singapore.

Coverage, measured live on 2026-10-14 over the 80 preset companies (11,883 postings): **54.7% carry a range** (Greenhouse 58.9%, Ashby 60.4%; SmartRecruiters and Recruitee 0%, their feeds carry no pay or no text). Before, only the feed fields counted: 17.8%. On 150 hand-checked snippets from real postings the rules made no wrong reading (about 97.6% of the pay ranges in the text are found; typos such as `$140,00` and hourly figures with no unit are missed). These are **posted ranges, not actual pay**: they skew to US tech and to roles that must publish a range by law.

#### The competitor hiring report row

With two or more companies, every run adds one free `report` row (never charged; turn it off with `"report": false`). It compares the companies of the run:

```json
{
  "type": "report", "watchlist": "default", "runAt": "2026-10-14T08:00:00.000Z",
  "basis": "posted pay ranges in open postings (read from the feed or the posting text), not actual pay",
  "companiesCompared": 18, "totalOpenPostings": 2225, "postingsWithToolData": 2225, "partial": false,
  "watchlistAverage": {"openRoles": 123.61, "growth30dPct": 4.2, "remoteShare": 0.58, "postedPayShare": 0.56},
  "companies": [{
    "company": "Vercel", "ats": "greenhouse", "board": "vercel", "openRoles": 90,
    "netChange7d": 3, "netChange30d": 8, "growth30dPct": 9.8,
    "functionMix": {"sales": 0.26, "engineering": 0.24, "finance": 0.08},
    "seniorityMix": {"unknown": 0.53, "manager": 0.24, "senior": 0.1, "director": 0.06},
    "remoteShare": 0.09, "postedPayShare": 0.66, "postingsWithPay": 59,
    "medianPostedSalaryByFunction": [{"function": "engineering", "currency": "USD", "interval": "1 YEAR", "n": 18, "median": 245000}],
    "topTools": [{"tool": "Next.js", "postings": 28}, {"tool": "TypeScript", "postings": 21}],
    "vsAverage": {"openRoles": "below", "growth30dPct": "above", "remoteShare": "below", "postedPayShare": "above"}
  }],
  "tools": [{"tool": "AWS", "postings": 660, "share": 0.297, "topFunctions": [{"function": "engineering", "postings": 257}, {"function": "sales", "postings": 91}],
             "previousShare": 0.281, "shareChange": 0.016, "rising": true}],
  "risingTools": [{"tool": "AWS", "shareChange": 0.016}],
  "previousReport": {"runAt": "2026-10-07T08:00:00.000Z", "companiesInCommon": 18}
}
```

- **Per company:** the numbers count the whole board (filters do not change them). `netChange7d` and `netChange30d` are null until your saved history is long enough. `functionMix` and `seniorityMix` are shares of open roles. `medianPostedSalaryByFunction` is the median of the yearly midpoint of posted ranges per function and currency, only where at least 3 ranges exist; on-target-earnings and one-sided ranges are left out and one role posted in several places counts once. `vsAverage` says `above`, `below` or `average` against the average of the companies in the run.
- **Tools:** the 25 most mentioned tools of a fixed list of about 60 (AWS, Kubernetes, Snowflake, dbt, React and so on) across all postings, with the share of postings and the top 3 functions. The share counts postings whose feed carries text (not Workable or SmartRecruiters; `postingsWithToolData` says how many).
- **Rising:** each run keeps a small summary per watchlist (`watchlistName`) and compares the share of postings with the previous report, over the companies in both. A tool is `rising` when its share grew by at least one point and it appears in at least 3 postings. The first run has nothing to compare with.
- A run that was restarted or stopped early marks the report `partial: true` and does not replace the saved summary.

Other free rows: `report` (see above), `input` (invalid entry, `detected`, `ats_not_found`, `detection_deferred`, `duplicate`), `digest` (one per run), and `run` (a run that delivered nothing, with the reason). The dataset has five views (postings, companies, report, issues, digest). Views choose columns, not rows, so **filter the `type` column** to see one kind of row.

### 💸 Pricing

Pay per result, cheaper on higher Apify plans. A result is one posting row delivered (listed on the first run, new, closed or edited) or one company read successfully.

| Your Apify plan | Per 1,000 results | Per result |
|---|---|---|
| Free and Starter | **$1.50** | $0.0015 |
| Scale | **$1.25** | $0.00125 |
| Business | **$1.00** | $0.0010 |
| Enterprise | **$1.00** | $0.0010 |

Apify's standard Store tiers apply: Free and Starter are the Free and Bronze tiers, Scale is Silver, Business is Gold, Enterprise is Platinum and Diamond. The run's cost line shows what you were charged.

**Free:** domain detection, boards that cannot be read (not found, error, empty feed), invalid entries, the digest row, the competitor report row, and Slack and webhook alerts.

Worked examples:

| | Results | Free / Starter | Scale | Business | Enterprise |
|---|---|---|---|---|---|
| 🆕 First run, 3 companies, about 200 open jobs (the default boards, `postedWithinDays` cleared) | about 203 | **$0.30** | $0.25 | $0.20 | $0.18 to $0.16 |
| 📅 Weekly check of 50 companies, about 50 postings changed | about 100 | **$0.15** | $0.125 | $0.10 | $0.09 to $0.08 |
| 🏢 `presets: ["fintech"]` (20 companies), `emitBaseline` off | 20 | **$0.03** | $0.025 | $0.02 | $0.018 to $0.016 |
| 😴 A quiet company, per run | 1 | **$0.0015** | $0.00125 | $0.0010 | $0.0010 |

- 🔕 **Want to skip the first-run listing?** Turn `emitBaseline` off, set `postedWithinDays`, or filter by title, department, location, remote or description, and you pay only for matching rows. A first run of the `ai-labs` preset lists about 4,300 postings (October 2026); with `emitBaseline` off it is 20 results.
- 📰 **Only closed or only new postings?** `statuses` limits the rows (and the charge) to those. The company read still counts as one result.

A company that just posted 100 roles costs more, because each delivered posting is one result. Set a **maximum cost per run** to stay in control.

### 🔌 Alerts and integrations

![Slack digest listing companies that changed and their new job titles](https://raw.githubusercontent.com/FuloxDev/apify-assets/main/ats-hiring-signals/slack-digest.png)

- 💬 **Slack, simplest:** paste an incoming-webhook URL into `slackWebhookUrl`. Sent only when something changed, free.
- 🔗 **Apify Slack integration:** Actor > Integrations > Slack, trigger "Run succeeded", to get a message after every run.
- 📧 **Gmail / email:** Integrations > Gmail (or Make) on "Run succeeded", attach the dataset as CSV.
- 📑 **Google Sheets:** Integrations > Google Sheets appends the dataset after every run. Filter the `type` column to `posting`.
- ⏰ **Schedule:** Schedules > create, daily or weekly with your saved input. Quiet days cost one result per company.
- 🔁 **n8n / Make / Zapier:** run the Actor, send posting rows to a sheet or CRM, and board rows with `signal` other than `steady` to Slack. A `webhookUrl` delivers the digest as JSON without polling.
- 🧱 **Clay:** add an HTTP API column calling this Actor's `run-sync-get-dataset-items` endpoint with your Apify token and body `{"boards":["{{domain}}"],"emitBaseline":false}`. Read `signal`, `summary` and `leadershipHires` from the board row.
- 📰 **Job board feed:** see the recipe below.
- 🛠️ **API:** call it from any language and read the dataset.
- 🤖 **MCP and AI agents:** call with `{"boards": ["acme.com"], "emitBaseline": false}` and read the dataset. Row types: `posting` (status `baseline|new|closed|changed`), `board` (status `ok` or a failure), `input`, `digest`, `report` (free comparison of the companies in the run, with tool ranking), `run`. To answer "what changed since yesterday", run the same input again under the same `watchlistName` and read the `new`, `closed` and `changed` rows plus each `board` row's `signal` and `summary`. Failed-board, `input` and `run` rows are free.

### 📰 Recipe: a job board feed

Keep a job board, newsletter or Sheet fed with fresh postings, and take down the ones that closed.

1. Create a **Task** with your companies (or `presets`) and `{"postedWithinDays": 7, "statuses": ["new"]}`. The first run records the starting point and delivers only company rows; every later run delivers just the postings that appeared since the last run.
2. Add a second Task with `{"statuses": ["closed"]}` on the same companies if you also need to remove listings. This is an expired-jobs feed: each closed row has `jobId`, `url` and `daysOpen`.
3. **Schedule** each Task daily (Schedules > create).
4. Read the result from the Task's last run: `https://api.apify.com/v2/actor-tasks/TASK_ID/runs/last/dataset/items?format=json` (add `&token=YOUR_TOKEN` for a private account), or use the Google Sheets integration to append every run. `format=xml` and `format=csv` work too.
5. Rows of other types (`board`, `digest`) share the dataset; keep the rows where `type` is `posting`.

Give each Task its own `watchlistName`, so each keeps its own snapshots.

### 🆚 How it compares (October 2026)

Facts from each Actor's Apify Store page and README, read 2026-10-14. Prices per 1,000 results are not like-for-like: some include enrichment.

| | This Actor | [fantastic-jobs/career-site-job-listing-api](https://apify.com/fantastic-jobs/career-site-job-listing-api) | [bovi/greenhouse-lever-ashby-job-scraper](https://apify.com/bovi/greenhouse-lever-ashby-job-scraper) |
|---|---|---|---|
| Coverage | 7 ATSs: Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee, Personio | 54 to 58 ATS platforms and 200,000+ career sites (its README and Store text differ) | 6 ATSs: Greenhouse, Lever, Ashby, Recruitee, SmartRecruiters, Personio |
| Input | Company domains, careers URLs or `<ats>:<slug>`, up to 100; presets; filters; watchlists | Search by title, location, organization, domain, ATS and more; time window 1h to 6 months; up to 5,000 jobs | Company list by ATS and name, or name auto-detect; preset lists; keyword filters |
| Change tracking | Yes: new, closed and edited postings against a saved snapshot per watchlist | Incremental time windows of new and indexed jobs | Yes: only new since last run, plus closed |
| Closed jobs included | Yes, same run and price | Separate Actor ([expired jobs](https://apify.com/fantastic-jobs/expired-jobs-api-for-career-site-job-listing-api), $0.75 per daily run) | Yes, in diff mode |
| Edits with old values | Yes (`changedFields`, `previous`) | Not on Apify (README: its own API only) | Not documented |
| Enrichment | Rule-based seniority, function, remote, tools mentioned; optional description text; no AI | AI enrichment (salary, skills, experience level), company data, derived company domain | Seniority and remote type |
| Alerts | Slack and webhook digest, free | Not documented | Not documented |
| Price per 1,000 | $1.50 Free/Starter, $1.25 Scale, $1.00 Business and Enterprise | $12 Free/Starter, $6 Scale, $4 Business and up, plus a small start fee | $1.50 Free, falling to $1.425 on Enterprise |

**Where fantastic-jobs does better:** it searches a database of 54+ ATS platforms and about 200,000 career sites, adds AI enrichment and company data, and finds jobs at companies you are not watching yet. This Actor reads only the seven ATSs above, from the companies you give it, and is built to tell you what changed there.

### 🏆 Why this one

- 🌐 **Start from a domain**, not from a board name you have to look up.
- 🧩 **Seven applicant tracking systems** in one run and one output format.
- 🏢 **Presets, a time window and a status filter:** start from a ready-made list and pay only for the rows you want.
- 💶 **Volume discounts** down to $1.00 per 1,000.
- 🧾 **Edits with old values**, not just new and removed postings.
- 📈 **Signals, not just rows:** surge, freeze, leadership hires, first-time functions, reposts, tools.
- 🔔 **Built-in Slack and webhook alerts**, free.
- 🔓 **No login, no API key, no personal data.**
- 💵 **Quiet companies cost almost nothing** (one result per company per run).

### ⚠️ Limitations

- **Seven ATSs only:** Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee, Personio. Not BambooHR, Workday, Teamtailor or LinkedIn, and no EU-hosted Greenhouse or Lever boards yet. A company on another system shows `ats_not_found` (free).
- **Salary is posted ranges, not actual pay.** Only postings that show a range have one: about half on Greenhouse and Ashby, none on SmartRecruiters (the list feed has no pay) and few elsewhere; European postings rarely do. Ranges read from the text can still be wrong in odd wording (about 1 in 40 snippets is missed, typos included), and a figure with no unit that is clearly an hourly rate is not read. A `$` without a country hint is USD. Medians in the report are posted ranges of the open postings in your run, not market benchmarks.
- **Domain detection** reads the company's public pages (home, `/careers`, `/jobs`) for a link to a supported board. If there is none it tries the company name on each ATS, and a guessed board must carry the company's name. For a wrong or missing match, list the board yourself (`lever:acme`) and use `resetState` to drop the saved lookup. A miss is remembered for 7 days.
- SmartRecruiters boards over 2,000 postings are refused (`board too large`) rather than half read.
- Workable and SmartRecruiters feeds carry no posting text, so `techMentions` is empty for them, `description` is `null` and a `descriptionIncludes` keyword never matches. Closed rows keep no text either. `function`, `seniority`, `remote` and `techMentions` come from fixed word lists, not AI: good for filtering and counting, not exact.
- Changes are found by comparing with the snapshot saved at your last run, so you see changes between your runs, not their exact time. Tracked edits: title, department, team, location, workplace type, employment type and salary. Description edits are ignored.
- **Presets** are lists of 20 companies checked on 2026-10-14; a company that moves to an unsupported ATS shows `ats_not_found` (free). A run finds at most 20 new company domains (the rest follow on the next run), so one preset fits one run.
- If a feed suddenly returns no postings, or loses over 90% of them, the company is reported as `suspicious_empty` and no closures are reported until the count recovers (or you use `resetState`).
- Companies are read one after another, about one second apart. Lever boards take 7 to 16 seconds each; the 40-minute run limit fits 100 boards. If one company has more rows than your remaining maximum cost per run allows, the run stops with a free `run` row naming that company and the cap it needs.
- Slack and webhook URLs are checked by their text (https, a public-looking host; Slack only `hooks.slack.com`), not resolved.

### ❓ FAQ

**Is this legal?**
The Actor reads public job board feeds that companies publish so that candidates and search engines can find their jobs. It collects no personal data: no recruiter names and no applicant data. Job description text is only included when you turn on `includeDescription`; it is the public posting text. As always, you are responsible for how you use the data and for following the laws that apply to you.

**Is this a Greenhouse, Lever and Ashby jobs scraper?**
It reads the public job board feeds of those three and of SmartRecruiters, Workable, Recruitee and Personio, and keeps a snapshot so the next run shows only what changed.

**How do I get an alert when a company posts new jobs or starts hiring?**
Add the company domain, set a `watchlistName`, schedule the run and paste a Slack webhook into `slackWebhookUrl`. You get a message only when something changed. Each company row also carries a `signal` (`surge`, `freeze`, `expanding`, `contracting` or `steady`).

**How does change tracking work?**
At the end of each run the Actor saves a snapshot of every company's open postings in a key-value store named `ats-hiring-signals-state` in your account. The next run compares the live board with that snapshot and delivers the new, closed and edited postings.

**What are watchlists?**
A `watchlistName` keeps its own snapshots. Use one per list of companies, or one per client, so their changes never mix. Use one watchlist per company per account unless each task has its own name.

**How fresh is the data?**
Live from each company's public job feed at the time of the run.

**How much will it cost me?**
$1.50 per 1,000 results on the Free and Starter plans, $1.25 on Scale, $1.00 on Business and Enterprise. See the worked examples in Pricing. Detection, failed boards, the digest, the report row and alerts are free, and a maximum cost per run caps your spend.

**Can I avoid paying for the first-run listing?**
Yes. Turn `emitBaseline` off, set `postedWithinDays`, or use filters so only matching postings are listed.

**Can I get salary from job postings?**
Yes, as a posted range. It comes from the feed where the company publishes it (for example Ashby, Lever, Recruitee and Personio when set), otherwise from the posting text by fixed rules (`salarySource: "description"`). Postings with no range have empty salary fields. See "Salary: where the numbers come from".

**Do alerts cost anything?**
No. If a Slack or webhook URL is refused or fails, the digest row says why (`alert`) and the run still succeeds.

**Something is wrong or missing. Who do I ask?**
Open an issue on the Actor's Issues tab. I reply within 12 hours.

### 🛠️ Need a custom feature?

Another ATS, a different signal, a field you need? **Open an issue** on the Actor page and describe it.

### Changelog

- 1.3.6 (2026-10-21): Personio: the feed's own salary range is read (`salarySource: structured`), and postings written only in the board's own language (for example German) now get their description, tools and `descriptionIncludes` matching.
- 1.3.4 (2026-10-18): a refused charge stops the run with an explanation instead of failing it.
- 1.3 (2026-10-17): the default input lists only the last 7 days' postings (`postedWithinDays: 7`), so a first try costs about $0.02 instead of $0.30; clear it before a watchlist's first run for the full listing.
- 1.3 (2026-10-14): salary ranges read from the posting text when the feed has none (`salarySource`, `salaryOte`; 55% of postings on the 80 presets, up from 18%); free competitor `report` row (company comparison, median posted salary by function, top 25 tools, rising tools, `report` input) with a new `report` view; no change to prices or to the single result event.
- 1.2 (2026-10-14): volume discounts by Apify plan ($1.50 down to $1.00 per 1,000); `postedWithinDays`; `statuses` (new-only and expired-jobs feeds); company `presets` (ai-labs, dev-tools, fintech, b2b-saas); optional `description` text and `descriptionIncludes`; job board feed recipe.
- 1.1 (2026-10-14): SmartRecruiters, Workable, Recruitee and Personio; company domains in with free board detection; hiring signals per company (leadership hires, seniority, functions, surge/freeze, reposts, remote share, tool mentions); filters; watchlists; digest row; Slack and webhook alerts; `emitBaseline` is now on by default; dataset views.
- 1.0 (2026-10-14): first version (Greenhouse, Lever, Ashby).

# Actor input Schema

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

Company domains (acme.com) or career pages (https://acme.com/careers): the job board is found for you, free. Or name the board yourself: greenhouse:vercel, lever:spotify, ashby:linear, smartrecruiters:Ubisoft2, workable:mindvalley, recruitee:bunq, personio:acme, or a link such as https://jobs.lever.co/spotify, https://apply.workable.com/mindvalley, https://bunq.recruitee.com or https://acme.jobs.personio.de. Invalid entries are skipped free. Duplicates removed. Up to 100.

## `presets` (type: `array`):

Add a ready-made list of well-known companies without typing them: ai-labs (OpenAI, Anthropic, Cohere...), dev-tools (Vercel, GitLab, Datadog...), fintech (Stripe, Plaid, Brex...), b2b-saas (Notion, Asana, Miro...). 20 companies each, checked 2026-10-14. They are added to your list, count toward the 100-company limit, and their boards are found for you, free. A run finds up to 20 new company domains, so use one or two presets per first run.

## `emitBaseline` (type: `boolean`):

On a company's first run, output every open posting (after your filters) as a baseline row; each row is charged. Turn off to record the starting point only (one charge for the company check).

## `titleIncludes` (type: `array`):

Keep postings whose title contains any of these words (not case sensitive). Example: engineer, sales.

## `titleExcludes` (type: `array`):

Drop postings whose title contains any of these words. Example: intern.

## `departments` (type: `array`):

Keep postings whose department, team or function (engineering, product, design, data, sales, marketing, customer success, finance, legal, people, operations, security) contains any of these.

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

Keep postings whose location contains any of these words. Example: Berlin, United States.

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

Keep only postings marked remote.

## `postedWithinDays` (type: `integer`):

Keep postings first published (or, when the board gives no date, first seen) within this many days, 1 to 365. Older postings are not listed and not charged. Company counts in the board row stay whole. Leave empty for no limit.

## `statuses` (type: `array`):

Which posting rows to deliver: baseline (first-run listing), new, closed, changed. Empty = all. Example: \["closed"] gives an expired-jobs feed; \["new"] a new-jobs feed. Each company check is still charged once.

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

Adds a plain-text description field (HTML removed, up to 5,000 characters) to posting rows. Greenhouse, Ashby, Lever, Recruitee and Personio publish the text; Workable and SmartRecruiters do not (the field is null). Same price.

## `descriptionIncludes` (type: `array`):

Keep postings whose description contains any of these words (not case sensitive). Example: kubernetes, visa sponsorship. Works on the five ATSs that publish the text; closed rows carry no text, so they are not listed while this is set.

## `watchlistName` (type: `string`):

Letters, digits, - and \_ (up to 40). Each name keeps its own saved snapshots, so one account can track several lists of companies without mixing their changes.

## `report` (type: `boolean`):

Adds one free report row that compares the companies in this run: open roles, 7 and 30 day change, function and seniority mix, remote share, median posted salary by function (posted ranges, not actual pay), top tools, and the top 25 tools across all postings with the ones that are rising since your last report. Default: on when two or more companies are read; set false to turn it off. The row is never charged.

## `slackWebhookUrl` (type: `string`):

A Slack incoming webhook (https://hooks.slack.com/...). A digest is posted only when something changed. Free.

## `webhookUrl` (type: `string`):

An https URL that receives the same digest as JSON (n8n, Make, Zapier, your own endpoint), only when something changed. Free. Private and local addresses are refused.

## `resetState` (type: `boolean`):

Forget the saved snapshot (and the saved board lookup) of the listed companies; this run is their first run again.

## Actor input object example

```json
{
  "boards": [
    "ashby:linear",
    "greenhouse:vercel",
    "lever:spotify"
  ],
  "presets": [],
  "emitBaseline": true,
  "titleIncludes": [],
  "titleExcludes": [],
  "departments": [],
  "locations": [],
  "remoteOnly": false,
  "postedWithinDays": 7,
  "statuses": [],
  "includeDescription": false,
  "descriptionIncludes": [],
  "watchlistName": "default",
  "resetState": false
}
```

# Actor output Schema

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

One row per job posting: first-run listing, new, closed or edited since the last run.

## `companies` (type: `string`):

One row per company board read: open roles, trends, surge or freeze, leadership hires, seniority mix.

## `report` (type: `string`):

One row per run comparing the companies: open roles, change, function and seniority mix, remote share, median posted salary, top tools, and the top 25 tools with rising ones.

## `digest` (type: `string`):

One summary row per run with totals, surges, freezes and the alert status.

## `issues` (type: `string`):

Domains not found, invalid input, failed or skipped boards, with the reason. Never charged.

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

Counts for the run: boards processed, events charged, detection results.

# 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": [
        "ashby:linear",
        "greenhouse:vercel",
        "lever:spotify"
    ],
    "postedWithinDays": 7
};

// Run the Actor and wait for it to finish
const run = await client.actor("fulox/ats-hiring-signals").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": [
        "ashby:linear",
        "greenhouse:vercel",
        "lever:spotify",
    ],
    "postedWithinDays": 7,
}

# Run the Actor and wait for it to finish
run = client.actor("fulox/ats-hiring-signals").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": [
    "ashby:linear",
    "greenhouse:vercel",
    "lever:spotify"
  ],
  "postedWithinDays": 7
}' |
apify call fulox/ats-hiring-signals --silent --output-dataset

```

## MCP server setup

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

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/TGZlHBupirhE5pC9k/builds/8NHMLaKK09iq4SwQ4/openapi.json
