# Hiring Signals Scraper - Company Job Openings from ATS Boards (`neverempty/hiring-signals-scraper`) Actor

For B2B sales and recruiting teams hunting companies that are growing: one row per company with open jobs, new roles in the last 7 and 30 days, roles by department and location, remote counts, and whether sales, engineering or executive seats are open. Stripe showed 613 open jobs on 2026-09-11.

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

## Pricing

from $7.30 / 1,000 company returneds

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?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

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

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

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

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

# README

## Hiring Signals Scraper - Company Job Openings from ATS Boards

**Built for B2B sales and recruiting teams who want to find the companies that are growing their teams this month**: give it a list of companies and get back **one row per company** telling you how hard it is hiring right now — open jobs, **new roles in the last 7 and 30 days**, roles by department, location and country, remote / hybrid / on-site counts, and whether it is hiring **sales, engineering or executive** roles. Up to **500** companies per run.

**One row, measured: Stripe on 2026-09-11** — `openPostings` **613**, `openRoles` **611** (the same job posted in two places counted once), `newRolesLast30Days` **227**, and sales / engineering / executive **116 / 147 / 14**.

**The easiest way to get a hiring signal wrong is to count jobs by their update date, and that is exactly what the boards invite you to do.** Greenhouse rewrites `updated_at` in bulk when a company re-saves its board: **613 of 613** Stripe jobs and **453 of 453** Datadog jobs showed as updated within 7 days, which would report both as hiring their whole team this week. This Actor uses `first_published` there, which goes back to 2023 at Stripe and 2019 at Datadog — and the one date each other system actually publishes (`createdAt` at Lever, where Palantir still has live postings created in December 2009; `publishedAt` at Ashby).

**And it counts roles, not postings.** Stripe's 613 postings are 611 roles; Datadog's 453 are 410; Airbnb's 167 are 164 — merged only on Greenhouse's own job ID plus an identical title, so two different jobs are never folded into one.

**A board that does not exist is not reported as a company that is not hiring.** Workable answers with an empty but live board for most common names: on 2026-09-11 it returned HTTP 200 with 0 jobs for **16 of 20** well-known companies. And Workday's job list stops counting at **2,000** — NVIDIA's board said `total: 2000` while Workday's own filters added up to **2,614** — so that ceiling is reported rather than passed off as the company's size.

**One charge per company row, whatever the number of jobs behind it.** A company with 600 open jobs costs the same as one with 3, and note rows are never charged. The data comes from each company's own public job board on **Greenhouse**, **Lever**, **Ashby**, **Workable** and **Workday** — the same boards the company's careers page shows. No login, no proxies, no API key. Export as JSON, CSV or Excel.

*Unofficial. This Actor is not affiliated with, endorsed by, or sponsored by Greenhouse, Lever, Ashby, Workable or Workday. All product names are trademarks of their respective owners.*

***

### What one row looks like

| Column | Example (Stripe, measured 2026-09-11) | What it means |
|---|---|---|
| `openPostings` | 613 | Job postings on the board right now |
| `openRoles` | 611 | Jobs after counting the same job posted in several places once (2 postings merged) |
| `newRolesLast7Days` | 64 | Jobs whose **first** publication was in the last 7 days |
| `newRolesLast30Days` | 227 | Same, last 30 days |
| `salesRoles` / `engineeringRoles` / `executiveRoles` | 116 / 147 / 14 | Open jobs in each group (definitions below) |
| `byDepartment` | `[{"name": "...", "roles": 20}, ...]` | Jobs per department, largest first |
| `byLocation` | `[{"name": "Singapore", "roles": 43}, ...]` | Jobs per location |
| `topTitles` | `[{"title": "Software Engineer, New Grad", "roles": 6}, ...]` | The most common job titles |
| `newestRoles` | title, date, location and link of the 5 newest jobs | |
| `boardUrl` | `https://job-boards.greenhouse.io/stripe` | The public list of jobs |

### New means first published, never last updated

