# ATS Jobs Hiring Radar: Greenhouse, Lever, Ashby, Time-to-Fill (`rayla/ats-hiring-radar`) Actor

Scrape ATS jobs from Greenhouse, Lever, Ashby, SmartRecruiters, Recruitee and Personio via their official public APIs, then monitor hiring: new and closed roles with days-to-fill, stale roles, hiring surges and your own keyword signals. No API keys, no proxies. 100 free job rows per run.

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

## Pricing

from $20.00 / 1,000 company hiring snapshots

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## ATS Jobs Hiring Radar: Greenhouse, Lever, Ashby, Time-to-Fill

Scrape ATS jobs from Greenhouse, Lever, Ashby, SmartRecruiters, Recruitee and Personio through their official public job-board APIs, then monitor what changes: new roles, closed roles with days to fill, stale roles, hiring surges and your own keyword signals. No API keys, no proxies, no browser.

Try it free: the first 100 job rows of every run cost nothing, and the default input (GitLab on Greenhouse, Notion on Ashby, Palantir on Lever) runs in under 10 seconds.

### What does ATS Hiring Radar do?

- Reads six applicant tracking systems (ATS) through the public job-board endpoints each vendor documents for careers pages. Nothing is scraped from HTML job pages, LinkedIn or job aggregators.
- Returns every job in one normalised schema: stable `uid`, the employer's own `postedAt` and `updatedAt`, `department`, `location`, ISO-2 `countryCode`, `remoteType`, `seniority` (7 levels), `function` (9 classes), `normalizedTitle`, apply URL and plain-text description.
- Tags compensation with its provenance: structured pay ranges from the ATS (`source: "ats"`) or a range parsed from the description (`source: "description-regex"`), never a guess from bare numbers.
- Monitor mode keeps per-company history in a named key-value store on your account and, after the first run, returns only new jobs plus closures, 7/30/90-day deltas, surge and contraction flags.
- Computes time-to-fill from observed closures: `daysToClose` per closed role, `medianDaysToFill90d`, `p75DaysToFill90d`, `staleShare` and `hardToFillFunctions` per company.
- Evaluates your own regex Signal rules against title, description, department or location and emits a typed signal row with an evidence snippet for every match.
- Pushes fired signals to your HTTPS endpoint during the run, signed with HMAC-SHA256, on top of the normal Apify integrations.

### Who is it for?

- **B2B sales and RevOps teams** who want to know which target accounts started hiring for a tool they sell, opened leadership roles, or grew open roles by 25% or more in a month.
- **Recruiting agencies** hunting mandates: which companies have roles open longer than 60 days in your specialty, and what their median time to fill looks like.
- **Job boards and talent-intelligence products** that need a clean multi-ATS feed with employer timestamps, stable ids and compensation provenance instead of re-scraped HTML.
- **Investors and analysts** tracking headcount intent per company over months rather than per run.
- **AI agents** on the Apify MCP server: one call, a descriptive input schema with titles for every option, pay-per-event pricing and limited permissions.

### What you get

Every run writes three kinds of rows to the default dataset, each carrying `recordType` and `runAt`, and the dataset has a matching view for each: **Jobs**, **Company snapshots** and **Signals**.

#### Job rows (`recordType: "job"`)

One row per open role. Example (Ashby, Notion, read live on 2026-09-17):

```json
{
  "recordType": "job",
  "runAt": "2026-09-17T08:00:00.000Z",
  "uid": "ashby:notion:1fc309c8-da20-4ff2-84c7-8b863ece2b0a",
  "ats": "ashby",
  "companyKey": "ashby-notion",
  "company": "notion",
  "companySlug": "notion",
  "companyDomain": null,
  "title": "Software Engineer, Developer Platform",
  "normalizedTitle": "software engineer, developer platform",
  "department": "Engineering",
  "team": "Engineering",
  "location": "San Francisco, California",
  "city": "San Francisco",
  "countryCode": "US",
  "secondaryLocations": [],
  "remoteType": "hybrid",
  "employmentType": "FullTime",
  "seniority": "mid",
  "function": "engineering",
  "postedAt": "2026-08-24T14:44:49.699Z",
  "updatedAt": null,
  "firstSeenAt": "2026-09-17T08:00:00.000Z",
  "daysOpen": 24,
  "isStale": false,
  "isNew": true,
  "compensation": null,
  "url": "https://jobs.ashbyhq.com/notion/1fc309c8-da20-4ff2-84c7-8b863ece2b0a",
  "applyUrl": "https://jobs.ashbyhq.com/notion/1fc309c8-da20-4ff2-84c7-8b863ece2b0a/application",
  "descriptionText": "Notion is building the connected workspace ...",
  "language": null,
  "source": {
    "endpoint": "https://api.ashbyhq.com/posting-api/job-board/notion?includeCompensation=true",
    "fetchedAt": "2026-09-17T08:00:01.000Z"
  }
}
```

When the ATS publishes a pay range, `compensation` is structured and tagged. GitLab's Greenhouse board exposed 86 such ranges out of 217 jobs on 2026-09-17:

```json
"compensation": { "min": 139200, "max": 235200, "currency": "USD", "period": "year", "source": "ats", "raw": "United States Salary Range" }
```

#### Company snapshot rows (`recordType: "company_snapshot"`)

One row per company per run. This is where the monitoring value lives:

