# Multi-Board Jobs — No Duplicates (`steadyfetch/multi-job-board-scraper`) Actor

The same role found on two boards is one row and one charge. Blocked boards, empty searches and merged duplicates are never charged. One keyword across Indeed and any company's own hiring board, merged and de-duplicated into one feed.

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

## Pricing

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

## Multi-Board Jobs — No Duplicates

**Never pay for a job we didn't deliver.** One keyword across Indeed and any company's own
hiring board, merged and de-duplicated into one feed. **The same role found on two boards is
one row — and one charge.**

**From $1.80/1,000 job listings** — all-inclusive pay per event, no start fee, charged only
on delivery. One price for the whole feed: no per-source fee, no per-search fee.

Every row carries `charged` and `missReason`, so you can reconcile the invoice from the
dataset itself without opening the console. **Only rows with `charged: true` were billed.**

***

### Output — a merged row

```json
{
  "title": "Senior Backend Engineer",
  "company": "Acme",
  "location": { "raw": "Berlin, Germany", "city": "Berlin", "region": null, "country": "Germany" },
  "salary": { "min": 85000, "max": 115000, "currency": "EUR", "period": "year" },
  "descriptionText": "…full description from the company's own board…",
  "source": "greenhouse",
  "sources": [
    { "board": "greenhouse", "jobUrl": "https://job-boards.greenhouse.io/acme/jobs/123", "applyUrl": "…", "postedAt": "2026-08-24T09:00:00.000Z", "salary": null },
    { "board": "indeed", "jobUrl": "https://www.indeed.com/viewjob?jk=…", "applyUrl": null, "postedAt": null, "salary": { "min": 85000, "max": 115000, "currency": "EUR", "period": "year" } }
  ],
  "merged": true,
  "duplicateCount": 1,
  "dedupeConfidence": "high",
  "dedupeKey": "mb:…",
  "charged": true,
  "missReason": null
}
```

Plus **one coverage row per source and per company board** (`source`, `board`, `status`,
`delivered`, `resolved`, `missReason`) and **one summary row** with `delivered`, `uniqueFound`,
`duplicatesMerged`, `stoppedBy` and `chargedEvents`.

**[See a real run's full output →](https://api.apify.com/v2/datasets/JRI3Qr8SZvvuAb4yT/items?clean=true\&format=json)**
— eight live jobs pulled from four different company boards in one run, plus the coverage row for
each board and the summary. Every charged row is marked `charged: true`; nothing else is.

### Agent / API paste-block

```
Actor:      steadyfetch/multi-job-board-scraper
Required:   query          (job title/keyword, or a full Indeed search URL)
Optional:   companies          (array of company domains / careers URLs / board URLs / names)
            sources            (array: ["indeed","ats"], default both)
            countries          (array of ISO-2, default ["US"] — each is its own Indeed search)
            location           (string, e.g. "Austin, TX")
            radiusKm           (integer 1-160, needs a location — Indeed only)
            dedupe             (boolean, default true — merge the same role across boards)
            postedWithinDays   (integer, 1-30)
            sortBy             (relevance | date, default relevance — Indeed only)
            remoteOnly         (boolean, default false — filtered rows are never charged)
            maxItems           (integer, default 100 — hard cap on UNIQUE jobs)
            maxItemsPerCompany (integer, default 200)
            maxRunSeconds      (integer, default 1200 — clean stop, never a timeout kill)
Charges:    job-listing      once per UNIQUE job after de-duplication
Note:       omit a field to use its default — do NOT send null.
Token:      https://console.apify.com/settings/integrations
```

```bash
curl -X POST "https://api.apify.com/v2/acts/steadyfetch~multi-job-board-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"query":"backend engineer","companies":["stripe.com","https://jobs.lever.co/leverdemo"],"countries":["US"],"maxItems":50}'
```

***

### What it does

You give **one keyword** and, optionally, the **companies** you care about. The actor:

1. searches **Indeed** for your keyword (across the countries you pick), and
2. pulls each company's **own hiring board** — Greenhouse, Lever, Ashby, Workday, Workable,
   SmartRecruiters, Rippling, Personio or Teamtailor — filtered to roles that match your keyword, then
3. **merges the same role found on more than one board into a single row**, keeping every
   source link, and charges you **once**.

Company boards are how you reach roles that never appear on a job aggregator. About **half**
of companies resolve from a bare domain (mid-market best; large enterprises mostly run their
own Workday/Oracle, so paste a board URL for those). **You are never charged for a company we
cannot resolve.**

### The de-duplication, in plain terms

A "duplicate" is the same role — same company, same title, in an overlapping location — found
on two different sources. When we find one, you get **one row** with a `sources` array listing
every board it was on, `duplicateCount`, and a `dedupeConfidence` of `high` (matched on a
shared city) or `medium` (matched on a country-wide or remote posting).

We tuned this on a corpus of real jobs to **never delete a distinct job**. When two roles at
one company genuinely can't be told apart from an aggregator's single row, we **keep them
separate** — tagged with a shared `dedupeGroupId` — rather than merge them and silently drop
one. A wrong merge is a job you never see, so we always err on the side of showing you more.

Turn `dedupe` off and each board's copy is its own row (the identical posting fetched twice is
still never charged twice).

