# Indeed Jobs Scraper - Salaries, Remote & Company Ratings (`eiv/indeed-jobs-scraper`) Actor

Scrape Indeed job search results in 23 countries: parsed salary ranges as numbers, remote work model, employer rating and review count, posting date and full descriptions. No login, no API key. Tells you when Indeed quietly ignored your location.

- **URL**: https://apify.com/eiv/indeed-jobs-scraper.md
- **Developed by:** [Eimantas V](https://apify.com/eiv) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.90 / 1,000 job scrapes

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

## Indeed Jobs Scraper

Scrape Indeed search results across **23 countries** — no login, no API key, no quota.

One row per job, plus a summary row per search that tells you exactly what was and was not collected.

***

### Salary as numbers, not prose

Most job exports hand you `"$85,000 - $145,000 a year"` and leave you to parse it. This one gives you the range Indeed already parsed:

| | |
|---|---|
| `salaryMin` / `salaryMax` | `85000` / `145000` |
| an open-ended range | `"From $80,000 a year"` gives `salaryMin: 80000`, `salaryMax: **null**` — Indeed's own payload says `-1` there, and a `-1` silently wrecks every average and threshold it touches |
| `salaryCurrency` | `USD` |
| `salaryPeriod` | `yearly` — so you never compare an hourly rate against an annual one |
| `salaryText` | the original wording, kept |
| **`salaryIsEstimate`** | **`true` when the figures are Indeed's own estimate rather than pay the employer published** |

That last field is the one that matters. Indeed estimates pay for postings that don't state any, and a salary dataset that silently mixes the two isn't a salary dataset. Filter on it, or report the two separately.

### What else is on every job

| | |
|---|---|
| **Employer** | name, Indeed rating out of 5, and how many reviews it's based on — a 5.0 from 2 reviews and a 4.1 from 9,000 are different facts |
| **Remote** | a remote flag *and* Indeed's work model, so "remote" can mean fully remote rather than hybrid |
| **Timing** | UTC posting timestamp, plus Indeed's own "3 days ago" wording |
| **Type** | full-time, contract, internship and so on, where the employer set one |
| **Links** | the canonical posting URL — not Indeed's tracking redirect, which expires |
| **Flags** | urgently hiring, new, **sponsored**, expired, easy-apply |
| **Text** | Indeed's summary snippet, and optionally the complete posting |

A company with no rating comes back as `null`, not the `0` Indeed sends. Zero stars and no stars are different things.

***

### The thing Indeed does that quietly ruins job datasets

**Indeed does not reject a location it can't parse.** Ask it for `Bostn` and it doesn't error — it searches a much wider area and hands you a full page of results that look perfectly fine. You get 45 jobs from across the country and no indication anything went wrong.

So every summary row carries **`locationUsed`**: the location Indeed *actually* searched, read back off the page. When it doesn't match what you asked for, the log says so. Check that one field and a whole class of silently-wrong data disappears.

The same applies to sponsored placements repeating across pages, and to two of your searches matching the same posting. Both are deduplicated **run-wide by job key**, so you're never charged twice for one job.

***

### Indeed shows you adverts first

This is not a scraping quirk, it is how Indeed works, and it catches people out: **the entire sponsored block sits above the organic results.** Measured on a real run, the first 20 results for "software engineer" in Remote were **20 sponsored postings and no organic ones at all**.

Sponsored jobs are real vacancies, so they are kept by default — but you are never left guessing:

- every row carries **`isSponsored`**
- **Exclude sponsored adverts** drops them, and filtered rows are never written or charged
- raising **Max jobs per search** past the advert block reaches the organic listings too

***

### An empty result always tells you why

Every search gets a **summary row** carrying how many jobs Indeed itself reports exist, how many were examined, how many your filters dropped, how many pages were read, and why it stopped. When a search produced nothing, `zeroReason` says which:

| `zeroReason` | What it means |
|---|---|
| `search-matched-nothing` | Indeed returned a results page with no jobs on it |
| `all-jobs-filtered-out` | Jobs were found; **your** salary or remote filter removed them |
| `run-job-limit-reached` | Your own run cap stopped it |
| `run-timeout-reached` | The run stopped itself before the platform could kill it |

Searches that couldn't be read at all get a typed **error row** instead, and error rows are never charged. A blocked page partway through a search reports `stoppedOn: "upstreamFailed"` rather than `resultsExhausted` — because "the results ran out" and "we were blocked at page 4" produce the same row count and mean opposite things.

***

### How to collect a lot of jobs

Each search returns **about 45 jobs for one request**, and that is the ceiling per search. So volume comes from running *many* searches rather than paging through one, and the Actor is built for exactly that — searches run in parallel and every row says which one it came from.

Three ways to multiply, all of which give Indeed a genuinely different first page:

| Split by | Example |
|---|---|
| **Keyword** | `registered nurse`, `ICU nurse`, `nurse practitioner`, `LPN` |
| **City** | Boston, Worcester, Providence, Springfield — rather than one wide radius |
| **Date window** | re-run daily with **Posted within: Last 24 hours** and **Sort by: Date posted** |

That last one is the most efficient pattern by far: a daily run with a 24-hour window keeps a complete rolling picture of a market for one request per search per day, and nothing is charged twice because postings are deduplicated by job key across the whole run.

***

### Four ways to narrow it

- **Keywords + location + country** — the ordinary path, and 23 Indeed sites are selectable because a UK search sent to the US site returns US jobs without complaining
- **Indeed URLs** — paste search pages straight in, used verbatim so any filter you set on Indeed itself survives. This is the way to use Indeed filters this Actor does not model, and each URL is its own first page
- **Posted within / job type / radius / sort by date** — Indeed's own filters
- **Minimum salary, remote-only and exclude-sponsored** — applied here, on the results, because Indeed treats its versions as advisory. Minimum salary compares against the *top* of the range, so a role at 80k–150k still matches a 100k threshold

Filtered jobs are **never written and never charged**.

***

### Honest about limits

- **One page per search, about 45 jobs.** Indeed serves the first page of a search to an automated client and refuses the rest. That is measured rather than assumed — across five runs, page two came back HTTP 502 through the unblocking proxy every time, and HTTP 403 on a plain connection. Rather than ship a page setting that quietly returns one page, this Actor defaults to one and tells you: set it higher and you get page one's jobs plus a summary row reading `stoppedOn: "upstreamFailed"` and an error row saying what happened. **Depth comes from more searches, not deeper pages** — see below.
- **Posting dates are the original date.** Employers repost, so a role can carry an old date and still be live. Anything older than a month is `30+ days ago` — that's all Indeed will say.
- **Full descriptions are slow.** A search request hands over about 45 jobs at once; a description costs one request *per job*. That's roughly forty times the work for the same rows, which is why it's off by default and priced separately.
- **Many postings state no pay at all.** Any minimum-salary filter above 0 drops them.
- **Not every search reports a total.** `totalResultsReported` is taken from Indeed's own "Page 1 of N jobs" counter, and is `null` when the page does not print one rather than being estimated.

***

### Pricing

Pay per result. You're charged for rows that were actually written.

| Event | Price |
|---|---|
| **Job scraped** | **$1.50 per 1,000 jobs** |
| Full description fetched | $4.00 per 1,000, only when descriptions are on |
| Actor start | $0.00005 per run |

Prices fall on every paid Apify plan, to **$0.90 per 1,000 jobs** at the top tier.

Summary rows are free. Error rows are free. Filtered jobs are free. Duplicate postings are charged once. A request that came back blocked and was retried on a fresh IP is not charged.

***

### Getting started

Put a job title in **Search keywords**, a city in **Location**, press **Start**. The defaults collect one page per search — about 45 jobs — with descriptions off, which is one request.

Then:

- Set **Sort by** to `Date posted` and **Posted within** to `Last 24 hours` for a feed you can re-run on a schedule
- Turn on **Remote jobs only** and set **Minimum salary** to profile a market
- Add several **Search keywords** and cities at once — they run in parallel, every row says which search it came from, and this is how you get volume
- Turn on **full descriptions** only when you need the text, and expect it to take far longer

Export to JSON, CSV, Excel or XML, or pull the dataset straight from the Apify API.

# Actor input Schema

## `searchQueries` (type: `array`):

What to search for, one per line - a job title, a skill, a company name, or any phrase Indeed accepts. Each line is run as its own search and every row says which search it came from. Leave empty and fill in Indeed URLs instead if you already have the searches you want.

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

City, state, postcode or "Remote", applied to every search keyword. Worth knowing: Indeed does not reject a location it cannot parse - it quietly searches a wider area instead and returns a full page of results. This Actor reads back the location Indeed actually used and puts it on the summary row as locationUsed, and warns in the log when it differs from what you asked for.

## `country` (type: `string`):

Indeed runs a separate site per country and they are not interchangeable - a UK search sent to the US site returns US jobs without complaining. This picks the right one.

## `startUrls` (type: `array`):

Paste Indeed search pages directly, for example https://www.indeed.com/jobs?q=nurse\&l=Boston. A URL is used exactly as given, including any Indeed filter this Actor does not model, so nothing you set on Indeed itself is silently dropped. A /viewjob link is a single posting rather than a search and is rejected with a reason.

## `sort` (type: `string`):

Indeed's own ranking, or newest first. Date order is the one to use when you re-run a search on a schedule and only want what has appeared since.

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

Only jobs posted in the last N days. Indeed's date is the ORIGINAL posting date, and employers repost, so a role you have seen before can reappear as new.

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

Restrict to one employment type. Many postings carry no type at all, and those are excluded by any setting other than Any.

## `remoteOnly` (type: `boolean`):

Keep only postings Indeed marks as remote. Applied both as Indeed's own remote filter and again on the returned rows, because the filter alone lets hybrid roles through.

## `excludeSponsored` (type: `boolean`):

Drop paid placements and keep only organic results. Worth knowing before you leave this off: Indeed puts its entire sponsored block ABOVE the organic listings, so a small job limit can return nothing but adverts - measured, the first 20 results for "software engineer" in Remote were 20 sponsored postings and no organic ones. Sponsored jobs are real vacancies, so this is off by default, but every row carries isSponsored either way and filtered rows are never written or charged.

## `radiusMiles` (type: `integer`):

How far from the location to search. Ignored when no location is set, because Indeed has nothing to measure from.

## `minSalary` (type: `integer`):

Drop jobs whose advertised pay tops out below this, in the country's own currency and matching the posting's own period. Compared against the TOP of the range, so a role advertised at 80,000-150,000 still matches a 100,000 threshold. Postings that state no pay at all are dropped by any value above 0, so leave it at 0 unless you only want jobs with published salaries.

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

Open each posting and pull its complete description. This costs one extra request PER JOB, so it is far slower than the search itself: a search page hands over about 45 jobs in a single request, while descriptions need 45 requests for the same rows. Without it you still get Indeed's own summary snippet, roughly 200 characters.

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

Output cap for each search keyword or URL. One request returns about 45 jobs and that is the practical ceiling per search, because Indeed only serves the first page - see Max pages per search. Setting this higher is harmless, it simply will not be reached; to collect more, add more searches.

## `maxTotalJobs` (type: `integer`):

Hard cap across every search, so a long keyword list cannot run away with the bill. The same job matched by two searches is written and charged once.

## `maxPagesPerSearch` (type: `integer`):

Work cap: result pages read per search. Leave this at 1. Indeed serves the FIRST page of a search to an automated client and refuses the rest - measured across five runs, page two came back HTTP 502 through the unblocking proxy every time, and HTTP 403 on a plain connection. Setting it higher does not fail silently: you get the jobs from page one, a summary row reading stoppedOn "upstreamFailed", and an error row naming what happened. To collect more jobs, add more searches rather than more pages - another keyword, another city, or a narrower Posted within window, each of which is a fresh first page.

## `maxConcurrency` (type: `integer`):

How many requests may be in flight at once, across the whole run rather than per search. Searches run in parallel; the pages within one search cannot, because each needs the one before it.

## `requestDelayMs` (type: `integer`):

Minimum gap between the start of one request and the next, applied globally rather than per worker. Note this is not the main defence here: Indeed limits by IP address rather than by rate, so the fresh proxy session used for every request is what keeps a run working.

## `proxyConfig` (type: `object`):

Leave this alone. Indeed refuses Apify's datacenter ranges outright - measured, HTTP 403 on the very first request, four attempts in twelve seconds - so the default group is the only one that works. The exit country is pinned automatically to whichever Indeed site you picked, which matters more than it sounds: an exit node in the wrong country makes Indeed serve a LOCALISED page, so salaries come back worded "por hora", dates as "hace 3 dias" and job types as "Tiempo completo", and the result set differs too.

## Actor input object example

```json
{
  "searchQueries": [
    "software engineer"
  ],
  "location": "Remote",
  "country": "us",
  "sort": "relevance",
  "datePosted": "any",
  "jobType": "any",
  "remoteOnly": false,
  "excludeSponsored": false,
  "radiusMiles": 0,
  "minSalary": 0,
  "includeDescription": false,
  "maxJobsPerSearch": 50,
  "maxTotalJobs": 1000,
  "maxPagesPerSearch": 1,
  "maxConcurrency": 5,
  "requestDelayMs": 500,
  "proxyConfig": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "UNBLOCKER"
    ]
  }
}
```

# Actor output Schema

## `dataset` (type: `string`):

One row per job with the salary range as numbers, remote model, employer rating, posting date and apply link. Every search also gets a summary row saying how many jobs Indeed reported, which location it actually searched, and why the run stopped.

# 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 = {
    "searchQueries": [
        "software engineer"
    ],
    "location": "Remote"
};

// Run the Actor and wait for it to finish
const run = await client.actor("eiv/indeed-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 = {
    "searchQueries": ["software engineer"],
    "location": "Remote",
}

# Run the Actor and wait for it to finish
run = client.actor("eiv/indeed-jobs-scraper").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 '{
  "searchQueries": [
    "software engineer"
  ],
  "location": "Remote"
}' |
apify call eiv/indeed-jobs-scraper --silent --output-dataset

```

## MCP server setup

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

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/u6SCn43g9SUKWxeMp/builds/gbkvlYMoXwm0Wakzm/openapi.json
