# LinkedIn Job Scraper API - Salary, Skills & Full Descriptions (`thodor/linkedin-job-scraper-api`) Actor

LinkedIn job scraper tool that searches by keyword and location and returns every posting with the full description, salary, skills, and applicant count. No login or cookies.

- **URL**: https://apify.com/thodor/linkedin-job-scraper-api.md
- **Developed by:** [Thodor](https://apify.com/thodor) (community)
- **Categories:** Lead generation, Social media
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

### What does LinkedIn Job Scraper API do?

LinkedIn job scraper tool that searches by keyword and location and returns every matching posting with the full description, salary, skills, seniority, and applicant count on each row. Search `python developer` in `Amsterdam, Netherlands` and get the jobs back as structured JSON. No login, no cookies, no LinkedIn account.

Real output from a run on `python developer` and `data engineer` in Amsterdam:

| Job title | Company | Location | Posted | Seniority | Applicants |
| --- | --- | --- | --- | --- | --- |
| Python Software Engineer | IMC Trading | Amsterdam, North Holland, Netherlands | 2026-07-26 | Mid-Senior level | 200+ |
| Backend Engineer (Python / FastAPI / SQL) | MarvelX AI | Amsterdam, North Holland, Netherlands | 2026-07-17 | Associate | 200+ |
| Python Developer | MN | The Hague, South Holland, Netherlands | 2026-07-29 | Mid-Senior level | 98 |
| Data Engineer | Samsung Electronics | Schiphol-Rijk, North Holland, Netherlands | 2026-07-22 | Associate | 200+ |
| Data Engineer | CIMSOLUTIONS | North Holland, Netherlands | 2026-07-28 | Associate | 25 |

That run took 20 seconds for 10 jobs across two keywords. Every row also carries the full job description as HTML and as plain text, the employment type, posting and expiry dates, required skills, education level, the hiring organization block, and the original LinkedIn URL.

### Why use LinkedIn Job Scraper API?

Most LinkedIn job scrapers give you what the search results page shows: title, company, location, date. This one opens every job and returns what is actually inside it.

- **Full descriptions, in HTML and plain text.** The description is where the tech stack, the team, the salary range, and the reporting line live. `description_text` is ready to feed to an LLM without stripping tags first.
- **Remote and seniority on every row.** `is_remote` and `seniority` come from each job's own page, so they are accurate even though LinkedIn's public search ignores its own filters for both. Filter on them in your spreadsheet or query.
- **Applicant counts as a competition signal.** A three-day-old posting with 12 applicants is a different opportunity than one with 200.
- **Expiry dates.** `valid_through` tells you when a posting closes, which no search-results scraper can give you.
- **Company details on request.** Switch on **Add company details** and every job carries the employer's industry, exact headcount, follower count, HQ, and founded year. Each company is fetched once per run however many of its jobs you get, and it is the one option billed on top of the jobs.

A worked example: run `data engineer` in `Amsterdam, Netherlands` on a daily schedule with **Posted within** set to `Past 24 hours`, diff the `job_id` column against yesterday, and push the new rows to Slack. That is a live feed of every company staffing a data team in your market, with the tech stack from each description and the applicant count telling you how contested each one is. Swap the diff for a group-by on `company` and the same feed becomes a hiring-intent list for sales.

Salary research works the same way: `base_salary` is populated when the employer publishes structured pay, and when it is not, the range is usually written into `description_text`, which is why the plain-text field is there.

### How to use LinkedIn Job Scraper API

1. Open the Actor and type your search terms into **Search keywords**, one per line. Each term runs as its own search.
2. Set **Location** the way you would type it on LinkedIn: `Berlin, Germany`, `London, United Kingdom`, `United States`. Leave it empty to search worldwide.
3. Click **Start**. Jobs stream into the dataset as they are found, so the **Output** tab fills up before the run finishes.
4. Export as JSON, CSV, or Excel, or pull the dataset over the [Apify API](https://docs.apify.com/api/v2#/reference/datasets).

Already built the search on LinkedIn? Paste the URL into **Search URLs** instead and the keywords and location are read from it.

Running a list of terms from a spreadsheet? The Console's **Bulk edit** tab on the **Search keywords** field takes one term per line, so a column pastes straight in.

#### Use it as a LinkedIn jobs API

Every run is an HTTP endpoint. Call the Actor synchronously and get the jobs back in the response body:

```bash
curl -X POST "https://api.apify.com/v2/acts/thodor~linkedin-job-scraper-api/run-sync-get-dataset-items?token=<APIFY_TOKEN>" \
  -H 'Content-Type: application/json' \
  -d '{"keywords": ["python developer"], "location": "Amsterdam, Netherlands"}'
```

The same call from Python:

```python
from apify_client import ApifyClient

client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("thodor/linkedin-job-scraper-api").call(
    run_input={
        "keywords": ["python developer"],
        "location": "Amsterdam, Netherlands",
        "maxJobsPerSearch": 0,
    }
)
for job in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(job["title"], job["company"], job["location"])
```

#### Run it on a schedule

Set **Posted within** to `Past 24 hours` and put the Actor on a daily [Apify Schedule](https://docs.apify.com/platform/schedules). Each run then returns only what appeared since the last one, so the dataset becomes a feed of new postings rather than a full re-scrape. Add a webhook and new jobs land in Slack, Airtable, or your CRM the morning they go live. The n8n and Make integrations take the same trigger.

### Input

```json
{
  "keywords": ["python developer", "data engineer"],
  "location": "Amsterdam, Netherlands",
  "postedWithin": "30d",
  "maxJobsPerSearch": 25,
  "scrapeDetails": true
}
```

| Field | Type | Default | What it does |
| --- | --- | --- | --- |
| `keywords` | array | `python developer` | Search terms, one per line. Each runs as its own search and a job matched by two terms is returned once. |
| `location` | string | empty | Written as you would type it on LinkedIn. Empty means worldwide. |
| `postedWithin` | dropdown | Any time | Limits results to the last 24 hours, week, or month. |
| `jobType` | dropdown | Any | Full-time, part-time, contract, temporary, internship, volunteer, other. |
| `maxJobsPerSearch` | integer | `25` | Stop after this many jobs per keyword. **Set it to `0`** for everything LinkedIn serves. |
| `scrapeDetails` | boolean | `true` | Open each job for the full record. Turn off for a much faster run with search-results fields only. |
| `enrichCompanies` | boolean | `false` | Attach employer firmographics to every job, one lookup per company per run. |
| `searchUrls` | array | empty | Paste LinkedIn job search URLs instead of typing keywords. |

### Output

![LinkedIn Job Scraper API output showing job title, company, location, remote flag, posted date, applicant count, employment type and seniority for each scraped LinkedIn job](https://api.apify.com/v2/key-value-stores/LHcvkclm26dcJvwP1/records/linkedin_job_search_output_example.png)

Those nine columns are the **Output** tab's default view, not the whole record. Each row also carries the full description as HTML and plain text, the expiry date, required skills, education level, the employer block, and the structured location, which all come through on export and over the API.

One dataset row per job:

```json
{
  "search_keywords": "python developer",
  "search_location": "Amsterdam, Netherlands",
  "job_id": "4445431410",
  "title": "Python Software Engineer",
  "company": "IMC Trading",
  "company_url": "https://www.linkedin.com/company/imc-trading",
  "location": "Amsterdam, North Holland, Netherlands",
  "is_remote": false,
  "posted": "2026-07-26",
  "date_posted": "2026-07-26T02:18:40.000Z",
  "valid_through": "2026-08-26T02:18:40.000Z",
  "employment_type": "FULL_TIME",
  "seniority": "Mid-Senior level",
  "job_function": "Engineering and Information Technology",
  "industries": "Financial Services",
  "applicants": 200,
  "employer_job_id": "f862f028-49d1-4487-96d8-47b7e73fd508",
  "base_salary": null,
  "skills": null,
  "education": { "@type": "EducationalOccupationalCredential", "credentialCategory": "bachelor degree" },
  "description_html": "<p>...</p>",
  "description_text": "...",
  "job_location": { "locality": "Amsterdam", "region": null, "country": "NL", "street": null },
  "view_url": "https://nl.linkedin.com/jobs/view/python-software-engineer-at-imc-trading-4445431410"
}
```

The example is trimmed to fit. You can preview results in the **Output** tab while the run is still going, and download the dataset as JSON, CSV, Excel, HTML, XML, or RSS from the **Storage** tab. Everything is also available through the [Apify API](https://docs.apify.com/api/v2#/reference/datasets) and the Make, Zapier, n8n, and Google Sheets integrations.

### Data table

| Field | Notes |
| --- | --- |
| `job_id`, `view_url` | Stable LinkedIn identifiers. Use `job_id` to deduplicate across runs and detect new postings. |
| `search_keywords`, `search_location` | Which of your searches produced this row, so multi-keyword runs stay traceable. |
| `title`, `company`, `location`, `posted` | The basics, on every row even with details switched off. |
| `is_remote` | `true` when LinkedIn flags the posting as remote, read from the job's own page rather than the search results. |
| `date_posted`, `valid_through` | Exact timestamps from LinkedIn's structured data, including when the posting expires. |
| `applicants` | Capped by LinkedIn at 200. A row showing `200` means "200 or more", and `applicants_raw` keeps the original wording. |
| `seniority`, `job_function`, `industries`, `employment_type` | Always in English, on every job in every country. |
| `months_of_experience` | Required experience as a number, so `24` means two years. Filter on it instead of parsing prose. |
| `employer_job_id` | The employer's own requisition ID, separate from LinkedIn's `job_id`. Joins against a careers page or ATS. |
| `base_salary` | Populated when LinkedIn publishes structured pay, which is uncommon on public postings. Usually `null`. |
| `description_html`, `description_text` | Full posting body, as HTML and as plain text with paragraphs and list items on their own lines. |
| `skills`, `education`, `hiring_org`, `job_location` | Present when LinkedIn publishes them, `null` when it does not. |
| `company_details` | Employer firmographics, when **Add company details** is on. `null` otherwise. |
| `detail_error` | `null` on success. Set when a detail page failed, so you can retry those rows. |

### How much does it cost to scrape LinkedIn jobs?

Billing is per job returned, at the per-1,000 rate shown on this page, and `maxJobsPerSearch` doubles as a hard cost cap. Searches, result pages, and jobs that fail to load are all free.

The 10-job Amsterdam run above bills as 10 jobs, well under a cent per job at any plan tier. The monthly usage credit included in Apify's free plan covers a few thousand jobs.

**Add company details** is the one thing billed on top, because it fetches a company page that produces no row of its own. It is off unless you switch it on, and it bills once per company per run however many of that company's jobs you get, so 40 jobs across 12 employers bills 40 jobs plus 12 companies. A lookup that fails is not billed.

### Tips and advanced options

- **Search for remote work with the keyword.** LinkedIn ignores its own remote filter on public searches, so `remote python developer` as a search term is the way to bias results that way. Every row still carries `is_remote` from the job's own page, so you can drop the ones that are not.
- **Detect new postings, not all postings.** Set **Posted within** to `Past 24 hours` on a daily schedule and every run is already just the new ones. Keep the `job_id` set from last time and diff it if a missed run would matter.
- **Turn details off for a headcount sweep.** If you only need to know how many jobs match a term in a market, **Scrape full job details** off makes the run several times faster.
- **Split a broad search to get past the 1,000 cap.** Run the same keyword against several locations, or the same location across `Past week` windows, and each search gets its own 1,000. Jobs found twice are still only returned once.

### What you get and what you do not

Public LinkedIn job search is generous but bounded. Being specific about the edges:

- **One search returns at most 1,000 jobs.** LinkedIn's ceiling for a logged-out visitor, not a limit of this Actor. Split by location or posting window to cover more.
- **No recruiter or job poster.** LinkedIn shows that only to logged-in members, so it is absent rather than guessed at.
- **No external apply URL.** Also login-gated. `view_url` always gets you to the posting itself.
- **`base_salary` is usually null.** Employers rarely publish structured pay. When they do you get currency, range, and period.
- **Remote and experience level are not search filters.** LinkedIn ignores its own, so you filter on the `is_remote` and `seniority` columns after the run instead.

### Under the hood

**No login, no cookies, no account.** Jobs come from LinkedIn's public guest endpoints, the same pages a logged-out visitor sees. Requests use Chrome TLS impersonation so they look like an ordinary browser.

**Only filters LinkedIn honours are offered.** Its public search takes remote and experience-level parameters and then returns the same results either way, so those are not exposed here as settings you could trust. Both values are accurate on the job pages themselves and ship on every row as `is_remote` and `seniority`.

**Job type is LinkedIn's own filter and it works, with one wrinkle.** When too few postings match, LinkedIn relaxes the filter and returns adjacent types rather than an empty page, so a narrow search can include employment types you did not ask for. `employment_type` on each row is always the truth.

**Jobs are not returned newest first.** LinkedIn orders them by its own relevance ranking and ignores any request to sort by date, so **Posted within** is the way to bias a run toward recent postings. Sort on `date_posted` if you need strict chronology.

**Requests go out directly first.** Only when LinkedIn refuses does the Actor retry through a US proxy, up to 5 times, each on a different IP. A clean run never touches the proxy. Refusal means HTTP 403, 429, or 999, or a 200 served from the login wall.

**Runs stop themselves when LinkedIn shuts the door.** If 15 requests fail back to back, the run ends with an explanation rather than grinding on. Any success resets that counter. Whatever was scraped before the stop stays in the dataset.

### FAQ

**Do I need a LinkedIn account or cookies?**
No. The Actor reads LinkedIn's public guest endpoints, the same pages a logged-out visitor sees. Your account is never used, so it cannot be restricted or banned for your runs.

**Is this legal?**
It collects only publicly visible job postings, which are published deliberately so people can find and apply to them. It does not touch private data and it does not log into anyone's account. You are responsible for how you use the output, and GDPR applies to you if you store personal data an employer chose to write into a description.

**Does it include the recruiter who posted the job?**
No. LinkedIn shows the job poster only to logged-in members. Fields that require login are left out rather than filled with guesses.

**Why is `base_salary` usually null?**
LinkedIn publishes structured pay only when the employer provides it, which is uncommon. When it exists you get currency, range, and pay period. Otherwise salary often sits in the description, and `description_text` is there to mine for it.

**Why is there no remote or seniority filter?**
LinkedIn's public job search accepts both and then ignores them, so a toggle here would do nothing. Every row carries `is_remote` and `seniority` read from the job's own page, which is accurate, so filter on those columns after the run.

**Why did my search return fewer jobs than LinkedIn shows?**
LinkedIn caps any public job search at 1,000 results and does not serve more to a logged-out visitor. Split the search by location or by posting window to cover more.

**How current is the data?**
Fetched live from LinkedIn at the moment of the run. There is no cached database in between, so you get what the page shows right then.

**Can I search within one company instead?**
Use the LinkedIn Company Jobs Scraper, which takes a company and returns every opening it has, and is not subject to the 1,000-result search ceiling.

### Support

Found a bug or need a field that is not here? Open an issue on the **Issues** tab and it will be looked at.

# Actor input Schema

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

What to search for, one term per line. Each term runs as its own search, and a job found by two terms is only returned once.

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

Where to search, written the way you would type it on LinkedIn: `Berlin, Germany`, `London, United Kingdom`, `United States`. Leave empty to search worldwide. Applies to every keyword.

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

Only return jobs posted inside this window. Narrowing it makes runs much faster and is the reliable way to get recent postings, because LinkedIn ignores any request to sort by date.

## `jobType` (type: `string`):

Filter by employment type. LinkedIn applies this itself, but relaxes it when too few postings match, so a narrow search can still return adjacent types. Check the `employment_type` field on each row if it matters.

## `maxJobsPerSearch` (type: `integer`):

Stop after this many jobs per keyword. Defaults to 25 so a first run finishes quickly. **Set it to 0 for everything LinkedIn will serve**, which is up to 1,000 per search.

## `scrapeDetails` (type: `boolean`):

Open each job to collect the full description, salary, skills, applicant count, seniority, and expiry date. Turn it off for a much faster run that returns only what the search results page shows.

## `enrichCompanies` (type: `boolean`):

Attach the employer's industry, exact headcount, follower count, HQ, founded year, and website to every job. Billed separately from the jobs, once per company per run no matter how many of its jobs you get, and only when the lookup succeeds.

## `searchUrls` (type: `array`):

Paste LinkedIn job search URLs instead of typing keywords. Build the search on LinkedIn, copy the address bar, drop it here. The keywords and location are read from the URL; other LinkedIn filters in it are ignored.

## Actor input object example

```json
{
  "keywords": [
    "python developer"
  ],
  "location": "United States",
  "postedWithin": "any",
  "jobType": "any",
  "maxJobsPerSearch": 25,
  "scrapeDetails": true,
  "enrichCompanies": false,
  "searchUrls": []
}
```

# Actor output Schema

## `results` (type: `string`):

No description

# 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": [
        "python developer"
    ],
    "location": "United States",
    "maxJobsPerSearch": 25
};

// Run the Actor and wait for it to finish
const run = await client.actor("thodor/linkedin-job-scraper-api").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": ["python developer"],
    "location": "United States",
    "maxJobsPerSearch": 25,
}

# Run the Actor and wait for it to finish
run = client.actor("thodor/linkedin-job-scraper-api").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 '{
  "keywords": [
    "python developer"
  ],
  "location": "United States",
  "maxJobsPerSearch": 25
}' |
apify call thodor/linkedin-job-scraper-api --silent --output-dataset

```

## MCP server setup

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

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/X1cnSUZY5D7QUHaDC/builds/Iqu1BOAsDWrIebYks/openapi.json