```json
{
  "recordType": "company_snapshot",
  "runAt": "2026-09-17T08:00:00.000Z",
  "companyKey": "greenhouse-gitlab",
  "ats": "greenhouse",
  "company": "GitLab",
  "companySlug": "gitlab",
  "companyDomain": "gitlab.com",
  "isBaselineRun": false,
  "scanComplete": true,
  "capped": false,
  "openRoles": 217,
  "newThisRun": 6,
  "closedThisRun": 4,
  "openRolesDelta7d": 2,
  "openRolesDelta30d": 31,
  "openRolesDelta90d": 40,
  "openRolesPct30d": 0.168,
  "surge": false,
  "contraction": false,
  "staleRoles": 57,
  "staleShare": 0.264,
  "medianDaysToFill90d": 45,
  "p75DaysToFill90d": 71,
  "closedRolesTracked90d": 9,
  "hardToFillFunctions": [
    { "function": "engineering", "staleRoles": 31 },
    { "function": "sales", "staleRoles": 14 },
    { "function": "customer", "staleRoles": 6 }
  ],
  "byFunction": { "engineering": 121, "sales": 44, "marketing": 12, "customer": 15, "operations": 9, "people": 5, "finance_legal": 6, "product_design": 4 },
  "bySeniority": { "mid": 74, "senior": 96, "staff": 21, "director": 14, "executive": 3, "junior": 6, "intern": 2 },
  "byRemote": { "remote": 212, "hybrid": 3, "onsite": 1 },
  "byCountry": { "US": 96, "GB": 18, "DE": 9, "CA": 7 },
  "leadershipOpen": 17,
  "newFunctions": [],
  "newCountries": ["PL"],
  "historyRuns": 12,
  "firstTrackedAt": "2026-06-01T08:00:00.000Z"
}
```

The counts above are illustrative for a company tracked for three months; a first (baseline) run has `isBaselineRun: true` and every delta, closure and time-to-fill field is `null`. Every extract-mode snapshot has that baseline shape too, with `historyRuns: 0` and `firstTrackedAt: null`, because extract mode stores nothing. Snapshots always describe the whole board: **Job filters** narrow the job rows and the job-level signals, never `openRoles`, `byFunction` or the stale figures.

#### Signal rows (`recordType: "signal"`)

One row per fired signal, only when something fires:

```json
{
  "recordType": "signal",
  "runAt": "2026-09-17T08:00:00.000Z",
  "signalType": "rule_match",
  "companyKey": "greenhouse-gitlab",
  "company": "GitLab",
  "ats": "greenhouse",
  "uid": "greenhouse:gitlab:8638232002",
  "title": "AI Transformation Owner, CRO",
  "url": "https://job-boards.greenhouse.io/gitlab/jobs/8638232002",
  "ruleId": "salesforce-in-jd",
  "field": "description",
  "evidence": "...hands-on experience with Salesforce, Gainsight and the wider revenue tooling stack...",
  "daysToClose": null,
  "openedAt": null,
  "closedAt": null,
  "daysOpen": null,
  "openRolesDelta30d": null,
  "openRolesPct30d": null,
  "function": null,
  "countryCode": null,
  "seniority": null,
  "dedupeKey": "rule_match:greenhouse-gitlab:greenhouse:gitlab:8638232002:salesforce-in-jd"
}
```

A `job_closed` signal carries `daysToClose`, `openedAt` and `closedAt`; `stale_role` carries `daysOpen`; `hiring_surge` and `hiring_contraction` carry `openRolesDelta30d` and `openRolesPct30d`; `new_function` carries `function`; `new_country` carries `countryCode`; `leadership_hire` carries `seniority`.

Three more record types are never billed: `company_error` (`input`, `reason`, `httpStatus`, `message`), `rule_error` (`ruleId`, `message`) and one `run_summary` per run, which is also saved to the default key-value store under the key `OUTPUT`.

### Which ATS platforms does it support?

Every endpoint below is the vendor's documented public job-board API, read with plain GET requests and no credentials. Counts were observed live on 2026-09-17.

| ATS | Accepted input forms | Endpoint read | Compensation source | Verified example |
|---|---|---|---|---|
| Greenhouse | `greenhouse:gitlab`, `https://boards.greenhouse.io/gitlab`, `https://job-boards.greenhouse.io/gitlab` | `boards-api.greenhouse.io/v1/boards/{token}/jobs?content=true&pay_transparency=true` | `pay_input_ranges` (ats), else description regex | gitlab: 217 jobs, 86 with structured pay ranges, 0.3 s |
| Lever | `lever:palantir`, `https://jobs.lever.co/palantir` | `api.lever.co/v0/postings/{site}?mode=json` (EU boards: `api.eu.lever.co`) | `salaryRange` (ats), else description regex | palantir: 313 postings, 6 MB, 2 s |
| Ashby | `ashby:notion`, `https://jobs.ashbyhq.com/notion` | `api.ashbyhq.com/posting-api/job-board/{name}?includeCompensation=true` | compensation summary (ats), else description regex | notion: 128 jobs |
| SmartRecruiters | `smartrecruiters:BoschGroup`, `https://jobs.smartrecruiters.com/BoschGroup` | `api.smartrecruiters.com/v1/companies/{id}/postings?limit=100&offset=N` (paginated) | description regex | BoschGroup: 4,820 postings (capped by Max jobs per company); smartrecruiters: 1 |
| Recruitee | `recruitee:tellent`, `https://tellent.recruitee.com` | `{company}.recruitee.com/api/offers/` | `salary` (ats), else description regex | tellent: valid empty board |
| Personio | `personio:personio`, `https://personio.jobs.personio.de` | `{company}.jobs.personio.de/xml?language=en` | description regex | personio: 1 position |

Workable (`workable:slug`, `https://apply.workable.com/slug`) is recognised by the input parser but disabled in this version: its documented account endpoint answered, but no board with published jobs could be verified yet, so it returns `company_error` with reason `unsupported_ats` instead of silently returning nothing. It will be enabled in a patch release once a populated board has been confirmed.

