# Jobs Feed API - Deduplicated LinkedIn, Indeed & ATS Jobs (`sergeyfaraday/jobs-feed-api`) Actor

Deduplicated job postings from Greenhouse, Lever, Ashby, LinkedIn (public) and Indeed in one normalized schema, with a delta mode that returns only new, changed and removed jobs between runs - guest pages only, no login.

- **URL**: https://apify.com/sergeyfaraday/jobs-feed-api.md
- **Developed by:** [Sergey Faraday](https://apify.com/sergeyfaraday) (community)
- **Categories:** Jobs, Lead generation, AI
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 1 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 unique jobs

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

## Jobs Feed API - deduplicated LinkedIn, Indeed and ATS jobs (no login)

**Not a scraper - a jobs feed.** One call, all sources (Greenhouse, Lever, Ashby, Indeed, Glassdoor, LinkedIn public), one normalized schema, deduplicated across sources - and a **delta mode that emits and bills only new, changed and (opt-in) removed jobs** between runs. This Actor sells state, not rows: schedule it and every morning only what changed is waiting for you.

> Status: v0.6 - ATS sources plus three guest sources, ranked by strength of published basis: **Indeed** (opt-in, read in a real browser - it degrades, never fails the run, when challenged; descriptions from Indeed are snippets), **Glassdoor** (opt-in, read in a real browser - first search page per query and location inside your `datePosted` window; narrow the window or add a location for completeness) and **LinkedIn public** (guest job pages, no login, no accounts - add `linkedin-public` to *Sources*). Indeed, Glassdoor and LinkedIn run through the **built-in residential proxy** - no proxy setup needed (bring your own in *Advanced* to cut costs on heavy workloads). By default LinkedIn runs in **list mode**: full posting pages are fetched only when you turn on `detailFetch` (each page billed as `job-detail-fetch`).

### Unique jobs across LinkedIn, Indeed & ATS boards

Unique job postings from LinkedIn (public), Indeed and ATS boards (Greenhouse, Lever, Ashby) arrive in one normalized schema, deduplicated across sources. The same job on Greenhouse, LinkedIn and Indeed is one row with `seenOn[]` - not three charges. Re-runs bill only what changed.

Duplicates are never separate rows. Two listings merge only when they share the same canonical apply URL or ATS requisition (`exact`), or - in `standard` mode - when they belong to the same company and have near-identical title and description (`strong`) - "near-identical description" also covers a listing that contains the other one plus appended boilerplate (the LinkedIn guest page vs the ATS text), and it never holds for two listings in different cities or countries. Look-alikes that do not clear that bar are kept as separate rows and annotated `possibleDuplicateOf`. Two distinct positions on the same board (multi-location postings) are **never** merged. LinkedIn and Indeed re-list one posting as a card per city - those cards fold into the ATS row (`seenOn` lists every card, `location.all` every city). Glassdoor cards carry only a short snippet, so a Glassdoor card merges with the matching Greenhouse/Lever/Ashby row when title and snippet match closely; otherwise it is kept as its own row and annotated `possibleDuplicateOf` - expect fewer automatic merges than from LinkedIn, whose cards carry the full text. `strict` mode merges exact matches only.

### Deduplicated job feed - pay per unique job

You pay per unique job, never per row scanned: `unique-job` for a job new to your delta scope, `job-update` when a tracked field changes, `job-removed` (opt-in) when it disappears. Unchanged jobs cost nothing.

#### What 30 days of monitoring costs

Scenario: watch **3 queries across 50 companies** (≈ 500 live postings, ~3 % new and ~2 % changed per day) every day for a month.

| | Re-scraping stack (per-row Actors) | Jobs Feed API in delta mode |
|---|---|---|
| Rows you pay for | 500 rows × 30 days = 15,000 | day 1: 500 new · days 2-30: ~15 new + ~10 updates per day |
| Price per row | $0.0004 - $0.005 (typical LinkedIn/Indeed job scrapers on the Store, Free plan, 2026-08-18¹) | `unique-job` $0.0014 · `job-update` $0.0012 · `job-removed` $0.0020 (Free plan; Bronze/Silver/Gold discounts) |
| **Month** | **$6 - $75**: $6 at the cheapest listed price ($0.0004), **$10.50 at the median listed price ($0.0007)**, $75 at the priciest ($0.005) | **≈ $1.7** (day 1 $0.70, then ≈ $0.03/day): **3.5x** below the cheapest listing, **6x** below the median, 44x below the priciest |
| Plus | your own dedup, your own diff, re-processing 15,000 rows | rows arrive already unique and already classified `new` / `updated` / `removed` |

¹ Store listing prices of the five most-used LinkedIn job scrapers and four Indeed scrapers on the day of writing, `apify-default-dataset-item` Free-plan tier: $0.0004, $0.0005, $0.0007, $0.002, $0.005 (LinkedIn) and $0.00005 - $0.006 (Indeed). Real delta pair on this Actor (run `FAC4Fbr7KcJUOKfay` → `xhgLzFfBp7WYy2S0D`, Discord Greenhouse + LinkedIn, 2026-08-18): day 1 = 50 rows = $0.045, day 2 = 0 rows = $0. The left column uses the five LinkedIn listing prices sampled in this footnote; their median is $0.0007. The table is a **model**, not a measurement: churn figures are the modeling assumption, not a promise; your `REPORT.deltaStats` shows the real ones.

**One-off bulk is a different case.** On "LinkedIn bulk with full details" (thousands of rows, every posting page fetched, once) a per-row LinkedIn scraper is cheaper than this Actor - deliberately. Our case is uniques + state with *selective* details: score postings from the list first, fetch full pages only for the ones that already look like matches (`detailFetch: selective`), and pay per state change from day 2 on.

#### Fair billing

Rows are billed only after they are delivered and committed. A run that fails before its first committed row bills nothing. If a failure occurs later, only rows already delivered and committed remain billed. Failed sources never add charges. Within retained delta state, an unchanged committed job is never billed twice. A crash may under-charge, never intentionally over-charge.

Three row events, mutually exclusive per row: `unique-job` (a job seen for the first time in your delta scope), `job-update` (a known job whose title, salary, description, location or employment type changed) and, only when you opt in with `trackRemovals`, `job-removed` (a tracked job that disappeared from every source it was seen on). Unchanged jobs cost nothing. Actor start costs nothing. One service event exists: `job-detail-fetch`, one per full posting page fetched from a guest source - and only when you turned `detailFetch` on; the default list mode never fires it.

Two lines worth comparing before you pick a scraper:

- **Rows your filters rejected are never billed.** With `matchProfile.minMatchScore` (or `earlyApplicantsOnly`) set, filtered rows never reach the dataset and never reach your bill - the report's `matchFiltered` counts them. Some per-row scrapers collect and bill every row their crawl touched, whether or not it matched your filters.
- **No per-run minimum.** A run that finds 3 new jobs bills 3 events.

### Greenhouse, Lever & Ashby in one API

Greenhouse, Lever and Ashby boards are the default sources and need no keys: **press Start with just a query.** With no `companies`, the ATS sources scan the catalog of verified Greenhouse / Lever / Ashby boards bundled with this build (v2026.08.3: 704 boards, ~23K postings; rebuilt weekly from public discovery) - largest boards first, within `maxScannedJobs` - and keep the postings that match your queries. The catalog is built from public discovery only: the companies you pass in your own runs never enter it. Today's catalog is biased toward large tech boards (`catalog` in the run report shows the version and how many boards were scanned); set `catalogMode: "off"` to require explicit companies.

```json
{
  "companies": [
    { "domain": "discord.com" },
    { "domain": "ramp.com", "ashbyBoard": "ramp" },
    { "domain": "acme.com", "greenhouseBoard": "acme", "leverSite": "acme" }
  ],
  "queries": ["backend engineer", "data engineer"],
  "maxItems": 500
}
```

- `companies` - one entry per company. Only `domain` is required; the Actor discovers Greenhouse / Lever / Ashby boards from the company site and, as a fallback, by probing the domain label. Pass `greenhouseBoard` / `leverSite` / `ashbyBoard` (the public board slugs; legacy name `greenhouseToken` still accepted) to skip discovery - they always win.
- `queries` - a keyword filter for career boards: a posting is kept when every word of at least one query appears in its title, or (ATS boards, which return the description at list time) when the query appears as a phrase in the description. Case-insensitive, deterministic; under `maxItems`, title matches are emitted first. Not a ranked full-text search. Leave empty to get every posting of the listed companies.

### Only new and changed jobs - delta mode

Delta mode makes every later run return only what changed: run 1 emits every job as `new`, run 2 with the same input emits nothing (unless a job changed) and charges nothing. It needs two storages you own, so that billing state is yours and two runs can never write it at once:

1. **Key-value store** - create one (Console → Storage → *Key-value stores* → Create, e.g. `jobs-feed-state`) and pick it in *Delta state store*.
2. **Request queue** - create an **empty** one (Storage → *Request queues* → Create, e.g. `jobs-feed-lock`) and pick it in *Delta lock queue*. One queue per store - never share it.
3. **Open the Actor, fill the input** - your queries and/or companies, sources - and pick the store in *Delta state store* and the queue in *Delta lock queue*. Optional: *Track removals* on to receive `removed` rows.
4. **Save as Task** (top right) → the task's **Schedule** tab → the hour you want → Save. Schedule it hourly or daily; the choice only changes how often the delta is computed.

That is the whole setup. Tomorrow's dataset contains only what changed - wire it into anything that reads a dataset (n8n / Make / Zapier "Apify → run task → get dataset items"). The run report (`REPORT` in the run's key-value store) carries `meta.origin` (`SCHEDULER` / `API` / `WEB`) and `deltaStats: { new, updated, known, coalesced, evicted, rebaselined }`.

