# LinkedIn Jobs Scraper (`zyra/linkedin-jobs-actor`) Actor

Reliable LinkedIn jobs data API for production applications. Typed, structured postings -- descriptions, salaries, applicant counts and the job poster -- by search or by URL. No login, no cookies, no account of yours involved.

- **URL**: https://apify.com/zyra/linkedin-jobs-actor.md
- **Developed by:** [Zyra](https://apify.com/zyra) (community)
- **Categories:**
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 2 bookmarks
- **User rating**: 5.00 out of 5 stars

## Pricing

from $0.25 / 1,000 job postings

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

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

## What's an Apify Actor?

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

**A reliable LinkedIn jobs data API for production applications.**

Query LinkedIn's public job listings and get back typed, structured records —
job titles, companies, locations, salaries, full descriptions, applicant
counts, and the person who posted the job where LinkedIn names them.

Built to be scheduled and depended on, not run once by hand:

- **Typed output.** `applicants` is a number, `posted_at` is an ISO 8601
  datetime, `industries` is a list. Nothing downstream has to parse strings.
- **Stable field names.** Documented in the output schema and versioned, so
  the table you load into does not break under you.
- **Honest nulls.** `has_details` tells you whether a posting's page was
  actually read, so a blank salary means "this job does not state one" rather
  than "we never looked".
- **Safe to re-run.** A run that gets moved between machines mid-way resumes
  where it left off and never writes the same posting twice.
- **Incremental by design.** `skipJobIds` drops postings you already have
  before they cost anything, so polling on a schedule stays cheap.

No login. No cookies. No LinkedIn account of yours is involved at any point.
Everything returned is what LinkedIn shows a logged-out visitor.

***

### What you can do with it

- **Job boards and aggregators** — keep a feed of fresh listings for a role,
  a city, or a set of companies.
- **Recruitment and talent research** — watch who your competitors are hiring
  and what they are paying.
- **Lead generation** — a company hiring three sales engineers is a company
  with a budget. The poster's name and profile come with the listing, where
  LinkedIn shows one.
- **Market research** — salary bands, remote-work adoption, and which skills
  keep appearing, over time.

### Quick start

Paste a LinkedIn search URL, or fill in the fields:

```json
{
  "keywords": "python developer",
  "location": "Berlin",
  "maxItems": 100,
  "fetchDetails": true
}
```

That returns 100 job postings with their full descriptions. To also get who
posted each one:

```json
{
  "keywords": "recruiter",
  "location": "United States",
  "maxItems": 100,
  "fetchDetails": true,
  "withRecruiter": true
}
```

### Input

Everything is optional, but a run needs something to search for: either
`jobUrls`, or at least one of `keywords`, `location`, `geoId`, `companyIds` —
or a `searchUrl` that carries them. A bad input fails immediately and tells
you why, before any work is done.

#### What to search for

| Field | Type | Description |
| --- | --- | --- |
| `searchUrl` | string | Paste a LinkedIn jobs search URL and its filters are read out of it. Anything you also set below wins. |
| `keywords` | string | Free text, as you would type it into LinkedIn's search box. |
| `location` | string | A place name, e.g. `Berlin` or `United Kingdom`. Resolved to LinkedIn's own place id when the name is unambiguous. |
| `geoId` | string | LinkedIn's numeric id for a place. Use it when you already know exactly which "London" you mean. |
| `distance` | integer | Radius in miles around the location, `0`–`100`. LinkedIn ignores it for remote searches. |
| `experienceLevels` | array | `internship`, `entry_level`, `associate`, `mid_senior`, `director`, `executive` |
| `jobTypes` | array | `full_time`, `part_time`, `contract`, `temporary`, `volunteer`, `internship`, `other` |
| `workplaceTypes` | array | `on_site`, `remote`, `hybrid` |
| `datePosted` | string | `any_time`, `past_month`, `past_week`, `past_24_hours` |
| `salary` | string | Minimum USD band, `40k` … `200k`. LinkedIn applies this to US searches only. |
| `sortBy` | string | `relevance` (LinkedIn's default) or `date` — use `date` when polling for what is new. |
| `easyApply` | boolean | Only postings applied to on LinkedIn itself. |
| `companyIds` | array | Numeric LinkedIn company ids, to restrict the search to those employers. |

#### Or: specific postings

| Field | Type | Description |
| --- | --- | --- |
| `jobUrls` | array | Fetch these exact postings instead of searching. A posting URL, a share link, or the bare numeric id all work. Every search field above is ignored. |

#### How much, and how detailed

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `maxItems` | integer | `100` | How many postings to return, up to `1000`. |
| `fetchDetails` | boolean | `true` | Open each posting for its description, seniority, employment type, job function, industries and applicant count. |
| `withRecruiter` | boolean | `false` | Also return who posted the job. Needs `fetchDetails`. |
| `includeDescriptionHtml` | boolean | `false` | Keep the description's original formatting alongside the plain text. |

#### Narrowing the results

Applied before each posting is opened, so anything you discard costs nothing.

| Field | Type | Description |
| --- | --- | --- |
| `skipJobIds` | array | Postings you already have. Dropped before they are fetched — this is what makes a repeated run cheap. |
| `titleInclude` | array | Keep only titles containing at least one of these (case insensitive). |
| `titleExclude` | array | Drop titles containing any of these. Applied after the include list. |

#### Performance

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `concurrency` | integer | `5` | How many postings to fetch at once. |
| `maxRetries` | integer | `2` | Retries when a request comes back blocked or rate limited. |
| `requestTimeoutSecs` | integer | `120` | Searches read LinkedIn live, so this is generous by default. |

### Output

One row per posting:

```json
{
  "source": "linkedin",
  "job_id": "4417748171",
  "job_url": "https://www.linkedin.com/jobs/view/software-engineer-python-at-acme-4417748171",
  "title": "Software Engineer, Python",
  "company": "Acme Inc.",
  "company_url": "https://www.linkedin.com/company/acme",
  "location": "Berlin, Berlin, Germany",
  "workplace_type": "Remote",
  "posted_at": "2026-07-16T00:00:00Z",
  "posted_text": "1 month ago",
  "salary": null,
  "seniority": "Mid-Senior level",
  "employment_type": "Full-time",
  "job_function": "Engineering",
  "industries": ["IT Services and IT Consulting"],
  "applicants": 171,
  "benefits": "Be an early applicant",
  "description": "About the role…",
  "logo_url": "https://media.licdn.com/…",
  "recruiter_name": "Jane Doe",
  "recruiter_url": "https://www.linkedin.com/in/jane-doe",
  "recruiter_headline": "Talent Acquisition Leader @ Acme",
  "scraped_at": "2026-08-31T10:20:20.838463Z",
  "has_details": true
}
```

#### Fields

| Field | Notes |
| --- | --- |
| `job_id`, `job_url` | LinkedIn's own id, and the posting's public URL |
| `title`, `company`, `company_url`, `location` | From the search result |
| `workplace_type` | `Remote`, `On-site` or `Hybrid`, when stated |
| `posted_at` | A real timestamp, not a string you have to parse |
| `posted_text` | What LinkedIn displayed, e.g. `3 days ago` |
| `logo_url`, `benefits` | The company logo, and lines like "Be among the first 25 applicants" |
| `salary` | As written in the posting. Most postings do not state one. |
| `seniority`, `employment_type`, `job_function` | With `fetchDetails` |
| `industries` | A **list**, not a comma-joined string |
| `applicants` | A **number**, not `"64 applicants"` |
| `description` | Plain text. Add `includeDescriptionHtml` for the markup too. |
| `recruiter_name`, `recruiter_url`, `recruiter_headline` | With `withRecruiter` |
| `has_details` | Whether the posting itself was opened — see below |
| `scraped_at` | When this row was read |

**Types are real types.** `applicants` is `171`, not `"171 applicants"`.
`posted_at` is an ISO 8601 timestamp. `industries` is a list. You should not
have to write a parser for a scraper's output.

**A field LinkedIn did not state comes back `null`** — never an empty string,
never a guess. `has_details` tells you whether the posting was actually
opened, so you can tell "this job has no salary" apart from "this row was
never enriched".

### Things worth knowing

**About 1,000 results per search.** LinkedIn's public job search stops
producing new listings somewhere past a thousand, whatever is asked for. For
more than that, split the search — by location, by `datePosted`, or by
company — and run it more often.

**Roughly half of postings name a poster.** `withRecruiter` returns the
person LinkedIn shows under "Direct message the job poster". Many postings do
not show one, and for those the three recruiter fields are `null`. That means
"this posting names nobody", not "we failed to look".

**`easy_apply` and `apply_url` are `null`.** LinkedIn stopped showing
logged-out visitors whether a posting is Easy Apply or where an off-site
application goes. Rather than guess, these come back empty. The `easyApply`
**filter** still works — you can restrict a search to Easy Apply postings,
you just are not told which ones they are afterwards.

**The same search twice returns a slightly different slice.** LinkedIn
rotates results for logged-out visitors. Runs are de-duplicated internally by
job id, so you never get the same posting twice in one dataset, but two runs
minutes apart will not match exactly.

**Public data only.** What a logged-out visitor can see. Recruiter contact
details, applicant lists and anything behind a login are not included.

**Location names can be ambiguous.** "Turkey" is a town in North Carolina as
well as a country. When a name is resolved to a place, the run summary records
which place was chosen — check it if results look geographically odd, and set
`geoId` to settle it.

### Polling for new jobs

Sort by date, keep the ids you have seen, and skip them next time:

```json
{
  "keywords": "python developer",
  "location": "Berlin",
  "sortBy": "date",
  "datePosted": "past_24_hours",
  "maxItems": 100,
  "skipJobIds": ["4417748171", "4448412664"]
}
```

Skipped postings are dropped before they are fetched, so a run that finds
nothing new costs almost nothing.

### Support

Found a bug, or a field you need that is not here? Open an issue on the
actor's Issues tab.

# Actor input Schema

## `searchUrl` (type: `string`):

Paste a LinkedIn jobs search URL and its filters are read back out of it. Any field you also set below wins over what the URL carried.

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

Free text, exactly as you would type it into LinkedIn's search box.

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

A place name, e.g. 'Berlin' or 'United Kingdom'. Resolved to LinkedIn's own geo id where the name is unambiguous; the run summary reports which place was chosen.

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

LinkedIn's numeric id for a place. More precise than the name, and it skips the lookup: set it when you already know exactly which 'London' you mean.

## `distance` (type: `integer`):

How far around the location to search. LinkedIn ignores it for a remote search.

## `experienceLevels` (type: `array`):

Any number of levels. Leaving it empty means every level.

## `jobTypes` (type: `array`):

Any number of types. Leaving it empty means every type.

## `workplaceTypes` (type: `array`):

On-site, remote or hybrid. Leaving it empty means all three.

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

How recent a posting must be.

## `salary` (type: `string`):

USD floor. LinkedIn only applies this to US searches; elsewhere it narrows nothing.

## `sortBy` (type: `string`):

Relevance is LinkedIn's default. Sort by date when you are polling for what is new.

## `easyApply` (type: `boolean`):

Only postings that are applied to on LinkedIn itself.

## `companyIds` (type: `array`):

Numeric LinkedIn company ids, one per line. Restricts the search to those employers.

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

Fetch these exact postings instead of searching. A posting URL, a share link, or the bare numeric id -- all three work. When this is set, every search field above is ignored.

## `maxItems` (type: `integer`):

How many postings to return. A thousand is the ceiling, because that is roughly where LinkedIn's guest search stops producing new results, whatever is asked for.

## `fetchDetails` (type: `boolean`):

Open each posting's page for its description, seniority, employment type, job function, industries and applicant count. One extra request per posting -- slower, and the reason most people run this at all.

## `withRecruiter` (type: `boolean`):

Also return the name, profile URL and headline of the person the posting invites you to message. Needs 'Fetch full postings'. Roughly half of postings name nobody, and those come back empty rather than guessed. Slower: it reads each posting's own page instead of the lighter summary.

## `includeDescriptionHtml` (type: `boolean`):

Also store the description's original markup alongside the plain text. It is the largest field on a row; leave it off unless you need the formatting.

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

Postings you already have, one id or URL per line. They are dropped before anything is fetched for them, which is what makes a repeated run cheap.

## `titleInclude` (type: `array`):

Keep only postings whose title contains at least one of these, case insensitive. Leave empty to keep every title.

## `titleExclude` (type: `array`):

Drop postings whose title contains any of these, case insensitive. Applied after the include list.

## `concurrency` (type: `integer`):

How many postings to fetch at once. Raise it for speed, lower it if postings start coming back without their descriptions.

## `maxRetries` (type: `integer`):

How many times to try again when a request comes back blocked or rate limited.

## `requestTimeoutSecs` (type: `integer`):

A search scrapes LinkedIn live before it answers, so this needs to be generous. Two minutes by default.

## Actor input object example

```json
{
  "keywords": "python developer",
  "location": "Remote",
  "easyApply": false,
  "maxItems": 100,
  "fetchDetails": true,
  "withRecruiter": false,
  "includeDescriptionHtml": false,
  "concurrency": 5,
  "maxRetries": 2,
  "requestTimeoutSecs": 120
}
```

# Actor output Schema

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

One row per posting, newest page first. Fields only a posting's own page can supply are null unless the run had 'Fetch full postings' on -- each row's has\_details says which.

## `jobsCsv` (type: `string`):

The same rows as a spreadsheet.

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

How many postings were scraped against how many were asked for, the place a free-text location resolved to, any job ids that could not be fetched, whether the run stopped at its charging limit, and the error that ended it early if one did.

# 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": "Remote"
};

// Run the Actor and wait for it to finish
const run = await client.actor("zyra/linkedin-jobs-actor").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": "Remote",
}

# Run the Actor and wait for it to finish
run = client.actor("zyra/linkedin-jobs-actor").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": "python developer",
  "location": "Remote"
}' |
apify call zyra/linkedin-jobs-actor --silent --output-dataset

```

## MCP server setup

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

```

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/A1kL53YWFzJQbSazC/builds/UYfk3aFOklvcBMzik/openapi.json
