# Indeed Fast Job Scraper: Annual Salary & Change Tracking (`acebuilds/indeed-jobs-scraper`) Actor

Indeed job listings scraper with 50+ fields per job. Pay normalized to an annual salary so hourly and salaried roles filter together. Structured location, sponsored-vs-organic flag, employer hiring signals, remote filter and change tracking. Clean JSON hiring data.

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

## Pricing

from $2.50 / 1,000 job listings

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 - Annual Salary & Change Tracking

Scrape **Indeed job listings** into clean JSON with **50+ fields per job**. Every pay rate
is converted to an **annual salary**, so an hourly contract and a salaried role can be
filtered and ranked against each other — something Indeed itself will not do for you.

Built for **hiring data**, **job market analysis**, recruitment research and lead
generation. Works as a **jobs API**: run it on a schedule and pull structured **employment
data** straight from the dataset.

Search several job titles in one run (results de-duplicated automatically), filter by
what a job really pays, screen out staffing agencies, and re-run on a schedule to get
only what changed.

***

### What makes this different

| | This actor | Typical Indeed scraper |
|---|---|---|
| Salary normalized to annual | ✅ hourly/daily/weekly/monthly → yearly | ❌ raw, mixed periods |
| Filter by minimum annual pay | ✅ | ❌ |
| Multiple search terms per run | ✅ merged + de-duplicated | ❌ one term |
| Company rating filter | ✅ | ❌ |
| Exclude staffing agencies | ✅ | ❌ |
| Change tracking between runs | ✅ new / removed / repriced | ❌ |
| Title include / exclude rules | ✅ | ❌ |

**Why normalizing salary matters.** Indeed quotes pay in whatever period the employer
picked. A search that returns "$52 an hour", "$4,800 a month" and "$95,000 a year"
gives you three numbers that cannot be compared, sorted, or filtered. This actor
converts all of them to an annual figure first, so `minAnnualSalary: 120000` means
what you think it means.

***

### Input

Only `queries` is required.

```json
{
  "queries": ["software engineer", "backend engineer"],
  "country": "us",
  "location": "Austin, TX",
  "radius": 25,
  "sort": "date",
  "postedWithinDays": 7,
  "maxResults": 50,
  "minAnnualSalary": 120000,
  "requireSalary": true,
  "excludeAgencies": true,
  "minCompanyRating": 3.5,
  "mode": "snapshot"
}
```

| Field | Type | Default | Notes |
|---|---|---|---|
| `queries` | array | — | **Required.** Search terms; results merged and de-duplicated |
| `country` | string | `us` | 20 Indeed country sites supported |
| `location` | string | — | City, state or postal code |
| `locations` | array | `[]` | Several locations; every search term is run against each |
| `radius` | integer | `0` | Miles from `location` |
| `jobType` | string | any | `fulltime`, `parttime`, `contract`, `temporary`, `internship` |
| `sort` | string | `relevance` | Or `date` for newest first |
| `postedWithinDays` | integer | `0` | `0` = no limit |
| `maxResults` | integer | `200` | Your budget cap — billing is per job returned |
| `minAnnualSalary` | integer | `0` | Filters on the normalized annual figure |
| `requireSalary` | boolean | `false` | Drop listings with no pay information |
| `remoteOnly` | boolean | `false` | Remote and hybrid roles only |
| `minCompanyRating` | integer | `0` | 1–5; unrated employers are dropped when set |
| `excludeAgencies` | boolean | `false` | Screen out recruiters and staffing firms |
| `titleMustInclude` | array | `[]` | Title must contain one of these |
| `titleMustExclude` | array | `[]` | Title must contain none of these |
| `mode` | string | `snapshot` | Or `watchlist` for change tracking |
| `watchlistName` | string | `default` | Names the saved baseline |
| `fetchSurface` | string | `auto` | Advanced — see *Cost control* below |

***

### Output

One row per job. This is a real record from a live run:

```json
{
  "recordType": "job",
  "jobKey": "7ed4a2ce361aab9c",
  "title": "Java Developer",
  "company": "Pyramid Consulting, Inc",
  "companyRating": 4.1,
  "companyReviewCount": 251,
  "location": "Columbus, OH 43215",
  "isRemote": false,
  "remoteType": null,
  "remoteLabel": null,
  "jobTypes": ["Contract"],
  "salaryMin": 50,
  "salaryMax": 51,
  "salaryPeriod": "HOURLY",
  "salaryCurrency": "USD",
  "annualSalaryMin": 104000,
  "annualSalaryMax": 106080,
  "annualSalaryEstimated": true,
  "postedAt": "2026-01-23T06:00:00.000Z",
  "postedRelative": "30+ days ago",
  "urgentlyHiring": false,
  "easyApply": true,
  "expired": false,
  "snippet": "Design and develop Java-based applications...",
  "query": "software engineer",
  "url": "https://www.indeed.com/viewjob?jk=7ed4a2ce361aab9c"
}
```

Note `annualSalaryEstimated: true` — that role is quoted hourly, and $104,000 is the
full-time-equivalent conversion, not a figure the employer published. Yearly-quoted
roles come back with the flag `false`.

***

### Watchlist mode

Set `mode: "watchlist"` and schedule the run. The first run captures a baseline and is
**free** — it reports no changes because there is nothing to compare against yet. Every
run after that returns only:

- **`new`** — postings that appeared since the last run
- **`removed`** — postings that disappeared (usually filled or expired)
- **`repriced`** — postings whose salary band moved, with `previousAnnualSalaryMin` and `salaryDelta`

```json
{
  "recordType": "change",
  "changeType": "repriced",
  "title": "Senior Backend Engineer",
  "company": "Example Corp",
  "annualSalaryMin": 165000,
  "previousAnnualSalaryMin": 150000,
  "salaryDelta": 15000,
  "url": "https://www.indeed.com/viewjob?jk=..."
}
```

Use different `watchlistName` values to track several searches independently.

***

### Cost control

Billing is **pay per event** — you are charged only for job rows you actually receive.
Error rows, the watchlist baseline, and "no changes" results are free.

`maxResults` is a hard budget cap: the run stops once it is reached.

Pricing is **tiered by your Apify plan** — $0.004 per job on the Free plan, falling to
$0.0015 on higher plans. You are charged only for job rows delivered to your dataset.

A search returns roughly **45 jobs per term + location**, so a run over 4 combinations
returns ~180 jobs. `maxResults` is a hard cap: the run stops the moment it is reached.

`fetchSurface` selects which Indeed pages are fetched. `auto` (the default) tries the
lighter mobile pages using your own first search and falls back to desktop if they are
unavailable — the page it fetches is kept, so choosing a surface costs no extra request.

***

### What people use it for

- **Salary benchmarking** — compare what roles actually pay across cities, using one
  normalized annual figure instead of mixed hourly/monthly/yearly quotes.
- **Recruitment and hiring data** — track which companies are hiring, how many hires they
  need, and which employers respond to applicants.
- **Job market analysis** — measure posting volume, remote share, and pay bands by region.
- **Lead generation** — find employers actively spending on recruitment. The
  `isSponsored` flag separates paid placements from organic listings, which no other
  Indeed scraper exposes.
- **Job board feeds** — pull fresh listings on a schedule and diff them with watchlist mode.

### Requirements and limitations

- **A paid Apify plan with Unblocker access is required.** Indeed is behind a
  Cloudflare JavaScript challenge. Measured directly: residential *and* datacenter
  proxies both return HTTP 403 even with full browser header and TLS fingerprinting.
  Proxy quality is not the issue — the challenge requires executing JavaScript, which
  Apify Unblocker does. Without it this Actor cannot fetch anything.
- **Indeed occasionally serves a challenge page with HTTP 200.** The Actor detects this
  and retries the same offset up to three times rather than cutting the run short.
- **Unblocker requests to Indeed fail transiently, and often.** Measured over 15
  consecutive requests to a single search URL on 2026-08-27, 47% returned a page and the
  rest came back as an empty HTTP 502 after 50-80 seconds; successes arrive in 2-4
  seconds. The Actor therefore retries the first page of every search up to eight times
  before giving up, which is why an occasional run takes a few minutes longer than usual.
  If every attempt fails the run **fails** rather than reporting success with no jobs —
  re-running it usually works.
- **Annualized salaries assume full-time** (40 hours/week, 52 weeks). A part-time
  hourly role is annualized as if full-time. Raw `salaryMin`/`salaryMax` and
  `salaryPeriod` are always included so you can recompute.