Not supported: Workday, iCIMS, Taleo, SuccessFactors, Teamtailor, BambooHR, JazzHR and Breezy. These either require tenant tokens or have no documented public feed, and this Actor does not scrape HTML careers pages. A company on one of them returns a `company_error` row with reason `detection_failed` or `unsupported_ats` and is never billed.

### How this differs from other ATS job scrapers

Several Store Actors read the same public ATS APIs and return job rows, and they do that job well. ATS Hiring Radar prices job rows at the same market level and adds an analytics layer that none of them ship, based on a review of their READMEs and input schemas on 2026-09-16/17:

| Capability | Typical multi-ATS job scrapers | ATS Hiring Radar |
|---|---|---|
| Job rows from Greenhouse, Lever, Ashby and more, with auto-detected ATS | Yes | Yes, six ATS, `ats:slug`, board URL or plain domain |
| New-since-last-run flag | Yes (compares to the previous run only) | Yes (`isNew`), plus closures and deltas against dated history up to 400 snapshots per company |
| Closed roles | Some flag `is_closed` per job | `job_closed` signal with `daysToClose`, only from complete scans, feeding `medianDaysToFill90d` and `p75DaysToFill90d` |
| Time-to-fill and stale-role analytics | Not offered | `daysOpen`, `isStale`, `staleShare`, `hardToFillFunctions`, `stale_role` signals |
| Trend horizons | Previous run only, or stateless | 7-, 30- and 90-day deltas, `openRolesPct30d`, `surge` / `contraction` |
| Custom keyword alerts | Fixed signal types, or filters that drop rows | Your own regex Signal rules on title, description, department or location, each match a typed `signal` row with `ruleId` and evidence |
| Compensation | Salary string or parsed number | `min`, `max`, `currency`, `period` plus `source` telling you whether the ATS or a regex produced it |
| Billing | Per job row | Job rows at $0.002 (100 free per run); snapshots and fired signals billed only when produced, so an unchanged watchlist pays only snapshots |
| Signal delivery | Run-finished webhooks | Per-signal batches during the run, HMAC-SHA256 signed, with a retry outbox |

### How much does it cost to scrape ATS jobs?

ATS Hiring Radar uses pay-per-event pricing. You pay for what a run produces, on top of normal Apify platform usage.

| Event | Event name in `run_summary.charged` | Price | When it is charged |
|---|---|---|---|
| Actor start | `apify-actor-start` | $0.00005 per run (Apify default, per GB of memory) | Once at the start of every run |
| Job record | `job-record` | $0.002 per job row ($2 per 1,000) | For every `job` row beyond the first 100 in a run; the first 100 job rows of every run are free |
| Company hiring snapshot | `company-snapshot` | $0.02 per company per run | Once per company that returned a board, even when the board is empty; never for companies that returned an error |
| Hiring signal | `signal` | $0.03 per fired signal | Only when a Signal rule matches or a built-in signal fires |

Worked examples:

- **One-company trial** (a board with 80 open roles, monitor mode): 80 job rows are inside the free 100, one snapshot: about **$0.02**.
- **5,000-job extract** across 25 companies in extract mode: 4,900 billable job rows x $0.002 = $9.80, plus 25 snapshots x $0.02 = $0.50: about **$10.30**. Turn Emit job rows off if you only want the snapshots: $0.50.
- **100-company weekly watchlist** in monitor mode: 100 snapshots x $0.02 x 4.3 runs per month = $8.60, plus new job rows beyond the free 100 per run (typically a few dollars), plus roughly 100-150 fired signals x $0.03 = $3-4.50: about **$12-15 per month**.

Set a spending limit per run in the Apify Console or API (`maxTotalChargeUsd`). When the limit is reached, the Actor delivers what still fits for the company being processed, in this order: its snapshot first, then its signals, then its job rows (the free 100 first). The rest of that company's rows and signals are withheld, the remaining companies are skipped, the `run_summary` gets `stoppedReason: "budget"` and the run exits SUCCEEDED. Nothing withheld is charged. In monitor mode the withheld job rows are not marked as seen and the withheld signals wait in the state store (up to 1,000 per company), so the next run delivers both, each exactly once; in extract mode nothing is remembered and the next run simply produces everything again. `company_error`, `rule_error` and `run_summary` rows are always free.

### How to scrape Greenhouse, Lever and Ashby jobs

1. Open the Actor on Apify and paste your companies into **Companies**, one per line: `greenhouse:gitlab`, `https://jobs.lever.co/palantir`, `notion.so` or any mix.
2. Pick a **Mode**: *Extract all open jobs* for a one-off dataset, *Monitor changes between runs* for a watchlist you will run on a schedule. Extract mode is stateless: it reads nothing from and writes nothing to the state store, returns every open job on every run, one snapshot per company computed from that run alone (`isBaselineRun: true`; deltas, closures and time-to-fill `null`) and only `rule_match` and `leadership_hire` signals. Monitor mode keeps per-company history and is the mode for closures, trends and the other signals.
3. Optionally narrow the output with **Job filters** and add **Signal rules**.
4. Click Start. Open the **Jobs**, **Company snapshots** or **Signals** view in the Output tab, or export as JSON, CSV, Excel or via the API.

#### How to scrape Greenhouse jobs

Input `greenhouse:{board token}` or the board URL (`https://boards.greenhouse.io/{token}` and `https://job-boards.greenhouse.io/{token}` both work). Rows include `first_published` as `postedAt`, `updated_at` as `updatedAt`, the first department, the primary office, and structured pay ranges where the employer publishes them.