### Sources

- **Indeed** — needs a keyword. United States, United Kingdom and Germany are verified end to
  end; any listing that comes back outside the country you asked for is reported, never charged.
- **Company boards (ATS)** — need company domains. Greenhouse, Lever, Ashby, Workday, Workable,
  SmartRecruiters, Rippling, Personio, Teamtailor — each the company's own documented public
  board.

Narrow with `sources` (for example `["ats"]` to skip Indeed). Google Jobs is deliberately not a
source — its jobs tab returns only ~10 rows per query with no way to page deeper, so it would
add cost without adding coverage.

### What you are never charged for

| situation | run status |
|---|---|
| A board answered with an access check or rate limit | SUCCEEDED — re-runnable, the coverage row says so |
| A company we could not resolve to a board | SUCCEEDED — uncharged, lists what was tried |
| A source ran and matched nothing | SUCCEEDED — a real answer |
| A duplicate merged into another row | SUCCEEDED — charged once, not per board |
| An input we could not read | SUCCEEDED — the row names the fix |
| Your own `maxItems` / `maxRunSeconds` / cost cap | SUCCEEDED — summary row names the knob |

We never guess. An access check is reported as an access check, not as "no jobs found" — a
temporary problem is never dressed up as a permanent verdict, and it is never charged.

**This actor may fail when a board changes things.** The two you are most likely to see are
a board answering with an access check or rate limit, and a company we could not resolve to
a hiring board. Both ship as a coverage row with **`charged: false`**, the run still
SUCCEEDS, and the row says whether re-running helps.

### FAQ

**Does `{}` charge anything?** No. With no keyword and no companies the run returns uncharged
sample rows that show the exact output shape — including one merged row — and contacts no board.

**Can I just scrape a company's whole board?** Yes — pass the companies with `sources: ["ats"]`.
For deep, unfiltered board pulls by domain, the sibling **Career-Site Jobs — Paste a Domain**
is purpose-built for exactly that.

**Why did a company return nothing?** Either it has no live openings matching your keyword, or
its board could not be positively identified from the domain — the coverage row tells you which,
and neither is charged. Paste the board URL directly if you know it.

***

### Steadyfetch jobs suite

One actor per source, one job each, the same row shape — so a pipeline built on one source takes the
next without a rewrite. All-inclusive pay per event, no start fee, charged only on delivery.

