# Multi-ATS Jobs: Greenhouse, Lever, Ashby, SmartRecruiters (`shipsatnight/multi-ats-job-scraper`) Actor

Greenhouse, Lever, Ashby and SmartRecruiters in one normalized schema, with real delta tracking: each job is marked new, updated, or unchanged since your last run. Includes Ashby salary bands. $1 per 1,000 jobs, descriptions included  never billed for a job that did not reach your dataset.

- **URL**: https://apify.com/shipsatnight/multi-ats-job-scraper.md
- **Developed by:** [Donovan](https://apify.com/shipsatnight) (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 $1.00 / 1,000 job returned with descriptions

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

## Multi-ATS Job Scraper

Scrape job postings from **Greenhouse, Lever, Ashby, and SmartRecruiters** in one run, normalized into a single schema. Point it at a list of company boards and get back consistent job records regardless of which platform each company uses.

Built for hiring-signal feeds, recruiting tools, job aggregators, and sales teams targeting companies that are actively hiring.

### What sets this apart

There are several Multi-ATS scrapers on the store covering the same four platforms. Where this one is different:

- **Real change tracking, not just a fresh dump.** `deltaMode` marks every job `new`, `updated`, or `unchanged` against the previous run, using each platform's own update timestamp where one exists and a content fingerprint where it doesn't (Lever and Ashby expose neither). Schedule it daily and you get a stream of what actually changed, not the same thousand postings re-processed every morning.
- **Salary data where it exists.** Ashby's API exposes a compensation band on most postings; this actor surfaces it as a structured `{ currency, min, max, period }` object instead of dropping it or leaving it buried in the description text.
- **$1 per 1,000 jobs, descriptions included.** Comparable actors in this category typically charge a flat $2–3 per 1,000 jobs regardless of whether you want descriptions. Here, descriptions cost the same as titles-only, and the per-board fee is separate so delta-mode reruns stay cheap.
- **You're not billed for data you didn't get.** If a run hits your spending cap mid-scrape, it stops cleanly instead of continuing to "scrape" jobs it can no longer charge for or deliver — pushes and charges are computed from the same number, and `stopped_at_charge_limit` in the run summary tells you when a result set is partial. In delta mode, anything not delivered stays unmarked so it comes back on your next run instead of vanishing.

### Why this instead of four separate scrapers

Every ATS returns a different shape. Greenhouse nests locations under `offices`, Lever puts them in `categories.allLocations`, Ashby uses `secondaryLocations`, SmartRecruiters splits city/region/country. This actor flattens all of that into one job record, so your downstream code never branches on which platform a posting came from.

It also uses each platform's public JSON API directly. No headless browsers, no proxies, no anti-bot workarounds, which keeps runs fast and cheap.

### Delta mode: turn it into a hiring-signals feed

Set `deltaMode: true` and the actor returns only jobs that are **new** or **changed** since your last run. State persists between runs in a key-value store keyed by job ID.

Schedule it daily and you get a clean stream of "who started hiring for what today" instead of re-processing the same thousands of postings every morning. Every returned record carries a `delta_status` of `new` or `updated`.

### Input

```json
{
  "companies": [
    { "ats": "greenhouse",      "slug": "stripe",     "name": "Stripe" },
    { "ats": "lever",           "slug": "ro",         "name": "Ro" },
    { "ats": "ashby",           "slug": "ramp",       "name": "Ramp" },
    { "ats": "smartrecruiters", "slug": "BoschGroup", "name": "Bosch" }
  ],
  "deltaMode": true,
  "includeDescription": true,
  "filterKeywords": ["engineer", "security"],
  "filterScope": "title",
  "maxJobsPerCompany": 500
}
```

| Field | Type | Default | What it does |
|---|---|---|---|
| `companies` | array | required | Boards to scrape. Each needs `ats` and `slug`; `name` is optional labelling. |
| `deltaMode` | boolean | `false` | Return only new or changed postings since the last run. |
| `includeDescription` | boolean | `true` | Include the full job description. |
| `filterKeywords` | string\[] | `[]` | Keep only jobs matching at least one keyword (case-insensitive). |
| `filterScope` | enum | `title` | Match against `title`, or `title_and_description`. |
| `maxJobsPerCompany` | integer | unlimited | Cap jobs per board. Useful for testing and cost control. |

#### Finding a company's slug

It's in the company's careers URL:

| ATS | Careers URL | Slug |
|---|---|---|
| Greenhouse | `boards.greenhouse.io/`**`stripe`** | `stripe` |
| Lever | `jobs.lever.co/`**`ro`** | `ro` |
| Ashby | `jobs.ashbyhq.com/`**`ramp`** | `ramp` |
| SmartRecruiters | `jobs.smartrecruiters.com/`**`BoschGroup`** | `BoschGroup` |

**SmartRecruiters identifiers are case-sensitive and must match exactly.** `BoschGroup` returns results; `bosch` silently returns zero. If a SmartRecruiters board comes back empty, check capitalization first.

### Output

Every record, from every platform, looks like this:

```json
{
  "id": "ashby:ramp:34413f8d-26bf-4bbc-8ade-eb309a0e2245",
  "ats": "ashby",
  "company": { "slug": "ramp", "name": "Ramp" },
  "title": "Security Engineer, Cloud",
  "department": "Engineering",
  "team": "Backend",
  "locations": ["New York, NY (HQ)", "Remote (US)", "Remote (Canada)", "Miami, FL"],
  "employment_type": "FullTime",
  "remote": true,
  "posted_at": "2026-04-07T17:12:35.753Z",
  "updated_at": null,
  "url": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245",
  "description_html": "<div>...</div>",
  "description_text": "About Ramp\n\nRamp is a financial operations platform...",
  "compensation": {
    "currency": "USD",
    "min": 211400,
    "max": 290600,
    "period": "1 YEAR"
  },
  "raw_source": "ashby",
  "scraped_at": "2026-08-01T22:14:03.118Z",
  "delta_status": "new"
}
```

Fields a platform doesn't provide come back as `null` rather than being omitted, so the schema is stable across sources.

### Platform coverage and limitations

Being upfront about what each source actually gives you:

| | Greenhouse | Lever | Ashby | SmartRecruiters |
|---|---|---|---|---|
| Descriptions | Inline, free | Inline, free | Inline, free | **Extra request per job** |
| Compensation | Not exposed | Not exposed | **Yes** | Not exposed |
| `updated_at` | **Yes** | No | No | No |
| Requests per company | 1 | 1 | 1 | 1 per 100 jobs, plus 1 per description |

Two things worth knowing:

**Only Greenhouse exposes a real update timestamp.** For the other three, delta mode detects changes using a content fingerprint (title, locations, department, employment type, compensation, description length). That reliably catches meaningful edits, but a change confined to description wording of identical length won't register.

**SmartRecruiters is the expensive one.** Its list endpoint omits the ad body, so each description needs its own request. Set `includeDescription: false` for SmartRecruiters-heavy runs if you only need titles and locations.

### Pricing

Pay per event:

| Event | Price |
|---|---|
| Actor started | $0.00005 |
| Company board crawled | $0.002 |
| Job returned (no description) | $0.001 |
| Job returned (with description) | $0.001 |

That works out to **$1 per 1,000 jobs, descriptions included**. A 200-job Greenhouse board with full descriptions costs about **$0.20**. In delta mode after the first run you pay the per-board fee plus only the postings that actually changed, so a daily feed over a dozen boards usually lands in the low cents.

Jobs are charged only after they're written to the dataset, so you're never billed for a record you can't see.

If you set a maximum cost for the run, the actor stops cleanly once it's reached rather than scraping boards whose results would be discarded. The run summary reports `stopped_at_charge_limit: true` so you know the result set is partial, and in delta mode the jobs it couldn't deliver stay unrecorded and come back on the next run instead of being silently skipped.

### Reliability

- Exponential backoff with jitter on `429` and `5xx`, honoring `Retry-After`.
- A bad company slug fails that board only, with a clear error naming the slug. Other boards in the same run continue.
- Individual SmartRecruiters description fetches that fail emit the job without a description instead of dropping it.
- Every run writes a `RUN_SUMMARY` record to the key-value store with counts, per-board failures, and warnings.
- The run only fails outright if *every* board failed, so a partial result is never silently reported as success.

### Local development

```bash
bun install
bun run typecheck
bun run src/main.ts   # reads storage/key_value_stores/default/INPUT.json
```

# Actor input Schema

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

Which company job boards to scrape. Each entry needs an `ats` (greenhouse, lever, ashby, or smartrecruiters) and the company's `slug` on that platform. `name` is optional and only used to label output. Note: SmartRecruiters identifiers are case-sensitive and must match exactly, e.g. "BoschGroup", not "bosch".

## `deltaMode` (type: `boolean`):

Return only postings that are new or have changed since the previous run. Turns this actor into a daily hiring-signals feed. State persists across runs in a named key-value store.

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

Include the full job description. Free on Greenhouse, Lever, and Ashby (their APIs return it inline). On SmartRecruiters it requires one extra request per job and is billed accordingly.

## `filterKeywords` (type: `array`):

Only return jobs matching at least one of these keywords (case-insensitive). Leave empty to return everything.

## `filterScope` (type: `string`):

Match keywords against the job title only (recommended), or against the title and full description. Descriptions usually repeat company boilerplate on every posting, so searching them tends to match nearly every job.

## `maxJobsPerCompany` (type: `integer`):

Cap the number of jobs returned per company board. Useful for testing or for keeping costs predictable on very large boards. Leave empty for no limit.

## Actor input object example

```json
{
  "companies": [
    {
      "ats": "greenhouse",
      "slug": "stripe",
      "name": "Stripe"
    },
    {
      "ats": "ashby",
      "slug": "ramp",
      "name": "Ramp"
    }
  ],
  "deltaMode": false,
  "includeDescription": true,
  "filterKeywords": [],
  "filterScope": "title"
}
```

# Actor output Schema

## `jobPostings` (type: `string`):

Normalized job postings from every scraped company board, one record per job.

## `runSummary` (type: `string`):

Counts of companies and jobs scraped, per-board failures, and delta/charge-limit status for this run.

# 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 = {
    "companies": [
        {
            "ats": "greenhouse",
            "slug": "stripe",
            "name": "Stripe"
        },
        {
            "ats": "ashby",
            "slug": "ramp",
            "name": "Ramp"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("shipsatnight/multi-ats-job-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 = { "companies": [
        {
            "ats": "greenhouse",
            "slug": "stripe",
            "name": "Stripe",
        },
        {
            "ats": "ashby",
            "slug": "ramp",
            "name": "Ramp",
        },
    ] }

# Run the Actor and wait for it to finish
run = client.actor("shipsatnight/multi-ats-job-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "companies": [
    {
      "ats": "greenhouse",
      "slug": "stripe",
      "name": "Stripe"
    },
    {
      "ats": "ashby",
      "slug": "ramp",
      "name": "Ramp"
    }
  ]
}' |
apify call shipsatnight/multi-ats-job-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=shipsatnight/multi-ats-job-scraper",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/cKsJ9ejW53Y8mVKMK/builds/JSCLAObKbt0B6LEJg/openapi.json
