# Salary Data from Job Postings - Greenhouse, Lever, Ashby (`mibedk/ats-pay-range-benchmark`) Actor

Reads open postings on the public Greenhouse, Ashby and Lever job boards you name, extracts employer-published pay ranges, normalizes them (min/max/currency/interval, role family, seniority) and rolls them up into median, p25 and p75 salary benchmarks.

- **URL**: https://apify.com/mibedk/ats-pay-range-benchmark.md
- **Developed by:** [Mikkel Bech-Hansen](https://apify.com/mibedk) (community)
- **Categories:** Jobs, Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $5.00 / 1,000 pay records

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

## Salary Data from Job Postings - Greenhouse, Lever, Ashby

Give this actor a list of company job-board slugs and it reads every open posting on their public Greenhouse, Ashby and Lever boards, pulls out the pay range the employer published on the posting itself, and returns one normalized record per job: minimum, maximum, currency, pay interval, role family, seniority, location and whether equity is offered. It also rolls those records up into benchmark rows — median, 25th and 75th percentile — grouped by role family, seniority, location and currency. The numbers are what the employer wrote on its own job ad under pay-transparency law, not crowd-sourced estimates and not a model's guess.

Most job-board scrapers hand you the raw posting and leave you to pull the salary out of the HTML yourself. This one does that part: it reads Greenhouse's structured `pay-range` block, Ashby's typed compensation object, Lever's `salaryRange` field, and falls back to parsing the range out of the description text for employers who write it in prose. It needs no login, no API key and no proxy.

### What it is good for

- Compensation benchmarking against a named peer set, sourced from postings you can click through and verify.
- Tracking how a competitor's published bands move over time (re-run on a schedule and diff).
- Measuring pay-transparency coverage — what share of a company's open roles actually disclose a range.
- Salary data for a market report, with a citable source for every number.

### Input

Every field has a working default. Running it with no configuration at all scans ten well-known boards and returns 300 records.

| Field | Type | Default | Notes |
|---|---|---|---|
| `greenhouseBoards` | array of strings | `["anthropic", "databricks", "datadog", "gitlab", "cloudflare"]` | Slug is the last path segment of the board URL: `job-boards.greenhouse.io/anthropic` → `anthropic`. A full URL is accepted and reduced to its slug. |
| `ashbyBoards` | array of strings | `["openai", "ramp", "sierra", "harvey", "notion"]` | Slug from `jobs.ashbyhq.com/openai` → `openai`. |
| `leverBoards` | array of strings | `[]` | `jobs.lever.co/leverdemo` → `leverdemo`. Off by default — see limitations. |
| `onlyWithPayRange` | boolean | `true` | When `false`, postings with no published pay are returned too (and are never charged for). |
| `maxPayRecords` | integer | `300` | Hard cap on records, and therefore on cost. 1–200000. |
| `maxPostingsPerBoard` | integer | `0` (no limit) | Stops one very large board from consuming the whole record budget. |
| `roleFamilies` | array (select) | `[]` (all) | `engineering`, `data-and-ml`, `product`, `design`, `sales`, `marketing`, `customer-success`, `operations`, `finance`, `legal`, `people-and-hr`, `security`, `research`, `other`. |
| `seniorities` | array (select) | `[]` (all) | `intern`, `entry`, `mid`, `senior`, `staff`, `principal`, `manager`, `director`, `executive`, `unspecified`. |
| `locationContains` | string | `""` | Case-insensitive substring, matched against both the raw and the normalized location. |
| `currencies` | array of strings | `[]` (all) | ISO codes, e.g. `["USD", "GBP"]`. |
| `minAnnualPay` | integer | `0` (off) | Drops postings whose annualized maximum is below this, in the posting's own currency. |
| `includeBenchmarks` | boolean | `true` | Writes roll-up rows to the named `benchmarks` dataset and the `BENCHMARKS` key-value record. |
| `benchmarkMinSampleSize` | integer | `3` | Minimum postings before a benchmark row is emitted. 1–1000. |
| `maxConcurrency` | integer | `5` | 1–20. Each request returns an entire board, so 5 is already fast. |
| `proxyConfiguration` | object | no proxy | Not needed. These endpoints have no anti-bot protection. |

If you clear all three board fields you get an error telling you so, rather than an empty run.

### Output

**Default dataset — one record per posting.** A real record from a test run:

```json
{
  "source": "greenhouse",
  "companySlug": "databricks",
  "companyName": "Databricks",
  "jobId": "8638847002",
  "title": "AI Engineer — GTM Analytics",
  "jobUrl": "https://databricks.com/company/careers/open-positions/job?gh_jid=8638847002",
  "department": "Sales Operations",
  "team": null,
  "employmentType": null,
  "location": "United States",
  "locationBucket": "United States",
  "isRemote": false,
  "postedAt": "2026-07-30T17:59:18.000Z",
  "updatedAt": "2026-08-18T18:38:59.000Z",
  "roleFamily": "data-and-ml",
  "seniority": "unspecified",
  "hasEquity": true,
  "hasBonus": true,
  "hasCommission": true,
  "scrapedAt": "2026-08-24T23:31:42.913Z",
  "hasPayRange": true,
  "payMin": 146600,
  "payMax": 201650,
  "payMid": 174125,
  "currency": "USD",
  "payInterval": "year",
  "annualMin": 146600,
  "annualMax": 201650,
  "annualMid": 174125,
  "payRangeLabel": "Zone 1 Pay Range",
  "paySource": "pay-range-block",
  "payRangeCount": 4,
  "additionalRanges": [
    { "label": "Zone 2 Pay Range", "payMin": 132000, "payMax": 181500, "currency": "USD", "payInterval": "year" },
    { "label": "Zone 3 Pay Range", "payMin": 124600, "payMax": 171400, "currency": "USD", "payInterval": "year" },
    { "label": "Zone 4 Pay Range", "payMin": 117300, "payMax": 161250, "currency": "USD", "payInterval": "year" }
  ]
}
```

`payMin`/`payMax` are exactly as published. `annualMin`/`annualMax` are the same figures annualized (hourly × 2080, monthly × 12) so that rows with different intervals are comparable. `paySource` tells you how the number was obtained: `structured` (a typed API field — Ashby, Lever), `pay-range-block` (Greenhouse's machine-generated block), or `prose-text` (parsed out of the description). When a posting lists several ranges — geographic zones, or a range per city — the first is the primary and the rest are in `additionalRanges`.

**Named dataset `benchmarks`, and key-value record `BENCHMARKS`** — the roll-ups:

```json
{
  "recordType": "benchmark",
  "roleFamily": "sales",
  "seniority": "unspecified",
  "locationBucket": "New York, NY",
  "currency": "USD",
  "sampleSize": 18,
  "companyCount": 3,
  "companies": ["anthropic", "databricks", "ramp"],
  "p25": 134363,
  "median": 192250,
  "p75": 228375,
  "minP25": 114750,
  "minMedian": 162000,
  "maxMedian": 223000,
  "maxP75": 261250,
  "lowestMin": 68000,
  "highestMax": 374000,
  "equityShare": 1,
  "generatedAt": "2026-08-24T23:31:42.913Z"
}
```

`p25`/`median`/`p75` are percentiles of the posting **midpoints**. `minMedian` and `maxMedian` are the median band floor and median band ceiling, which is what you want if you are setting your own band rather than a single number.

**Key-value record `COVERAGE`** — per board: postings found, postings carrying a range, and coverage percentage. Read this before trusting a benchmark; a company at 35% coverage is telling you far less than one at 96%.

**Key-value record `SUMMARY`** — counts for the whole run.

### Cost

Billing is per delivered record, not per posting read. Postings with no published pay range are never charged for, even when you ask to receive them.

| Records returned | Approximate cost |
|---|---|
| 100 | $0.50 |
| 300 (the default run) | ~$1.80 |
| 1,000 | ~$5.50 |
| 5,000 | ~$28 |

Benchmark rows are $0.01 each; a 300-record run produced 30 of them in testing. Set `maxPayRecords` to cap spend exactly — the run stops the moment it is reached. `ACTOR_MAX_TOTAL_CHARGE_USD` is respected too: the actor stops cleanly at your limit instead of continuing to work unpaid.

Runs are fast and cheap in platform time — 5 boards and 1,998 postings took 3.5 seconds; 30 boards and 7,504 postings took 9.1 seconds.

### Known limitations

Read this section. Most of it is about coverage, and coverage is the thing that will surprise you.

- **Coverage is uneven and outside this actor's control.** Employers publish ranges mainly where pay-transparency law requires it — California, Colorado, New York, Washington, Illinois, and increasingly the EU. In testing: Ramp 96%, Sierra 91%, Anthropic 88%, OpenAI 85%, Databricks 54%, Datadog 49%, GitLab 38%, Cloudflare 35%, **Stripe 3%**. A company whose roles are mostly outside those jurisdictions will return very little. Check `COVERAGE` before concluding the actor failed — an empty result for a given company usually means that company does not disclose.
- **Greenhouse pay ranges live in the posting HTML, not a typed field.** This actor keys off Greenhouse's `pay-range` block and falls back to a currency-pair regex over the description. If Greenhouse changes that markup the structured path breaks until the actor is updated; the Ashby and Lever paths are unaffected because they read typed API fields.
- **The prose fallback is good but not perfect.** It requires a pay word ("salary", "compensation", "hourly rate", "OTE") near the figures, which keeps out things like "we process $200B in spend", but an unusually worded posting can still be missed. Missed is the failure mode, not wrong — implausible values (bands wider than 20×, hourly rates over $2,000) are rejected rather than returned.
- **No currency conversion, ever.** Currency is part of the benchmark grouping key. A "sales, New York, USD" row and a "sales, London, GBP" row are separate rows. There is no FX source this actor could honestly cite, so it does not invent one.
- **Annualizing is a convention, not a fact.** Hourly is × 2080 and monthly is × 12. For a three-month internship posted at a monthly rate, the annualized figure is arithmetically correct and practically misleading. `payMin`, `payMax` and `payInterval` always carry the figure as published — use those when the distinction matters.
- **Role family and seniority are derived from the job title,** using a keyword map. It handles the usual cases, including the distinction between "Product Manager" (an individual contributor) and "Engineering Manager" (people management), but a company with idiosyncratic titles will land in `other` and `unspecified`. `unspecified` seniority is the largest bucket in most runs and generally means mid-level.
- **Ranges are what the employer advertises, not what it pays.** Published bands are typically wide, sometimes cover several levels, and often represent base salary only — `hasEquity`, `hasBonus` and `hasCommission` flag when the posting mentions other components, but this actor does not attempt to value them. Sales roles frequently publish OTE rather than base.
- **Lever is a weak third source.** Many companies that once used Lever now 404, and Lever postings carry pay ranges far less often — a test board returned 384 postings with only 4 ranges. It is off by default for that reason.
- **You supply the board slugs.** There is no company-name search. The defaults and the extra verified slugs below are a starting point; for a specific company, open its careers page and take the slug from the board URL.
- **This is a snapshot of currently open postings.** There is no history. To build a time series, schedule the actor and keep the datasets.
- **A board that 404s is skipped with a warning**, not treated as a failure. If *every* board fails, the run exits non-zero rather than reporting success with an empty dataset.

#### Extra verified board slugs

Confirmed to return postings at build time. Boards do get renamed or closed.

**Greenhouse:** `stripe`, `airbnb`, `coinbase`, `robinhood`, `instacart`, `affirm`, `figma`, `discord`, `reddit`, `twilio`, `asana`, `brex`, `duolingo`, `webflow`, `lyft`, `pinterest`, `samsara`, `flexport`, `chime`, `carta`, `scaleai`, `dropbox`

**Ashby:** `linear`, `replit`, `clickhouse`, `cursor`, `supabase`, `posthog`, `hex`, `neon`, `whoop`, `abridge`

### Legal

This actor is not affiliated with, endorsed by, or connected to Greenhouse, Ashby, Lever, or any of the employers whose job boards it reads.

It reads public, unauthenticated JSON endpoints that these applicant-tracking systems expose to render company job boards. No login, credential or API key is used, and nothing is bypassed to reach them. Even so: **a platform's Terms of Service commonly restrict automated access and bulk collection regardless of whether the data is publicly reachable.** Greenhouse, Ashby and Lever each publish their own terms, and using this actor may not comply with them. "Publicly accessible" and "permitted by the terms" are different questions, and only the second one protects you.

You are responsible for your own compliance — with those terms, with the terms of the employers whose boards you scan, and with any law that applies to how you collect and use the results. That includes any onward use: republishing the data, incorporating it into a commercial product, or making employment and pay decisions from it. If your use is commercial or high-stakes, take your own legal advice; nothing here is legal advice.

The actor returns company-level job postings only. It does not collect recruiter names, applicant data, or any other personal data, and it should not be modified to do so. Pay figures are reproduced as the employer published them; verify against the linked posting before relying on any individual number.

# Actor input Schema

## `greenhouseBoards` (type: `array`):

Company slugs on Greenhouse. The slug is the last path segment of the company's board URL: job-boards.greenhouse.io/anthropic -> "anthropic". Leave as-is to use the built-in seed list. A full URL is accepted and reduced to its slug.

## `ashbyBoards` (type: `array`):

Company slugs on Ashby. The slug is the first path segment of jobs.ashbyhq.com/openai -> "openai". Ashby publishes a typed compensation field, so coverage is usually higher than Greenhouse.

## `leverBoards` (type: `array`):

Company slugs on Lever, e.g. jobs.lever.co/leverdemo -> "leverdemo". Empty by default: many companies that once used Lever now return 404, and Lever postings carry pay ranges less often than the other two.

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

On by default. Turn it off to also receive postings with no published pay, which is useful for measuring what share of a company's roles disclose. Postings without a range are never charged for.

## `maxPayRecords` (type: `integer`):

Hard cap on dataset records, and therefore on cost. The run stops as soon as it is reached. Range 1-200000.

## `maxPostingsPerBoard` (type: `integer`):

Caps how many postings are read from each company, so one very large board cannot use up the whole record budget. 0 means no per-board limit.

## `roleFamilies` (type: `array`):

Keep only these normalized role families. Leave empty for all. The family is derived from the job title and department.

## `seniorities` (type: `array`):

Keep only these seniority levels, derived from title tokens. Leave empty for all. "unspecified" covers titles with no level word, which are usually mid-level.

## `locationContains` (type: `string`):

Case-insensitive substring match against the posting's raw location and its normalized bucket, e.g. "New York", "CA", "Remote". Leave empty for all locations.

## `currencies` (type: `array`):

ISO codes such as USD, GBP, EUR. Leave empty for all. Note that this actor never converts between currencies.

## `minAnnualPay` (type: `integer`):

Drop postings whose annualized maximum falls below this figure, in the posting's own currency. 0 disables the filter.

## `includeBenchmarks` (type: `boolean`):

Writes median/p25/p75 rows grouped by role family x seniority x location x currency to the named "benchmarks" dataset and to the BENCHMARKS key-value record.

## `benchmarkMinSampleSize` (type: `integer`):

A group needs at least this many postings before a benchmark row is emitted. Below about 3 the percentiles are not meaningful.

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

These are public JSON endpoints with generous limits, but 5 is polite and plenty fast - each request returns an entire board.

## `proxyConfiguration` (type: `object`):

Not needed. These endpoints have no anti-bot protection and the actor works fine without a proxy, which is why proxying is off by default. Enable it only if your own network blocks the requests.

## Actor input object example

```json
{
  "greenhouseBoards": [
    "anthropic",
    "databricks",
    "datadog",
    "gitlab",
    "cloudflare"
  ],
  "ashbyBoards": [
    "openai",
    "ramp",
    "sierra",
    "harvey",
    "notion"
  ],
  "leverBoards": [],
  "onlyWithPayRange": true,
  "maxPayRecords": 300,
  "maxPostingsPerBoard": 0,
  "roleFamilies": [],
  "seniorities": [],
  "locationContains": "New York",
  "currencies": [],
  "minAnnualPay": 0,
  "includeBenchmarks": true,
  "benchmarkMinSampleSize": 3,
  "maxConcurrency": 5,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `payRecords` (type: `string`):

One record per posting: normalized min/max pay, currency, interval, role family, seniority and location.

## `benchmarks` (type: `string`):

Median, p25 and p75 annualized pay by role family, seniority, location and currency.

## `coverage` (type: `string`):

For each board scanned: postings found, postings carrying a published pay range, and the resulting coverage percentage.

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

Counts of boards scanned, postings read, records returned and benchmark rows produced.

# 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 = {
    "greenhouseBoards": [
        "anthropic",
        "databricks",
        "datadog",
        "gitlab",
        "cloudflare"
    ],
    "ashbyBoards": [
        "openai",
        "ramp",
        "sierra",
        "harvey",
        "notion"
    ],
    "leverBoards": [],
    "currencies": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("mibedk/ats-pay-range-benchmark").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 = {
    "greenhouseBoards": [
        "anthropic",
        "databricks",
        "datadog",
        "gitlab",
        "cloudflare",
    ],
    "ashbyBoards": [
        "openai",
        "ramp",
        "sierra",
        "harvey",
        "notion",
    ],
    "leverBoards": [],
    "currencies": [],
}

# Run the Actor and wait for it to finish
run = client.actor("mibedk/ats-pay-range-benchmark").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 '{
  "greenhouseBoards": [
    "anthropic",
    "databricks",
    "datadog",
    "gitlab",
    "cloudflare"
  ],
  "ashbyBoards": [
    "openai",
    "ramp",
    "sierra",
    "harvey",
    "notion"
  ],
  "leverBoards": [],
  "currencies": []
}' |
apify call mibedk/ats-pay-range-benchmark --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,mibedk/ats-pay-range-benchmark"
        }
    }
}
```

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/OwnSC67ZyzUl62v5z/builds/g3grRBguc4VrE9XW6/openapi.json