Details worth knowing:

- Four distinct dates per row (`postedAt`, `earliestPostedAt`, `firstSeenAt`, `lastSeenAt`), and `datePosted` is applied at the source where supported, post-filtered before billing where not.
- Delta state is bound to your **exact** input scope (queries, companies, sources, dedup mode). Changing it stops the run with `DELTA_SCOPE_MISMATCH` before scraping ($0). Set *If the delta store was built for other queries/companies* to **rebase** to intentionally start a new delta history (the first run of the new scope bills as new).
- If a second run starts while one is running, it fails before scraping with `DELTA_LOCK_HELD` ($0).
- A job that disappears and comes back after 45 days is a new occurrence (`repostOf` links to the old one) and bills once more.
- Delta guarantee is **exact within retained state**: 50 MB of delta core per generation (≈ 35-40K occurrences at the measured ~1.3 KB each; hard cap 200,000). Beyond that the oldest occurrences are evicted (`deltaStats.evicted`) and could re-emit if seen again. Removal summaries have their own 60 MB budget and are evicted first, never the occurrences.
- Emission priority is new jobs first, then updates, then removals: under `maxItems` or your spending limit, updates and removals may wait for the next run; they are not lost.

#### Closed positions - `trackRemovals` (opt-in)

Set `trackRemovals: true` (delta mode only) and a job that disappears from **every** source it was seen on gets one `deltaStatus: "removed"` row: the last known summary (title, company, apply URL, location, salary, employment type, `seenOn`; no description), `removedAt`, `daysOpen` (time-to-fill proxy: last seen minus first seen) and `removalConfidence`. Billed once as `job-removed`.

