# Greenhouse Jobs Scraper — Salary Ranges by Company (`mochiboo/greenhouse-jobs-salary-scraper`) Actor

Every open role from any company's Greenhouse job board, with the disclosed salary range parsed into payMin / payMax / currency / period. Pass company names, board tokens or board URLs. Departments, offices, remote flag, posting age. Re-run on a schedule. Company-level data only.

- **URL**: https://apify.com/mochiboo/greenhouse-jobs-salary-scraper.md
- **Developed by:** [mochi](https://apify.com/mochiboo) (community)
- **Categories:** Jobs, Automation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $30.00 / 1,000 results

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/platform/actors/running/actors-in-store#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

## Greenhouse Jobs Scraper — open roles + disclosed salary ranges

Pull every open role from any company's **Greenhouse** job board, with the **disclosed salary range parsed into numbers** — `payMin`, `payMax`, `payCurrency`, `payPeriod` — instead of buried in a paragraph of HTML.

Give it company names, Greenhouse board tokens, or board URLs. Re-run it on a schedule to watch a market's hiring and pay move.

```
Input:  ["Scale AI", "gitlab", "https://job-boards.greenhouse.io/figma"]
Output: one row per open posting, with the pay band already parsed
```

***

### What you get per posting

| Field | Example | Notes |
|---|---|---|
| `company` · `boardToken` | `Scale AI` · `scaleai` | Board name as Greenhouse reports it |
| `requestedAs` · `matchConfidence` | `Scale AI` · `exact` | How your input mapped to the board — see *Company matching* |
| `title` · `jobUrl` · `jobId` · `requisitionId` | `DevOps Engineer, Infrastructure & Security` | `requisitionId` is the employer's own req number where they publish it |
| `location` · `offices[]` · `isRemote` | `San Francisco, CA • New York, NY` · `["US"]` · `false` | `isRemote` is derived from the location text |
| `departments[]` · `department` | `["Sales"]` · `Sales` | `department` is the first of the list |
| `payMin` · `payMax` | `198400` · `311000` | Numbers, not strings |
| `payCurrency` · `payCurrencySymbol` | `USD` · `$` | ISO-4217 |
| `payPeriod` · `payPeriodInferred` | `year` | `hour` | `month` · `false` | See *How the period is decided* |
| `payIsOte` | `true` | The posting quoted On-Target Earnings, not base |
| `paySource` | `structured` | `prose` | Where the range was found |
| `payRangeRaw` | `$198,400 — $311,000 USD` | The exact source text, so every number is auditable |
| `payRangeCount` | `3` | More than one band quoted — see *Multi-band postings* |
| `firstPublished` · `updatedAt` | ISO timestamps | |
| `daysSinceFirstPublished` · `daysSinceUpdated` | `61` · `6` | The hiring-signal fields: how long a req has been open |
| `descriptionText` | full posting text | Only when `includeDescription` is on |

***

### Measured pay-range coverage — read this before you buy

**Most postings on most boards do not disclose pay.** Measured across **3,064 live postings on 10 boards**:

| Board | Postings with a parsed range |
|---|---|
| Duolingo | 67 / 67 — **100%** |
| Anthropic | 351 / 400 — 88% |
| Robinhood | 113 / 128 — 88% |
| Discord | 43 / 45 — 96% |
| Figma | 109 / 176 — 62% |
| Databricks | 426 / 803 — 53% |
| Datadog | 204 / 429 — 48% |
| GitLab | 72 / 184 — 39% |
| Cloudflare | 104 / 284 — 37% |
| Stripe | 19 / 548 — **3%** |
| **Total** | **1,508 / 3,064 — 49%** |

That spread is the employer's choice, not a parser limitation — US pay-transparency law only binds certain roles and locations, so a company can post hundreds of roles and disclose on a handful. Set `onlyWithPayRange` to keep just the rows that carry a number.

Currencies seen in that sample: USD 1,421 · EUR 34 · GBP 27 · CAD 21 · CNY 3 · JPY 2.

***

### Who this is for

- **Compensation benchmarking** — pull a peer set every month and see where your bands sit. `payMin`/`payMax`/`payPeriod` are already normalised, so it drops straight into a sheet.
- **Recruiting and talent intel** — what a competitor is paying, in which offices, for which levels.
- **Sales / GTM signal** — `daysSinceFirstPublished` and department mix tell you who is expanding which function. A req that has been open 200 days is a different signal from one opened last week.
- **Market research** — remote mix, office footprint, department growth over time.

The data changes constantly: postings open, close and get re-published. This is built to be **re-run on a schedule** — set `postedWithinDays` to pick up only what is new since your last run.

***

### Input

| Input | Default | What it does |
|---|---|---|
| `companies` | 6 sample boards | Company names, board tokens, or board URLs |
| `maxResults` | `20` | Total cap, **shared round-robin across the companies you list** so the last company is never starved |
| `onlyWithPayRange` | `false` | Keep only postings that disclose pay |
| `titleKeywords` | `[]` | Case-insensitive substring match on the title |
| `locationKeywords` | `[]` | e.g. `Remote`, `London` |
| `departments` | `[]` | e.g. `Engineering`, `Sales` |
| `postedWithinDays` | `0` | Only roles first published in the last N days. `0` = no filter |
| `includeDescription` | `false` | Adds `descriptionText`. Off by default — it multiplies dataset size ~20x |
| `allowUnverifiedMatches` | `false` | See below |

***

### Company matching, and why it can refuse

You can pass a plain company name. It is normalised to a candidate board token and looked up — but the board's own company name is then **checked against what you asked for**.

This exists because of a real trap. The company "Remote" maps to the board token `remote`, which belongs to **"General Assembly Remote Jobs"**. Returning another company's open roles as if they were yours would quietly corrupt a benchmark, so by default that input is **skipped and reported**:

```
! "Remote" -> board "remote" exists but is named "General Assembly Remote Jobs" — not confident it is "Remote"
```

Every row carries `matchConfidence`: `token` (you gave a token or URL), `exact`, or `fuzzy`. Set `allowUnverifiedMatches` to accept doubtful matches anyway.

**If a company comes back unresolved, the usual reason is that it is not on Greenhouse at all** — plenty of companies use a different ATS. Passing the board URL from their careers page always works.

***

### How the period is decided

`payPeriod` is read from the label the employer put next to the number — "Annual Base Salary Range", "Hourly Base Pay Range", "Gross Monthly Pay Range". Across the 1,508 ranges in the sample, **every one was resolved from the text**; `payPeriodInferred` was `false` on all of them. If a posting ever gives a number with no period wording at all, the field falls back to magnitude and `payPeriodInferred` is set to `true` so you can filter those rows out.

### Multi-band postings

Some employers publish a separate band per pay zone — Robinhood quotes three on many roles. `payMin`/`payMax` are the **first** band and `payRangeCount` tells you how many there were, so you can spot those rows rather than silently treating a three-zone posting as a single band. Turn on `includeDescription` to read the rest.

### What the parser refuses to do

It will not invent a salary. Each of these appears in real postings and each produced a bogus number until it was fixed:

- `500-5000 FTE` — a customer headcount band, not 500–5000 in a currency called "FTE"
- `1-7, BGP` — a routing protocol in a networking role
- `3 - 5 years of experience`
- `$231,000-$275,000 OTE` — a real range, but "OTE" is not a currency code

A range is only accepted if it carries a real currency symbol or a valid ISO-4217 code, and the parser scans the **whole** posting rather than stopping at the first number-shaped thing it sees.

***

### Source, limits and compliance

- **Source:** Greenhouse's documented **public Job Board API** — `https://boards-api.greenhouse.io/v1/boards/<token>/jobs`. No key, no login, no browser. `boards-api.greenhouse.io/robots.txt` restricts only `/embed/`.
- **Greenhouse boards only.** Lever and Ashby are deliberately not supported; each of them restricts automated access in a way we will not work around. If the company you want is not on Greenhouse, this actor will tell you so rather than return nothing silently.
- **Only open, published roles.** There is no history: a role that closed yesterday is gone. To build a time series, run on a schedule and keep your datasets.
- **Organisation-level data only.** Company, role, office, department, pay band. No applicant or employee identity is read or emitted — the endpoint exposes none.
- Every run prints a full tally: written · with a pay range · postings seen · filtered out · unresolved companies · board fetch failures. A short run always has a stated reason.

# Actor input Schema

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

Company names ("Scale AI"), Greenhouse board tokens ("figma"), or board URLs (https://job-boards.greenhouse.io/figma). Names are resolved to a board and the match is verified against the board's own company name, so you will not silently get another company's roles.

## `maxResults` (type: `integer`):

Stop after this many postings across all companies. Raise it for a full pull; a single large board can hold 500+ open roles.

## `onlyWithPayRange` (type: `boolean`):

Keep only postings where a salary range was found. Useful for compensation benchmarking. Note that most boards disclose pay on well under half their roles — see the README for measured coverage.

## `titleKeywords` (type: `array`):

Keep postings whose title contains any of these (case-insensitive substring). Empty = no title filter.

## `locationKeywords` (type: `array`):

Keep postings whose location contains any of these, e.g. "New York", "Remote", "London". Empty = no location filter.

## `departments` (type: `array`):

Keep postings in departments matching any of these, e.g. "Sales", "Engineering". Empty = no department filter.

## `postedWithinDays` (type: `integer`):

Keep only roles first published within this many days — this is the field to set when you re-run on a schedule and want new openings only. 0 = no age filter. Postings with no publish date fail this filter rather than passing it.

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

Add the full posting text as descriptionText. Off by default because it multiplies dataset size roughly 20x; the salary range is already extracted without it.

## `allowUnverifiedMatches` (type: `boolean`):

By default, if a guessed board token exists but its company name does not look like what you asked for, the company is skipped and reported rather than returning someone else's jobs. Turn this on to accept those matches anyway.

## Actor input object example

```json
{
  "companies": [
    "duolingo",
    "anthropic",
    "figma",
    "discord"
  ],
  "maxResults": 20,
  "onlyWithPayRange": false,
  "titleKeywords": [
    "engineer"
  ],
  "locationKeywords": [
    "Remote"
  ],
  "departments": [
    "Engineering"
  ],
  "postedWithinDays": 0,
  "includeDescription": false,
  "allowUnverifiedMatches": false
}
```

# 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": [
        "duolingo",
        "anthropic",
        "figma",
        "discord"
    ],
    "titleKeywords": [
        "engineer"
    ],
    "locationKeywords": [
        "Remote"
    ],
    "departments": [
        "Engineering"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("mochiboo/greenhouse-jobs-salary-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": [
        "duolingo",
        "anthropic",
        "figma",
        "discord",
    ],
    "titleKeywords": ["engineer"],
    "locationKeywords": ["Remote"],
    "departments": ["Engineering"],
}

# Run the Actor and wait for it to finish
run = client.actor("mochiboo/greenhouse-jobs-salary-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": [
    "duolingo",
    "anthropic",
    "figma",
    "discord"
  ],
  "titleKeywords": [
    "engineer"
  ],
  "locationKeywords": [
    "Remote"
  ],
  "departments": [
    "Engineering"
  ]
}' |
apify call mochiboo/greenhouse-jobs-salary-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,mochiboo/greenhouse-jobs-salary-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/2kdL8asTGg8lDVA3Z/builds/Jly0bXuTXSVV5CcUS/openapi.json
