# HiringSignal — Hiring & Buying Intent Signals (`roboot/hiringsignal-ai`) Actor

Track hiring changes across companies and turn them into evidence-backed B2B buying signals. Detect team expansion, executive hiring, hiring surges and slowdowns across Greenhouse, Lever, Ashby and Workday.

- **URL**: https://apify.com/roboot/hiringsignal-ai.md
- **Developed by:** [Daniel Coldibeli](https://apify.com/roboot) (community)
- **Categories:** Lead generation, Jobs
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.00 / 1,000 company comparisons

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

## HiringSignal — Turn Hiring Changes Into B2B Buying Signals

**Don't just scrape jobs. Monitor what changed.**

Track companies over time and discover meaningful hiring changes — team expansion, executive hiring, hiring surges and slowdowns — with evidence from the original job postings.

**Give HiringSignal a list of company domains. The first run establishes a free baseline. Run them again later and get the changes that matter.**

**Companies → Free baseline → Compare later → Hiring Signals → Buying Intent**

### Why HiringSignal?

Most job scrapers tell you what jobs exist right now.

HiringSignal tells you **what changed since the last run** — and turns those changes into structured signals you can use for prospecting, account research, recruiting intelligence, market monitoring, and automated workflows.

- **Team and function expansion** — security, data, AI, sales, and engineering
- **Executive hiring** — new leadership roles opening at a company
- **Hiring surges** — overall posting volume jumping sharply
- **Confirmed hiring slowdowns** — closures jumping sharply, not just a quiet week
- **Rule-based buying-intent categories** — a plausible purchase need implied by the hiring pattern
- **Evidence linking every signal back to the underlying postings** — no black box, nothing to take on faith

### Example signal

*Illustrative example for explanation only — not a claim about a real company.*

- **Company:** `example.com`
- **Change:** 4 new jobs, 1 confirmed closed
- **Signal:** `SECURITY_TEAM_EXPANSION`
- **Buying intent:** `SECURITY_TOOLING_NEED`
- **Evidence:** "Senior Security Engineer"; "Application Security Lead"

### Who it's for

- **Sales intelligence and prospecting**: see which accounts are building a security, data, AI, or sales team right now.
- **Recruiting and talent intelligence**: track hiring activity by function at target or competitor companies.
- **Market research and competitive intelligence**: follow expansion, executive hires, and slowdowns across a watchlist.
- **Agents and automated workflows**: consistent, typed records with clear statuses that can be consumed through the Apify API or MCP.

### How it works

1. **Resolve.** Each company domain is resolved to its public careers page, and the applicant tracking system (ATS) behind it is identified.
2. **Collect.** The current job postings are collected from that ATS and normalized to one shape.
3. **Compare.** The collection is compared with the company's previous one: which postings are new, still open, or confirmed closed.
4. **Signal.** Confirmed changes become deterministic Hiring Signals, and selected signals become Buying Intent events.

**The first run for a company establishes its baseline.** There is nothing to compare against yet, so it produces no signals. That is expected, and it is free. From the second run on, every run reports what changed.

### Quick start

1. Enter one or more company domains under **Company domains** (for example `stripe.com`), up to 50 per run.
2. Click **Start**. Each new company shows `baseline_established` with the message *"Baseline established — monitoring started. Run this company again later to detect hiring changes."*
3. Run it again later with the same domains, or set up an Apify **Schedule** (daily or weekly works well for most watchlists). Now each company shows `compared`, with what changed and any signals detected.
4. Open the **Output** tab to see one row per company. Switch to the **Hiring signals** or **Buying intent** view to see one row per signal or event, with evidence.

### What you get

#### Company monitoring results (Output tab)

One record per company per run:

| Field | Meaning |
|---|---|
| `companyDomain` | The monitored company |
| `status` | `baseline_established`, `compared`, `rebaselined`, `suspicious_zero`, `collection_failed`, or `processing_error` |
| `message` | Plain-language explanation of the result |
| `atsProvider` | Where the postings came from (greenhouse, lever, ashby, workday, generic) |
| `previousTotal`, `currentTotal`, `newJobsCount`, `closedJobsCount`, `netChange` | What changed since the previous run (set when `status` is `compared`). A posting is reported in `closedJobsCount` once, in the run where it's first confirmed closed — never again in later runs. |
| `truncated` | True when this company's postings hit **Max jobs per company** — the real total may be higher than collected. While true, `newJobsCount`/`closedJobsCount` are reported as 0 instead of risking a false signal from an incomplete window; see `maxJobsPerCompany` under [Input](#input). |
| `signals` | Hiring Signals, each with strength, confidence, metrics, and evidence |
| `buyingIntents` | Buying Intent events, each with an explanation and evidence |
| `billable` | Whether this company counted as a billable event in this run |

The Output tab has three views: **Company overview**, **Hiring signals** (one row per signal), and **Buying intent** (one row per event).

Illustrative record (the structure matches real output; the values are made up):

```json
{
  "companyDomain": "example.com",
  "status": "compared",
  "message": "Compared with the previous run: 4 new, 1 closed, net +3 open postings. 1 hiring signal and 1 buying-intent event detected.",
  "atsProvider": "greenhouse",
  "previousTotal": 38,
  "currentTotal": 41,
  "newJobsCount": 4,
  "closedJobsCount": 1,
  "netChange": 3,
  "truncated": false,
  "signalsCount": 1,
  "signals": [
    {
      "signalType": "SECURITY_TEAM_EXPANSION",
      "strength": "medium",
      "confidence": 0.8,
      "evidence": [
        { "jobId": "…", "title": "Senior Security Engineer", "reason": "SECURITY_TEAM_EXPANSION: title/description matched \"security engineer\" (SECURITY, via title)" },
        { "jobId": "…", "title": "Application Security Lead", "reason": "SECURITY_TEAM_EXPANSION: title/description matched \"application security\" (SECURITY, via title)" }
      ],
      "metrics": { "previousActiveJobs": 38, "currentActiveJobs": 41, "newJobsCount": 4, "closedJobsCount": 1, "categoryNewJobsCount": 2 }
    }
  ],
  "buyingIntentsCount": 1,
  "buyingIntents": [
    {
      "category": "SECURITY_TOOLING_NEED",
      "strength": "medium",
      "confidence": 0.8,
      "sourceSignalTypes": ["SECURITY_TEAM_EXPANSION"],
      "explanation": "SECURITY_TOOLING_NEED inferred from SECURITY_TEAM_EXPANSION (2 new SECURITY-tagged job(s))."
    }
  ],
  "billable": true
}
```

#### Raw job postings

Every normalized posting collected in the run is in the run's **jobs** dataset: title, department or team, location, remote and employment type, salary when the source exposes it, description (optional), apply URL, and ATS. Fields a source doesn't expose are left out, never guessed.

#### History across runs

Three named datasets in your account keep the full history across runs: `hiring-signals`, `buying-intent-events`, and `temporal-summaries` (one change record per company per run). For API consumers, each run also writes `SIGNALS`, `BUYING-INTENTS`, and summary records to its key-value store.

### Hiring Signals

| Signal | Meaning |
|---|---|
| `AI_TEAM_EXPANSION` | New AI/ML roles opened, past a threshold |
| `SECURITY_TEAM_EXPANSION` | New security roles opened |
| `DATA_TEAM_EXPANSION` | New data roles opened |
| `SALES_TEAM_EXPANSION` | New sales/GTM roles opened |
| `ENGINEERING_EXPANSION` | New engineering roles opened |
| `EXECUTIVE_HIRING` | New executive-level roles opened |
| `HIRING_SURGE` | Overall new-posting volume jumped sharply |
| `HIRING_SLOWDOWN` | Confirmed closures jumped sharply |

Signals are deterministic and rule-based. Roles are classified by function using keyword and phrase rules, not a black-box model, so every signal can be traced to its postings.

### Buying Intent

| Hiring Signal | Buying Intent category |
|---|---|
| `AI_TEAM_EXPANSION` | `AI_ML_TOOLING_NEED` |
| `SECURITY_TEAM_EXPANSION` | `SECURITY_TOOLING_NEED` |
| `DATA_TEAM_EXPANSION` | `DATA_INFRASTRUCTURE_NEED` |
| `SALES_TEAM_EXPANSION` | `SALES_GTM_TOOLING_NEED` |

A Buying Intent event is a rule-based inference: a plausible category of purchase need implied by the hiring pattern. `EXECUTIVE_HIRING` and `HIRING_SURGE` can appear as corroborating context but never raise an event's strength or confidence. HiringSignal does not score, rank, or prioritize companies. It gives you the evidence, and the judgment stays with you.

### Pricing

HiringSignal uses pay-per-event pricing with one billable event, **Company comparison** (`company-monitored`): you're charged once for each company comparison it completes against that company's previous run. The current price is shown in this Actor's pricing section.

You are **not** charged for:

- a company's **first baseline** (`baseline_established`);
- a **re-baseline** after the comparison basis changes — the detected job source, or the job-collection window (`rebaselined`);
- a collection that failed or looked unreliable (`collection_failed`, `suspicious_zero`); the previous state is kept unchanged;
- a company whose results could not be fully saved (`processing_error`, or `compared` with `billable: false`).

Each record's `billable` field shows exactly which companies were charged in a run. If you set a maximum cost per run, HiringSignal stops before starting a company that would exceed it, and the run log says how many were skipped.

### Input

| Field | Default | Description |
|---|---|---|
| `companyDomains` (required) | — | Domains or URLs to monitor, normalized to bare hostnames and deduplicated. Up to 50 per run. An entry that isn't a usable domain is skipped with a warning in the run log — the rest of the batch still runs. If none of the entries are usable, the run fails (nothing to process). |
| `maxJobsPerCompany` | 100 | Maximum postings collected per company (hard cap 500). Comparisons (new/closed postings, and the signals built from them) are computed only on this collected window, not the company's true total — a company with more open postings than this cap is marked `truncated` (see [Company monitoring results](#company-monitoring-results-output-tab)) and `newJobsCount`/`closedJobsCount` are reported as 0 rather than risking a false signal from a window that doesn't cover every posting. Raising or lowering this value for an already-truncated company triggers one free re-baseline instead of reporting a false mass of new or closed postings. |
| `includeDescription` | true | Include each posting's full description in the jobs dataset. |
| `closeConfirmationRuns` (advanced, hidden from the form) | 2 | Consecutive runs a posting must be missing before it counts as closed. This protects against a single failed collection looking like a mass closure. The default is the protective setting for almost every use case; still available to API callers. Hard cap 10. |
| `validationMode` (advanced, hidden from the form) | false | Adds a detailed report to the log and the `VALIDATION-REPORT` record, showing which job source was detected for each company. Still available to API callers; for normal use, the default dataset's `atsProvider`, `careersUrl`, `status`, and `message` columns already cover this. |

### Supported job sources

- **Greenhouse**, **Lever**, **Ashby**: public job board APIs. Ashby also covers a company-hosted proxy pattern, observed working in limited validation.
- **Workday**: the public Candidate Experience Site API for careers sites on `*.myworkdayjobs.com`. Verified against real tenants, though accuracy has not yet been measured as broadly as for Greenhouse, Lever, and Ashby.
- **Generic fallback**: best-effort extraction of job links from other careers pages' HTML.

Coverage depends on how each company publishes its jobs. Companies on Greenhouse, Lever, Ashby, or Workday work best. A careers page that only renders jobs with client-side JavaScript, with no static HTML or public API behind it, may return few or no jobs. No domain is guaranteed to produce jobs or signals.

### Limitations

- **The first run for a company never produces signals.** That is the baseline, and it is free.
- **Generic-fallback job identity is URL-based.** If a careers site reissues a posting under a new URL, it can look like one job closing and another opening. Treat new/closed activity from generic-fallback companies with that in mind. Greenhouse, Lever, Ashby, and Workday use the source's own stable IDs and aren't affected.
- **The careers-page resolver** looks at the homepage's links and common careers paths. It can miss a careers page reachable only through JavaScript navigation or an unusual URL.
- **Posting dates are a proxy.** Some sources only expose a "last updated" time.
- **Country is included only when the source provides it as a field.** It is never guessed from a location string.
- **Classification is keyword/phrase-based, not NLP.** A vague title and description produce no category rather than a guess.
- **Companies are processed one at a time**, to stay polite to the sites being read. A full 50-company run takes longer than a small one. The default timeout is one hour at 1 GB of memory.
- **History datasets grow over time.** The named history datasets are never pruned by this Actor. Their retention follows your Apify account's storage settings, so export or clear old data as needed.

### Security & compliance

- Only public, unauthenticated pages and public ATS API endpoints are read, the same data any visitor to a careers page can see.
- No logins, session cookies, CAPTCHA solving, or anti-bot evasion.
- No data about job applicants is collected, and no application is ever submitted.
- Runs with Apify's limited permissions.

### Troubleshooting

- **All companies show `baseline_established`.** That's the first run. Run again later to get comparisons.
- **A company shows `collection_failed` or has 0 jobs.** Its careers page may not be on a supported ATS, or may render jobs only with JavaScript. The default output's `atsProvider`, `careersUrl`, and `message` columns show what was detected for each company; API callers can also pass `validationMode: true` for an additional detailed report in the run log and under the `VALIDATION-REPORT` key-value record.
- **`compared` but no signals.** Nothing changed enough to qualify as a signal since the previous run. The message says so explicitly.
- **`compared` but `newJobsCount`/`closedJobsCount` are always 0.** Check `truncated` — if true, this company has more postings than `maxJobsPerCompany`, so change detection is suppressed rather than risking a false signal. Raise `maxJobsPerCompany` (see [Input](#input)) if you need full coverage for this company.
- **"must contain at most 50 entries."** Split your list across several runs or Schedules.
- **One company didn't run, but the rest did.** A domain that isn't a usable domain is skipped with a warning in the run log; every other domain in the same input is still processed.

# Actor input Schema

## `companyDomains` (type: `array`):

Company domains to monitor, e.g. "stripe.com". Bare domains or full URLs are both accepted and normalized to a bare hostname. Duplicates are ignored. Up to 50 domains per run. Run again later with the same domains (or on an Apify Schedule) to detect changes.

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

Maximum number of job postings to collect per company (hard limit: 500).

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

Whether to include each job posting's full description text in the output.

## `validationMode` (type: `boolean`):

Advanced/API field, hidden from the form. When enabled, prints an additional detailed report to the run log and stores it under the 'VALIDATION-REPORT' key in the Key-Value Store — shows which job board system (Greenhouse, Lever, Ashby, Workday, or generic) was detected for each company. The default dataset's atsProvider/careersUrl/status/message columns already cover this for normal use.

## `closeConfirmationRuns` (type: `integer`):

Advanced/API field, hidden from the form. How many consecutive runs a job posting must be missing from before it's marked as closed, rather than just temporarily missing. Protects against a single failed collection being misread as a real closure. The default (2) is the protective setting for almost every use case. Hard-capped at 10.

## `diagnosticDomain` (type: `string`):

Internal diagnostic field, not needed for normal use. When set, the Actor runs an isolated, read-only discovery diagnostic for this one domain only and skips the production pipeline entirely (no state, no diff, no signals, no buying-intent, no dataset writes). Result is stored under the 'RAW-DIAGNOSTIC' key. 'Company domains' above is still required by this schema but is ignored in this mode.

## Actor input object example

```json
{
  "companyDomains": [
    "stripe.com"
  ],
  "maxJobsPerCompany": 100,
  "includeDescription": true,
  "validationMode": false,
  "closeConfirmationRuns": 2
}
```

# Actor output Schema

## `companyResults` (type: `string`):

One record per company: status (baseline_established, compared, rebaselined, suspicious_zero, collection_failed, processing_error), change counts, hiring signals, buying-intent events with evidence, and whether the company was billable.

## `jobs` (type: `string`):

Every normalized job posting collected in this run.

# 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 = {
    "companyDomains": [
        "stripe.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("roboot/hiringsignal-ai").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 = { "companyDomains": ["stripe.com"] }

# Run the Actor and wait for it to finish
run = client.actor("roboot/hiringsignal-ai").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 '{
  "companyDomains": [
    "stripe.com"
  ]
}' |
apify call roboot/hiringsignal-ai --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,roboot/hiringsignal-ai"
        }
    }
}
```

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/IRvWaygAkM5YmKVl8/builds/LR00gE9nJMbJygTO7/openapi.json