A removal is *disappearance from our observation*, never proven closure, and it is judged honestly:

- The missing counter advances only on **conclusive** scans: the source was healthy and the board or search that originally surfaced the job was enumerated completely (no failed fetch, no `maxScannedJobs` truncation, no pagination cap). Failed or truncated scans neither advance nor reset it. `removalConfirmRuns` (default 2) consecutive conclusive misses are required.
- `removalConfidence: "high"` - every source that carried the job is an ATS board (full board reads). `"best-effort"` - LinkedIn/Indeed, whose search enumeration is capped; in practice their removals are rare, and the first run warns `REMOVALS_BEST_EFFORT_SOURCE` when such sources are selected. Reliable removals are an ATS-board feature. Glassdoor never contributes removal evidence (windowed first-page reads cannot prove a job disappeared) - and because a removal needs every source that carried the job to have fully enumerated, **any job Glassdoor also surfaces stops being eligible for removal reporting while Glassdoor is in your scope**, even if its primary source is an ATS board. Turn Glassdoor off in a removals-focused task.
- A removed job seen again later is a **new** occurrence with `repostOf` pointing at the closed one (a reopened position), billed as `unique-job` - not silently revived.
- The first run of a fresh state emits no removals; a removal is emitted at most once per occurrence.
- Removal rows keep a compact summary stored separately from the delta core (`snapshotAvailable: true`); under heavy scopes that summary may be evicted and the row degrades to title, apply URL and `seenOn` (`snapshotAvailable: false`).