- **Salary is only present when Indeed shows it.** Roughly 80% of listings carry pay
  data; `requireSalary` drops the rest.
- **Agency filtering is heuristic** name matching. It catches the large staffing firms
  and will miss small ones.
- **Indeed serves only the first page of results to logged-out visitors.** Page 2 onward
  returns a "create an account or sign in" wall, verified directly across repeated trials.
  In practice that means **about 45 jobs per search term + location**. This Actor gets
  volume from breadth instead: every term in `queries` is run against every entry in
  `locations`, and results are de-duplicated across the whole matrix. To collect ~450 jobs,
  use 10 term/location combinations rather than expecting 10 pages.
- **Data comes from Indeed's search cards**, not individual job pages — so you get
  summaries, not full job descriptions.
- Indeed can change its page structure at any time. The parser reads Indeed's embedded
  job data rather than CSS selectors, which is considerably more stable, but not immune.

# Actor input Schema

## `queries` (type: `array`):

One or more job titles or keywords. Results from every term are merged and de-duplicated, so you pay once for a job that matches several searches.

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

Which Indeed country site to search.

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

City, state or postal code. Leave empty to search the whole country.

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

Run every search term against each of these locations and merge the results (de-duplicated). Indeed only serves the first page of results to logged-out visitors, so adding locations — not deeper paging — is how you collect more jobs.

## `radius` (type: `integer`):

How far from the location to search. 0 uses Indeed's default.

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

Restrict to one employment type.

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

Indeed's own ordering. Overridden by salary ranking when a salary filter is set.

## `postedWithinDays` (type: `integer`):

Only jobs posted in the last N days. 0 means no limit.

## `maxResults` (type: `integer`):

Stop after this many matching jobs. You are charged per job returned, so this is your budget cap. Note Indeed returns about 45 jobs per search term + location, so reaching a high number needs several terms or locations.

## `minAnnualSalary` (type: `integer`):

Filter by what the job actually pays per year. Hourly, daily, weekly and monthly rates are converted to an annual figure first (assuming full-time), so an hourly role and a salaried role compete on equal terms. 0 disables.

## `requireSalary` (type: `boolean`):

Drop listings with no pay information at all.

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

Keep only roles Indeed marks as remote or hybrid.

## `minCompanyRating` (type: `integer`):

Drop employers rated below this on Indeed (1-5). 0 disables. Companies with no rating are dropped when this is set.

## `excludeAgencies` (type: `boolean`):

Filter out recruiters and staffing firms so you see direct employers. Heuristic name matching — it will miss small agencies.

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

Keep a job only if its title contains at least one of these words.

## `titleMustExclude` (type: `array`):

Drop any job whose title contains one of these words (e.g. senior, intern).

## `mode` (type: `string`):

Snapshot returns everything matching now. Watchlist returns only what changed since your last run with the same watchlist name — new, removed and repriced jobs.

## `watchlistName` (type: `string`):

Names the saved baseline. Use different names to track different searches independently.

## `fetchSurface` (type: `string`):

Auto tries Indeed's lighter mobile pages first, using your own first search, and falls back to desktop if they are unavailable. The page it fetches is reused, so this costs no extra request.

## Actor input object example

```json
{
  "queries": [
    "software engineer",
    "backend engineer"
  ],
  "country": "us",
  "location": "Austin, TX",
  "locations": [
    "Austin, TX",
    "Dallas, TX",
    "Houston, TX"
  ],
  "radius": 0,
  "jobType": "",
  "sort": "relevance",
  "postedWithinDays": 0,
  "maxResults": 200,
  "minAnnualSalary": 0,
  "requireSalary": false,
  "remoteOnly": false,
  "minCompanyRating": 0,
  "excludeAgencies": false,
  "titleMustInclude": [],
  "titleMustExclude": [],
  "mode": "snapshot",
  "watchlistName": "default",
  "fetchSurface": "auto"
}
```

# Actor output Schema

## `jobs` (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 = {
    "queries": [
        "software engineer"
    ]
};

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

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

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,acebuilds/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/FfJdvaTd9G6sqdoe4/builds/Q05SNNATm19wCc4KY/openapi.json