The easiest way to get hiring signals wrong is to count jobs by their *update* date. Greenhouse's board API has two dates on every job, `first_published` and `updated_at`, and companies re-save their boards in bulk: on 2026-09-11 **all 613** of Stripe's jobs had an `updated_at` in the last 7 days, and so did **all 453** of Datadog's. Counted by `updated_at`, every job at those companies looks new. Counted by `first_published`, Stripe had **64** jobs first published that week.

This Actor uses exactly one date per system and names it in the `newRolesDateField` column:

| System | Date used | Why this one (measured 2026-09-11) |
|---|---|---|
| Greenhouse | `first_published` | `updated_at` is rewritten in bulk (613 of 613 Stripe jobs, 453 of 453 Datadog jobs updated within 7 days). `first_published` goes back to 2023 at Stripe and 2019 at Datadog. |
| Lever | `createdAt` | The only date Lever publishes. It is not moved by edits: Palantir still has live postings created in December 2009. |
| Ashby | `publishedAt` | The only date Ashby publishes; OpenAI's oldest live posting shows 2023-05-25. Ashby does not say whether re-publishing a job resets it. |
| Workable | `created_at` | Workable also has `published_on`, which is later than `created_at` for 14 of Blueground's 21 jobs (up to about ten months later), so it can move when a job is published again. `created_at` never does. |
| Workday | `postedOn` | Workday only shows a relative "Posted 3 Days Ago" (and "Posted 30+ Days Ago" after a month). Days are counted from that text; "30+" is never counted as new. Workday does not say whether reposting resets it. |

A job posted in several places counts as new only when its **earliest** posting is new, so adding a second city to an old job does not make it look like fresh hiring. If any job on a board has no date, the new-role counts stay empty and `newRolesNote` says why - they are never counted from part of the board.

### A job posted in five cities is counted once

Many companies post the same job once per location. Counting postings overstates hiring, so `openRoles` merges postings under these rules - and only these:

| System | Merged when | What can still differ between merged postings | Measured 2026-09-11 |
|---|---|---|---|
| Greenhouse | The postings share Greenhouse's own job ID (`internal_job_id`) **and** have the same title (ignoring case and spaces) | Location | Stripe 613 postings = 611 roles; Datadog 453 = 410; Airbnb 167 = 164. Postings under one job ID with **different titles stay separate roles**: Datadog lists seven different "Manager I, Engineering - …" titles under one ID, and Stripe posted two differently titled Account Executive roles under one ID on 11 June and 25 August. An English and a French posting of the same Airbnb job stay two roles, because their titles differ |
| Lever, Ashby | Title, department, team **and the full description text** (including requirement lists) are identical, ignoring whitespace | Location, **and also** workplace type and pay: of Plaid's 7 merged groups, 6 mix hybrid and on-site postings and 4 show different pay; of OpenAI's 6, 4 and 3 | Palantir: 84 of 310 postings merged (226 roles); Plaid (Ashby) 12 of 99; OpenAI (Ashby) 7 of 784 |
| Workable | Title, department and full description identical | Location | Blueground: none |
| Workday | Never - every Workday posting carries its own requisition number | - | |

A merged role takes its date, workplace type and title from its **earliest** posting, and is counted in every location it is posted in. Same title with a different description stays two roles (Spotify has several same-titled jobs with different descriptions; none are merged). The number merged is in `duplicatePostingsCollapsed`, and `rolesCountedBy` names the rule used.

### Not hiring, not found, and not readable are different rows