`trackRemovals` is off by default while its false-removal rate is measured on our canary; the default will flip on with 14 days of notice.

#### Switching from another scraper - `seedFromDataset` (free)

"I have months of data from another scraper - will your first run re-bill me for jobs I already have?" No. Pick a dataset you already own in *Seed delta state from a dataset* (your own jobs-feed rows, or a LinkedIn / Indeed job-scraper export - format auto-detected, or set *Seed format*) on the **first** run of a fresh delta store: the state is seeded from it - no rows, no events - and that same run emits only the jobs that are actually new to you. Rules: delta mode on, empty state (`SEED_STATE_NOT_EMPTY` otherwise, $0), ≤ 50,000 items (`SEED_TOO_LARGE`), rows must parse (≥ 5 % unparseable ⇒ `SEED_FORMAT_MISMATCH`, $0). Fields your export did not carry (often the description in list exports) are baselined silently on the first live observation - a field appearing for the first time is not a change; only fields present in the seed *and* different live produce `job-update`. Duplicates in your export (their data has no cross-source dedup) coalesce on the first live run at no charge. Seeded jobs older than 45 days are not treated as reposts. CSV/URL seeding is not in v1 (only datasets on your account).

### Several locations and freshness windows in one run

Several locations in one run: pass queries as objects with their own location and freshness window - `"queries": [{ "q": "backend engineer", "location": "Dubai", "datePosted": "7d" }]`. The top-level `location` and `datePosted` set the default for every query that does not override them.

- `location` and `datePosted` are scope filters ("Dubai", "last 24 hours"): LinkedIn/Indeed searches are narrowed server-side by the platform's own geo and freshness parameters; ATS postings are matched by their location text and posted date.
- `datePosted` (`24h`/`3d`/`7d`/`14d`/`30d` or days as a number) is also the cost control - without it every run re-scans the full history of every board.
- Changing filters changes the delta scope (a `DELTA_SCOPE_MISMATCH` guard protects your state); with `trackRemovals`, a job that merely leaves the window is never mistaken for a removal.

### For job seekers - match profile, early-applicant signal

Job seekers get a match-scored daily feed: set a match profile of your resume keywords and every morning only the new jobs scoring above your threshold arrive - typically $2-4 a month.

The same engine, pointed at your search: a delta feed sends you **the 20 new matching jobs every morning**, and at these prices that is a consumer subscription, not a data budget - a daily run over your target companies typically bills **$2-4 a month** (day 1 seeds your scope as `unique-job`s, every day after that only what appeared, changed or closed).

Set a **match profile** and let the Actor score every job against your resume keywords:

```json
{
  "queries": ["backend engineer"],
  "matchProfile": {
    "keywords": [
      { "keyword": "TypeScript", "aliases": ["TS"], "weight": 2 },
      { "keyword": "Kubernetes", "aliases": ["k8s"] },
      { "keyword": "GraphQL" }
    ],
    "minMatchScore": 60
  }
}
```

- Every row gets `matchScore` (0-100: matched weights over total weights), `matchedKeywords`, `unmatchedKeywords` and `matchBasis`. Matching is whole-word and case-insensitive; an alias hit counts as its keyword.
- `matchBasis` is the honesty marker: `title+snippet` means the score was computed in list mode and can miss skills that only appear in the description; `title+description` means the full text was scored.
- **Rows that don't match your profile are never emitted and never billed** - `matchFiltered` tells you how many were dropped. Set `minMatchScore: null` to annotate without filtering.
- `detailFetch: "selective"` (with `detailFetchMinScore`) makes the expensive part self-targeting: postings are scored provisionally from the list, only the ones that already look like matches get their full page fetched (billed `job-detail-fetch`), and `minMatchScore` is applied to the final score computed on the full text. Plainly: a fetched page is billed even when the final score then filters the row - you paid to find out. Provisional scoring can miss description-only skills; the threshold trades cost for recall.
- `earlyApplicantsOnly: true` keeps only jobs still showing LinkedIn's "Be among the first N applicants" (the N is on every row as `applicantsHint`); `easyApplyFlag` marks in-platform applications where visible. Together with `job-update` and `job-removed` this is the freshness-signal line: apply first, re-engage on change, stop when closed.

