# LinkedIn Jobs Scraper With Salary Range Filters (`scraper-engine/linkedin-jobs-scraper`) Actor

Scrape LinkedIn job listings with titles, companies, locations, salaries, seniority, and descriptions. Ideal for recruiters, analysts, and job-market insights with clean structured output.

- **URL**: https://apify.com/scraper-engine/linkedin-jobs-scraper.md
- **Developed by:** [Scraper Engine](https://apify.com/scraper-engine) (community)
- **Categories:** Lead generation, Automation, Jobs
- **Stats:** 327 total users, 0 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$19.99/month + usage

To use this Actor, you pay a monthly rental fee to the developer. The rent is subtracted from your prepaid usage every month after the free trial period.You also pay for the Apify platform usage, which gets cheaper the higher Apify subscription plan you have.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#rental-actors

## 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

### LinkedIn Jobs Scraper — Salary Range Filters and Parsed Pay Fields

LinkedIn Jobs Scraper With Salary Range Filters extracts public LinkedIn job listings and turns advertised pay into numbers you can sort on. Every row carries `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryPeriod` and a normalized `salaryYearlyMin` / `salaryYearlyMax`, alongside `title`, `companyName`, `location`, `publishedAt` and the full `description` — as typed JSON, no HTML parsing. Set a yearly pay floor, keep only listings that state pay, or push a salary floor to LinkedIn itself. Start a run from the Apify Console with no required input.

### What is LinkedIn Jobs Scraper With Salary Range Filters?

It is an Apify Actor that queries LinkedIn's public, logged-out job surfaces and returns 34 fields per posting, ten of which describe compensation. It searches by role, location, company and LinkedIn's own filters, then screens results against a yearly pay floor, a currency, or a stated-pay requirement before anything is written to your dataset.

No LinkedIn account, cookie or session token is required. Everything it returns is what a signed-out visitor sees.

Built for compensation analysts, recruiters, labour-market researchers and AI engineers who need pay as a number rather than a sentence.

### What LinkedIn job and pay data is publicly available to scrape?

A signed-out visitor can read the full job posting on LinkedIn: title, employer, location, posting date, applicant count, employment type, seniority, industry and the complete description body. What a visitor cannot see is anything belonging to LinkedIn's logged-in product — and, critically, LinkedIn's structured pay field is not part of the public page.

| Data category | Publicly available (no login) | Gated behind a LinkedIn login or paid seat |
| ----- | ----- | ----- |
| Job identity and employer | Title, company name, company URL, numeric company ID, job URL | — |
| Posting timeline | Exact ISO posting date on the search card, plus LinkedIn's relative "3 days ago" text | — |
| Competition signal | Applicant count as LinkedIn words it (exact, "over N", "be among the first N") | Applicant profiles and screening answers |
| Classification | Employment type, seniority level, job function, industry | — |
| Description body | Full plain text and the original HTML markup | — |
| Structured pay range | Not published on the logged-out surface — measured 0 of 48 search cards and 0 of 30 detail fragments carried a salary field or a pay currency in the criteria list | LinkedIn's own salary insights product |
| Job poster identity | Name and profile URL where LinkedIn renders a poster block — measured on 6 of 60 listings | Recruiter contact details, InMail |
| Apply destination | Not on the guest surface — the apply-URL element was absent on 60 of 60 fragments, so no apply URL or Easy Apply flag is returned | The apply flow itself |

This Actor only returns publicly visible data — what any visitor sees. Nothing behind a login wall.

### Where do the salary numbers come from?

Every pay figure in the output is parsed out of the job description's free text. LinkedIn publishes no structured pay field on its public job pages, so there is nothing else to read: `salarySource` is `"description"` whenever a figure was found and `"none"` when it was not. It never says `"structured"`, because a structured source does not exist here.

That has consequences worth understanding before you set a filter.

#### A job with no stated pay is not a failure

Many LinkedIn postings simply do not mention money. Those rows come back with `hasSalary: false`, `salarySource: "none"` and every pay column `null`. That is an honest result, not a scrape error.

#### What the pay filters actually do to those rows

| Input | Where it runs | What happens to a job that states no pay |
| ----- | ----- | ----- |
| `useLinkedInSalaryBucket` | Server-side — sent to LinkedIn as `f_SB2` in the search query | LinkedIn decides; the Actor never sees the excluded jobs |
| `payFloorYearly` | After the job page is fetched and parsed | ✅ Kept — a floor cannot judge a job that states nothing |
| `payCurrency` | After the job page is fetched and parsed | ✅ Kept — same reason |
| `onlyJobsStatingPay` | After the job page is fetched and parsed | ❌ Dropped — this is the only input that rejects on absence |

**A yearly pay floor on its own does not silently delete every posting that stayed quiet about money.** `payFloorYearly` and `payCurrency` screen only jobs that actually stated a figure. If you want a pay-only dataset, you have to ask for it explicitly with `onlyJobsStatingPay`.

The floor compares against the **top** of the stated range, so a job advertising "$90,000 – $140,000" satisfies a `payFloorYearly` of 120000. A job that states pay but whose period could not be resolved is rejected as not comparable rather than guessed at.

#### Filtered-out rows are not charged

The three post-fetch filters run after the detail page has been retrieved. A screened-out job is never pushed to the dataset and never fires the `row_result` charged event — you asked not to receive it, so it is not billed. The run log names the reason for each one: `no_stated_pay`, `currency_mismatch`, `below_pay_floor` or `pay_not_comparable`.

Fetching still takes time, which is why the Actor scans a wider candidate pool when a pay screen is on: it collects up to `maxJobs × 8` candidates, capped at `maxJobs + 400` and at LinkedIn's 1,000-result ceiling, and stops the moment `maxJobs` rows have passed.

#### Period is normalized, currency is not

An hourly rate and an annual salary are made comparable before filtering. `salaryYearlyMin` and `salaryYearlyMax` are the stated figures multiplied by a fixed, stated assumption:

| Stated period | Multiplier to yearly |
| ----- | ----- |
| `hour` | 2080 (40 hours × 52 weeks) |
| `day` | 260 (5 days × 52 weeks) |
| `week` | 52 |
| `month` | 12 |
| `year` | 1 |

An hourly rate has no true annual equivalent without knowing the contracted hours, so treat a normalized figure from an hourly posting as an estimate built on that assumption. `salaryPeriodSource` tells you which case you are in: `"stated"` when the listing wrote a unit ("$40–50/hr"), `"inferred"` when the Actor deduced the period from magnitude ("$150,000 to $350,000" reads as yearly). An inferred period is never passed off as a stated one.

**Currency is detected but never converted.** `payFloorYearly` compares raw numbers, so a `₹1,800,000` posting clears a floor of `150000` and a `£120,000` posting does not. If your search spans currencies, set `payCurrency` as well — otherwise the comparison is arithmetic across units that are not the same unit.

#### Why funding rounds do not end up in the salary column

A bare currency regex over a job description returns nonsense: "raised $20M from Headline" becomes a $20 salary, and "$500/month dinner stipend" becomes $6,000 a year. A money figure is accepted here only when it carries a period unit, sits within 90 characters of genuine compensation wording, or forms a two-sided range between 1,000 and 2,000,000 that can only be pay. It is rejected outright when funding, revenue, valuation, budget or reimbursement wording sits within 70 characters, and a perk word ("stipend", "commuter", "wellness") vetoes it unless real compensation wording is also nearby. A normalized yearly figure outside 5,000–5,000,000 is discarded rather than emitted.

### What data can I extract with this LinkedIn jobs scraper?

Each row describes one job posting across four groups: job and employer identity, compensation, hiring signals and classification, and the description body. All 34 keys are present on every row — an unavailable value comes back as `null`, never as a missing key.

| Field name | Description |
| ----- | ----- |
| `id` | LinkedIn's numeric job posting ID — stable, use it to deduplicate across runs |
| `title` | Job title as posted |
| `companyName` | Hiring employer's name |
| `location` | Location as LinkedIn renders it on the posting |
| `publishedAt` | Exact posting date, taken from the search card's ISO timestamp |
| `postedTime` | LinkedIn's relative string, e.g. "3 days ago" |
| `salary` | Raw pay phrase lifted from the description, exactly as written |
| `salaryText` | The pay token the structured parser matched, before decomposition |
| `hasSalary` | `true` when a pay figure was credibly found, `false` otherwise |
| `salaryMin` | Lower stated figure as a number, in the stated currency and period |
| `salaryMax` | Upper stated figure, `null` when the posting quoted a single number |
| `salaryCurrency` | Currency code detected from the symbol or code: `USD`, `GBP`, `EUR`, `INR`, `AUD`, `CAD`, `SGD`, `CHF`, `NZD` |
| `salaryPeriod` | `hour`, `day`, `week`, `month` or `year` |
| `salaryPeriodSource` | `stated` when the listing wrote the unit, `inferred` when it was deduced from magnitude |
| `salaryYearlyMin` | `salaryMin` normalized to a yearly figure |
| `salaryYearlyMax` | `salaryMax` normalized to a yearly figure |
| `salarySource` | `description` when pay was parsed out of the body, `none` when no pay was stated |
| `applicationsCount` | Applicant figure as a string, with commas stripped |
| `applicantsCountType` | Precision of that figure: `exact`, `at_least` or `fewer_than` |
| `applicantsText` | The applicant line exactly as LinkedIn wrote it |
| `contractType` | LinkedIn's Employment type criterion, e.g. "Full-time" |
| `experienceLevel` | LinkedIn's Seniority level criterion, e.g. "Mid-Senior level" |
| `workType` | LinkedIn's **Job function** criterion, e.g. "Engineering and Information Technology" |
| `sector` | LinkedIn's Industries criterion |
| `jobUrl` | Canonical link back to the posting |
| `companyUrl` | Employer's LinkedIn company page, query string stripped |
| `companyId` | Employer's numeric LinkedIn company ID |
| `benefits` | Benefit keywords detected in the description prose — a text signal, not a LinkedIn structured block |
| `posterFullName` | Name of the person who posted the role, where LinkedIn renders one |
| `posterProfileUrl` | That person's LinkedIn profile URL, read from the same anchor so the two always agree |
| `description` | Full plain-text description body |
| `descriptionHtml` | The same body with its original HTML markup preserved |
| `errorReason` | `null` on a real job row; set on an uncharged accounting row |
| `scrapedAt` | UTC timestamp of extraction, ISO 8601 |

#### Compensation fields

Ten of the 34 keys describe pay, and they are built to be audited rather than trusted blindly. `salary` and `salaryText` keep the employer's own wording so you can check any number against the sentence it came from. `salaryMin` / `salaryMax` hold the stated figures in the stated period. `salaryYearlyMin` / `salaryYearlyMax` hold the comparable annual figures. `salaryCurrency`, `salaryPeriod` and `salaryPeriodSource` describe the units, and `hasSalary` plus `salarySource` tell you whether there was anything to parse at all.

#### Hiring signal and classification fields

`applicationsCount` never lies about its own precision: LinkedIn states applicants three different ways, and `applicantsCountType` records which one you got, with `applicantsText` preserving the original line. `publishedAt` is an exact date rather than a rounded relative string, which is what makes a daily delta job possible. `contractType`, `experienceLevel`, `workType` and `sector` come straight from LinkedIn's criteria list.

#### Add-on: need more LinkedIn data?

Pay data pairs well with the people and company records behind the posting. LinkedIn Company URL - Mass Profile Finder With Firmographics resolves the employers you find here into full company records, and LinkedIn Profile Phone Number Scraper By Seniority & Title Filter works the other direction, from a role to the people holding it. Both are listed in the related scrapers table at the end.

### Why not build this yourself?

LinkedIn has no public jobs API that returns arbitrary search results to an anonymous caller. Its Talent Solutions and partner APIs require an approved partnership and serve the jobs you posted, not the open market — so the public surface is the only route to market-wide pay data, and building it yourself means owning every one of the following.

The guest search endpoint serves 10 cards per page, not 25; a scraper that steps 25 skips 15 of every 25 result slots, which measured out at 40 unique jobs instead of 98 across the first 100 slots. LinkedIn intermittently pins a guest session so it stops honouring the `start` parameter and re-serves page one forever — waiting does not fix it, clearing the cookie jar and re-warming does. Fetching the HTML search page before calling the API is itself what triggers the pin, so the obvious warm-up is the bug. HTTP 999 is a throttle rather than a ban and needs backoff, not a proxy switch. The detail fragments declare UTF-8 in the HTTP header but carry no in-document meta charset, so a parser handed raw bytes will mojibake every "£" and every curly apostrophe. And pay must be pulled out of prose without mistaking a Series B for a salary.

Each is a small problem. Together they are why a first-pass LinkedIn jobs scraper produces a pay dataset you cannot use.

### ⚠️ What this scraper does not do

Four things deserve a straight answer rather than a footnote.

**`sortOrder` is ignored by LinkedIn.** The parameter is accepted so existing input JSON keeps working, and any value is taken without error, but the public job endpoint returns results in its own order — the returned job ID lists were byte-identical with no sort, with Most recent and with Most relevant. No sort parameter is sent, and the run log says so when you set one. Use `publishedAt` for recency; LinkedIn does honour that.

**The 1,000-result ceiling is LinkedIn's, not the Actor's.** Paging stops at `start=1000`, where LinkedIn returns HTTP 400. `maxJobs` accepts up to 1000 for that reason. Going wider means splitting one search across `geoId` values, employers or `publishedAt` windows and merging on `id`.

**Proxy groups you pick are not used.** Enabling Apify Proxy switches on a fixed ladder — datacenter first, residential on failure — regardless of the groups selected in the proxy editor. The toggle is honoured; the group list is not.

**`workType` means two different things.** As an *input* it filters on-site / remote / hybrid through LinkedIn's own search parameter. As an *output field* it carries LinkedIn's "Job function" criterion, e.g. "Engineering and Information Technology". They are unrelated values that share a name for backward compatibility.

### How to use LinkedIn Jobs Scraper With Salary Range Filters

The Actor runs on the Apify platform. Start it from the Apify Console, or call it through the Apify API with your Apify token — there is no separate signup and no LinkedIn credential anywhere in the flow.

1. Open the Actor on Apify and click **Try for free**.
2. No parameter is required. Set **Role or job title** and **Where**, or leave the prefilled example — which searches Software Engineer roles at Google in the United States — and edit from there. Clear **Employers to scrape** for a market-wide sweep instead of a company-scoped one.
3. Set the pay screen you want: a **Minimum yearly pay** floor, **Only jobs that state pay** for a pay-only dataset, **Pay currency** to keep a multi-country search comparable, or **LinkedIn salary floor** to narrow at the source.
4. Add the ordinary filters if you need them — **Date posted**, **Employment type**, **Seniority**, **On-site / Remote**, **LinkedIn region ID (geoId)**.
5. Set **Maximum jobs to save**, click **Start**, and export the dataset as JSON, CSV or Excel.

Leave the proxy off to begin with. LinkedIn's public job surfaces are reachable directly, and that is the cheapest configuration; turn Apify Proxy on only if your region sees blocks or empty pages.

#### How to scale to bulk job extraction

`companyInput` is a list, so one run can sweep many employers against the same role, location and pay screen — mix company names, LinkedIn company URLs and numeric company IDs freely in the same array. Job IDs are deduplicated across every employer in the run, so a company reached under two spellings will not produce duplicate rows. Deduplication is per run; merging across runs is on you.

Because a single search stops at roughly 1,000 results, wide collection is better done by splitting than by raising `maxJobs`. Run the same role across several `geoId` values or `publishedAt` windows and merge the datasets on `id`, which is a stable LinkedIn identifier, so deduplication is exact rather than fuzzy.

### What can you do with LinkedIn salary data?

- 💰 A compensation analyst benchmarking a role uses `salaryYearlyMin`, `salaryYearlyMax` and `payCurrency` set to `USD` to build a like-for-like distribution for one title in one market, keeping only rows where `salaryPeriodSource` is `stated` when explicitly-quoted periods matter.
- 🧾 A recruiter setting an offer range turns on `onlyJobsStatingPay`, filters by `experienceLevel`, and reads `salaryText` to see exactly how competing employers phrased the band they published.
- 📈 A labour-market researcher runs the same role and `geoId` weekly, keys on `id` and `publishedAt`, and tracks the share of postings where `hasSalary` is `true` as a pay-transparency time series.
- 🎯 A B2B sales team treats hiring as a buying signal, joins `companyId` onto existing account records, and ranks accounts by how many `sector`-matched roles they posted inside a `publishedAt` window.
- 🤖 An AI engineer indexes `description`, `title` and `sector` into a vector store and attaches `salaryYearlyMin` / `salaryYearlyMax` as numeric metadata, so a RAG agent answers "what does this role pay in Berlin" with filterable numbers instead of prose it has to re-parse.

Any agent framework that can make an HTTP request can call the Actor and consume these rows directly.

### How does the scraper handle rate limits and blocking?

It runs against LinkedIn's public guest endpoints with plain HTTP requests — no login, no cookie you supply, no browser, no TLS impersonation — and paces itself deliberately rather than racing. Detail pages are fetched two at a time, in batches of three, with a 1.2-second delay plus jitter before each request and a 2.5-second pause between batches; search pages are requested 1.2 seconds apart. Each request gets three attempts with a 20-second timeout.

HTTP 999 is LinkedIn's throttle rather than a block, so it is retried with a widening backoff instead of being treated as a failure. HTTP 403 and 429 trigger a proxy escalation on the first attempt when Apify Proxy is available, then exponential backoff. A suspiciously small HTML response — LinkedIn's empty-page signature — is retried the same way. When LinkedIn pins a guest session and starts re-serving page one, the Actor clears the cookie jar, re-warms the session and re-requests the same page, up to three times per search.

It does not solve CAPTCHAs and does not claim to. If a job still cannot be fetched or parsed after its retries, it is written as an uncharged accounting row rather than silently disappearing from the count.

### ⬇️ Input

No parameter is required. Called with an empty input, the Actor falls back to a United States search with no role keyword, `maxJobs` of 20, pay parsing on and every pay filter off. From the Console, the prefilled example fields give you Software Engineer roles at Google in the United States.

| Parameter | Required | Type | Description | Example Value |
| ----- | ----- | ----- | ----- | ----- |
| `payFloorYearly` | No | integer | Drop jobs whose stated pay tops out below this yearly figure, applied after hourly, weekly and monthly rates are normalized. Minimum 0. Empty means no floor. Jobs stating no pay are kept unless `onlyJobsStatingPay` is also on. | `150000` |
| `onlyJobsStatingPay` | No | boolean | Keep only listings where a pay figure was found. Everything with `hasSalary: false` is skipped — not saved, not charged. Default `false`. | `true` |
| `payCurrency` | No | string | Keep only jobs whose parsed pay is in this currency. One of `""`, `USD`, `GBP`, `EUR`, `INR`, `AUD`, `CAD`, `SGD`, `CHF`, `NZD`. Default `""` (any currency). | `"USD"` |
| `useLinkedInSalaryBucket` | No | string | LinkedIn's own salary-bucket filter, sent with the search so LinkedIn narrows results at the source. `"1"` = $40,000+ through `"9"` = $200,000+, in LinkedIn's USD banding. Default `""`. | `"5"` |
| `parsePayFields` | No | boolean | Fill `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryPeriod`, `salaryYearlyMin` and `salaryYearlyMax` by parsing the description. The pay filters need this on. Default `true`. | `true` |
| `roleSearchTerms` | No | string | The role to search for. Overridden by `keywords` when that is set. | `"Data Analyst"` |
| `searchLocation` | No | string | City, region or country as LinkedIn understands it. Overridden by `location` when that is set. | `"United Kingdom"` |
| `keywords` | No | string | Original base input, kept so existing input JSON keeps working. Takes precedence over `roleSearchTerms`. Default `""`. | `""` |
| `location` | No | string | Original base input, kept for compatibility. Takes precedence over `searchLocation`. Default `""`. | `""` |
| `companyInput` | No | array | Employers to scrape, as a name, a LinkedIn company URL or a numeric company ID. Empty searches all of LinkedIn. | `["Google", "https://www.linkedin.com/company/stripe/"]` |
| `maxJobs` | No | integer | How many matching jobs to save. Minimum 1, maximum 1000. Default `20`. With a pay screen on, a larger candidate pool is scanned to find this many matches. | `100` |
| `publishedAt` | No | string | Date window: `""` any time, `"r86400"` past 24 hours, `"r604800"` past week, `"r2592000"` past month. Default `""`. | `"r604800"` |
| `workType` | No | string | Work arrangement: `""` any, `"1"` on-site, `"2"` remote, `"3"` hybrid. Default `""`. | `"2"` |
| `contractType` | No | string | Employment type: `""` any, `"F"` full-time, `"P"` part-time, `"C"` contract, `"T"` temporary, `"I"` internship, `"V"` volunteer. Default `""`. | `"F"` |
| `experienceLevel` | No | string | Seniority: `""` any, `"1"` internship, `"2"` entry, `"3"` associate, `"4"` mid-senior, `"5"` director, `"6"` executive. Default `""`. | `"4"` |
| `geoId` | No | string | Numeric LinkedIn location ID sent alongside the location text. 16 countries are suggested; any custom geoId copied from a LinkedIn search URL also works. Non-numeric values are ignored. Default `""`. | `"103644278"` |
| `sortOrder` | No | string | `""`, `"relevance"` or `"date"`. **Ignored by LinkedIn** — kept for compatibility, logs a notice when set. Default `""`. | `""` |
| `proxyConfiguration` | No | object | Apify Proxy settings. Default is no proxy. When enabled, the ladder is datacenter then residential. | `{"useApifyProxy": false}` |

Setting `parsePayFields` to `false` disables `payFloorYearly`, `onlyJobsStatingPay` and `payCurrency` — the run logs a notice and continues with the structured pay columns empty, though the raw `salary` phrase is still extracted. `useLinkedInSalaryBucket` is unaffected, because it runs at the source.

#### Example input

```json
{
  "payFloorYearly": 150000,
  "onlyJobsStatingPay": true,
  "payCurrency": "USD",
  "useLinkedInSalaryBucket": "5",
  "parsePayFields": true,
  "roleSearchTerms": "Senior Data Engineer",
  "searchLocation": "United States",
  "keywords": "",
  "location": "",
  "companyInput": [],
  "maxJobs": 100,
  "publishedAt": "r604800",
  "workType": "2",
  "contractType": "F",
  "experienceLevel": "4",
  "geoId": "103644278",
  "sortOrder": "",
  "proxyConfiguration": { "useApifyProxy": false }
}
```

### ⬆️ Output

One row per job posting, the same 34 keys every run, in a schema that does not shift when LinkedIn changes its page layout. Missing values arrive as `null` rather than as absent keys, so downstream code never needs a presence check. Export as JSON, CSV or Excel, or read the dataset through the Apify API.

Jobs that could not be fetched or parsed are written as accounting rows with every data field `null` and `errorReason` set — `fetch_failed`, `unparseable_page`, `scrape_exception`, `throttled_999`, `blocked_empty_response`, `timeout`, `network_error` or an `http_<status>` code. **These rows are never charged.** Filter them out with `errorReason == null` to get clean results, or keep them to audit exactly which postings were missed. Jobs removed by your own pay filters are a separate case: they produce no row at all and are likewise not charged.

#### Example output

```json
{
  "id": "4021873455",
  "title": "Senior Data Engineer",
  "companyName": "Ramp",
  "location": "New York, NY",
  "publishedAt": "2026-07-21",
  "postedTime": "4 days ago",
  "salary": "$180,000 - $225,000",
  "salaryText": "$180,000 - $225,000",
  "hasSalary": true,
  "salaryMin": 180000,
  "salaryMax": 225000,
  "salaryCurrency": "USD",
  "salaryPeriod": "year",
  "salaryPeriodSource": "inferred",
  "salaryYearlyMin": 180000,
  "salaryYearlyMax": 225000,
  "salarySource": "description",
  "applicationsCount": "27",
  "applicantsCountType": "exact",
  "applicantsText": "27 applicants",
  "contractType": "Full-time",
  "experienceLevel": "Mid-Senior level",
  "workType": "Engineering and Information Technology",
  "sector": "Financial Services",
  "jobUrl": "https://www.linkedin.com/jobs/view/4021873455",
  "companyUrl": "https://www.linkedin.com/company/ramp",
  "companyId": "18936223",
  "benefits": "401(k), dental, equity, health insurance, paid time off, remote, vision",
  "posterFullName": "Priya Raman",
  "posterProfileUrl": "https://www.linkedin.com/in/priya-raman",
  "description": "Ramp is hiring a Senior Data Engineer for our Platform team. The base salary range for this role is $180,000 - $225,000 plus equity and benefits...",
  "descriptionHtml": "<div class=\"show-more-less-html__markup\"><p>Ramp is hiring a Senior Data Engineer for our Platform team.</p></div>",
  "errorReason": null,
  "scrapedAt": "2026-07-25T09:14:02Z"
}
```

An hourly posting decomposes the same way, with the period stated rather than inferred:

```json
{
  "salary": "$85/hr",
  "salaryText": "$85/hr",
  "hasSalary": true,
  "salaryMin": 85,
  "salaryMax": null,
  "salaryCurrency": "USD",
  "salaryPeriod": "hour",
  "salaryPeriodSource": "stated",
  "salaryYearlyMin": 176800,
  "salaryYearlyMax": null,
  "salarySource": "description"
}
```

### How does it work?

The Actor calls two of LinkedIn's public guest endpoints: a search endpoint that returns job cards ten at a time, and a detail endpoint that returns the posting fragment for a single job ID. It warms a session against the LinkedIn homepage so guest cookies are issued, then pages through search results, collecting job IDs plus the exact posting date, location and company URL carried on each card. Each collected ID is then fetched individually and parsed into a row.

Requests go out directly by default; Apify Proxy is opt-in and escalates from datacenter to residential when a block is detected. Responses are decoded as UTF-8 before parsing, so accented characters and typographic dashes survive intact instead of arriving as mojibake. Only publicly visible data is read — no login, no cookie you supply, no logged-in surface.

Because parsing is anchored to the LinkedIn markup that has proven durable on the guest surface, and every value is normalized before it is written, the output schema stays the same across runs even as LinkedIn's visual layout shifts.

### Integrations

It is an Apify Actor, so anything that can call the Apify API or read an Apify dataset can use it — your own code, a scheduler, or a no-code automation platform.

#### Calling the scraper programmatically

```python
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")

run = client.actor("<YOUR_USERNAME>/linkedin-jobs-scraper-with-salary-range-filters").call(run_input={
    "roleSearchTerms": "Senior Data Engineer",
    "searchLocation": "United States",
    "payFloorYearly": 150000,
    "payCurrency": "USD",
    "onlyJobsStatingPay": True,
    "maxJobs": 100,
})

jobs = [j for j in client.dataset(run["defaultDatasetId"]).iterate_items()
        if j.get("errorReason") is None]

for job in jobs:
    print(job["companyName"], job["title"], job["salaryYearlyMin"], job["salaryYearlyMax"])
```

Works in Go, Ruby, Node.js, cURL — any language that can make an HTTP request. Filtering on `errorReason is None` separates real postings from uncharged accounting rows.

#### No-code tools (n8n, Make, LangChain)

In n8n, use the HTTP Request node pointed at the Actor's run endpoint, then a second request to the dataset items endpoint to pull the rows; the pay columns arrive as numbers, so a Filter node can compare them without a conversion step. In Make, the Apify modules run the Actor and iterate the dataset straight into a Google Sheet, an Airtable base or a CRM. In LangChain, wrap the same run-and-fetch call as a tool and hand the agent typed rows — `description` is the text to embed, `salaryYearlyMin` and `salaryYearlyMax` the metadata to filter on.

Apify schedules can run the Actor on a cron expression and fire a webhook when it finishes, which is the usual pattern for a daily pay-transparency delta.

### Is it legal to scrape LinkedIn job listings?

Scraping publicly accessible job postings is broadly treated as permissible where no authentication is bypassed, and this Actor collects only what a signed-out visitor sees — it uses no account, cookie or session.

Almost every field in a row is a business record rather than personal data: a job title, an employer, a salary range and an industry describe a company's hiring activity, not an individual. GDPR does not attach to them the way it attaches to a profile scraper's output. What does apply is contractual and database-rights exposure — LinkedIn's Terms of Service govern automated access regardless of whether the data is personal, and republishing a large extracted collection is a different question from analysing it internally.

Two fields are the exception. `posterFullName` and `posterProfileUrl` identify a named individual, so storing them for EU or UK data subjects is processing personal data and needs a lawful basis under GDPR — typically documented legitimate interest for B2B recruitment — and falls within CCPA's scope for California residents. Nothing else in the row carries a personal identifier. Consult legal counsel if your use case involves bulk storage of personal data.

### ❓ Frequently asked questions

#### What LinkedIn job fields does this scraper return?

34 fields per posting. The most used are `title`, `companyName`, `salaryYearlyMin` and `salaryYearlyMax`, `publishedAt` and `description`, with ten compensation fields in total covering the stated figures, the currency, the period and whether that period was stated or inferred. The full list is in the data fields table above.

#### Does it require a LinkedIn account, login or cookie?

No. No login, no cookie, no session token, no LinkedIn credential of any kind. The Actor reads LinkedIn's public guest job surfaces anonymously. The only credential involved is your Apify token.

#### Where does the salary data come from, if LinkedIn does not publish one?

From the job description's free text. LinkedIn's public job pages carry no structured pay field — measured 0 of 48 search cards and 0 of 30 detail fragments — so every figure is parsed out of prose with compensation-context anchoring, and `salarySource` always says `description` when a figure was found. A `null` pay column means the employer did not state pay, not that the scrape failed.

#### Does a minimum salary filter drop jobs that state no pay?

No. `payFloorYearly` and `payCurrency` screen only jobs that actually stated a figure — a job with `hasSalary: false` passes both, because a floor cannot judge a number that does not exist. Only `onlyJobsStatingPay` rejects on absence, and it is off by default. That is deliberate: silently deleting every posting that did not publish a range would remove a large part of the market from your dataset without telling you.

#### What happens if no jobs pass the pay filter?

The dataset comes back with no saved rows, and the run log states exactly what happened: how many candidates were scanned, how many were screened out, the match rate, and a note that only *N* of the *M* requested jobs matched within the candidates available for that search. None of the screened-out jobs are charged. The usual fixes are lowering `payFloorYearly`, turning `onlyJobsStatingPay` off, widening `payCurrency` to any currency, or broadening the role and location — a narrow title in a small market may simply not contain enough postings that quote pay.

#### Are hourly rates comparable to annual salaries?

Yes for the period, no for the currency. Hourly, daily, weekly and monthly rates are normalized to `salaryYearlyMin` / `salaryYearlyMax` using fixed assumptions — 2,080 hours, 260 days, 52 weeks or 12 months per year — and `salaryPeriodSource` tells you whether the period was stated by the employer or inferred from magnitude. Currency is detected but never converted, so a floor set in one currency is compared as a raw number against figures in another. Set `payCurrency` whenever your search crosses currencies.

#### How many jobs can I extract in one run?

`maxJobs` accepts 1 to 1,000, and 1,000 is LinkedIn's ceiling rather than an Actor limit — the public endpoint returns HTTP 400 past `start=1000`, so a single role-and-location search cannot paginate further. To go wider, split across `geoId` values, employers or `publishedAt` windows and merge on `id`.

#### Can I scrape multiple employers at once?

Yes. `companyInput` is a list and accepts company names, LinkedIn company URLs and numeric company IDs in the same array. The Actor resolves each to a company ID, searches per employer, and deduplicates job IDs across the whole run so the same posting is never saved twice. If a company page cannot be resolved, that employer falls back to a keyword search on its name.

#### Which filters does LinkedIn actually honour?

`keywords`, `location`, `geoId`, `companyInput`, `publishedAt`, `workType`, `contractType`, `experienceLevel` and `useLinkedInSalaryBucket` all change the result set LinkedIn returns. `sortOrder` does not — it is accepted for compatibility and logs a notice. `payFloorYearly`, `payCurrency` and `onlyJobsStatingPay` are the Actor's own screens, applied after each job is fetched and parsed.

#### Does it work with Claude, ChatGPT and other AI agent tools?

Yes. It is callable as a standard HTTP endpoint through the Apify API, so LangChain, CrewAI, n8n or a hand-written tool definition can invoke it and receive typed JSON. No parsing step is needed before a row enters an LLM context window.

#### How does this compare to other LinkedIn job scrapers?

Checked on the Apify Store on 25 July 2026. `khadinakbar/linkedin-jobs-scraper` returns `salary_min` and `salary_max` as parsed numbers plus a skills array, and offers a `salaryBase` filter with values from 40000 to 120000; its listing states that salary data appears only where the employer added it. `afanasenko/linkedin-jobs-scraper` runs three operation modes and offers a `filterSalaryMin` filter, documenting that its filters apply after the upstream fetch and that "the paid event still fires on the raw fetch — filters narrow your output, not your bill." `mabdulmoghni/linkedin-jobs-scraper---customized-filters-with-notifications` documents five output fields — title, company, location, posted date and link — with no salary field, plus optional Telegram alerts. The observable differences here are that pay is decomposed into currency and period with a `salaryPeriodSource` flag separating stated from inferred, that the yearly normalization is its own filterable column, and that a row removed by a pay filter produces no charge.

#### What happens when LinkedIn changes its layout or anti-bot system?

The Actor is maintained and the output schema stays stable — field names and types do not change when LinkedIn's markup does. Parsing is anchored to the elements that have proven durable on the guest surface, and a value that cannot be read comes back `null` rather than as a corrupted guess.

#### Which fields work best for AI training data and RAG indexing?

For RAG, index `description` for the full text, with `title`, `companyName` and `sector` as the high-signal short fields, and `descriptionHtml` when the structure inside the posting matters. For training data and analytics, `salaryYearlyMin`, `salaryYearlyMax`, `publishedAt`, `experienceLevel` and `applicantsCountType` are the most consistently structured across records. Every field returns as a typed primitive — string, number, boolean or `null` — so no post-processing is needed before indexing.

### 🔗 Related scrapers

| Scraper | What it extracts |
| ----- | ----- |
| LinkedIn Post Scraper | Public LinkedIn posts with engagement counts and post text |
| LinkedIn Post Comments Scraper | Comments on a LinkedIn post, with commenter identity |
| LinkedIn Company URL - Mass Profile Finder With Firmographics | Company records and firmographics from LinkedIn company URLs |
| LinkedIn Profile Phone Number Scraper By Seniority & Title Filter | Public profile contact data filtered by seniority and job title |
| LinkedIn Phone Number Scraper: Lead Scoring | Public profile contact data with lead-quality scoring |

### 💬 Your feedback

Found a bug, or a pay format the parser missed? Open an issue on the Actor's Issues tab. Reports that include the exact input JSON and a job URL are the fastest to reproduce and fix — a description whose salary wording was misread is the single most useful thing you can send.

# Actor input Schema

## `payFloorYearly` (type: `integer`):

Drop jobs whose stated pay tops out below this yearly figure. Applied after hourly/monthly/weekly rates are normalized to a yearly number.

Example: 150000 keeps a job advertising '$80/hour' (≈ $166,400/yr) and drops one advertising '$120,000 – $140,000'.

Leave empty for no pay floor. Jobs that state no pay are kept unless you also switch on 'Only jobs that state pay'.

## `onlyJobsStatingPay` (type: `boolean`):

Keep only listings where a pay figure was actually found in the description. Everything with hasSalary = false is skipped — not saved and not charged.

Turn this on for pay benchmarking. Leave it off to get full market coverage including the many jobs that stay silent on pay.

## `payCurrency` (type: `string`):

Keep only jobs whose parsed pay is in this currency. Useful when a multi-country search mixes USD, GBP and EUR figures together.

Leave as Any currency to skip this filter.

## `useLinkedInSalaryBucket` (type: `string`):

Sends LinkedIn's OWN salary-bucket filter with the search, so LinkedIn narrows the results before they are returned — different from the pay floor above, which screens results after parsing.

🔬 Verified working while logged out and genuinely ordered: for 'Data Analyst' in the United States over 20 result slots, the $40,000+ and $200,000+ buckets shared only 1 of 20 jobs, while $40,000+ and $80,000+ shared 19 of 20.

Banding is LinkedIn's own and USD-based. Combine with the pay floor for the tightest screen.

## `parsePayFields` (type: `boolean`):

Fill salaryMin, salaryMax, salaryCurrency, salaryPeriod, salaryYearlyMin and salaryYearlyMax by parsing the description.

Keep this on — the pay filters above need it. Switch it off only if you want the raw description text and nothing else; the pay columns then come back empty.

## `roleSearchTerms` (type: `string`):

The role to search for. Examples: Software Engineer, Data Analyst, Product Manager, Registered Nurse.

Leave empty to cast a wider net. (The base field 'keywords' is still accepted and takes precedence when set.)

## `searchLocation` (type: `string`):

City, region or country as LinkedIn understands it. Examples: United States, London, Berlin, Remote.

(The base field 'location' is still accepted and takes precedence when set.)

## `keywords` (type: `string`):

Original base input, kept so existing runs and saved input JSON keep working. When this is filled it overrides 'Role or job title' above.

## `location` (type: `string`):

Original base input, kept so existing runs and saved input JSON keep working. When this is filled it overrides 'Where' above.

## `companyInput` (type: `array`):

Restrict the search to specific employers. Accepts a name (Google), a company URL (https://www.linkedin.com/company/google/) or a numeric company ID (1441).

Leave empty to search all of LinkedIn.

## `maxJobs` (type: `integer`):

How many MATCHING jobs to save. When a pay filter is active the actor scans a larger pool of candidates to find this many matches, and stops as soon as it has them.

⚠️ LinkedIn's public search tops out near 1,000 results per query, so values above that cannot be reached. Start small (20) to preview, then scale.

## `publishedAt` (type: `string`):

Only return jobs posted within this window. Choose Any time to disable.

## `workType` (type: `string`):

Restrict by work arrangement. Choose Any to skip this filter.

## `contractType` (type: `string`):

Restrict by employment type. Choose Any to skip this filter.

💡 Contract and Temporary roles are the ones most likely to quote an hourly rate rather than a yearly salary.

## `experienceLevel` (type: `string`):

Restrict by required seniority. Choose Any to skip this filter.

## `geoId` (type: `string`):

Optional numeric LinkedIn location ID, sent alongside the location text for consistent regional results.

🏙️ For a city or any other region, run the search on LinkedIn and copy the geoId number from the URL bar. Custom values are supported.

## `sortOrder` (type: `string`):

Kept so existing input JSON keeps working. LinkedIn's public job endpoint IGNORES sorting — measured: the returned job ID lists are byte-identical with no sort, with Most recent and with Most relevant. The actor therefore sends no sort parameter and logs that the value had no effect, rather than pretending it worked.

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

Enable Apify Proxy and pick groups if LinkedIn throttles your region.

## Actor input object example

```json
{
  "onlyJobsStatingPay": false,
  "payCurrency": "",
  "useLinkedInSalaryBucket": "",
  "parsePayFields": true,
  "roleSearchTerms": "Software Engineer",
  "searchLocation": "United States",
  "keywords": "",
  "location": "",
  "companyInput": [
    "Google"
  ],
  "maxJobs": 20,
  "publishedAt": "",
  "workType": "",
  "contractType": "",
  "experienceLevel": "",
  "geoId": "",
  "sortOrder": "",
  "proxyConfiguration": {
    "useApifyProxy": 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 = {
    "roleSearchTerms": "Software Engineer",
    "searchLocation": "United States",
    "companyInput": [
        "Google"
    ],
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("scraper-engine/linkedin-jobs-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 = {
    "roleSearchTerms": "Software Engineer",
    "searchLocation": "United States",
    "companyInput": ["Google"],
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("scraper-engine/linkedin-jobs-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "roleSearchTerms": "Software Engineer",
  "searchLocation": "United States",
  "companyInput": [
    "Google"
  ],
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call scraper-engine/linkedin-jobs-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=scraper-engine/linkedin-jobs-scraper",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/acts/bmDF2cfzD71awaeu5/builds/lOcNL1FYYv62f5OT8/openapi.json