#### How to scrape Lever jobs

Input `lever:{site}` or `https://jobs.lever.co/{site}`. Rows include `createdAt` as `postedAt`, `categories.department` (falling back to `categories.team`) as `department`, the Lever `country` code, `workplaceType` and `salaryRange` when present. Requests to Lever are serialised at one per second, so a watchlist of 60 Lever boards takes at least a minute.

#### How to scrape Ashby jobs

Input `ashby:{job board name}` or `https://jobs.ashbyhq.com/{name}`. Rows include `publishedAt`, `department`, `team`, `isRemote` and `workplaceType` mapped to `remoteType`, `secondaryLocations`, the country from the postal address, and Ashby's compensation summary when the company publishes it. The company must have enabled the public posting API; otherwise the board returns `company_error` with reason `not_found`.

#### How to scrape SmartRecruiters jobs

Input `smartrecruiters:{company identifier}` or `https://jobs.smartrecruiters.com/{identifier}`. Identifiers are case-sensitive: `BoschGroup` returns 4,820 postings while `bosch` returns zero. Large boards are paginated 100 at a time and capped by **Max jobs per company**; a capped board has `scanComplete: false` and `capped: true`, a partially fetched one `scanComplete: false` plus a `company_error` row, and neither produces closures or trends.

#### How to scrape Recruitee jobs

Input `recruitee:{subdomain}` or `https://{subdomain}.recruitee.com`. Rows include `published_at`, `updated_at`, the `remote` and `hybrid` flags, `locations[]` with country codes and the structured `salary` object. Recruiter mailbox addresses present in the feed are dropped.

#### How to scrape Personio jobs

Input `personio:{subdomain}` or `https://{subdomain}.jobs.personio.de`. The XML feed provides `createdAt`, `office`, `department`, `employmentType` and the job description sections, which are joined into `descriptionText`.

### How does monitor mode work?

1. **Baseline run.** The first time a company is seen in the chosen **State store name**, every job is returned, the snapshot has `isBaselineRun: true`, deltas and time-to-fill are `null`, and only `rule_match` and `leadership_hire` signals may fire.
2. **Later runs.** Only jobs with `isNew: true` are returned as job rows. Jobs that were in the store but are missing from a complete scan become closures: `closedThisRun` on the snapshot and a `job_closed` signal with `daysToClose` measured from the employer's `postedAt` (or from `firstSeenAt` when the ATS gives no date).
3. **Complete scans only.** A closure is recorded only when every page was fetched and the board did not hit **Max jobs per company**. A failed page yields `company_error` with reason `partial_scan` and leaves the stored jobs untouched; a capped board has `capped: true`. Neither writes a usable count into the trend history, so a truncated scan never produces false deltas, surges or contractions, false closures or a broken time-to-fill median.
4. **Mass disappearances are confirmed first.** When a complete scan answers zero postings for a board that had roles, or at least five roles making up 80% or more of the board vanish at once (a switched-off board, an ATS incident, an empty payload), nothing is closed yet: the snapshot reports `scanComplete: false`, a `company_error` row with reason `partial_scan` explains the hold, and the roles close on the next complete scan that still misses them, dated at the run in which they first went missing. A genuinely emptied board therefore closes its roles one run later.
5. **Stale roles.** `isStale` is computed on every run, but the `stale_role` signal alerts when a role becomes stale while you watch it: a role that is already past **Stale after (days)** when it is first seen (the whole baseline run, or a role that appears later with an old posting date) is counted as stale and never fires, and every role fires at most once.
6. **History.** Each run appends a dated snapshot (up to 400 per company; entries from partial, capped or held scans are kept but skipped by the trend calculations). Deltas compare the current count with the latest complete snapshot at least 7, 30 or 90 days old, so a daily and a weekly schedule produce the same 30-day figures.
7. **State lives on your account** in a named key-value store, one record per company. Adding or removing companies never resets the others. Use a different **State store name** per client or per schedule; two concurrent runs on the same store are serialised by a 10-minute lease. A role that leaves a capped board's window is kept for max(2 x **Stale after (days)**, 120) days and then dropped without a closure, and a record never holds more than max(3 x **Max jobs per company**, 5,000) roles (12,000 at most).
8. **Reset state** deletes the listed companies' records so the run becomes a fresh baseline for them.
9. Schedule it with Apify Schedules (daily for signals, weekly for trends).

Extract mode, by contrast, is stateless: it never reads or writes the state store, so every run returns every open job, a snapshot computed from that run alone and only `rule_match` / `leadership_hire` signals; a failed signal webhook batch is reported in `run_summary.webhookDeliveries` but cannot be retried.

### What are hiring signals and time-to-fill?

Definitions used by every snapshot field and signal type:

- `daysOpen` = round((now - (`postedAt` or `firstSeenAt`)) / 1 day). `isStale` = `daysOpen` >= **Stale after (days)** (default 60).
- `daysToClose` = days between `openedAt` (= `postedAt` or `firstSeenAt`) and `closedAt` (the run in which the job first disappeared from a complete scan; a held mass disappearance keeps that first date).
- `medianDaysToFill90d` and `p75DaysToFill90d` = median and 75th percentile of `daysToClose` over roles closed in the last 90 days; `closedRolesTracked90d` = their count.
- `staleShare` = `staleRoles` / `openRoles`; `hardToFillFunctions` = the three functions with the most stale roles.
- `openRolesDelta30d` = `openRoles` - open count in the latest complete snapshot at least 30 days old (partial, capped and held scans are skipped, and produce `null` trends themselves); `openRolesPct30d` = that delta divided by the older count. `surge` = `openRolesPct30d` >= **Surge / contraction threshold (%)** / 100; `contraction` = <= the negative of it.
- `newFunctions` / `newCountries` = functions or countries hired for now that never appeared in this company's history.
- `leadershipOpen` = open roles with `seniority` director or executive.

