# LinkedIn Jobs Scraper – Job Postings by Keyword & Place (`simple.actor/linkedin-jobs`) Actor

Scrape public LinkedIn job postings for any keyword and place: title, company, location, posted date, link, full description, seniority, employment type, industries, applicants and salary. Filter by date posted, company, Easy Apply and under-10 applicants. No login, no key, no browser. Unofficial.

- **URL**: https://apify.com/simple.actor/linkedin-jobs.md
- **Developed by:** [Simple Actor](https://apify.com/simple.actor) (community)
- **Categories:** Jobs, Lead generation
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.30 / 1,000 job returneds

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

## LinkedIn Jobs Scraper – Job Postings by Keyword & Place

Scrape public LinkedIn job postings as structured data. Give it a keyword and
a place — "software engineer" in "United States", "registered nurse" in
"Texas, United States", "marketing manager" in "London, England, United
Kingdom" — and every job LinkedIn lists for the search comes back as a row:
title, company, location, posted date, link and, by default, the full
description with seniority level, employment type, industries, applicant count
and the salary where the posting states one. In LinkedIn's own order, up to
the 1,000 jobs LinkedIn shows for any search.

Built for job-market research, recruiting, lead generation (a company that is
hiring is a company that is buying) and job boards: every open role of a kind
in a place, with the text to read and the id to deduplicate on.

**No login, no cookies, no API key, no browser, no account of yours involved.**
It reads what LinkedIn shows to anyone who opens a job search without signing
in.

**$0.30 per 1,000 jobs** — $0.03 for a default 100-job search — plus Apify's
standard $0.00005 per run start. Apify platform usage included, nothing else
to pay. A search that cannot be read, a job that is not delivered and a job you
told it to skip are never charged.

*Unofficial. Not affiliated with, endorsed by, or sponsored by LinkedIn.*

### Features

- **The whole posting, per job** — description as text and as HTML, seniority
  level, employment type, industries, applicant count, pay range when stated,
  whether it applies on LinkedIn or on the employer's site, and whether it has
  closed.
- **Everything LinkedIn lists** — paged automatically to the end of LinkedIn's
  own results, up to the 1,000 jobs LinkedIn shows for a search, with no job
  twice.
- **The filters LinkedIn still honours** — posted within any window down to a
  minute, one or more companies by name or id, Easy Apply only, under 10
  applicants only, and a distance around a city.
- **Places resolved and echoed** — every place goes through LinkedIn's own
  place lookup, and each row says which place and which LinkedIn geoId it was
  searched in. A place LinkedIn does not know is an error row, never a silent
  search of somewhere else.
- **Trim before you pay** — title must-include and must-not-include lists, and
  a list of job ids you already hold, are applied before a job is read, so a
  job they drop costs nothing.
- **Specific jobs by link** — paste job links or ids and get each as a row,
  with its description.
- **Many searches in one run** — several keywords, several places, every
  combination searched.
- **Runs stay green.** A place or company LinkedIn does not know, a job
  LinkedIn no longer has, a page it would not serve — each becomes an error
  row with a short code and a sentence, the run's status message counts them,
  and the run itself succeeds. The error rows are where to look for what could
  not be done.
- **Cheap to run** — plain HTTP at 128 MB, about one request per job, paced so
  LinkedIn keeps answering.

### Input

| Field | What it does |
|---|---|
| `keywords` | One search per line, as you would type it into LinkedIn's job search. Each is searched in each place. Leave empty to list every job in a place. |
| `locations` | A city, region or country as LinkedIn names it, or a LinkedIn geoId. Leave empty to search worldwide. |
| `maxJobs` | Jobs per search, default 100, ceiling 1,000 (LinkedIn's own). |
| `includeDescription` | On by default: every job is read in full. Off, rows carry only what the search list shows — about ten times fewer requests. Same price per job either way. |
| `postedWithin` | "24 hours", "3 days", "2 weeks", "90 minutes" — any window. |
| `companies` | Only these companies, by name or LinkedIn id. |
| `easyApplyOnly` | Only jobs that take applications on LinkedIn. |
| `underTenApplicantsOnly` | Only jobs LinkedIn marks as having fewer than ten applicants. |
| `distanceMiles` | 5 to 100 miles around a city. |
| `titleMustInclude` / `titleMustNotInclude` | Words or phrases a job's title must or must not contain, applied before the job is read. |
| `skipJobIds` | Job ids or links you already have; left out before anything is read. |
| `jobUrls` | Specific jobs to read directly, as links or ids, one row each. |

```json
{
    "keywords": ["software engineer"],
    "locations": ["United States"],
    "maxJobs": 100,
    "includeDescription": true,
    "postedWithin": "24 hours"
}
```

### Output

One item per job, in LinkedIn's order for each search. Rows are pushed as each
job completes, so a run's dataset grows while it runs.

| Field | What it holds |
|---|---|
| `type` | `job`, or `error` for a search or job that could not be answered |
| `searchKeywords`, `searchLocation`, `searchGeoId` | The search this row answers: the keywords, the place as LinkedIn resolved it, and LinkedIn's id for that place |
| `searchTotalJobs` | How many jobs LinkedIn says the search has (exact when small, capped near 12,000 when large) |
| `position` | The job's rank in LinkedIn's order, 1 first |
| `jobId` | LinkedIn's numeric id for the posting — stable and unique |
| `jobUrl` | The posting's public page, without tracking parameters |
| `title` | The job title |
| `companyName`, `companyUrl`, `companyLogoUrl`, `companyId` | The employer: name, LinkedIn page, logo, numeric id (the id and the logo need a description read) |
| `location` | Where the job is, as posted |
| `postedDate` | YYYY-MM-DD, as LinkedIn's search list states it |
| `postedTimeAgo` | LinkedIn's wording: "1 week ago", "3 days ago", "Just now" |
| `badge` | The search card's label when it has one: "Be an early applicant", "Actively Hiring", "Medical insurance +3 benefits" |
| `descriptionText` | The full description as plain text, paragraphs and bullet points on their own lines |
| `descriptionHtml` | The same description as LinkedIn's HTML |
| `seniorityLevel` | "Entry level", "Associate", "Mid-Senior level", "Director", "Executive" or "Not Applicable" |
| `employmentType` | "Full-time", "Part-time", "Contract", "Temporary", "Internship", "Volunteer" or "Other" |
| `industries` | The employer's industries, as one string |
| `applicants`, `applicantCount` | LinkedIn's wording ("189 applicants", "Over 200 applicants", "Be among the first 25 applicants") and the number: exact, 200 for "over 200", null for "first 25" |
| `salaryText`, `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryPeriod` | The pay range exactly as posted, and as numbers with a currency code and a period ("year", "hour"). Null when the posting states none, which is most of them |
| `applyType` | `linkedin` (Easy Apply) or `external` (the employer's own site) |
| `isClosed` | True when LinkedIn says the posting is no longer accepting applications |
| `hasDescription`, `descriptionError` | Whether the description was read, and if not, why (`rate_limited`, `read_failed`, `not_found`, `parse_failed`) |
| `scrapedAt` | When the run read the row |

A real row, for a search of "software engineer" in "United States"
(description shortened here):

```json
{
    "type": "job",
    "searchKeywords": "software engineer",
    "searchLocation": "United States",
    "searchGeoId": "103644278",
    "searchTotalJobs": 11940,
    "position": 1,
    "jobId": "4419969671",
    "jobUrl": "https://www.linkedin.com/jobs/view/senior-software-engineer-%E2%80%93-go-golang-at-general-motors-4419969671",
    "title": "Senior Software Engineer – Go (Golang)",
    "companyName": "General Motors",
    "companyUrl": "https://www.linkedin.com/company/general-motors",
    "companyLogoUrl": "https://media.licdn.com/dms/image/v2/D4E0BAQEfAZWzerj03w/company-logo_100_100/B4EZdkPGXYGcAQ-/0/1749733370529/general_motors_logo?e=2147483647&v=beta&t=ycfbgxUnJw_BkI-JSrXwekaeYZ6rWNAlvxirgJ7o00Q",
    "location": "Warren, MI",
    "postedDate": "2026-10-04",
    "postedTimeAgo": "1 week ago",
    "badge": "Actively Hiring",
    "companyId": "1472",
    "descriptionText": "Job Description\n\nAs a Senior Software Engineer – Go (Golang), you will design, develop, and deliver high-performance middleware and application software solutions supporting GM's next-generation in-vehicle infotainment platforms and connected vehicle systems. …",
    "descriptionHtml": "<strong>Job Description<br><br></strong>As a Senior Software Engineer – Go (Golang), you will design, develop, and deliver …",
    "seniorityLevel": "Not Applicable",
    "employmentType": "Full-time",
    "industries": "Motor Vehicle Manufacturing, Appliances, Electrical, and Electronics Manufacturing, and IT Services and IT Consulting",
    "applicants": "189 applicants",
    "applicantCount": 189,
    "salaryText": null,
    "salaryMin": null,
    "salaryMax": null,
    "salaryCurrency": null,
    "salaryPeriod": null,
    "applyType": "external",
    "isClosed": false,
    "hasDescription": true,
    "descriptionError": null,
    "scrapedAt": "2026-10-11T16:29:34.603Z"
}
```

A row with a stated salary carries it both ways:

```json
"salaryText": "$123,500.00/yr - $150,000.00/yr",
"salaryMin": 123500,
"salaryMax": 150000,
"salaryCurrency": "USD",
"salaryPeriod": "year"
```

#### Error rows

The run succeeds whatever a search or a job does; what could not be done is a
row with `type: "error"`, a short `error` code and an `errorDescription`
sentence, and the run's status message says how many there are. Error rows are
never charged.

| `error` | When |
|---|---|
| `bad_input` | Nothing to search, a window or distance that could not be read, or a job link that is not one |
| `location_not_found` | LinkedIn's place lookup has no match for the place given |
| `company_not_found` | LinkedIn's company lookup has no match for a company given |
| `not_found` | A job id LinkedIn answers 404 for — never issued, or removed |
| `rate_limited` | LinkedIn answered every attempt with its challenge page |
| `read_failed` | The page could not be read for another reason, on every attempt |
| `parse_failed` | LinkedIn answered with a page this Actor could not read a job from |
| `incomplete` | The search was cut short — the run's timeout or maximum charge was reached, or LinkedIn withheld a page of results on every read — and the row says how many jobs were delivered |

An empty dataset therefore means one thing: every search was read and
LinkedIn lists no jobs for it.

### Pricing

**$0.30 per 1,000 jobs**, the same on every Apify plan, charged per job row as
it is delivered. Apify's standard `apify-actor-start` event adds $0.00005 per
run.

| Run | Jobs | Price |
|---|---|---|
| Default: one search, 100 jobs with descriptions | 100 | $0.030 |
| Ten searches of 100 | 1,000 | $0.30 |
| One search to LinkedIn's limit, list only | 1,000 | $0.30 |
| A search that finds nothing, or an unknown place | 0 | $0.00005 |

Apify platform usage — compute, proxy, storage — is included in the price. A
job whose description could not be read is still delivered with the listing's
fields, says why under `descriptionError`, and is charged as a job; a job that
is not delivered at all is not. Jobs removed by `titleMustInclude`,
`titleMustNotInclude` or `skipJobIds` are never read and never charged.

### How it works, and what it cannot do

**Where the data comes from.** LinkedIn serves a job search, its paging and
each job's description to readers who are not signed in. This Actor reads
those pages, nothing else: no account, no cookie, no browser. About one request
in ten is answered with LinkedIn's "too many requests" page; it is retried and
clears. Requests are paced at about two and a half a second, which LinkedIn has
sustained for runs of over a thousand jobs.

**1,000 jobs per search is LinkedIn's limit, not this Actor's.** LinkedIn
shows at most 1,000 jobs for any search, however many it counts
(`searchTotalJobs` says how many). To get more, split the search — by city
instead of country, by "posted within", by company — and the Actor
deduplicates across searches in a run.

**Some things LinkedIn does not show to a reader who is not signed in**, so no
row carries them: the employer's own apply link (only whether the job applies
on LinkedIn or externally), whether a job is remote, hybrid or on-site (it is
usually in the title or the description), the job function, and an exact
posting time (the date is given).

**Job type, experience level, remote, salary and sort-order filters are not
offered**, because LinkedIn dropped them from its public search in August 2026:
they return the same jobs as no filter at all. Filter the rows on
`employmentType` and `seniorityLevel` instead, or put the word in the keywords
("contract software engineer", "software engineer intern") and use the title
lists.

**LinkedIn's search matches loosely.** "nurse" returns nursing jobs of every
kind; a quoted phrase and NOT are not honoured. The `titleMustInclude` and
`titleMustNotInclude` lists are the precise tool, and they cost nothing
because they run before a job is read.

**No person's data.** Some postings show a "message the job poster" block
with a named person. This Actor does not read it; a row describes the job.

### FAQ

**How long does a run take?** A default 100-job search with descriptions
takes about a minute and a half; a 1,000-job search with descriptions about a
quarter of an hour; a 1,000-job list-only search about three minutes. Rows
arrive as each job completes, so the dataset fills while the run goes.

**Why does a row say `descriptionError`?** LinkedIn answered that job's
description page with its challenge page on every attempt, or the posting was
removed between the search and the read. The listing's fields are real; run
the job again under `jobUrls` to fetch the description.

**Can I poll for new jobs?** Yes: run the same search on a schedule with
`postedWithin` set to the interval, and pass the ids you already hold as
`skipJobIds` so only new jobs are read and charged.

**What is a geoId?** LinkedIn's numeric id for a place; every row carries the
one it was searched in as `searchGeoId`. Pass it back as a location to search
exactly the same place again.

**Why did a company filter return an error row?** The name did not match a
company in LinkedIn's lookup. Check the spelling, or give the company's numeric
id (`companyId` on any of its job rows).

**Why no `proxy` setting?** The Actor reads LinkedIn through Apify's datacenter
proxy, which was measured to work as well as residential for these pages, and
the price includes it.

### Legal

This Actor reads only pages LinkedIn serves to the public without a login, and
only job postings — not profiles. Use the data in line with LinkedIn's terms
and the law where you are, and give people the rights they have over their
data; a job posting is an employer's public notice, not a person's record.

# Actor input Schema

## `keywords` (type: `array`):

What to search for, exactly as you would type it into LinkedIn's job search — "software engineer", "registered nurse", "marketing manager". Each line is searched in each place below. Leave it empty to list every job in a place. Note that LinkedIn's search matches loosely: "nurse" returns nursing jobs of every kind, and a quoted phrase or NOT is not honoured — use the title filters below to narrow the rows.

## `locations` (type: `array`):

Where to search — a city, region or country as LinkedIn names it ("United States", "London, England, United Kingdom", "Texas, United States"), or a LinkedIn geoId. Each place is resolved through LinkedIn's own place lookup and echoed on every row as searchLocation and searchGeoId, so you can see exactly what was searched; a place LinkedIn does not know is an error row, never a silent fallback. Leave it empty to search worldwide.

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

How many jobs to return for each keyword-and-place search. LinkedIn shows at most 1,000 jobs for any search, so that is the ceiling; narrow the search (a city instead of a country, a shorter "posted within") to get different jobs rather than more of them. The jobs come in LinkedIn's own order.

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

On, every job is read in full — description as text and HTML, seniority level, employment type, industries, applicant count, salary when stated, whether it applies on LinkedIn or on the employer's site, and whether it has closed. Off, the rows carry only what the search list shows (title, company, location, posted date, link), which is about ten times fewer requests and a faster run. The price per job is the same either way.

## `postedWithin` (type: `string`):

Only jobs posted within this long: "24 hours", "3 days", "2 weeks", "90 minutes". Any length works, down to a minute. Leave empty for any time.

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

Limit the search to jobs posted by these companies — names ("Google", "General Motors") or LinkedIn's numeric company ids. A name is resolved through LinkedIn's own company lookup, and one it cannot resolve is an error row rather than a silently widened search.

## `easyApplyOnly` (type: `boolean`):

Only jobs that take applications on LinkedIn itself.

## `underTenApplicantsOnly` (type: `boolean`):

Only jobs LinkedIn marks as having fewer than ten applicants so far.

## `distanceMiles` (type: `integer`):

How far around a city to search, 5 to 100 miles. Leave empty for LinkedIn's default (about 25 miles). Has no effect on a region or a country.

## `titleMustInclude` (type: `array`):

Keep a job only if its title contains at least one of these words or phrases (case does not matter): \["senior", "staff", "principal"].

## `titleMustNotInclude` (type: `array`):

Drop a job if its title contains any of these words or phrases (case does not matter): \["intern", "manager"].

## `skipJobIds` (type: `array`):

Job ids or job links you already have. They are left out before anything is read, so a run that repeats a search delivers and charges only what is new.

## `jobUrls` (type: `array`):

Read these jobs directly, with no search — a LinkedIn job link (https://www.linkedin.com/jobs/view/4419969671) or the id on its own, one per line. Each becomes one row with the full description. A job LinkedIn no longer has is an error row.

## Actor input object example

```json
{
  "keywords": [
    "software engineer",
    "data analyst"
  ],
  "locations": [
    "United States",
    "London, England, United Kingdom"
  ],
  "maxJobs": 100,
  "includeDescription": true,
  "postedWithin": "24 hours",
  "companies": [
    "Google",
    "Microsoft"
  ],
  "easyApplyOnly": false,
  "underTenApplicantsOnly": false,
  "distanceMiles": 25,
  "titleMustInclude": [
    "senior",
    "staff"
  ],
  "titleMustNotInclude": [
    "intern"
  ],
  "skipJobIds": [
    "4419969671"
  ],
  "jobUrls": [
    "https://www.linkedin.com/jobs/view/4419969671"
  ]
}
```

# Actor output Schema

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

One row per job: title, company, location, posted date, seniority, employment type, applicants, salary and link.

## `descriptions` (type: `string`):

Each job's full description as text, with its title, company and link.

## `raw` (type: `string`):

Every field of every item, exactly as pushed.

# 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 = {
    "keywords": [
        "software engineer"
    ],
    "locations": [
        "United States"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("simple.actor/linkedin-jobs").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 = {
    "keywords": ["software engineer"],
    "locations": ["United States"],
}

# Run the Actor and wait for it to finish
run = client.actor("simple.actor/linkedin-jobs").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 '{
  "keywords": [
    "software engineer"
  ],
  "locations": [
    "United States"
  ]
}' |
apify call simple.actor/linkedin-jobs --silent --output-dataset

```

## MCP server setup

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

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/5ORbEowyEgti7yjmE/builds/3tgcJOmXASSdmoITG/openapi.json
