# Company Job Boards Scraper & Hiring Signals (ATS) (`owlabee/ats-hiring-signals`) Actor

Scrape job postings from company career pages on Greenhouse, Lever, Ashby, SmartRecruiters and Workable via their public job-board APIs. Normalized jobs (title, department, location, remote, salary, apply URL) plus hiring signals: new, removed and changed roles per company. Pay per job and signal.

- **URL**: https://apify.com/owlabee/ats-hiring-signals.md
- **Developed by:** [Owlabee](https://apify.com/owlabee) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 job scrapeds

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

## Company Job Boards Scraper & Hiring Signals (ATS)

### What it does

This Actor scrapes job postings straight from the **public job-board APIs** that company career pages are built on: **Greenhouse**, **Lever**, **Ashby**, **SmartRecruiters** and **Workable**. Give it a list of boards (`greenhouse:stripe`, `https://jobs.lever.co/spotify`, `ashby:openai`, …) and it returns one normalized record per open job: title, department, team, location (raw plus best-effort city/country), a remote flag, employment type, posted/updated dates, apply URL, optional plain-text description and salary data where the provider exposes it (Ashby and Lever).

Switch to **signals mode** and it becomes a hiring monitor. Each board is compared with the snapshot from the previous run and you get change events — `new-role`, `removed-role`, `title-changed`, `location-changed` — plus one `board-summary` per company with open-role count, net change, top departments, top locations, remote share and a 0–100 **hiring velocity score**. That is the data sales teams, recruiters, investors and researchers use to spot companies that are expanding, freezing or shifting focus, without touching LinkedIn.

Because it reads official job-board APIs over plain HTTP, it needs no browser, no proxies and no login, which keeps it fast and cheap: **1,000 jobs cost about $1.00**. Results are available as JSON, CSV, Excel, through the Apify API, webhooks and the Apify MCP server.

### How to use

1. Enter your **Job boards**, one per line, either as `provider:boardId` or by pasting the career-page URL. The provider is detected from the hostname (`boards.greenhouse.io`, `job-boards.greenhouse.io`, `jobs.lever.co`, `jobs.ashbyhq.com`, `careers.smartrecruiters.com`, `apply.workable.com`, `<company>.workable.com`).
2. Pick a **Mode**: `jobs` for a full export, `signals` to detect changes since the last run. Optionally enable **Include job description**, lower **Max jobs per board**, set a **Webhook URL**.
3. Click **Start**. For signals, schedule the same task (daily or weekly); the first run writes a baseline, every later run writes only what changed plus a summary per board.

### Input

| Field | Type | Required | Description |
|---|---|---|---|
| `boards` | string\[] | yes | `provider:boardId` (greenhouse, lever, ashby, smartrecruiters, workable) or a public board URL. Duplicates are merged. |
| `mode` | `jobs` | `signals` | no | `jobs` (default) lists every open job; `signals` diffs against the previous snapshot. |
| `includeDescription` | boolean | no | Add `descriptionText` (HTML → text, max 3,000 chars). Default `false`. SmartRecruiters and Workable need one extra request per job for this. |
| `maxJobsPerBoard` | integer | no | Cap per board (default 2000). Boards over the cap are marked `truncated`; removed-role signals are suppressed for them. |
| `stateStoreName` | string | no | Named key-value store holding snapshots for signals mode (default `ats-hiring-signals-state`). |
| `webhookUrl` | string | no | POST target for the end-of-run summary (per-board summaries, counts, charges). |
| `maxConcurrency` | integer | no | Boards fetched in parallel (default 5). Same-host requests are additionally limited to ~2/s. |

### Output example

A `job` record (`mode: jobs`, or baseline run in signals mode):

```json
{
  "type": "job",
  "provider": "greenhouse",
  "boardId": "stripe",
  "companyName": "Stripe",
  "jobId": "4012345007",
  "title": "Senior Backend Engineer, Payments",
  "department": "Engineering",
  "team": "Payments Platform",
  "location": "San Francisco, CA",
  "city": "San Francisco",
  "country": "United States",
  "remote": false,
  "employmentType": "full-time",
  "postedAt": "2026-09-01T16:00:00.000Z",
  "updatedAt": "2026-10-10T22:02:11.000Z",
  "applyUrl": "https://boards.greenhouse.io/stripe/jobs/4012345007",
  "jobUrl": "https://boards.greenhouse.io/stripe/jobs/4012345007",
  "descriptionText": null,
  "compensation": null,
  "fetchedAt": "2026-10-16T12:00:00.000Z"
}
```

Ashby and Lever jobs carry `compensation` as `{ "min": 295000, "max": 440000, "currency": "USD", "interval": "1 YEAR", "summary": "$295K - $440K" }` when the company publishes it.

A `new-role` signal (the other signal types share the shape; `title-changed` adds `previousTitle`, `location-changed` adds `previousLocation`):

```json
{
  "type": "new-role",
  "provider": "lever",
  "boardId": "spotify",
  "jobId": "a1b2c3d4-0000-4000-8000-000000000001",
  "title": "Staff Software Engineer, Platform",
  "department": "Engineering",
  "location": "New York, NY",
  "remote": false,
  "applyUrl": "https://jobs.lever.co/spotify/a1b2c3d4-0000-4000-8000-000000000001/apply",
  "postedAt": "2026-10-14T10:26:40.000Z",
  "job": { "jobId": "a1b2c3d4-…", "title": "Staff Software Engineer, Platform", "team": "Platform", "city": "New York", "country": "United States" },
  "detectedAt": "2026-10-16T12:00:00.000Z"
}
```

A `board-summary` record (one per board per signals run):

```json
{
  "type": "board-summary",
  "provider": "lever",
  "boardId": "spotify",
  "companyName": null,
  "baseline": false,
  "openRoles": 312,
  "previousOpenRoles": 298,
  "newSinceLastRun": 21,
  "removedSinceLastRun": 7,
  "changedSinceLastRun": 3,
  "netChange": 14,
  "topDepartments": [{ "name": "Engineering", "count": 140 }, { "name": "Sales", "count": 45 }],
  "topLocations": [{ "name": "Stockholm, Sweden", "count": 120 }, { "name": "New York, NY", "count": 80 }],
  "remoteShare": 0.18,
  "hiringVelocityScore": 58,
  "truncated": false,
  "previousSnapshotAt": "2026-10-09T12:00:00.000Z",
  "detectedAt": "2026-10-16T12:00:00.000Z"
}
```

Error items look like `{ "type": "error", "provider": "lever", "boardId": "nonexistent", "error": "BOARD_NOT_FOUND", "errorMessage": "HTTP 404 ..." }`. Error codes: `BOARD_NOT_FOUND`, `UNKNOWN_PROVIDER`, `UNRECOGNIZED_URL`, `BLOCKED`, `WORKABLE_BLOCKED`, `RATE_LIMITED`, `TIMEOUT`, `FETCH_FAILED`. They are charged like a job record because the work to discover the problem was done.

The run's key-value store also gets a `SUMMARY` record with counts, charges and all board summaries; the same payload is POSTed to `webhookUrl` when set.

### Pricing

Pay per event. You only pay for what the Actor produces; set a **maximum cost per run** in the run options to cap spend.

| Event | Price | Notes |
|---|---|---|
| Job scraped (`job-scraped`) | $0.001 | Per `job` record, including baseline job records on the first signals run and error items |
| Hiring signal (`hiring-signal`) | $0.005 | Per `new-role`, `removed-role`, `title-changed`, `location-changed` and per `board-summary` record |
| Actor start | $0.00005 | Apify platform event |

**Examples:** 1,000 jobs ≈ **$1.00**. A weekly check of 50 companies ≈ 50 summaries = **$0.25** + $0.005 per detected change, after a one-off baseline charged at the per-job price (50 companies × ~100 jobs ≈ $5.00). A quiet week with 50 companies and 60 changes costs ≈ $0.55.

### Limits and fair use

- Only the providers' **public, unauthenticated job-board APIs** are used: `boards-api.greenhouse.io`, `api.lever.co` (and `api.eu.lever.co`), `api.ashbyhq.com/posting-api`, `api.smartrecruiters.com/v1/companies`, `apply.workable.com/api`. Private or password-protected boards are not supported.
- Requests to each provider host are rate-limited to about 2 per second with exponential backoff on 429/5xx; very large boards or `includeDescription` on SmartRecruiters/Workable (one extra request per job) take proportionally longer.
- Workable's career-page API sits behind a WAF. The Actor first tries the v3 search endpoint, then falls back to the v1 widget endpoint; if both are blocked you get a `WORKABLE_BLOCKED` error item for that board and the run continues. No residential proxies are used.
- `city`/`country` are best-effort parses of free-text locations and may be `null`; `remote` is `true` when the provider flags it or the title/location says so.
- Snapshots in signals mode store only the fields needed to diff (no descriptions), so a board with 2,000 roles uses well under 1 MB of key-value storage. If a board hits `maxJobsPerBoard` the summary is marked `truncated` and removals are not reported for it.
- The Store's daily automated test runs with the prefilled boards (`greenhouse:stripe`, `lever:spotify`) in `jobs` mode and `maxJobsPerBoard: 50`.

### Related Actors

Other pay-per-event Actors from the same account, all limited-permission and agent-friendly:

- [Domain WHOIS, DNS, Email Security & TLS Checker](https://apify.com/owlabee/domain-intelligence) — enrich each hiring company's domain with registrar, mail provider, tech stack and TLS status.
- [Website Change Monitor — Sitemap & Page Content Diff](https://apify.com/owlabee/website-change-monitor) — watch a company's own careers or pricing pages for content changes between runs.
- [US Government Grants & Contract Opportunities Feed](https://apify.com/owlabee/gov-opportunity-feed) — see which target accounts are bidding for or winning federal grants and contracts.

### FAQ

**Does this need proxies or an API key?** No. Greenhouse, Lever, Ashby and SmartRecruiters publish job-board APIs that need no credentials and work from datacenter IPs. Workable is the exception; see "Limits".

**How is cost controlled?** Set `maxJobsPerBoard`, keep `includeDescription` off unless you need the text, and set the run's maximum total charge. The Actor charges each record as it is written and stops cleanly when the limit is reached; in signals mode it then leaves the snapshot untouched so the next run re-detects the same changes.

**How do I find a company's board id?** Open the company's careers page and look at the URL of a job: `boards.greenhouse.io/<token>`, `jobs.lever.co/<company>`, `jobs.ashbyhq.com/<org>`, `jobs.smartrecruiters.com/<Company>/...`, `apply.workable.com/<subdomain>/...`. Paste that URL as-is; the Actor extracts the id.

**What happens on the first signals run?** There is no previous snapshot, so the Actor writes every current job as a `job` record (charged at $0.001 each) plus a `board-summary` with `baseline: true`. Change events start with the second run. Use a different `stateStoreName` per schedule if you monitor the same board at different cadences.

**Can I run it on a schedule or call it from an AI agent?** Yes — use Apify Schedules, the API or the Apify MCP server. It is pay-per-event with limited permissions, so agents can discover and call it. Example prompt: "Watch these 40 companies' career pages every Monday and tell me which ones opened new sales roles."

**Does it include closed or unlisted jobs?** No. Only jobs the provider currently lists publicly are returned; Ashby jobs with `isListed: false` are skipped. A job that disappears between runs shows up as `removed-role`.

**Why is `department` or `postedAt` null for some jobs?** Providers expose different fields: Greenhouse has no posted date on some boards (`first_published` can be null), Lever has no `updatedAt`, SmartRecruiters departments are optional. The Actor never guesses these.

### Legal

Only public job postings published by the companies themselves through their applicant-tracking systems are accessed, with no authentication and at polite rates. Use the data in compliance with each provider's terms and applicable law. This Actor is not a consumer reporting agency; its output describes job openings, not people, and must not be used for employment, credit, insurance or tenant screening decisions.

# Actor input Schema

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

Company job boards to scrape, one per line. Use `provider:boardId` (greenhouse:stripe, lever:spotify, ashby:openai, smartrecruiters:Visa, workable:acme) or paste the public board URL (boards.greenhouse.io/<token>, job-boards.greenhouse.io/<token>, jobs.lever.co/<company>, jobs.ashbyhq.com/<org>, careers.smartrecruiters.com/<Company>, apply.workable.com/<subdomain>). The provider is detected from the URL.

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

`jobs` writes one normalized record per open job. `signals` compares each board with its previous snapshot (stored in the key-value store named below) and writes new-role, removed-role, title-changed and location-changed records plus one per-board summary. The first signals run for a board is a baseline and writes job records instead.

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

Add `descriptionText` (HTML converted to plain text, trimmed to 3,000 characters). Greenhouse, Lever and Ashby return descriptions in the same request; SmartRecruiters and Workable need one extra request per job, so large boards take longer.

## `maxJobsPerBoard` (type: `integer`):

Upper bound on job records (and snapshot size) per board. Boards that exceed it are marked `truncated` and removed-role signals are suppressed for them so a partial fetch never looks like mass closures.

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

Named key-value store that holds the previous snapshot of each board for signals mode. Use a different name per schedule or customer to keep baselines separate.

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

If set, the Actor POSTs one JSON payload at the end of the run with per-board summaries and counts. Delivery failures are logged, never fatal.

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

How many boards are fetched in parallel. Requests to the same provider host are additionally rate-limited to about 2 per second.

## Actor input object example

```json
{
  "boards": [
    "greenhouse:stripe",
    "lever:spotify"
  ],
  "mode": "jobs",
  "includeDescription": false,
  "maxJobsPerBoard": 50,
  "stateStoreName": "ats-hiring-signals-state",
  "maxConcurrency": 5
}
```

# Actor output Schema

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

One item per job posting or hiring signal in the default dataset (type, provider, boardId, title, department, location, remote, salary, applyUrl, postedAt; error items carry error and errorMessage). Fields are described in dataset_schema.json.

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

SUMMARY record in the run's default key-value store: boards processed, jobs and signals produced, charges and webhook delivery status, written when the run finishes.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "boards": [
        "greenhouse:stripe",
        "lever:spotify"
    ],
    "mode": "jobs",
    "includeDescription": false,
    "maxJobsPerBoard": 50
};

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

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

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

```

## Python example

```python
from apify_client import ApifyClient

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

# Prepare the Actor input
run_input = {
    "boards": [
        "greenhouse:stripe",
        "lever:spotify",
    ],
    "mode": "jobs",
    "includeDescription": False,
    "maxJobsPerBoard": 50,
}

# Run the Actor and wait for it to finish
run = client.actor("owlabee/ats-hiring-signals").call(run_input=run_input)

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

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

```

## CLI example

```bash
echo '{
  "boards": [
    "greenhouse:stripe",
    "lever:spotify"
  ],
  "mode": "jobs",
  "includeDescription": false,
  "maxJobsPerBoard": 50
}' |
apify call owlabee/ats-hiring-signals --silent --output-dataset

```

## MCP server setup

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

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

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/BdgjEVFQcT7yed9nv/builds/8K5EMwGtqPXtnhkFL/openapi.json