| Source | Actor |
|---|---|
| Indeed | **[Indeed Jobs — Stops at Your Limit](https://apify.com/steadyfetch/indeed-jobs-scraper)** |
| A company's own career site (paste a domain) | **[Career-Site Jobs — Paste a Domain](https://apify.com/steadyfetch/company-jobs-by-domain)** |
| Glassdoor, with the employer rating | **[Glassdoor Jobs + Employer Rating](https://apify.com/steadyfetch/glassdoor-jobs-scraper)** |
| Several boards at once, de-duplicated | **this actor** |

**The rest of the steadyfetch shelf** — same contract everywhere: all-inclusive pay per event,
no start fee, charged only on delivery.

| Family | Actors |
|---|---|
| Ad creative intelligence | [Facebook](https://apify.com/steadyfetch/facebook-ads-transcript-scraper) · [Google Ads video](https://apify.com/steadyfetch/google-ads-video-transcript-scraper) · [TikTok](https://apify.com/steadyfetch/tiktok-ads-transcript-scraper) · [LinkedIn](https://apify.com/steadyfetch/linkedin-ads-transcript-scraper) · [Google Ads text & OCR](https://apify.com/steadyfetch/google-ads-creative-text-scraper) |
| Trends & keywords | [Google Trends](https://apify.com/steadyfetch/google-trends-scraper) · [Trends Now](https://apify.com/steadyfetch/google-trends-now-scraper) · [Breakout keywords](https://apify.com/steadyfetch/breakout-keywords-scraper) · Autocomplete keywords · Keyword volume & CPC · [Social trends](https://apify.com/steadyfetch/social-trends-scraper) |
| YouTube transcripts | [YouTube videos](https://apify.com/steadyfetch/youtube-transcript-scraper) · [YouTube channels](https://apify.com/steadyfetch/youtube-channel-transcripts) |
| Instagram | [Reel transcripts](https://apify.com/steadyfetch/instagram-reel-transcript-scraper) · [Profile posts](https://apify.com/steadyfetch/instagram-profile-posts) |
| **Jobs** | [Indeed](https://apify.com/steadyfetch/indeed-jobs-scraper) · [Career sites by domain](https://apify.com/steadyfetch/company-jobs-by-domain) · [Glassdoor](https://apify.com/steadyfetch/glassdoor-jobs-scraper) · [Multi-board](https://apify.com/steadyfetch/multi-job-board-scraper) |
| Amazon | [Products](https://apify.com/steadyfetch/amazon-product-scraper) · [Search](https://apify.com/steadyfetch/amazon-search-scraper) · Bestsellers · Sellers |
| Any media file | [Speech to Text](https://apify.com/steadyfetch/media-transcriber) · any link or file |

Unlinked names are publishing shortly on the same account — search **steadyfetch** on Apify Store.

# Actor input Schema

## `query` (type: `string`):

Job title or keyword, for example "software engineer". You can also paste a full Indeed search URL such as https://uk.indeed.com/jobs?q=data+analyst.

## `countries` (type: `array`):

Which Indeed country sites to search. Each country is a separate search. United States, United Kingdom and Germany are verified end to end; any listing that comes back outside the country you asked for is reported and never charged.

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

City, region or postcode to search Indeed near, as you would type it there (for example "Austin, TX"). Leave empty to search the whole country.

## `radiusKm` (type: `integer`):

How far around the Indeed location to search. Only used when a location is set.

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

Company domains (stripe.com), careers-page URLs, board URLs (https://jobs.lever.co/leverdemo) or plain company names. Their boards are filtered to roles that match your keyword.

## `atsFilter` (type: `array`):

Restrict company-board resolution to named platforms. Leave empty to check all of them.

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

Which job sources to include. Leave empty for both. "Indeed" needs a keyword; "Company boards" needs company domains.

## `dedupe` (type: `boolean`):

On by default. When on, the same role found on more than one board becomes ONE row with every source link kept — and one charge. When off, each board's copy is its own row (the identical posting fetched twice is still never charged twice).

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

Only jobs Indeed posted in the last N days.

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

Ask Indeed to order its results by date instead of relevance. Company boards are unaffected.

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

Keep only listings marked remote. Listings filtered out are never charged.

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

Hard cap on delivered UNIQUE jobs (after de-duplication) across every source in this run.

## `maxItemsPerCompany` (type: `integer`):

Hard cap on roles pulled from any single company board before keyword filtering.

## `maxRunSeconds` (type: `integer`):

The run stops cleanly before this many seconds and reports what is left, instead of being killed by a timeout.

## Actor input object example

```json
{
  "query": "software engineer",
  "countries": [
    "US"
  ],
  "companies": [
    "stripe.com"
  ],
  "sources": [
    "indeed",
    "ats"
  ],
  "dedupe": true,
  "sortBy": "relevance",
  "remoteOnly": false,
  "maxItems": 100,
  "maxItemsPerCompany": 200,
  "maxRunSeconds": 1200
}
```

# Actor output Schema

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

One row per unique job after de-duplication: title, company, location, salary, links, and `sources` — every board the same role was found on. Every row carries `charged` and `missReason`, so the invoice reconciles from the dataset itself. Only rows with charged = true were billed.

## `coverage` (type: `string`):

One row per source and per company board: how many jobs it returned, whether it resolved, or why it did not. Blocked, empty and unresolved boards are never charged.

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

Unique jobs delivered, duplicate sightings merged away, ambiguous roles kept separate, what stopped the run, and the charged-event total.

## `errors` (type: `string`):

Present only when a source answered with a block, a rate limit or an unreadable page: the source and the reason. These are re-runnable, not permanent.

# 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 = {
    "query": "software engineer",
    "companies": [
        "stripe.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("steadyfetch/multi-job-board-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 = {
    "query": "software engineer",
    "companies": ["stripe.com"],
}

# Run the Actor and wait for it to finish
run = client.actor("steadyfetch/multi-job-board-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 '{
  "query": "software engineer",
  "companies": [
    "stripe.com"
  ]
}' |
apify call steadyfetch/multi-job-board-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,steadyfetch/multi-job-board-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/KW2AscOMWeyR4hy3C/builds/UqzE4hkK33Xz7ljAc/openapi.json
