# ZipRecruiter Scraper API - US Jobs, Salary & Apply URL (`dami_studio/ziprecruiter-scraper`) Actor

Scrape ZipRecruiter jobs: title, company, city, employment type, remote flag, benefits, exact posted date and the employer’s apply URL. Every row flags whether the pay is employer-published or a ZipRecruiter estimate. $0.80/1,000 jobs - the lowest of any established ZipRecruiter scraper.

- **URL**: https://apify.com/dami\_studio/ziprecruiter-scraper.md
- **Developed by:** [Dami's Studio](https://apify.com/dami_studio) (community)
- **Categories:** Jobs, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.80 / 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/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

## ZipRecruiter Scraper API

Pulls job postings off ZipRecruiter — title, company, city and state, employment type, remote flag,
benefits, the exact posting date, the employer's real apply URL, and pay **with a flag saying where
the pay figure came from**. No login, no ZipRecruiter account, no API key.

Search by keyword and location, filter the way the site does, or paste ZipRecruiter URLs.

***

### The pay problem, and what this Actor does about it

ZipRecruiter shows a salary on most job cards. A lot of those salaries were never written by the
employer — they are ZipRecruiter's own model output. The site is upfront about this internally: every
pay figure it serves carries a source label. Most scrapers throw that label away and hand you one
`salary` column that silently mixes the two.

Measured on **1,413 postings** returned by three real runs of this Actor (a ten-keyword national
search, a ten-keyword Chicago search, and one nurse search in New York paged to exhaustion):

| Where the pay figure came from | Share | What this Actor does with it |
|---|---|---|
| Employer filled in a pay field | **49.0%** | `salaryMin` / `salaryMax`, `salarySource: employer_provided` |
| Employer wrote pay in the job description | **18.5%** | `salaryMin` / `salaryMax`, `salarySource: employer_description_parsed` |
| ZipRecruiter estimate, shown on the site labelled "Estimated" | **4.6%** | `estimatedSalaryMin` / `estimatedSalaryMax`, `salaryIsEstimate: true` |
| ZipRecruiter model output the site never displays at all | **27.7%** | `estimatedSalaryMin` / `estimatedSalaryMax`, `salarySource: ziprecruiter_internal_model` |
| No pay figure anywhere | 0.1% | all pay fields `null` |

So **67.5% of postings have pay the employer actually stated**, and 32.3% carry a machine-generated
number. Of the 392 rows in that last category, **392 of 392 — every single one — had no pay text on
the ZipRecruiter page at all.** The number exists only in the page's data payload.

**99.9% of postings carry some pay number.** A scraper that dumps them all into one `salary` column
reports "99.9% salary coverage". Two thirds of that is real.

The two figures are never merged. `salaryMin`/`salaryMax` are employer figures only. Estimates live
in `estimatedSalaryMin`/`estimatedSalaryMax`. Tick **Only employer-published pay** and the estimates
are dropped before anything is charged — you do not pay for rows you discard. (Verified: a 25-job run
with that box ticked saw 33 postings, returned 25, and was billed for 25.)

The split moves a lot with the search. Across the three runs the employer-published share was 57.7%
(national, ten keywords), 68.3% (Chicago, ten keywords) and 70.5% (New York nursing). Check
`salarySource` on your own run rather than assuming.

***

### What you get per job

`id`, `openSeatId`, `title`, `url`

`companyName`, `companyId`, `companyUrl`, `companyLogo`

`locationText`, `city`, `state`, `stateCode`, `postalCode`, `county`, `country`, `countryCode`

`locationTypes`, `locationTypeText`, `isRemote`, `isHybrid`, `isInPerson`

`employmentTypes`, `employmentTypeText`

`salarySource`, `salaryIsEstimate`, `salaryIsPublishedByEmployer`, `salaryMin`, `salaryMax`,
`salaryMinAnnual`, `salaryMaxAnnual`, `salaryInterval`, `salaryCurrency`, `salaryText`,
`estimatedSalaryMin`, `estimatedSalaryMax`, `estimatedSalaryMinAnnual`, `estimatedSalaryMaxAnnual`,
`estimatedSalaryInterval`, `estimatedSalaryText`, `salaryShownOnSiteAsEstimate`

`benefits`, `benefitsText`

`postedAt`, `repostedAt`, `postedAtRelative`, `isReposted`, `isActive`, `isNew`, `updatedAt`

`applyType`, `hasZipApply`, `applyUrl`, `applyRedirectUrl`

`shortDescription`, and with **Fetch full description and company rating** turned on:
`description`, `descriptionHtml`, `companyRating`, `companyRatingScale`, `companyReviewCount`,
`companyDescription`, `companyWebsite`, `companyIndustry`, `companyHq`

`searchQuery`, `searchLocation`, `searchUrl`, `page`, `position`, `scrapedAt`

#### Two fields worth calling out

**`postedAt` is a real timestamp**, not "3 days ago". ZipRecruiter renders `29 days ago` on the card;
the payload behind it has `2026-02-11T00:49:45Z`. Both are returned — `postedAt` for filtering,
`postedAtRelative` for reference. `repostedAt` is separate, because a lot of postings are refreshed.

**`applyUrl` is the employer's own URL**, not a ZipRecruiter redirect. ZipRecruiter wraps the
destination in a `job-redirect?match_token=` link; the token is base64 and the real URL is inside it,
so this Actor unwraps it without following the redirect. You get
`https://jobs.bswhealth.com/us/en/job/26013231/LVN-Clinic-Float-PRN` rather than a tracking hop.
39.3% of the 1,413 postings measured were hosted on an external ATS, and `applyUrl` resolved on
**97.1% of those**. The rest are 1-Click Apply jobs hosted on ZipRecruiter itself, where
`hasZipApply` is true and `applyRedirectUrl` is the link. That external share swings by sector —
13% on a Denver dental search, 49% on a New York nursing search.

***

### Filters

Everything here maps to a real ZipRecruiter facet, and every value was checked against the live site.

| Input | Values |
|---|---|
| `days` | 1, 5, 10, 30 |
| `radius` | 5, 10, 25, 50, 5000 (= 100+ miles) |
| `locationType` | on-site / remote only / hybrid only |
| `employmentType` | full-time, part-time, contract, contract-to-hire, temporary, as-needed (per diem), other |
| `experienceLevel` | no experience, junior, mid, senior |
| `applyType` | 1-Click Apply only |
| `salaryMin` / `salaryMax` | annual USD |

**Why the values are a fixed list and not free text.** ZipRecruiter does not reject a filter value it
does not recognise. `days=zzz` returns HTTP 200 and the entire unfiltered result set — 650 jobs
instead of 72. A typo would quietly bill you for rows that do not match what you asked for. So the
Actor checks each value against the site's own vocabulary first and refuses the ones that are not in
it, with an uncharged diagnostic row telling you what was rejected.

***

### Examples

Nursing jobs in Austin, posted in the last five days, employer pay only:

```json
{
  "searchQueries": ["registered nurse"],
  "location": "Austin, TX",
  "days": "5",
  "onlyEmployerPublishedSalary": true,
  "maxItems": 200
}
```

Remote software work nationwide, with full descriptions and employer ratings:

```json
{
  "searchQueries": ["software engineer", "backend engineer"],
  "location": "United States",
  "locationType": "only_remote",
  "includeDescription": true,
  "maxItems": 100
}
```

From a URL you already have open:

```json
{
  "startUrls": ["https://www.ziprecruiter.com/jobs-search?search=welder&location=Houston%2C+TX&radius=50"]
}
```

***

### What this does not do

- **US only.** ziprecruiter.ca serves a different application that has none of this data, and
  ziprecruiter.co.uk 404s on the search route. Do not expect Canadian or UK jobs.
- **About 750 jobs per search, not the whole board.** ZipRecruiter stops paginating around page 41.
  One real run — nurse jobs within 50 miles of New York — returned **753 unique postings** against a
  claimed 813 matches, and then ran dry. Split by city, job title or `days` window to go wider; there
  is no way to page past the site's own limit.
- **Company rating is often absent, and it needs the detail fetch.** It is not on the search card,
  and it only exists for employers that have been reviewed — small businesses usually have none. On a
  15-job dental run only 1 job had a rating. Turn on *Fetch full description and company rating* and
  expect a run roughly three times slower.
- **Full descriptions land on about 80% of jobs** with the detail fetch on; the rest are postings
  whose body ZipRecruiter does not serve to a logged-out visitor.
- **Employer ratings are on a 0–10 scale**, not five stars — that is ZipRecruiter's own scale.
  `companyRatingScale` says so on every row.
- **Some search pages come back empty and are retried.** ZipRecruiter intermittently answers a
  perfectly valid page with a 24 KB shell at HTTP 200. The Actor retries those, and only gives up
  after two consecutive failures. That is a site behaviour, not a block.
- **No résumés, no employer contact details, no applications submitted.**

***

### Cost

**$0.0005 to start a run, then $0.80 per 1,000 jobs.** A flat rate — no volume tiers to unlock and
no plan gates, the same number on your first run as on your thousandth.

Sample rows, diagnostic rows, duplicate postings, failed pages, and rows dropped by
*Only employer-published pay* are **never charged**.

Real runs: 5 jobs in 4.9 seconds; 400 jobs in 51 seconds; 753 jobs in 102 seconds.

***

### FAQ

**Is there an official ZipRecruiter API?**
There is a partner API, but it is not open to the public — `api.ziprecruiter.com/jobs/v1` needs a key
issued to approved partners and returns 502 without one. This Actor reads the same public search
pages a browser does.

**Do I need a ZipRecruiter account or login?**
No. Everything here is what a logged-out visitor sees.

**How do I scrape ZipRecruiter jobs by keyword and city?**
Set `searchQueries` to the keyword and `location` to `"City, ST"`. One search runs per keyword.

**Can I get remote jobs only?**
Yes — set `locationType` to `only_remote`. Setting `location` to "Remote" does not do the same thing;
ZipRecruiter treats that as a place name and returns very few results.

**Are the salaries real?**
Two thirds are. Across 1,413 measured postings, 49.0% came from a pay field the employer filled in
and 18.5% were written by the employer into the job description. The remaining 32.3% are
ZipRecruiter's own model. Every row says which, in `salarySource`, and the two kinds never share a
column. See the table at the top.

**What is `ziprecruiter_internal_model`?**
A pay range ZipRecruiter calculated and did *not* display on the site. It is in the page payload but
not on the page. It is returned here for completeness, in the `estimatedSalary*` fields only, and
never as `salaryMin`/`salaryMax`.

**Why is `postedAt` months old when the card says "3 days ago"?**
Because the card shows the repost date. `postedAt` is the original posting timestamp and
`repostedAt` is when it was last refreshed — both come straight from ZipRecruiter's payload.

**How many jobs can one search return?**
Around 750. A real run took a New York nursing search to exhaustion and got 753 unique postings out
of a claimed 813. ZipRecruiter stops serving results past page 41 regardless of how many matches it
claims. Use narrower searches — by city, title or posting age — to cover more ground.

**Can I get the full job description?**
Yes, tick *Fetch full description and company rating*. It costs one extra request per job.

**Does it work for company pages?**
Paste a specific job URL into `startUrls` and you get that job. Whole-company crawling is not
supported yet; search by company name in `searchQueries` instead.

**Is scraping ZipRecruiter legal?**
This Actor reads public job listings. You are responsible for how you use the data, including
ZipRecruiter's terms and any applicable data-protection rules. It does not touch résumés, candidate
profiles or anything behind a login.

# Actor input Schema

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

One search per entry, for example "registered nurse" or "warehouse associate". Leave empty and set a location to get every job in that place.

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

A city and state ("Austin, TX"), a state ("Texas") or "United States". ZipRecruiter is a US board; ziprecruiter.ca and .co.uk do not serve this data.

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

Paste ZipRecruiter search URLs (https://www.ziprecruiter.com/jobs-search?search=...\&location=...) or individual job pages (https://www.ziprecruiter.com/c/<Company>/Job/<Title>/-in-<City>,<ST>?jid=...). Search filters in the URL are read and re-used.

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

Stop after this many jobs. You are charged only for jobs actually returned.

## `days` (type: `string`):

Only jobs posted in the last N days. ZipRecruiter accepts exactly these values; anything else is ignored by the site and silently returns the unfiltered list, so it is rejected here instead.

## `radius` (type: `string`):

Distance from the location. Ignored for state-wide and country-wide locations.

## `locationType` (type: `string`):

Restrict to on-site, remote or hybrid postings. Uses ZipRecruiter’s own location-type facet.

## `employmentType` (type: `string`):

Restrict to one employment type. "As needed / per diem" is ZipRecruiter’s PRN bucket.

## `experienceLevel` (type: `string`):

Restrict to one seniority band, as classified by ZipRecruiter.

## `applyType` (type: `string`):

"1-Click Apply" restricts the search to postings that accept an application on ZipRecruiter itself.

## `salaryMin` (type: `integer`):

ZipRecruiter's own salary filter. It uses the site's pay figure, which for some postings is an estimate - see "Only employer-published pay" below if you need certainty.

## `salaryMax` (type: `integer`):

ZipRecruiter’s salary ceiling filter, as a whole-dollar annual figure.

## `onlyEmployerPublishedSalary` (type: `boolean`):

Keep only postings whose pay came from the employer (a structured pay field, or pay written into the job description). Drops ZipRecruiter's own estimates. Dropped rows are NOT charged. About 76% of postings survive this filter.

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

Adds the complete job description plus the employer's ZipRecruiter review score and review count. Costs one extra request per job, so a run takes roughly three times longer. Without it you still get a ~200-character summary.

## `egressMode` (type: `string`):

"auto" uses the plain Apify container address and falls back to Apify datacenter proxy on a block. Change this only if a run gets challenged repeatedly.

## `proxyConfiguration` (type: `object`):

Optional. Supply your own proxy servers to override the routing above.

## Actor input object example

```json
{
  "searchQueries": [
    "registered nurse"
  ],
  "location": "Austin, TX",
  "maxItems": 50,
  "egressMode": "auto"
}
```

# 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": [
        "registered nurse"
    ],
    "location": "Austin, TX",
    "maxItems": 50,
    "days": "",
    "radius": "",
    "locationType": "",
    "employmentType": "",
    "experienceLevel": "",
    "applyType": "",
    "onlyEmployerPublishedSalary": false,
    "includeDescription": false,
    "egressMode": "auto"
};

// Run the Actor and wait for it to finish
const run = await client.actor("dami_studio/ziprecruiter-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": ["registered nurse"],
    "location": "Austin, TX",
    "maxItems": 50,
    "days": "",
    "radius": "",
    "locationType": "",
    "employmentType": "",
    "experienceLevel": "",
    "applyType": "",
    "onlyEmployerPublishedSalary": False,
    "includeDescription": False,
    "egressMode": "auto",
}

# Run the Actor and wait for it to finish
run = client.actor("dami_studio/ziprecruiter-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": [
    "registered nurse"
  ],
  "location": "Austin, TX",
  "maxItems": 50,
  "days": "",
  "radius": "",
  "locationType": "",
  "employmentType": "",
  "experienceLevel": "",
  "applyType": "",
  "onlyEmployerPublishedSalary": false,
  "includeDescription": false,
  "egressMode": "auto"
}' |
apify call dami_studio/ziprecruiter-scraper --silent --output-dataset

```

## MCP server setup

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