# LinkedIn Salary Benchmark — Pay Ranges by Title & Location (`northbell/linkedin-salary-benchmark`) Actor

Salary benchmarks from live LinkedIn postings: give a job title and a place, get the median and p10-p90 pay, by seniority and title level, with top companies and the change since your last run. Hourly and monthly pay is converted to yearly. No login.

- **URL**: https://apify.com/northbell/linkedin-salary-benchmark.md
- **Developed by:** [Northbell](https://apify.com/northbell) (community)
- **Categories:** Jobs, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $40.00 / 1,000 benchmarks

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?

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

## LinkedIn Salary Benchmark — Pay Ranges by Title & Location

Give it job titles and places — `data engineer | Germany`, or `{"title": "registered nurse", "location": "Canada"}` — and get one row per benchmark: the **median, p10 / p25 / p75 / p90 and range of the pay that live LinkedIn postings advertise**, how many postings state pay at all, the pay **by seniority and by level in the title**, the **top companies** and five postings to check the number against. It reads up to hundreds of public postings per benchmark, turns hourly and monthly pay into yearly, and keeps each currency apart. **$50 per 1,000 benchmarks ($0.05 each)**, no login, no API key.

Run it again — or put it on a monthly schedule — and every row also tells you **what moved since the last run**: the previous median, the change in money and in percent, and how many postings stood behind it. LinkedIn only shows the postings that are open today; this keeps the history for you.

### What you get

One row per job title, location and currency (`type`: `benchmark`). All pay figures are **yearly**, in the currency the postings were written in.

| Field | What it is |
|---|---|
| `title`, `location`, `currency`, `periodBasis`, `datePosted`, `titleMatch` | the benchmark you asked for; `currency` is `null` only when no posting stated pay |
| `jobsListed`, `jobsOffTitle`, `jobsRead`, `jobsClosed`, `jobsFailed` | how many postings LinkedIn listed, how many were left out because the title lacked your words, how many were opened and read, how many had closed since, how many could not be read |
| `jobsWithPay`, `payShare` | postings that state pay in this currency, and that as a share of `jobsRead` (0 to 1) |
| `min`, `p10`, `p25`, `median`, `p75`, `p90`, `max` | the distribution of **one number per posting: the middle of its pay range** (a posting that says $100k–$140k counts once, at $120k) |
| `typicalRangeLow`, `typicalRangeHigh` | the median advertised **floor** and **ceiling**: the range employers typically post |
| `statedPeriods` | how the pay was written, e.g. `{"year": 41, "hour": 6}` (hourly × 2080, monthly × 12, weekly × 52, daily × 260) |
| `byExperience` | LinkedIn's own seniority label → `{n, median}`. LinkedIn leaves most postings at "Not Applicable", so that is often the largest bucket |
| `byTitleLevel` | level read from the title words (Intern, Junior, Senior, Lead / Principal, Manager / Director, No level in title) → `{n, median}` |
| `topCompanies` | up to 10 companies with the most postings that state pay: `{company, n, median}` |
| `otherCurrencies` | pay found in other currencies in the same search: `[{currency, n}]` |
| `sampleJobUrls` | five postings whose pay sits closest to the median: open them to check the figure |
| `lowSample`, `note` | `lowSample` is `true` when fewer than 5 postings state pay; `note` says why a row should be read with care |
| `previousMedian`, `medianChange`, `medianChangePct`, `previousJobsWithPay`, `previousObservedAt`, `isNew` | the change since your previous run of the same title, location and currency (`null` on the first run, `isNew` is `true`) |
| `observedAt` | when the row was made |

Searches that fail come back as free rows with `type: "error"` (`input`, `title`, `location`, `kind`, `message`).

**Long lists and the run timeout.** At the default pace a 100-posting benchmark takes about 6 minutes. Two minutes before the run's time limit, the benchmark in progress stops reading and returns what it read (its `note` says so), and the ones not started come back as free `kind: "time-limit"` rows — nothing read is lost to a timeout.

### Use it for

- **Pricing an offer or a raise** against what employers actually advertise for the same title in the same place, with a realistic range instead of a single average
- **Compensation and recruiting teams** — a monthly benchmark per role and city, with the change since last month
- **Market research** — which roles and countries are open about pay (`payShare`), how wide the posted ranges are, who pays most

### Input

```json
{
  "benchmarks": [
    { "title": "data engineer", "location": "United States" },
    "registered nurse | Canada"
  ],
  "maxJobsPerBenchmark": 100,
  "datePosted": "past-month"
}
```

- `benchmarks` — up to 50 pairs per run, each `{"title", "location"}` or the text `title | location`. The location is a country, region or city as LinkedIn spells it; `Worldwide` works too.
- `maxJobsPerBenchmark` — postings to read per benchmark, 10 to 500 (default 100).
- `datePosted` — `past-month` (default), `past-week` or `any`.
- `titleMatch` — `all-words` (default) keeps postings whose title contains every word you typed, so a *data engineer* benchmark is not filled up with software engineers. `any` keeps everything LinkedIn lists for those words (use it for a title with OR or quotes).
- `maxRequestsPerMinute` — how fast pages are requested (default 20).

The previous median is remembered per title, location and currency in a named key-value store in your Apify account, so the second run already shows changes. Schedule it (Apify → Schedules) for a history. A run that LinkedIn cut short (a low sample after refusals) is not remembered, so the next run is still compared with your last proper benchmark.

### Pricing

**$0.05 per benchmark** ($50 per 1,000), plus $0.01 per run. One benchmark is priced for the work behind it: it opens up to `maxJobsPerBenchmark` postings, and each posting is one page request, plus one request per 10 listed. In live runs that was about **1.2 requests per posting**, so the default 100 postings is roughly 125 requests and **about 6 minutes at the default pace of 20 requests a minute**. At 256 MB that is around 0.025 compute units of platform usage (about a cent), already included in the price; nothing is billed on top. The cost grows with the postings you ask for: 500 postings is five times that, about half an hour per benchmark.

- **Free:** error rows (a place LinkedIn does not know, no posting with your title, LinkedIn refusing), a row where **fewer than 3 postings state pay** (`jobsWithPay` 0 to 2: one or two numbers are not a benchmark; `note` says so), and a row that LinkedIn cut short by refusing requests and that ended up a `lowSample`.
- A search whose pay comes in two currencies (say a Germany search with some postings in US dollars) gives **one row per currency that has at least 5 postings with pay**, each one benchmark; smaller groups are only counted in `otherCurrencies`. Most searches give one row.
- The $0.01 run-start fee is the one charge every run pays, even one that returns nothing.

### Notes

- Public data only: what anyone sees on LinkedIn without signing in. Rows carry company names and posting links, nothing else from a posting: no descriptions, recruiter names, emails or phone numbers.
- **Only postings that state pay count.** Some do not, so `payShare` is part of the answer: measured on 30 postings each (October 8, 2026), 77% of *data engineer* postings in the United States stated pay, 57% of *registered nurse* in Canada, 50% of *accountant* in London, 30% of *software engineer* in "Remote" and none of *product manager* in Germany (it is 1 in 30 on some days). Where few postings state pay the row is flagged `lowSample` and explained in `note`; it is still returned.
- **How the number is made.** The pay comes from the posting's description and from LinkedIn's own "Base pay range" box when the employer filled it in (about 1 posting in 10), read by the same parser as the [LinkedIn Salary Scraper](https://apify.com/northbell/linkedin-jobs-salary-scraper). Numbers that are not pay (funding, bonuses, insurance limits) are rejected. "Up to X" and "from X" postings count at the number given. Yearly figures outside 8,000–2,000,000 are not treated as pay. Two spellings are straightened out before reading: LinkedIn's `CA$120,000` (Canadian dollars) and a currency written after the numbers (`101,300 - 160,000 USD annually`). Pay in any other layout (digits grouped with spaces, a table, an image) is not seen, so `payShare` is a floor, not an exact share.
- **Currencies.** Pay is grouped by the currency it was written in; nothing is converted between currencies. A bare "$" in a posting located in Canada, Australia, New Zealand, Singapore or Hong Kong is read as that country's dollar, otherwise as USD. Recognised: USD, GBP, EUR, CAD, AUD, INR and those dollars. Pay written in other currencies (¥, CHF, kr…) is read as "no pay stated".
- **Hourly pay** is annualised at 2080 hours, so part-time and contract rates are shown as a full-time year. `statedPeriods` shows how much of a benchmark was hourly.
- **Title filter.** LinkedIn's search is loose, so by default only postings whose title has every word you typed are read (a word may continue by up to three letters: *engineer* matches *engineers* and *engineering*, but *java* does not match *JavaScript*). The same posting listed twice (same company, title and place) counts once. `jobsOffTitle` says how many were left out.
- Fields that are empty on purpose: `previousMedian`, `medianChange`, `medianChangePct`, `previousJobsWithPay` and `previousObservedAt` are `null` on the first run of a title, location and currency; `otherCurrencies` is `[]` when all pay was in one currency; `note` is `null` unless something needs saying; with no pay stated (`jobsWithPay` 0) the currency and every figure are `null` and the lists are empty.
- **No remote share.** LinkedIn's public pages do not say whether a posting is remote, hybrid or on-site, and descriptions mention remote work in too many ways to count honestly, so no remote figure is made up.
- LinkedIn lists at most about 1,000 postings per search, so 500 is the ceiling. If LinkedIn rate-limits a request the Actor waits 5 s, then 10 s, and tries again; if it keeps refusing, the run stops reading, keeps what it has and says so in `note`.

To get the **postings themselves** with their pay, see [LinkedIn Salary Scraper](https://apify.com/northbell/linkedin-jobs-salary-scraper). To see **how fast applicants arrive** at a posting, see [LinkedIn Jobs Scraper with Applicant Count](https://apify.com/northbell/linkedin-jobs-applicants-scraper).

### For AI agents

`{"benchmarks": ["data engineer | United States"], "maxJobsPerBenchmark": 100}` returns one row with `median`, `p10` … `p90`, `jobsWithPay`, `payShare`, `byTitleLevel` and `topCompanies`, all yearly in `currency`. Rows have `type`: `benchmark` or `error`. If `lowSample` is `true`, read `note` before quoting the number.

# Actor input Schema

## `benchmarks` (type: `array`):

What to benchmark: a list of job title + location pairs, each as {"title": "data engineer", "location": "United States"} or as text, "data engineer | Germany". The location is a country, region or city as LinkedIn spells it ("Worldwide" works too). Each pair is one benchmark.

## `maxJobsPerBenchmark` (type: `integer`):

How many LinkedIn postings to open and read for each benchmark (10 to 500). Pay lives on each posting's page, so the run makes about one request per posting plus one per 10 listed. More postings give steadier percentiles; 100 is a good default.

## `datePosted` (type: `string`):

Only postings from this period. The past month keeps the benchmark current; choose any time for rare titles or small places.

## `titleMatch` (type: `string`):

All words in the title (default) keeps postings whose title contains every word you typed, so a data engineer benchmark does not fill up with software engineers. Anything LinkedIn lists keeps everything the LinkedIn search returns for those words.

## `maxRequestsPerMinute` (type: `integer`):

How fast the run asks LinkedIn for pages. Lower it if the log shows many rate-limit retries. The pace is shared by all runs of this Actor in your account.

## Actor input object example

```json
{
  "benchmarks": [
    {
      "title": "data engineer",
      "location": "United States"
    }
  ],
  "maxJobsPerBenchmark": 20,
  "datePosted": "past-month",
  "titleMatch": "all-words",
  "maxRequestsPerMinute": 20
}
```

# Actor output Schema

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

Median and percentiles of advertised yearly pay, how many postings state pay, and the change since the last 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 = {
    "benchmarks": [
        {
            "title": "data engineer",
            "location": "United States"
        }
    ],
    "maxJobsPerBenchmark": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("northbell/linkedin-salary-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 = {
    "benchmarks": [{
            "title": "data engineer",
            "location": "United States",
        }],
    "maxJobsPerBenchmark": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("northbell/linkedin-salary-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 '{
  "benchmarks": [
    {
      "title": "data engineer",
      "location": "United States"
    }
  ],
  "maxJobsPerBenchmark": 20
}' |
apify call northbell/linkedin-salary-benchmark --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,northbell/linkedin-salary-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/zZOfMMqcVdOKIMFbc/builds/M4h2h5blyKI9ykEzS/openapi.json
