# Greenhouse, Lever & Ashby Jobs Scraper + Changes (`domestic_buffalograss/company-hiring-tracker`) Actor

Get open jobs from companies on Greenhouse, Lever or Ashby and what changed since your last run: new and closed jobs, new jobs by function, new countries. $2 per 1,000 companies checked + $3 per 1,000 changed companies + $1 per 1,000 job rows. Not affiliated with Greenhouse, Lever or Ashby.

- **URL**: https://apify.com/domestic_buffalograss/company-hiring-tracker.md
- **Developed by:** [Sumitsubo](https://apify.com/domestic_buffalograss) (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 $1.50 / 1,000 company checkeds

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

## Greenhouse, Lever & Ashby Jobs Scraper + Changes

Get the open jobs of any company that uses Greenhouse, Lever or Ashby — and what changed since your last run. Paste domains or careers URLs: the ATS is detected for you.

Every run returns one row per company: open jobs, new and closed roles since the last run, hiring growth, counts by function (Engineering, Sales, Marketing…), department and location, plus first jobs in new countries. By default you also get job rows: on the first run up to 50 open jobs per company (the most recently posted), then one row per new job.

Even the first run shows momentum: jobs opened in the last 7/30/90 days. **$2 per 1,000 companies checked + $1 per 1,000 job rows** ($3 per 1,000 changed companies; misses free). Not affiliated with Greenhouse, Lever or Ashby.

### Try it in one minute

Press **Start** with the default input. It checks three public job boards (Airbnb on Greenhouse, Palantir on Lever, Ramp on Ashby) and returns three company rows like this:

| company | ats | openTotal | engineeringOpen | salesOpen | postedLast30d | longOpenCount | summary |
|---|---|---:|---:|---:|---:|---:|---|
| Airbnb | greenhouse | 160 | 38 | 25 | 63 | 43 | Baseline: 160 open roles (Engineering 38, Sales 25, Customer Success 21). 63 posted in the last 30 days, 43 open for 90+ days. |

*(Numbers from a test run on 2026-10-11; yours will differ.)*

On this first run you also get up to 50 job rows per company, the most recently posted open jobs of each board (**First-run job rows per company**, default 50). The three boards list more than 50 open jobs each (160, 309 and 165 on 2026-10-11), so that is 3 company checks + 150 job rows = $0.006 + $0.150 ≈ $0.16; the jobs over the limit are counted in the company row (`baselineJobRowsOmitted`). Set **Job rows** to `none` for the three company rows only ($0.006).

Run it again later with the same **Watchlist name** and every row tells you what changed: `+7 (9 new, 2 closed)`, `New: Engineering 7, Sales 2`, `First job in DE`, `New department: AI Platform`.

### Who it is for

| You are | Look at |
|---|---|
| **SDR / RevOps / founder doing outbound** (Clay, HubSpot, n8n) | `salesOpen`, `marketingOpen`, `gtmOpen`, `newJobs[]` (titles + links as a reason to reach out), `signalTags` (`gtm_push`, `hiring_up`) |
| **Investor / analyst** | `openTotal`, `growth`, `delta`, `trend12` (last 12 runs), `newCountries`, `hiring_down`, `suspectedFetchAnomaly` |
| **Competitive intelligence** | `newDepartments`, `newLocations`, `newCountries`, `countsBy.department`, weekly Slack digest |
| **Recruiting agency** | `hiring_up`, `longOpenCount` / `long_open_heavy` (roles that stay open), `newJobs[]`, job rows |

### Input

- **Companies** — one per line: a domain (`stripe.com`), a careers page (`https://stripe.com/jobs`) or a board URL (`https://jobs.lever.co/palantir`, `https://boards.greenhouse.io/airbnb`, `https://job-boards.greenhouse.io/embed/job_app?for=airbnb`, `https://jobs.ashbyhq.com/ramp`, Lever EU `https://jobs.eu.lever.co/...`). Up to 5,000. Lines that are exactly the same are merged (the log says how many). Greenhouse EU boards (`job-boards.eu.greenhouse.io`) are not supported yet and are returned as `unsupported_url` (or `board_not_found` when a careers page links one) with that reason. Domains and careers URLs on ports other than 80/443 are not accepted.
- **Watchlist name** (`stateKey`) — changes are computed against the previous run with the same name. Use several names for several lists (every name is kept apart, including non-English names). One run per watchlist at a time: a second run started while the first is still working waits for it (up to 4 minutes, nothing is charged while waiting) and then runs; if the watchlist is still busy after that, it stops without charging. **Starting runs in parallel** (Clay, n8n, several schedules): give each parallel run its own watchlist name.
- **Job rows** (`outputJobs`) — `new` (**default**: on the first run of a board one row per open job up to **First-run job rows per company**, the most recently posted first (`change: "baseline"`), then one row per job that is new since the last run; $0.001 each), `all` (every open job, every run, no limit) or `none` (company rows only, no job-row charges). A first run therefore costs up to 50 job rows per company at the default: 30 companies with 50 or more open jobs each ≈ $0.06 in company checks + $1.50 in job rows = ~$1.56. Changing filters starts a new baseline, which outputs the open jobs again (up to the same limit).
- **First-run job rows per company** (`baselineJobRowsPerCompany`) — default 50, 0–1,000. With `outputJobs: "new"` the first run of a board (and a new baseline after the filters change) outputs at most this many job rows for it, the most recently posted first; 0 outputs none. The jobs over the limit are not output (and not charged): the company row counts them in `baselineJobRowsOmitted` and `statusDetail` says so. The baseline itself saves every open job, so none of them is reported as new later. Not used with `all`.
- **Count breakdowns** (`groupBy`) — any of `function`, `department`, `location`, `country`, `team` (other values are ignored with a warning in the log).
- **Filters** — track only some jobs: `titleIncludes` (whole words; Japanese, Chinese, Korean and Thai terms match inside words, so `エンジニア` finds `シニアエンジニア`), `functions`, `countries` (ISO codes such as `US`, `GB`, `DE`; `UK`, `USA` and English country names are converted), `departmentIncludes`. Values that cannot be read are ignored with a warning in the log. With a `countries` filter, jobs whose location names no recognisable country (country `null`, e.g. "Remote - EMEA") are left out. Changing filters starts a new baseline.
- **Overrides** — fix detection by hand: `[{"company": "acme.com", "ats": "lever", "token": "acme", "label": "Acme"}]`. `company` can be written as the domain or as a URL of it (`https://www.acme.com/careers` and `acme.com` match each other). An override that matches no line of Companies is reported in the log.
- **Digest** — Slack Incoming Webhook URL and/or any public HTTPS webhook URL (both stored as secrets), and whether to send only when something changed. Webhooks to private or internal addresses, URLs with a user name or password, and redirects are refused.
- **Domains detected per run** (`maxDetectionsPerRun`, Advanced) — default 1,000, at most 5,000. A domain without a saved detection costs up to 2 careers pages (the careers URL you gave and `/careers`, or `/careers` and `/jobs`) and up to 6 board-name guesses (free). Past this many in one run the rest are `detection_deferred` (free, no request) and later runs detect them; saved detections do not count, board URLs and overrides never do.
- **Spreadsheet-safe text** (`spreadsheetSafeText`, Advanced) — off by default. Turn on if you open the dataset in Excel or Google Sheets: text that starts with `=`, `+`, `-` or `@` (job titles, departments and locations are written by each employer) gets a leading apostrophe, so it is shown as text instead of being run as a formula.

### Output

#### Company row (one per input, every run)

| Field | Meaning |
|---|---|
| `company`, `input`, `domain`, `ats`, `token`, `boardUrl` | Who and where (`company` is filled on every row: the company or board name, else the domain, else the input line) |
| `status`, `statusDetail` | `baseline` (first run), `ok`, `board_not_found`, `needs_review`, `fetch_failed`, `detection_deferred`, `anomaly`, `ats_switched`, `too_large`, `unsupported_url`, `duplicate` — see below; `statusDetail` says why |
| `atsDetectedBy`, `confidence`, `detectionNote` | How the board was found: `url`, `careers-page`, `slug-probe`, `override` (plus `(cached)`) |
| `openTotal`, `prevTotal`, `delta`, `deltaPct` | Open jobs the board lists now, what it listed last run, difference. Jobs missing for the first time are not in `openTotal` (they are in `missingOnceCount`) |
| `listedTotal`, `missingOnceCount` | Jobs the board lists right now (the same as `openTotal`); jobs listed last run that are missing this run for the first time — closed only if they are still missing on the next run (see "Two misses to close") |
| `prevOpenEstimate` | Jobs open at the previous check as far as anyone can tell: listed then, plus those missing once then. The base of `hiring_up` / `hiring_down` (one incomplete answer does not shrink it) |
| `newJobsCount`, `closedJobsCount`, `replacedCount`, `repostedCount` | New jobs since last run; jobs closed this run (missing two runs in a row — each close is counted once, on the run that confirms it); jobs missing for the first time in the same run as new jobs appeared, each offsetting one new job (a replacement); reposts: a role closed and re-posted with the same title and location within 14 calendar days counts as `reposted`, not new — also when the close and the re-post fall in different runs |
| `closedOffsetCount`, `returnedReplacedCount` | Of the closes, those that had already offset a new job (not counted again); jobs back with the same id that had offset a new job — they were never gone, so the new job they offset counts as growth now |
| `growth`, `changed` | Hiring growth: new jobs minus replaced jobs minus confirmed closes that no new job offset, plus `returnedReplacedCount` (other returned jobs, reposts and filter moves do not count). One turnover — as many roles filled as posted — is growth 0. `changed` is `true` when the company counts (and is billed) as changed in this run: new jobs, confirmed closes that no earlier change already covered, or jobs back that had offset new ones (see Pricing) |
| `returnedCount`, `movedIntoFilterCount`, `movedOutOfFilterCount` | Jobs back with the same id after being missing or closed (not new); jobs that entered or left your filters while staying on the board (e.g. a renamed department; not new, not closed) |
| `prevCheckedAt`, `daysSincePrev` | When this board was last read successfully, and how many days ago |
| `changeWithheld` | `true` when the board changed but the run's spending limit had no room for "Company changed": the row shows the current counts only (the change fields are empty) and the next run with room reports the change — see Pricing |
| `baselineJobRowsOmitted` | First run with `outputJobs: "new"`: open jobs not sent as job rows because of **First-run job rows per company** (0 when all were sent; empty on other runs) |
| `atsSwitchedTo`, `schemaWarnings` | The new board when `status` is `ats_switched`; notes about the feed (e.g. duplicate job ids counted once) |
| `engineeringOpen` … `otherOpen`, `engineeringDelta` … | Listed jobs per function and the difference from what was listed last run (10 functions: Engineering, Product, Design, Data, Sales, Marketing, Customer Success, Operations, G\&A, Other) |
| `gtmOpen`, `engShare`, `topGrowingFunction` | Sales + Marketing + CS, engineering share, the function with the most new jobs this run |
| `newDepartments`, `newLocations`, `newCountries` | Never seen at this company before (also when an existing job moves there). After more than 1,000 distinct values on one axis the oldest are forgotten and `seenHistoryCapped` is `true` |
| `postedLast7d`, `postedLast30d`, `postedLast90d`, `longOpenCount`, `longOpenShare` | From posting dates — available from the first run (dates in the future are ignored; on Lever the date is when the posting was created) |
| `signalTags` | `hiring_up` (`growth` of at least +3 jobs and +10% of `prevOpenEstimate`, from 5 such jobs), `hiring_down` (`growth` of at least −3 jobs and −20% of `prevOpenEstimate`, from 5), `drop_confirmed`, `new_country`, `new_department`, `gtm_push`, `engineering_push`, `fresh_postings`, `long_open_heavy` |
| `summary` | One sentence, rule-based (no AI): *"164 open (+4 vs last run): 9 new, 5 closed. New: Engineering 7, Sales 2. Closed: G\&A 3, Sales 2. First job in DE."* — the functions of the new and the confirmed closed jobs |
| `countsBy` | `{ "department": { "Payments": { "now": 12, "prev": 9, "delta": 3 } } }` for each breakdown (listed counts) |
| `trend12` | Listed jobs over the last 12 runs |
| `newJobs[]`, `closedJobs[]` | Up to `maxJobsInRow` jobs each (default 20): title, function, department, location, country, link, posted date |
| `recentJobs[]` | First run only: the most recently posted jobs, up to 10 |

#### Job row (on by default)

`rowType: "job", company, ats, jobId, title, function, department, team, location, country, remote, publishedAt, url, change (new | open | baseline)`

With the default `outputJobs: "new"` the first run of each board gives one job row per open job (`change: "baseline"`), at most `baselineJobRowsPerCompany` (default 50) per company, the most recently posted first; every later run gives one per new job (`change: "new"`). `all` gives every open job every run. Set `none` to get company rows only.

#### Run summary

The key-value store record `OUTPUT` holds the run summary: status counts, the 10 companies with the highest `growth`, `detection` (of the companies this run output: domains detected, domains deferred, and the limit), requests and bytes transferred, and the digest result (`OUTPUT.digest.skippedReason` says why no digest was sent). The run's status message shows the same counts and is updated after each group of (up to) 25 companies while the run works.

A run ends as **failed** when no company could be read at all (every one `fetch_failed`: ATS outage, blocking or network) or when another run kept the same watchlist busy for longer than the 4-minute wait; nothing is charged in these two cases (no event of this Actor, and there is no start fee). A run also ends as **failed** when it loses its watchlist lock while working: `lock_lost` (another run took the watchlist over) or `lock_unconfirmed` (the lock could not be read even after 3 tries: key-value store errors). These two can happen after earlier groups of companies were already output and charged: those charges stay (the rows were delivered), are recorded and are never billed again. The run then stops before outputting its next group — or, when the check right before saving fails, without saving the group it has just output and charged: the next run shows that group's changes again without charging them again. After a stop like this the run writes no shared state any more (a lock it could not remove expires after 10 minutes). A failed run sends no digest. If only some companies fail, the run succeeds and the status message says how many could not be read.

**Long lists and the time limit.** Before the run's timeout (2 hours by default) the Actor stops between two groups of 25 companies, saves where it stopped and ends as succeeded; the status message says from which company the next run continues. Inside a group it reads no further company in the last 3 minutes, and a group whose reading ran into the last minute is not output (nothing charged; the next run reads it). Start it again with the same companies and it picks up from there. The same happens when the run stops at its spending limit or is aborted: the next run with the same companies continues from the first company that was not output. A run that gets to the end of the list clears the saved position, so the run after it starts at the top again. There is one saved position per watchlist (`OUTPUT.nextIndex` shows it); a run of another list on the same watchlist that gets to its end leaves it in place. The position is also saved after every group, so a run that the platform ends anyway (for example while a very slow ATS answers) still leaves it, and requests to an ATS never wait into the last minute before the time limit (a board that has not answered by then is `fetch_failed`, free). If the platform moves (restarts) a run, the restarted run never outputs a second row for a company the first attempt already output, even when that company's result is different the second time, and also when the restart comes after the first attempt finished (it then outputs, charges and sends nothing). A change whose company row the first attempt may not have output is not charged by the restart; the next run shows it and charges it once. The first run on domains is the slowest because each domain's ATS is detected (up to 2 careers pages, then up to 6 board-name guesses): plan roughly 3 minutes per 100 domains at the default "Companies in parallel" = 4 when most domains have no board, about half that at 10 — an estimate from a simulated run, not a measurement on Apify. At most 1,000 domains are detected per run (`maxDetectionsPerRun`); the rest are `detection_deferred` and detected by the next runs. Board URLs need no detection, and detection results are cached, so later runs are much faster.

### Monitoring: schedule it

1. Save your input as a Task, with a **Watchlist name** such as `prospects-q4`.
2. Add a Schedule — weekly (`0 7 * * 1`) or daily.
3. Optional: paste a Slack Incoming Webhook URL to get a digest (fastest-growing companies, first jobs in new countries, new departments, sharp declines).

The digest is sent at the end of each run, so a weekly schedule gives a weekly digest. Failed sends never fail the run and are not charged. With "Only send when something changed" (the default) the digest is sent when at least one company changed, or got its first job in a new country or a new department; the first run of a watchlist only saves baselines, so the first digest then arrives after the second run. A run in which no company could be read sends no digest. A digest is only sent when the run's spending limit still has room for "Digest sent"; otherwise it is not sent (never delivered for free) and `OUTPUT.digest.skippedReason` says so. A restart of the same run does not send it a second time. "Growing" and "Sharp declines" are ranked by `growth`, and each line shows the parts that add up to it (new, replaced, closed, back); "Sharp declines" lists exactly the companies tagged `hiring_down`, so a turnover at a constant count is listed under other changes, not as growth or decline.

### Pricing

Pay per event. Prices below are the Apify Free plan prices (the most you pay; paid Apify plans may get lower prices — the Pricing tab shows yours).

| Event | Price | When |
|---|---:|---|
| Company checked | $0.002 | A board was read successfully (`ok` or `baseline`) |
| Company changed | $0.003 | From the second run, when new jobs were posted, jobs were confirmed closed that no earlier change already covered, or jobs that had offset new ones came back (`changed: true`). One turnover — some roles filled and as many posted — is one change, on the run that sees the new roles; the closes confirmed on the next run are not charged again |
| Job row | $0.001 | One row per job — **on by default** (`outputJobs: "new"`): the open jobs on a board's first run (and after the filters change), at most `baselineJobRowsPerCompany` (default 50) per company, then one per new job; every open job with `all`; never with `none` |
| Digest sent | $0.01 | A Slack or webhook digest was delivered (it is only sent when the spending limit has room for this event) |

There is no Actor start fee: nothing is charged when a run starts.

**Free:** detection, cached lookups, `board_not_found`, `needs_review`, `fetch_failed`, `detection_deferred`, `anomaly`, `ats_switched`, `too_large`, `unsupported_url`, duplicates, and failed digests.

| Example (Free-plan prices) | Assumptions | Approx. per month |
|---|---|---:|
| First run of 30 companies | 50 or more open jobs each = 1,500 job rows (default `outputJobs: "new"`, at most 50 per company) | ~$1.56 once |
| 30 companies, weekly, digest | 4.3 runs; 15% of companies change per run; 2 new-job rows per changed company | ~$0.40 |
| 300 companies, weekly, digest | same | ~$3.59 |
| 2,000 companies, daily | 30 runs = 60,000 checks; 9,000 changes; 21,500 new-job rows | ~$169 |

The monthly examples are after the first run. Your cost depends mostly on how many companies change and how many new jobs they post; set `outputJobs` to `none` to pay for company rows only.

Set a **maximum cost per run** in the run options to cap spending; the Actor stops cleanly at the limit, the companies it did not reach are not charged, and the next run with the same companies continues where it stopped. Each group of companies is planned before anything is output: every company read is charged as checked, then changes in input order. A company whose change no longer fits is output with its current counts and `changeWithheld: true` — what changed (new and closed jobs, deltas, `growth`, tags, summary) is not shown and no job rows are sent for it — and the next run with room reports that change and charges it once. Job rows are planned in the same step, before anything is output: a company whose job rows do not all fit says so in `statusDetail` (`baseline waits: 4 of 50 job rows sent; the rest next run` on a first run, `changed since last run; 2 of 3 job rows sent, the rest next run` on a change) and the status message counts them (`N job rows wait for budget`). The next run sends (and charges) the rest — rows already paid are sent again without a charge — and a board's first run is saved once all its rows are paid. `OUTPUT.safety.changesWithheld` counts the withheld changes, `OUTPUT.safety.jobRowsWaiting` the waiting job rows.

### No false alarms

- **Anomaly guard** — if a board that had 20+ jobs suddenly lists none, or fewer than 20% of its previous count, the row is marked `anomaly`, the previous snapshot is kept and nothing is charged. Jobs being replaced (high turnover, a long pause between runs) is not an anomaly: only the drop in the count is. A drop is accepted as real after **3 such runs in a row** (the counts may differ), or at once when the previous check is 28+ days old and the board still lists some jobs: the row is `ok` with `dropConfirmed: true` and the tags `hiring_down` + `drop_confirmed`, the closed jobs are reported (and charged as a change) and the digest lists it under sharp declines. A drop accepted at 0 jobs sets `staleBoard: true`.
- **Two misses to close** — a job that is missing for the first time is not reported as closed: it is shown in `missingOnceCount` (it is no longer in `openTotal`, which is what the board lists) and closed only if it is still missing on the next run. One incomplete answer from an ATS therefore reports no closures, no `hiring_down` and no change, and the jobs that come back are `returnedCount`, not new (also after a real close, when the same job id is back within 14 days, or at the next check after the close however long after — e.g. a board that recovers after a drop accepted after a long pause). This also applies to small boards that return an empty list once. `hiring_up` / `hiring_down` are measured against `prevOpenEstimate` (listed plus missing once at the previous check), so a run right after an incomplete answer is not measured against the shrunken count.
- **Replacements** — when new jobs appear in the same run as others disappear, each disappearance offsets one new job at once (`replacedCount`). One turnover at a constant count therefore shows `delta` 0, `growth` 0 and no `hiring_up`, is one change (billed once), and the close confirmed on the next run is counted in `closedJobsCount` (and `closedOffsetCount`) but is not a second change and no `hiring_down`. A disappearance that no new job offsets counts when it is confirmed. If a job that offset a new one comes back instead (it was only missing once), the offset is taken back: that run's `growth` rises by one per such job (`returnedReplacedCount`), so real growth that coincided with an incomplete answer is not lost. The same holds after a long pause between runs.
- **ATS switches** — when a domain's board suddenly looks gone (404, newly empty, or a sudden drop) and the board came from a cached detection, the careers page is checked again. If it now links another board, the row is `ats_switched` (free, not a drop) and the next run starts the new board with a baseline. Board URLs and overrides are taken as given.
- **Incomplete responses** — if the ATS answers but the answer is broken (departments missing on Greenhouse, more than 10% of postings without an id or title), the company is `fetch_failed`: nothing is reported as closed and nothing is charged. A careers page that cannot be read (timeouts, connection errors, server errors, "too many requests" on every page tried) is not taken as "no board": the domain is `fetch_failed` (free) and checked again on the next run.
- **Reposts** — the same title and location closed and re-opened within 14 calendar days are not counted as new, also across runs. (If two runs are more than 14 days apart, a close and a re-post between them cannot be dated and count as closed + new.)
- **Long-open roles** — jobs posted more than 90 days ago are counted separately (`longOpenCount`), so evergreen postings do not look like growth.
- **Same-name check** — when a domain is matched by guessing a board name, the board is accepted only if its job links point to that domain (Greenhouse, at least half of the links) or the postings write that domain as a web or e-mail address (Lever, Ashby; at least 30% of postings). A careers subdomain such as `careers.acme.com` is checked against `acme.com`. A matching company name, a similar domain (`notacme.com`) or a capitalised word is never enough. Otherwise you get `needs_review` (free) and can confirm it with **Overrides**. Guessed boards (and "not found" / "needs review" results) are re-checked after 7½ days, i.e. every second run on a weekly schedule. On Lever the guess first reads a sample of 25 postings, so a board of another company is not downloaded in full.
- **Duplicate job ids** — the same job id listed twice in one feed counts once (one job row, one charge); `schemaWarnings` says so.

### Status values

| status | Meaning | Charged |
|---|---|---|
| `baseline` | First check of this board (or filters changed) | Company checked (+ job rows) |
| `ok` | Compared with the last run | Company checked (+ changed, + job rows) |
| `board_not_found` | No Greenhouse/Lever/Ashby board for this domain, or the board returned 404 | No |
| `needs_review` | A board with this name exists but may belong to another company, or the careers page links several boards | No |
| `fetch_failed` | The ATS did not answer after 3 retries (or asked to wait more than 30 seconds), or answered with an incomplete list, or the careers page of a domain could not be read; snapshot kept | No |
| `detection_deferred` | This run already detected `maxDetectionsPerRun` domains (default 1,000); detected by a later run | No |
| `anomaly` | Sudden drop that looks like a data problem; snapshot kept | No |
| `ats_switched` | The careers page now links another board; tracked from the next run | No |
| `too_large` | The board's saved state would not fit in one key-value store record (tens of thousands of jobs); counts shown, changes not tracked — use filters to track a part of it | No |
| `unsupported_url` | Not a domain or a supported board URL | No |
| `duplicate` | Same board as an earlier line | No |

### Scope and limits

- Greenhouse, Lever and Ashby only. Workday, LinkedIn and Indeed are not supported.
- Input must be a domain or URL; company names alone are not supported.
- Companies with several boards (regions, subsidiaries) need one line per board.
- Lever EU boards (`jobs.eu.lever.co`) are found from a board URL or the careers page; guessing from the domain only tries the global Lever host.
- Greenhouse EU boards (`job-boards.eu.greenhouse.io`) are not supported yet (only the global Greenhouse job board API is read).
- At most 1,000 domains without a saved detection are detected per run (`maxDetectionsPerRun`, up to 5,000): a list of 5,000 new domains takes 5 runs to be fully detected at the default (the rest are `detection_deferred`, free). Detection itself is free and costs the operator requests, which is why it is capped.
- Countries come from the ATS's country field or from the location text (best effort: "Berlin, DE", "Austin, TX", "Remote (FR)"). A location that names no country, or a code that could mean two places ("Dublin, CA"), stays `null`. After "Remote" a two-letter code counts only when it has one meaning: "Remote, TX" / "Remote, WA" are US (state codes only), "Remote (FR)" is France, but "Remote, CA" (California or Canada), "Remote, CO" (Colorado or Colombia) and "Remote (DE)" (Delaware or Germany) stay `null`. A location that lists several places ("Austin, TX; San Francisco, CA", "New York, NY | London", "Chicago, IL, Denver, CO") gets a country only when all of them are in the same one; otherwise `null`. A list of countries in one place ("Remote - US, Canada", "Remote (DE, AT, CH)", "France, Spain") is `null` too. Australian states: "Perth, WA", "Darwin, NT" and "Adelaide, SA" are Australia (WA / NT / SA only after an Australian city; "Vancouver, WA" stays US), NSW / VIC / QLD / TAS / ACT always.
- Job functions are read from titles and departments written in English. Titles in other languages (e.g. Japanese 「ソフトウェアエンジニア」) mostly end up as `Other`; use `titleIncludes` with your own terms to track them.
- Greenhouse boards whose job links stay on greenhouse.io cannot be confirmed from a guess: paste the board URL or add an override (most companies link their board from their careers page, which is found automatically).
- Jobs are told apart by their id. When an ATS gives some jobs new ids for one run and the old ids come back the run after, that is read as real turnover: e.g. 10 jobs with new ids for one run show 10 new jobs replacing 10 (growth 0), then growth +10 on the run the old ids are back (10 back that had been counted as replaced) and −10 on the next run, when the 10 temporary ids are confirmed closed. Over the three runs the total is 0, but two of them show +10 and −10 (and can be tagged `hiring_up` / `hiring_down`).
- Job descriptions are never returned or stored — only titles, departments, locations, dates and links.
- No personal data: recruiter or hiring-manager names and custom fields are never read into the output.
- Data comes from each ATS's public job board API (no login). Not affiliated with Greenhouse, Lever or Ashby.

### FAQ

**How do I find a company's board name?** Open their careers page and click a job: the link contains `boards.greenhouse.io/<name>`, `jobs.lever.co/<name>` or `jobs.ashbyhq.com/<name>`. Paste that URL.

**A company shows `needs_review`.** We found a board with a matching name but could not confirm it is theirs. If it is, add an override: `{"company": "acme.com", "ats": "greenhouse", "token": "acme"}`.

**A company shows `board_not_found`.** It does not use one of the three supported ATSs, or its careers page does not link the board. Paste the board URL if you have it.

**Can I export to CSV / Clay / HubSpot?** Yes — the dataset exports to CSV, JSON or Excel. The main numbers are plain columns (`openTotal`, `growth`, `delta`, `engineeringOpen` …); `countsBy`, `trend12`, `newJobs[]`, `closedJobs[]` and `recentJobs[]` are nested (objects / lists), which CSV export flattens into many columns. Company rows and job rows share one dataset: the "Companies" view picks the company columns but does not remove job rows, so filter on `rowType` = `company` (or run with `outputJobs: "none"`) when you want exactly one row per company. Opening the export in a spreadsheet? Turn on **Spreadsheet-safe text** (`spreadsheetSafeText`) so employer-written text that starts with `=`, `+`, `-` or `@` cannot run as a formula.

**Two runs on the same watchlist?** Only one works at a time; the other waits for it up to 4 minutes (charging nothing), then runs — or, if the watchlist is still busy, stops without charging and says so in its status message. Use a different watchlist name for each run you start in parallel.

### How it compares

| | Per-job scrapers | Monthly hiring-data SaaS | This Actor |
|---|---|---|---|
| You choose the companies | Yes | Search their database | Yes (domains or URLs) |
| What changed since last run | Usually not, or new jobs only | Yes | New and closed jobs, hiring growth, new jobs by function, counts by function / department / country |
| Billing | Per job, every run | Monthly credits | Per company and per job row (up to 50 jobs per company once, then new ones); changes cost extra; misses free |
| First run useful | Raw jobs | Yes | Yes (up to 50 job rows per company + 7/30/90-day posting counts of every open job) |

Looking for a TheirStack or PredictLeads alternative for a known list of companies on Greenhouse, Lever or Ashby? This Actor is built for exactly that list-based monitoring.

# Changelog

This Actor's version history is a separate document: https://apify.com/domestic_buffalograss/company-hiring-tracker/changelog.md

# Actor input Schema

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

One company per line: a domain (stripe.com), a careers page URL (https://stripe.com/jobs), or a Greenhouse / Lever / Ashby board URL (https://jobs.lever.co/palantir). The ATS is detected for you. Company names alone are not supported; Greenhouse EU boards (job-boards.eu.greenhouse.io) are not supported yet. Identical lines are merged. Up to 5,000.

## `stateKey` (type: `string`):

Changes are computed against the previous run with the same watchlist name. Use different names to track different lists independently. One run per name at a time: a second run waits up to 4 minutes for the first, so give runs you start in parallel (Clay, n8n, several schedules) different names.

## `outputJobs` (type: `string`):

Company rows are always returned. New (default): on the first run of a board its open jobs (change = baseline), at most "First-run job rows per company" (default 50, the most recently posted first), then only jobs that are new since the last run. All: every open job, every run (no limit). None: company rows only. Each job row is billed (Job row event); a first run costs up to 50 rows per company at the default, e.g. 30 companies with 50 or more jobs each = 1,500 rows.

## `baselineJobRowsPerCompany` (type: `integer`):

With Job rows = New: on the first run of a board (and a new baseline after the filters change) at most this many job rows per company, the most recently posted first. 0 = no job rows on the first run. The jobs over the limit are not output or charged; the company row counts them (baselineJobRowsOmitted). The baseline still saves every open job. Not used with All.

## `groupBy` (type: `array`):

Breakdowns included in each company row (countsBy): function, department, location, country, team. Other values are ignored with a warning in the log.

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

Track only matching jobs. titleIncludes: words in the job title (whole words, any match; Japanese, Chinese, Korean and Thai terms match inside words). functions: Engineering, Product, Design, Data, Sales, Marketing, Customer Success, Operations, G\&A, Other (classified from English titles; other languages mostly end up as Other). countries: ISO codes such as US, GB, DE (UK, USA and English country names are converted); jobs whose location names no recognisable country (or places or a list of several countries, such as "Remote - US, Canada") are left out. departmentIncludes: text in the department or team name. Values that cannot be read are ignored with a warning in the log. Changing filters starts a new baseline.

## `overrides` (type: `array`):

Set the ATS and board name by hand when detection is wrong or says needs_review. Example: \[{"company": "acme.com", "ats": "lever", "token": "acme", "label": "Acme"}]. company is the domain or a URL of it (matched against the same domain in Companies); an override that matches no line is reported in the log. ats is greenhouse, lever or ashby; add "region": "eu" for Lever EU boards.

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

https://hooks.slack.com/services/... Stored as a secret.

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

Any HTTPS endpoint (n8n, Make, Zapier, your API). Receives the digest as JSON. Stored as a secret.

## `notifyOnlyWhenChanged` (type: `boolean`):

Skip the digest when nothing is news since the last run. It is still sent when at least one company changed (new jobs, jobs confirmed closed that no earlier change covered, or jobs back that had offset new ones) or got its first job in a new country or a new department, even with no job added or closed. The first run only saves baselines, so the first digest then comes after the second run. A run in which no company could be read never sends a digest.

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

At most 2 requests run at once per ATS host regardless of this value.

## `maxJobsInRow` (type: `integer`):

How many new / closed / recent jobs are embedded in the company row (newJobs, closedJobs, recentJobs).

## `maxDetectionsPerRun` (type: `integer`):

Domains with no saved detection are detected (up to 2 careers pages + up to 6 board-name guesses, free) in input order, at most this many per run. The rest are returned as detection_deferred (free, no request) and detected by later runs. Saved detections, board URLs and overrides do not count.

## `spreadsheetSafeText` (type: `boolean`):

Turn on if you open the dataset in Excel, Google Sheets or another spreadsheet (CSV/XLSX export). Text that starts with =, +, -, @ (or a tab or line break) gets a leading apostrophe, so the spreadsheet shows it as text instead of running it as a formula. Job titles, departments and locations are written by each employer, so this protects you from a title such as =HYPERLINK(...). Off by default because it changes those values for JSON consumers (-Senior Engineer becomes '-Senior Engineer).

## Actor input object example

```json
{
  "companies": [
    "https://boards.greenhouse.io/airbnb",
    "https://jobs.lever.co/palantir",
    "https://jobs.ashbyhq.com/ramp"
  ],
  "stateKey": "default",
  "outputJobs": "new",
  "baselineJobRowsPerCompany": 50,
  "groupBy": [
    "function",
    "department",
    "country"
  ],
  "filters": {
    "titleIncludes": [],
    "functions": [],
    "countries": [],
    "departmentIncludes": []
  },
  "overrides": [],
  "notifyOnlyWhenChanged": true,
  "maxConcurrency": 4,
  "maxJobsInRow": 20,
  "maxDetectionsPerRun": 1000,
  "spreadsheetSafeText": false
}
```

# Actor output Schema

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

One row per company (job count and change since the previous check) and the job rows chosen by outputJobs.

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

What the run did: companies by status, charges, transfer per ATS and safety counters.

# 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": [
        "https://boards.greenhouse.io/airbnb",
        "https://jobs.lever.co/palantir",
        "https://jobs.ashbyhq.com/ramp"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("domestic_buffalograss/company-hiring-tracker").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "companies": [
        "https://boards.greenhouse.io/airbnb",
        "https://jobs.lever.co/palantir",
        "https://jobs.ashbyhq.com/ramp",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("domestic_buffalograss/company-hiring-tracker").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "companies": [
    "https://boards.greenhouse.io/airbnb",
    "https://jobs.lever.co/palantir",
    "https://jobs.ashbyhq.com/ramp"
  ]
}' |
apify call domestic_buffalograss/company-hiring-tracker --silent --output-dataset

```

## MCP server setup

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

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/Kyl3e0prMqzi0ZfI9/builds/mtdDMiSVHBcyRUlfL/openapi.json