Built-in signal types (choose them under **Built-in signal types**):

| Signal type | Fires when | Payload |
|---|---|---|
| `rule_match` | A Signal rule matches a job (new jobs only after the baseline) | `ruleId`, `field`, `evidence`, `uid`, `title`, `url` |
| `job_closed` | A stored job disappears from a complete scan | `daysToClose`, `openedAt`, `closedAt`, `uid`, `title`, `url` |
| `stale_role` | A tracked role crosses **Stale after (days)** while you watch it (once per role; a role already stale when first seen, including the whole baseline run, never fires) | `daysOpen`, `uid`, `title`, `url` |
| `hiring_surge` | `surge` is true (once per company per day) | `openRolesDelta30d`, `openRolesPct30d` |
| `hiring_contraction` | `contraction` is true (once per company per day) | `openRolesDelta30d`, `openRolesPct30d` |
| `new_function` | A function appears for the first time | `function` |
| `new_country` | A country appears for the first time | `countryCode` |
| `leadership_hire` | A new job has seniority director or executive | `seniority`, `uid`, `title`, `url` |

Every signal has a `dedupeKey` (`{signalType}:{companyKey}:{uid or function or countryCode or run date}`; `rule_match` keys end in `:{ruleId}`, so two rules can match the same job) and, in monitor mode, is emitted and billed at most once per key per state store. Extract mode keeps no state, so identical runs repeat the same `rule_match` and `leadership_hire` keys.

### Custom signal rules

**Signal rules** is a JSON array. Each rule has an `id`, a `field` (`title`, `description`, `department` or `location`), a JavaScript regex `pattern` and optional `flags` (`i`, `u`):

```json
[
  { "id": "salesforce-in-jd", "field": "description", "pattern": "salesforce", "flags": "i" },
  { "id": "leadership-title", "field": "title", "pattern": "\\b(vp|vice president|head of|chief|director)\\b", "flags": "i" },
  { "id": "berlin-sales", "field": "location", "pattern": "berlin", "flags": "i" },
  { "id": "kubernetes-and-terraform", "field": "description", "pattern": "kubernetes[\\s\\S]{0,400}terraform", "flags": "i" }
]
```

Escaping note: the pattern is a JSON string, so every backslash must be doubled. Write `\\b` for a word boundary and `\\d` for a digit. A single `\b` inside JSON is the backspace character, which the Actor rejects as a control character. Patterns longer than 200 characters, patterns with control characters and patterns that do not compile produce a `rule_error` row with the reason and are skipped; the run still succeeds. Matching runs on the first 20,000 characters of the field, and `evidence` is a snippet of at most 160 characters around the first match. In monitor mode rules run on new jobs only after the baseline, so a rule added later fires for roles that appear from then on.

### Input

| Field | Type | Default | Meaning |
|---|---|---|---|
| Companies | array of strings | `greenhouse:gitlab`, `ashby:notion`, `lever:palantir` | `ats:slug`, board URL or plain company domain, one per entry |
| Mode | `extract` or `monitor` | `monitor` | `extract`: stateless, every open job every run, snapshot from that run only, only `rule_match` / `leadership_hire`; `monitor`: baseline, then changes from stored history |
| Emit job rows | boolean | `true` | Write `job` rows; off = snapshots and signals only |
| Include description text | boolean | `true` | Include `descriptionText` in job rows |
| Max description characters | integer 200-20000 | `4000` | Truncate `descriptionText` |
| Max jobs per company | integer 1-10000 | `1000` | Cap per company; a capped board has `capped: true`, no closures and no trends |
| Max companies | integer 1-2000 | `500` | Cap on companies per run |
| Job filters | object | all empty | `titleKeywords`, `excludeTitleKeywords`, `functions`, `seniorities`, `countries`, `remoteTypes`, `postedWithinDays`; applied to job rows and job-level signals before billing (snapshots always describe the whole board) |
| Stale after (days) | integer 7-365 | `60` | `isStale` threshold |
| Surge / contraction threshold (%) | integer 5-500 | `25` | 30-day change that raises `hiring_surge` / `hiring_contraction` |
| Signal rules | array of objects | two example rules | Regex rules producing `rule_match` signals |
| Built-in signal types | multi-select | all eight | Which built-in signals may fire |
| State store name | string | `ats-hiring-radar-state` | Named key-value store holding history |
| Reset state | boolean | `false` | Delete stored history for the listed companies first |
| Signal webhook URL | HTTPS URL | none | Receives signal batches during the run |
| Webhook secret | secret string | none | HMAC-SHA256 key for `x-hiring-radar-signature` |
| Concurrency | integer 1-20 | `8` | Companies processed in parallel (Lever always 1 request/s) |
| Debug logging | boolean | `false` | Log every HTTP request |

Minimal API input:

```json
{ "companies": ["greenhouse:gitlab", "ashby:notion", "lever:palantir"], "mode": "monitor" }
```

### Output