| `rowType` | `ok` | Meaning | Charged? |
|---|---|---|---|
| `company` | `true` | The board was read and lists at least one open job | **Yes** |
| `no-open-roles` | `true` | You named the board (`lever:acme` or its URL) and it is live with 0 open jobs | No |
| `name-only-empty-boards` | `false` | You gave only a name, and the only boards with that name are empty - see below | No |
| `ambiguous-name` | `false` | You gave only a name and more than one system has a board with open jobs under it. Both are listed; pick one | No |
| `board-not-found` | `false` | No board with that name exists (HTTP 404) | No |
| `duplicate-input` | `false` | This entry points at a board an earlier entry already asked for - another spelling, capitalisation, URL form or trailing slash of it, or a name that resolved to it. The board is returned and charged once; every repeat gets this row, so no entry disappears | No |
| `not-queried-time-limit` | `false` | The run was about to reach its time limit, so this company was not fetched (see Run time below) | No |
| `unavailable` | `false` | The board could not be read after retries; `note` says why | No |
| `bad-input` | `false` | The entry is not a board name or board URL (a company website such as `stripe.com` gets a note pointing to [ATS Job Board Finder](https://apify.com/neverempty/ats-board-finder)) | No |
| `budget-reached` | `false` | The run hit the **Max total charge** you set; every company not fetched gets this row | No |

**Why a name alone is checked on all four systems.** Workable answers an empty, live board for most common names: on 2026-09-11 it returned HTTP 200 with 0 jobs for **16 of 20** well-known companies (stripe, ramp, openai, figma, notion …) whose real boards are on other systems. A tool that stops at the first board it finds would report those companies as "not hiring". This Actor checks Greenhouse, Lever, Ashby and Workable, uses the one board that has jobs, and never turns an empty board found by name alone into "not hiring". A row found by name alone says so in `lookup` (`name-only`), lists the systems that only had an empty board under that name in `otherAtsEmptyBoards`, and lists any system that could not be read in `otherAtsUnreadable` - in that case another company's board with the same name there could not be ruled out, and `notes` says so.

### Workday's 2,000 limit

Workday's job list stops counting at 2,000: NVIDIA's board said `total: 2000` while Workday's own Time Type filter added up to **2,614** jobs, and asking for postings beyond number 2,000 returns the same jobs again (4,000 postings read, 2,000 distinct). So for boards at the limit this Actor takes the count from the Time Type filter (every job has exactly one value) and marks it in `openPostingsExact`. Departments, countries and remote counts for Workday come from Workday's own filters, which cover the whole board. **Some of those filters put one job under several values**, so their counts can add up to more than the number of jobs - Red Hat's Job Function filter adds up to 151 for 141 postings and its Country filter to 150. When that happens the row says so in `notes`; read those breakdowns as "jobs tagged with this value", not as shares that sum to the total.

Workday returns 20 postings per request (about 1.4 seconds each), so up to `workdayMaxPostings` are read per company. When a board is larger than that, the counts that need every posting - new roles, sales / engineering / executive roles, keyword matches - are left **empty** with a note, instead of being counted from part of the board. `allPostingsRead` tells you which case you are in.

### Role groups

A job counts in a group when its title matches, or its department does (a job can be in two groups, e.g. "Sales Engineer"):

| Group | Title contains | Or department contains |
|---|---|---|
| Sales | sales, account executive, account manager, account director, business development, BDR, SDR | sales |
| Engineering | engineer, engineering, developer, software, SRE, DevOps, programmer | engineering |
| Executive | chief … officer, CEO, CFO, CTO, COO, CMO, CIO, CISO, CHRO, CRO, president, vice president, VP, SVP, EVP, head of, general manager, managing director - but **not** assistant, chief of staff, coordinator, intern, "office of the", "to the CEO" | - |

"Account Executive" is a sales job, not an executive one. `executiveTitles` lists the matching titles so you can check.

When a board's department field is useless - Plaid's Ashby board puts **"All Departments"** on all 99 jobs, and Palantir's Lever board leaves it empty - the team field is used instead, and `departmentSource` says `ats-team-field`.

### What each system publishes

| | Greenhouse | Lever | Ashby | Workable | Workday |
|---|---|---|---|---|---|
| First-published date | yes | yes | yes | yes | relative text |
| Department | yes (extra request) | yes | yes | sometimes | from filters |
| Remote / hybrid / on-site | **no** | yes | yes | remote or not | if the board has the filter |
| Country | no | yes | yes | yes | if the board has the filter |
| Company name | yes | no | no | yes | no |

Greenhouse's public API does not say whether a job is remote, so the remote columns stay empty for Greenhouse boards; `remoteHintRoles` counts jobs whose location text mentions remote, kept separate so a guess never passes as a fact.

***

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `companies` | array | Stripe, OpenAI, Spotify, Blueground, Red Hat (used only when the list is empty) | One entry per company, up to 500: `"<ats>:<board name>"`, the job board URL, or just the board name |
| `roleKeywords` | array | *(none)* | Words to count in job titles, e.g. `SDR`, `Salesforce`. Each gets its own count in `keywordMatches` |
| `workdayMaxPostings` | integer | `1000` | Postings read per Workday board (20 to 2,000) |
| `maxRetries` | integer | `3` | Total attempts per board for temporary errors (HTTP 429 / 5xx). A 404 is not retried |

#### Ways to name a company

```jsonc
{
  "companies": [
    "greenhouse:stripe",                              // <system>:<board name>
    "https://jobs.lever.co/spotify",                  // the job board URL
    "https://redhat.wd5.myworkdayjobs.com/en-US/jobs",// Workday needs its board URL
    "plaid"                                           // name only: Greenhouse, Lever, Ashby and Workable are checked
  ],
  "roleKeywords": ["SDR", "Account Executive"]
}
```

The board name is the part after the system's domain: `stripe` in `job-boards.greenhouse.io/stripe`. Don't know it? [ATS Job Board Finder](https://apify.com/neverempty/ats-board-finder) turns a company website into its board name.

### Output columns

`rowType`, `ok`, `note`, `input`, `ats`, `boardSlug`, `companyName`, `boardUrl`, `lookup`, `otherAtsUnreadable`, `otherAtsEmptyBoards`, `openPostings`, `openPostingsExact`, `openRoles`, `duplicatePostingsCollapsed`, `rolesCountedBy`, `newRolesLast7Days`, `newRolesLast30Days`, `newRolesDateField`, `newRolesNote`, `rolesWithoutPostedDate`, `salesRoles`, `engineeringRoles`, `executiveRoles`, `hasSalesRoles`, `hasEngineeringRoles`, `hasExecutiveRoles`, `executiveTitles`, `remoteRoles`, `hybridRoles`, `onsiteRoles`, `remoteUnknownRoles`, `remoteSource`, `remoteHintRoles`, `byDepartment`, `departmentSource`, `byLocation`, `byCountry`, `countrySource`, `topTitles`, `newestRoles`, `keywordMatches`, `postingsRead`, `allPostingsRead`, `notes`, `source`, `checkedAt`

Every row - including the note rows - has all 47 columns. A value the board does not publish is `null`, never `0` or `false`: `hasExecutiveRoles` is only `false` when every posting was read and none matched.

### Pricing

One charge per `company` row, whatever the number of jobs behind it. A company with 600 open jobs costs the same as one with 3. Note rows are never charged.

### Measured, not estimated

- Every count on this page was taken from the live boards on 2026-09-11 and is kept in the test suite, which recounts them from the raw board responses in a second, independent way.
- Test suite: 78 checks. 65 deliberate defects injected into the source (counting by update date, merging different titles under one Greenhouse job ID, trusting Workday's 2,000, charging an empty board, charging the same board twice, dropping a repeated input without a row, ignoring the time limit …) - every one is caught. Paging, retries, failures part-way and the charge limit are all exercised through the real reader with recorded responses.

### Typical uses

- **Sales prospecting** - which of my 500 target accounts opened the most new roles this month, and which are hiring their first sales team?
- **Account-based marketing** - companies hiring a VP or Head of a function you sell to.
- **Recruiting and RPO** - which companies are scaling engineering right now.
- **Market research** - hiring by department and country across a sector.

### Run time

Measured on the Apify platform on 2026-09-11: Greenhouse, Lever, Ashby and Workable take about 1 to 3 seconds per company plus a one-second pause between companies (the five default companies took 17 seconds; six-company runs took 8 to 11 seconds). Workday takes about 1.4 seconds per 20 postings: three large Workday boards at 1,000 postings each took 88 seconds, and NVIDIA at 2,000 took 94 seconds.

Before each company the Actor checks how much of the run's time limit is left. If the next company could not be finished in time (it allows 20 seconds for an ATS board, 30 for a name that has to be checked on four systems, and about 1.6 seconds per 20 Workday postings you asked for), it stops and writes a free `not-queried-time-limit` row for that company and every one after it, so a long list never ends with companies silently missing. Raise the run timeout, or split the list, for long runs with many large Workday boards.

### Notes and limits

- Only public job boards are read, from each system's public job board API (Workday: the JSON its careers site loads). No login and no private data.
- One company per second at most (Lever's robots.txt asks for a one-second crawl delay).
- Postings are counted as the board lists them; a board does not say how many people a job is for.
- Up to 500 companies per run.

### Other tools by NeverEmpty

- **[ats-jobs-api](https://apify.com/neverempty/ats-jobs-api)** - every open job as its own row, from the same five systems
- **[ats-board-finder](https://apify.com/neverempty/ats-board-finder)** - a company website to the job board name this Actor takes

### Support

Found a board that is counted wrongly, or want another system supported? Open an issue on the **Issues** tab and include the board name - it will be checked against the live board before anything is changed.

# Actor input Schema

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

One entry per company. Use "<ats>:<board name>" (greenhouse:stripe, lever:spotify, ashby:openai, workable:blueground), paste the company's job board URL (for example https://jobs.lever.co/spotify or https://redhat.wd5.myworkdayjobs.com/jobs), or give just the board name to check Greenhouse, Lever, Ashby and Workable. Workday can only be found from its board URL. Each company returns one row; a board that does not exist, is empty or cannot be read returns a note row that is not charged. Up to 500 per run.

## `roleKeywords` (type: `array`):

Optional words to count in job titles, for example SDR, Salesforce or Data. Each word gets its own count of matching roles in the keywordMatches column (case-insensitive). Up to 20.

## `workdayMaxPostings` (type: `integer`):

Workday returns 20 postings per request, about 1.4 seconds each. Up to this many are read per Workday board (Workday itself stops listing after 2,000). Companies larger than this still get their total, department and remote counts from Workday's own filters, but title-based counts are left empty rather than counted from part of the board.

## `maxRetries` (type: `integer`):

How many times to try a board in total, including the first attempt, when the job board answers with a temporary error (HTTP 429 or 5xx). A 404 means the board does not exist and is not retried.

## Actor input object example

```json
{
  "companies": [
    "greenhouse:stripe",
    "ashby:openai",
    "lever:spotify",
    "workable:blueground",
    "https://redhat.wd5.myworkdayjobs.com/jobs"
  ],
  "roleKeywords": [],
  "workdayMaxPostings": 1000,
  "maxRetries": 3
}
```

# Actor output Schema

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

One row per company with its hiring signals, plus one note row when a board does not exist, is empty, is ambiguous or could not be read.

# 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:stripe",
        "ashby:openai",
        "lever:spotify",
        "workable:blueground",
        "https://redhat.wd5.myworkdayjobs.com/jobs"
    ],
    "roleKeywords": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/hiring-signals-scraper").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:stripe",
        "ashby:openai",
        "lever:spotify",
        "workable:blueground",
        "https://redhat.wd5.myworkdayjobs.com/jobs",
    ],
    "roleKeywords": [],
}

# Run the Actor and wait for it to finish
run = client.actor("neverempty/hiring-signals-scraper").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:stripe",
    "ashby:openai",
    "lever:spotify",
    "workable:blueground",
    "https://redhat.wd5.myworkdayjobs.com/jobs"
  ],
  "roleKeywords": []
}' |
apify call neverempty/hiring-signals-scraper --silent --output-dataset

```

## MCP server setup

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

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/zIsUHB1l2DdngKH3J/builds/l62g6rvtai1zfMo9t/openapi.json