A ready n8n flow - schedule → delta run with your match profile → jobs with `matchScore ≥ 80` to a Telegram/email digest - is in `templates/n8n/jobseeker-digest.json`.

### No login, no cookies - guest pages only

LinkedIn, Indeed and Glassdoor are read exactly as a logged-out visitor reads them: guest pages only, no accounts, no cookies, no session reuse, nothing to configure and nothing of yours to hand over. This is a locked design decision, not a default - there is no input that turns it on.

What that costs you, plainly: fields that exist only behind a login are permanently out of this Actor. Applicant counts beyond the public "Be among the first N applicants" hint, recruiter contacts, skill-match percentages and any other logged-in-only field will never appear in a row, on any plan. If your use case needs them, this is not the tool.

### Sources

Greenhouse, Lever and Ashby are read through their public board APIs. Indeed and Glassdoor are read through a real browser with an ordinary browser fingerprint (guest pages, no login - the sites serve them as they serve any visitor); LinkedIn through its public guest job pages. Every run reports what each source actually returned (`sourceHealth`) and where limits were hit (`capsHit`). You never wonder why you got fewer rows.

**Live source health:** [gist.github.com/sergey-faraday/d432228fab307fbf56a093b861165b65](https://gist.github.com/sergey-faraday/d432228fab307fbf56a093b861165b65) - 7-day success rate, latency, errors and last incident per source, measured by our own canary tasks (aggregated counts only; never your runs or your queries) and refreshed daily. Bad weeks are shown as they are.

| Source | How | Notes |
|---|---|---|
| Greenhouse / Lever / Ashby | public board APIs | full descriptions, structured salary where the ATS has it; discovered from the company domain or given as board slugs |
| Indeed (opt-in) | guest search page, read in a real browser | built-in residential proxy; reads the guest result page per query (newest first, ~45 postings); page 2 and beyond are login-gated on Indeed and are never fetched (no accounts, ever); snippet-only descriptions; `easyApplyFlag` where visible; explicit vs estimated salary flagged (`salary.estimated`) |
| Glassdoor (opt-in) | guest search page, first page only, read in a real browser | reads up to 30 jobs per query and location from inside your `datePosted` window (30 days when unset, and capped at 30); Glassdoor's pagination and detail pages are closed to crawlers and are never requested; snippet-only descriptions; `easyApplyFlag`; salary as a p10-p90 range with `salary.estimated` when not employer-provided; contributes no removal signal |
| LinkedIn (public) | guest job search (+ guest job page with `detailFetch`) | no login, no cookies; built-in residential proxy; list mode by default - descriptions and `applicantsHint` come from posting pages fetched under `detailFetch` (billed `job-detail-fetch`); the external apply link is not exposed on guest pages, so `applyUrl` is the LinkedIn job page; the same job on the company's ATS board is merged into one row (`seenOn` lists both) |

**Browser sources need memory and a moment:** run with at least 2,048 MB when Indeed or Glassdoor is selected (the run stops before scraping with `GUEST_NEEDS_MEMORY` otherwise), and the first guest run on a cold worker adds about 25-30 seconds of startup. ATS-only runs never start the browser.

Each guest source has an operator kill switch (`JOBS_FEED_DISABLE_LINKEDIN_PUBLIC`, `JOBS_FEED_DISABLE_INDEED`, `JOBS_FEED_DISABLE_GLASSDOOR`, set in Console, not in your input). A source that is switched off shows as `disabled` in `sourceHealth` and costs nothing.

`proxyConfiguration` (in *Advanced*) is optional: guest sources use the built-in Apify residential proxy by default and ATS boards go direct. Pass your own groups or `proxyUrls` to override it, or `{ "useApifyProxy": false }` with no URLs to force direct connections.

### Use with n8n

Two ready n8n templates ship in `templates/n8n/`: `daily-jobs-feed.json` routes `new` rows to Slack, `updated` to a Sheet and `removed` to a CRM webhook; `jobseeker-digest.json` runs a delta run with your match profile on a schedule and sends jobs with `matchScore ≥ 80` to a Telegram/email digest. Both use the standard "Apify → run task → get dataset items" nodes, so Make and Zapier work the same way.

### Use with Claude and AI agents (MCP)

Claude and any MCP client can call this Actor through Apify's MCP server (`apify/actors-mcp-server`): the `call-actor` tool with the Actor id `sergeyfaraday/jobs-feed-api` and the input below. Typed input, output, dataset and key-value-store schemas ship with the build, and the run report is a single JSON record (`REPORT`) in the run's key-value store, so an agent can read the result without parsing prose.

```json
{
  "queries": [{ "q": "backend engineer", "location": "Berlin", "datePosted": "7d" }],
  "sources": ["greenhouse", "lever", "ashby", "linkedin-public"],
  "maxItems": 50
}
```

### Current limits

- **Bulk on a single board**: for a one-off pull of thousands of rows from one board, a scraper priced per 1,000 raw rows costs less than this Actor. Our unit is a unique job plus its state over time, not a row.
- **Support**: issues answered within 24 hours on business days. That is our commitment, and other developers on the Store answer faster.
- **No logged-in LinkedIn depth**: applicant counts, skill matches and recruiter fields that exist only behind a login are permanently out (see *No login, no cookies*).
- **Corpus size**: our reach is the bundled catalog (704 verified boards) plus the companies you pass plus guest searches. Aggregators that index six-figure numbers of career sites cover more companies than we do; our answer is the unit of billing and the state between runs, not breadth.
- **Track record**: this is a new listing. No review history yet - the live source-health gist above is the only public record of how it behaves, and it is published in full, bad weeks included.

### LinkedIn jobs API pricing - what a row costs

A unique job costs $0.0014, a change to a known job $0.0012 and a closed position $0.0020 on the Free plan - you pay per event, never per row scanned; duplicates and unchanged jobs are free. Bronze / Silver / Gold plans get lower rates:

| Event | Fires when | Free plan |
|---|---|---|
| `unique-job` | a job is delivered for the first time in your delta scope | $0.0014 |
| `job-update` | a tracked field of a known job changed (title, salary, description, location, employment type) | $0.0012 |
| `job-removed` | opt-in `trackRemovals`: a tracked job disappeared from every source it was seen on | $0.0020 |
| `job-detail-fetch` | one full posting page fetched from a guest source, only with `detailFetch` on | $0.0020 |

Unchanged jobs and Actor start cost nothing. The console prefill caps a first click at 200 rows (≈ $0.28 at the Free-plan price); the API default is 1000. CSV and Excel export are verified against a live run on every release, starting with 0.6.0 (`tools/deploy/csv-export-smoke.js`).

Issues: please open a GitHub issue; response within 24 hours on business days. This Actor is not affiliated with Greenhouse, Lever, Ashby, LinkedIn, Indeed or Glassdoor.

### Output

Dataset: one row per unique job occurrence (`deltaStatus: new | updated`), see the *Jobs* view. Choose *Output view = LinkedIn-compatible field names* to get `jobUrl`, `companyName`, `location`, `postedAt`, `descriptionText` instead.

Every posting is normalized: title, company (name + registrable domain), apply URL, description, location (city/region/country + all locations for multi-location postings), work mode (remote/hybrid/onsite), salary (min/max/currency/period; parsed from the pay-transparency text when the ATS has no structured field), employment type, posted date, department, and `seenOn[]` with every source that carried it.

With a `matchProfile`, every row also carries `matchScore`, `matchedKeywords`, `unmatchedKeywords` and `matchBasis`; guest-source rows carry `applicantsHint` / `easyApplyFlag` where the signals are public.

Run report (`REPORT` in the run's key-value store): `sourceHealth[]` per source (`ok | degraded | failed | disabled`, listed/parsed counts, errors), `duplicatesReport`, `deltaStats`, `matchFiltered`, `costTelemetry` (`capsHit`, `underchargedRows`, `detailFetchesCharged`), `batchManifests[]`, `feedVersion`, `identityVersion`, `contentHashVersion`, `generation`. `sourceHealth[].notes[]` (Glassdoor) lists non-error observations - `GLASSDOOR_SLICE_TRUNCATED` (a window held more than 30 jobs; narrow `datePosted` or `location`) and `GLASSDOOR_LOCATION_UNMAPPED` (searched the United States and filtered by the card location).

### Limits and errors

Every run is bounded by caps you set: `maxItems` up to 50,000 new and updated rows, `maxScannedJobs` up to 100,000, and any cap that is hit is named in `capsHit`.

`maxItems` ≤ 50,000 (new + updated), `maxScannedJobs` ≤ 100,000, `maxDetailFetches` ≤ 50,000, ≤ 20 queries, ≤ 500 companies. When a cap is hit it is listed in `capsHit`. `maxDetailFetches` caps posting-page fetches when `detailFetch` is on (each successful fetch is billed `job-detail-fetch`; the cap also respects your maximum total charge). In the default list mode LinkedIn cards have no description and cannot be text-merged with the ATS listing of the same job - expect LinkedIn-only rows next to their ATS twins unless you enable `detailFetch`. Errors that stop a run before scraping (`INPUT_INVALID`, `DELTA_LOCK_HELD`, `DELTA_LOCK_QUEUE_INVALID`, `DELTA_SCOPE_MISMATCH`, `DELTA_STORE_UNREACHABLE`, `ALL_SOURCES_FAILED`) always bill $0. `onSourceError: fail` aborts before any row is written when any source fails ($0); the default `skip` continues with the other sources and reports the failure in `sourceHealth`.

# Actor input Schema

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

Job titles or search words, up to 20. Combined with Companies below. In the API each entry may also be an object {"q": "...", "location": "...", "datePosted": "..."} to scope one query to its own location and freshness window.

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

Keep only jobs in this location (e.g. "Dubai", "Berlin", "United States", "Remote"). LinkedIn/Indeed searches are scoped server-side by the platform's own geo resolution; ATS board postings are matched by their location text. A posting with no location information cannot confirm the filter and is dropped. Per-query override: pass queries as objects {"q": "...", "location": "...", "datePosted": "..."} in the API.

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

Keep only jobs posted within this window - and stop paying to re-scan history: LinkedIn/Indeed searches are narrowed server-side (f\_TPR / fromage), ATS postings are cut by their posted date (postings without a date are kept). The API also accepts an integer number of days (1-365). With trackRemovals, a job leaving the window is never mistaken for a removal.

## `companies` (type: `array`):

Up to 500 entries: `{"domain": "example.com", "greenhouseBoard": "...", "leverSite": "...", "ashbyBoard": "..."}`. Only `domain` is required - ATS boards are resolved automatically; the optional board slugs override the resolver.

## `sources` (type: `array`):

Which sources to scan. Greenhouse/Lever/Ashby read public board APIs (default). Guest sources - no login, no accounts - are listed by how cleanly their published rules permit automated reading: Indeed (the guest result page per query, newest first), Glassdoor (first search page per query and location inside your datePosted window), LinkedIn (guest job pages). Indeed and Glassdoor are read in a real browser: give the run at least 2048 MB of memory or it fails before scanning anything; the first such run on a cold worker adds about 25-30 seconds. Guest sources carry higher operational risk, run through the built-in residential proxy, and can be paused by the operator.

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

Caps emitted rows (new + updated combined). Hard limit 50 000. The console prefill is 200 so a first click costs at most about $0.28; the API default is 1000.

## `matchProfile` (type: `object`):

Score every job against your keywords: {"keywords": \[{"keyword": "TypeScript", "aliases": \["TS"], "weight": 2}], "minMatchScore": 60}. Rows below minMatchScore are NOT emitted and NOT billed (matchFiltered in the report counts them); minMatchScore null annotates only. Whole-word, case-insensitive; matchScore = matched weights / total weights x 100. The matchBasis field on every row tells you whether the score used the full description or only title + snippet.

## `earlyApplicantsOnly` (type: `boolean`):

Keep only jobs whose posting shows the "Be among the first N applicants" signal (LinkedIn detail pages). Filtered rows are not emitted and not billed. Needs detailFetch (the signal lives on the posting page).

## `detailFetch` (type: `string`):

Guest sources (LinkedIn) run in list mode by default - full posting pages (complete description, applicant hints) are never fetched implicitly. Each fetched page is billed as one job-detail-fetch event. "selective" needs a match profile: the provisional score is computed from title + snippet, so description-only skills can be missed - the threshold trades cost for recall; minMatchScore always applies to the FINAL score computed on the full text.

## `detailFetchMinScore` (type: `integer`):

Provisional match score (0-100, computed on title + snippet) a posting must reach before its full page is fetched. Only used when detailFetch is "selective". A fetched page is billed even when the final score then falls below minMatchScore - you paid to find out.

## `dedupTier` (type: `string`):

Duplicates are never separate rows: merged sources appear in `seenOn[]`. Probable matches are only annotated (`possibleDuplicateOf`).

## `deltaStateStore` (type: `string`):

Enable delta mode: pick a storage (KVS) you own. Only new and changed jobs are emitted and billed on later runs. Requires the lock queue below.

## `deltaLockQueue` (type: `string`):

An empty request queue dedicated to exactly one delta state store. Guarantees that two runs never write the same delta state at once. Two namespaces = two queues.

## `trackRemovals` (type: `boolean`):

Delta mode only. When a tracked job disappears from every source it was seen on for removalConfirmRuns conclusive scans, emit one job-removed row (last known summary, removedAt, daysOpen, removalConfidence) and bill the job-removed event. Off by default while the false-removal soak runs; ATS boards give high-confidence removals, LinkedIn/Indeed best-effort.

## `removalConfirmRuns` (type: `integer`):

How many consecutive conclusive scans a job must be missing before job-removed is emitted. Failed, truncated or capped scans never count.

## `seedFromDataset` (type: `string`):

Migration kit: pick a dataset you already own (your own jobs-feed rows, or a LinkedIn / Indeed job-scraper export) and the FIRST run seeds the empty delta state from it - free, no rows, no events - so it emits only jobs that are actually new to you. Requires delta mode and an empty state (fresh store); ≤ 50,000 items.

## `seedFormat` (type: `string`):

How to read the seed rows. Fields absent in the seed are baselined silently on the first live observation - they never trigger job-update on their own.

## `onScopeMismatch` (type: `string`):

Delta state is bound to the exact set of queries, companies, sources and filters.

## `onSourceError` (type: `string`):

`sourceHealth[]` in the run report always tells you what happened per source.

## `maxScannedJobs` (type: `integer`):

Upstream cost bound: stop listing after this many postings across sources. Reported in `capsHit`.

## `maxDetailFetches` (type: `integer`):

Upstream cost bound for detail pages (descriptions, salaries). Reported in `capsHit`.

## `outputView` (type: `string`):

Same data, two field layouts. Compat is a view over one truth.

## `catalogMode` (type: `string`):

With no companies given, the ATS sources enumerate the catalog of verified Greenhouse/Lever/Ashby boards bundled with this build (largest boards first, within maxScannedJobs) and keep the postings that match your queries. The catalog is built from public discovery only - your companies never enter it. Report field catalog shows version and boards scanned.

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

Bring your own proxy to reduce per-detail costs on heavy workloads. By default the guest sources (LinkedIn public, Indeed) use the built-in Apify residential proxy and ATS board APIs are fetched directly - no setup needed.

## Actor input object example

```json
{
  "queries": [
    "senior backend engineer"
  ],
  "datePosted": "any",
  "sources": [
    "greenhouse",
    "lever",
    "ashby"
  ],
  "maxItems": 200,
  "earlyApplicantsOnly": false,
  "detailFetch": "off",
  "detailFetchMinScore": 50,
  "dedupTier": "standard",
  "trackRemovals": false,
  "removalConfirmRuns": 2,
  "seedFormat": "auto",
  "onScopeMismatch": "fail",
  "onSourceError": "skip",
  "maxScannedJobs": 20000,
  "maxDetailFetches": 5000,
  "outputView": "jobsFeed",
  "catalogMode": "auto"
}
```

# Actor output Schema

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

One row per unique job occurrence (new or updated in delta mode).

## `runReport` (type: `string`):

sourceHealth\[], duplicatesReport, deltaStats, costTelemetry, batchManifests\[], versions and generation.

# 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": [
        "senior backend engineer"
    ],
    "maxItems": 200
};

// Run the Actor and wait for it to finish
const run = await client.actor("sergeyfaraday/jobs-feed-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 = {
    "queries": ["senior backend engineer"],
    "maxItems": 200,
}

# Run the Actor and wait for it to finish
run = client.actor("sergeyfaraday/jobs-feed-api").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": [
    "senior backend engineer"
  ],
  "maxItems": 200
}' |
apify call sergeyfaraday/jobs-feed-api --silent --output-dataset

```

## MCP server setup

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

```

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/0gP9blPMGiiRtZSSa/builds/qbw2A6sUkXwfLgG8f/openapi.json