- Dataset rows with `recordType` in `job`, `company_snapshot`, `signal`, `company_error`, `rule_error`, `run_summary` (fields above).
- Dataset views **Jobs**, **Company snapshots** and **Signals** in the Output tab; each view lists the columns relevant to one record type. When exporting a single record type, filter on `recordType` (for example `?fields=...&filter` in the API or a column filter in Excel).
- Key-value store record `OUTPUT` with the `run_summary`: `mode`, `companiesRequested`, `companiesProcessed`, `companiesFailed`, `companiesSkipped`, `jobRowsEmitted`, `freeJobRowsUsed`, `snapshots`, `signalsBySignalType`, `charged`, `stoppedReason`, `durationMs`, `webhookDeliveries`.
- The named state store you chose (monitor mode only), with one JSON record per company (`{ats}-{slug}`) and `detection_{domain}` records for resolved domains (the colon of the logical key `detection:{domain}` is stored as an underscore, because store keys allow no colon).

Export any view as JSON, CSV, Excel, XML or RSS from the Output tab or through the Apify API dataset endpoints.

### Integrations

- **Apify Schedules** run the watchlist daily or weekly; monitor mode makes unchanged runs cheap.
- **Signal webhook URL**: fired signals are POSTed during the run as JSON arrays of up to 100 signals with headers `content-type: application/json`, `x-hiring-radar-run-id` and, when **Webhook secret** is set, `x-hiring-radar-signature: sha256=<hex HMAC-SHA256 of the raw body>`. Failed batches are kept in an outbox in the state store and retried first thing on the next run.
- **Apify integrations** (Slack, Make, Zapier, n8n, Google Sheets, Airbyte and the run-finished webhooks) work on the dataset as with any Actor; use the **Signals** view for alerting flows and the **Company snapshots** view for dashboards.

### Use with AI agents (MCP)

The Actor is designed to be called by agents through the Apify MCP server (`mcp.apify.com`): every input option has a title, description and, where applicable, `enumTitles`; pricing is pay-per-event only, so an agent's spending limit bounds the cost; and the Actor runs with limited permissions. Example prompt for an agent with the Apify MCP tools: "Use call-actor on ats-hiring-radar with companies `greenhouse:gitlab` and `ashby:notion` in extract mode, then list the roles with `seniority` director or executive and their `postedAt`."

### Use cases

1. **Hiring-intent account list.** Companies = your target accounts; Signal rules = the tools or skills you sell; run daily; route `rule_match` signals to Slack.
2. **Stale-role mandate radar for agencies.** Companies = employers in your specialty; Stale after (days) = 45; read `stale_role` signals and `hardToFillFunctions`, and pitch on `medianDaysToFill90d`.
3. **Competitor headcount tracking.** Companies = competitors; weekly schedule; chart `openRoles`, `openRolesDelta30d`, `byFunction` and `byCountry` from the Company snapshots view.
4. **Salary transparency dataset.** Extract mode on Greenhouse boards with pay ranges; filter `compensation.source = "ats"` for employer-published ranges only.
5. **Job board backfill.** Extract mode with Include description text on, Max description characters 20000; join on `uid` across runs and use `postedAt` / `updatedAt` for freshness.

### Is it legal to scrape ATS job boards?

- The Actor reads only the public job-board APIs that Greenhouse, Lever, Ashby, SmartRecruiters, Recruitee and Personio document for embedding their customers' job listings on careers pages. No login, token, proxy or browser is used, and no HTML job page is scraped. The only page the Actor ever fetches is a company's home or careers page when you give a plain domain, and only to find which ATS it links to.
- Job postings are published by employers for the public. The Actor never touches candidate data, applications or recruiter inboxes; recruiter contact addresses present in some feeds are dropped before output.
- **GDPR note.** Output contains company and job information, not personal data. If you enrich the results with personal data downstream, you become the controller for that processing; keep a lawful basis and honour data-subject rights. The state store on your account holds job ids, titles and dates only.
- Respect the source: keep attribution to the employer's posting URL in anything you republish, and do not repost descriptions wholesale.
- Rate limits are respected by design: Lever is read at one request per second per its robots.txt, other hosts at most four concurrent requests, with exponential back-off on 429 and 5xx responses.
- This section is general information, not legal advice. ATS Hiring Radar is an independent tool and is not affiliated with, endorsed by or sponsored by Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee or Personio.

### Limits stated plainly

- A company must have enabled the public posting API on its ATS (always on for Greenhouse and Lever boards, opt-in for Ashby). Boards that are not exposed return `company_error` with reason `not_found`.
- Empty boards are valid: one snapshot with `openRoles: 0` and no job rows.
- SmartRecruiters identifiers are case-sensitive; a wrong-case identifier returns `company_error` with reason `not_found` on the first run.
- Workable is disabled in this version; Workday, iCIMS, Taleo, SuccessFactors, Teamtailor, BambooHR, JazzHR and Breezy are not covered.
- Closure detection needs at least two runs on the same State store name, and only complete scans produce closures.
- Boards larger than Max jobs per company are truncated (`capped: true`): they never produce closures or trends, their stale and time-to-fill figures cover the returned postings only, and roles that leave the truncated window are dropped from the state after max(2 x Stale after (days), 120) days without a closure.
- A mass disappearance (zero postings for a board that had roles, or at least five roles and 80% of the board gone at once) is held for one run and closes only when the next complete scan confirms it.
- Domain detection scans the home page and `/careers`, `/jobs`, `/join-us` for known ATS hosts; single-page careers sites that load their jobs via JavaScript may not be detected. Give the `ats:slug` form instead.
- The free quota is 100 job rows per run, not per account.

### FAQ

#### How do I find a company's slug?

Open the company's careers page and look at the job-board URL: `boards.greenhouse.io/{slug}`, `jobs.lever.co/{slug}`, `jobs.ashbyhq.com/{slug}`, `jobs.smartrecruiters.com/{slug}`, `{slug}.recruitee.com` or `{slug}.jobs.personio.de`. Paste that URL directly into Companies; the Actor extracts the slug.

#### Can I just paste a domain?

Yes. `gitlab.com` is resolved by scanning the home page and the usual careers paths for ATS links, then verifying the candidate board against the ATS API. The result is cached in your state store so later runs skip the scan. If detection fails you get a `company_error` with reason `detection_failed` and no charge.

#### Why did a company return `company_error`?

Check `reason`: `not_found` (board does not exist, is not public, or the identifier has the wrong case), `blocked` (the ATS answered 401/403), `timeout`, `unsupported_ats` (Workable or an ATS outside the list), `detection_failed` (no ATS link found on the domain) or `partial_scan` (a page failed, or a mass disappearance is being held for confirmation on the next complete scan; stored jobs left untouched). Error rows are never billed.

#### How fresh is the data?

Every run reads the live endpoints; nothing is cached between runs except detection results and your history. The employer's own `postedAt` and `updatedAt` are passed through unchanged.

#### Does it need a proxy?

No. The endpoints are public APIs intended for careers pages, so the Actor uses plain requests with a descriptive user agent and no proxy.

#### How do I get Slack alerts?

Either connect the Apify Slack integration to the run-finished event and read the **Signals** view, or set **Signal webhook URL** to a Make, Zapier or n8n webhook that posts to Slack; each signal row already contains `company`, `title`, `url` and `evidence`.

#### Can I filter by salary?

Not as an input filter in this version. Export the **Jobs** view and filter on `compensation.min`, `compensation.max`, `compensation.currency` and `compensation.source`; rows without a stated range have `compensation: null`.

#### How do I reset history?

Turn on **Reset state** for one run: the listed companies' records are deleted and that run is their new baseline. Other companies in the same store are untouched. To start completely fresh, use a new **State store name**.

#### Can I run it per client?

Yes. Give each client its own **State store name** (for example `radar-client-acme`) and its own schedule. History, dedupe keys and webhook outboxes are then fully separate.

#### What happens at my spending limit?

The Actor delivers what still fits for the company it is processing (snapshot first, then signals, then job rows), withholds the rest of that company's output, skips the remaining companies, writes the `run_summary` with `stoppedReason: "budget"` and exits with status SUCCEEDED. Withheld rows and signals are not charged. In monitor mode the next run delivers them: withheld job rows are not marked as seen in the state store and withheld signals wait in a per-company queue (up to 1,000). In extract mode the next run produces everything again anyway.

#### Why does my regex rule produce a `rule_error`?

Most often because of JSON escaping: `\b` in JSON is a backspace, so the pattern must read `\\b`. The `message` field states the exact problem (control character, too long, or the JavaScript regex compile error).

#### Do you store my data?

Your dataset and your state store live in your own Apify account. The Actor keeps no copy; the developer sees only aggregate run statistics that Apify shows for every Actor.

### Changelog

See CHANGELOG.md for the full history.

- **0.1.0 (2026-09-17)**: initial release with six ATS, monitor mode, time-to-fill analytics, eight signal types, custom Signal rules, signed signal webhook and pay-per-event pricing with 100 free job rows per run.
- **0.1.0 release-candidate fixes (2026-09-21)**: `stale_role` fires only for roles that become stale while tracked; extract mode is stateless; partial, capped and held scans no longer feed the trend history; mass disappearances are confirmed before closing; the state record is bounded; signals and rows withheld by a spending limit are delivered by the next monitor run; new `capped` snapshot field.

### Support

Report problems or request an ATS through the Issues tab of this Actor; issues are answered within 14 days, usually much sooner. Include the run id and the `company_error` rows when a board misbehaves.

If ATS Hiring Radar saves you time, please leave a review on its Store page; reviews help other users find it and tell us what to build next.

# Changelog

This Actor's version history is a separate document: https://apify.com/rayla/ats-hiring-radar/changelog.md

# Actor input Schema

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

One entry per company. Accepted forms: `greenhouse:gitlab`, `lever:palantir`, `ashby:notion`, `smartrecruiters:BoschGroup`, `recruitee:tellent`, `personio:personio`; a board URL such as https://boards.greenhouse.io/gitlab or https://jobs.ashbyhq.com/notion; or a plain company domain such as gitlab.com (the ATS is auto-detected and cached). SmartRecruiters identifiers are case-sensitive. Workable (`workable:slug`) is recognised but not enabled yet. Up to Max companies entries.

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

`extract` is stateless: it reads and writes no state, returns every open job on every run, one snapshot per company computed from that run only (isBaselineRun true; deltas, closures and time-to-fill null) and only rule\_match and leadership\_hire signals. `monitor` returns every job on the first (baseline) run and afterwards only new jobs plus closures, trends and signals computed from the history kept in the State store.

## `emitJobs` (type: `boolean`):

Write one dataset row per job (recordType `job`). Turn off to receive only company snapshots and signals.

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

Include the plain-text job description in each job row (truncated to Max description characters). Rules on the description field still run when this is off.

## `maxDescriptionChars` (type: `integer`):

Truncate descriptionText to this many characters.

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

Hard cap on job rows per company per run. A board that reaches the cap is truncated: its snapshot has capped true and scanComplete false, closure detection and trends are skipped to avoid false closures and surges, and roles that leave the truncated window are dropped from the state after max(2 x Stale after (days), 120) days without a closure.

## `maxCompanies` (type: `integer`):

Hard cap on companies processed per run.

## `filters` (type: `object`):

Applied to job rows and job-level signals before billing; company snapshots always describe the whole board. Keys: titleKeywords\[], excludeTitleKeywords\[], functions\[] (engineering, sales, marketing, product\_design, customer, finance\_legal, people, operations, other), seniorities\[] (intern, junior, mid, senior, staff, director, executive), countries\[] (ISO-2 codes), remoteTypes\[] (remote, hybrid, onsite), postedWithinDays (integer). Empty arrays and null mean no filter.

## `staleDays` (type: `integer`):

A role open at least this many days (from the ATS first-published date, or the date it was first seen) is flagged stale and counted in staleShare and hardToFillFunctions.

## `surgeThresholdPct` (type: `integer`):

Percentage change in open roles versus the stored snapshot closest to 30 days ago that raises a hiring\_surge (increase) or hiring\_contraction (decrease) signal.

## `rules` (type: `array`):

Custom rules evaluated against each job (new jobs only in monitor mode after the baseline). Each item: { id, field: title|description|department|location, pattern: JavaScript regex source (JSON-escaped: write \b for a word boundary, because a single \b in JSON is a backspace character), flags?: 'i' | 'u' | 'iu' }. Each match emits a `signal` row of type rule\_match with the matched snippet as evidence. Invalid patterns produce a rule\_error row, never a failed run.

## `signalTypes` (type: `array`):

Which built-in signals to emit. Each fired signal is billed as one `signal` event; nothing is billed when no signal fires.

## `stateStoreName` (type: `string`):

Named key-value store (on your account) that keeps per-company history between runs. Use a different name per watchlist, schedule or client.

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

Delete stored history for the listed companies before this run (the run becomes a new baseline for them).

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

Optional HTTPS endpoint that receives fired signals in batches (JSON array of up to 100 signals) during the run. Bodies are signed with HMAC-SHA256 in the x-hiring-radar-signature header when Webhook secret is set. Apify's own run-finished webhooks remain available as usual.

## `webhookSecret` (type: `string`):

Secret used to sign webhook bodies (sha256=<hex HMAC of the raw body>). Leave empty to send unsigned batches.

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

Companies processed in parallel. Lever is always serialised at 1 request per second (its robots.txt asks for Crawl-delay 1); other ATS hosts allow at most 4 concurrent requests.

## `debugLog` (type: `boolean`):

Log every HTTP request with status, size and timing.

## Actor input object example

```json
{
  "companies": [
    "greenhouse:gitlab",
    "ashby:notion",
    "lever:palantir"
  ],
  "mode": "monitor",
  "emitJobs": true,
  "includeDescription": true,
  "maxDescriptionChars": 4000,
  "maxJobsPerCompany": 1000,
  "maxCompanies": 500,
  "filters": {
    "titleKeywords": [],
    "excludeTitleKeywords": [],
    "functions": [],
    "seniorities": [],
    "countries": [],
    "remoteTypes": [],
    "postedWithinDays": null
  },
  "staleDays": 60,
  "surgeThresholdPct": 25,
  "rules": [
    {
      "id": "salesforce-in-jd",
      "field": "description",
      "pattern": "salesforce",
      "flags": "i"
    },
    {
      "id": "leadership-title",
      "field": "title",
      "pattern": "\\b(vp|vice president|head of|chief|director)\\b",
      "flags": "i"
    }
  ],
  "signalTypes": [
    "rule_match",
    "job_closed",
    "stale_role",
    "hiring_surge",
    "hiring_contraction",
    "new_function",
    "new_country",
    "leadership_hire"
  ],
  "stateStoreName": "ats-hiring-radar-state",
  "resetState": false,
  "concurrency": 8,
  "debugLog": false
}
```

# Actor output Schema

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

No description

# 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:gitlab",
        "ashby:notion",
        "lever:palantir"
    ],
    "filters": {
        "titleKeywords": [],
        "excludeTitleKeywords": [],
        "functions": [],
        "seniorities": [],
        "countries": [],
        "remoteTypes": [],
        "postedWithinDays": null
    },
    "rules": [
        {
            "id": "salesforce-in-jd",
            "field": "description",
            "pattern": "salesforce",
            "flags": "i"
        },
        {
            "id": "leadership-title",
            "field": "title",
            "pattern": "\\b(vp|vice president|head of|chief|director)\\b",
            "flags": "i"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("rayla/ats-hiring-radar").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:gitlab",
        "ashby:notion",
        "lever:palantir",
    ],
    "filters": {
        "titleKeywords": [],
        "excludeTitleKeywords": [],
        "functions": [],
        "seniorities": [],
        "countries": [],
        "remoteTypes": [],
        "postedWithinDays": None,
    },
    "rules": [
        {
            "id": "salesforce-in-jd",
            "field": "description",
            "pattern": "salesforce",
            "flags": "i",
        },
        {
            "id": "leadership-title",
            "field": "title",
            "pattern": "\\b(vp|vice president|head of|chief|director)\\b",
            "flags": "i",
        },
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("rayla/ats-hiring-radar").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:gitlab",
    "ashby:notion",
    "lever:palantir"
  ],
  "filters": {
    "titleKeywords": [],
    "excludeTitleKeywords": [],
    "functions": [],
    "seniorities": [],
    "countries": [],
    "remoteTypes": [],
    "postedWithinDays": null
  },
  "rules": [
    {
      "id": "salesforce-in-jd",
      "field": "description",
      "pattern": "salesforce",
      "flags": "i"
    },
    {
      "id": "leadership-title",
      "field": "title",
      "pattern": "\\\\b(vp|vice president|head of|chief|director)\\\\b",
      "flags": "i"
    }
  ]
}' |
apify call rayla/ats-hiring-radar --silent --output-dataset

```

## MCP server setup

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

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/R22fHvDmkoi6W1cSJ/builds/pzzVhv8ikOqZFReqD/openapi.json
