# ATS Job Postings Aggregator: Greenhouse, Lever, Ashby & more (`ottomath/ats-jobs-aggregator`) Actor

Get job postings from Greenhouse, Lever, Ashby and Personio boards in one dataset. Filter by keyword, location and date. $2 per 1,000 jobs.

- **URL**: https://apify.com/ottomath/ats-jobs-aggregator.md
- **Developed by:** [Ottomath](https://apify.com/ottomath) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 job postings

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/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## ATS Job Postings Aggregator: Greenhouse, Lever, Ashby & more

Turn a list of companies into a clean job postings dataset. The Actor reads the public job boards that companies run on Greenhouse, Lever, Ashby and Personio, filters the postings and returns one normalised row per job. Plain HTTP requests only: no browser, no login, no proxies.

### What it does

Give it company names, job-board addresses or career page addresses. For each company the Actor finds the job board, downloads the published postings from the vendor's public job-board API and converts them to one schema, so ATS jobs from different systems line up in a single table.

- **Greenhouse jobs scraper:** reads the public Job Board API, with full descriptions and published pay ranges.
- **Lever jobs API:** reads the public postings feed of global and EU accounts, with pay ranges and workplace type.
- **Ashby job board:** reads the public posting API, including compensation when the company publishes it.
- **Personio:** reads the public XML feed of open positions.
- **Career page scraper mode:** pass a company's own careers page. It is fetched once and scanned for a link or embed of one of these systems.

Every job is normalised (department, team, locations, country code, remote flag, workplace type, employment type, seniority guess, pay range, dates, links, description), filtered by your rules and deduplicated by `id`. A company that cannot be fetched produces one free error row that says why. A per-company report is saved to the key-value store under `SUMMARY`.

The Actor identifies itself as `OttomathActor/1.0 (+https://apify.com)`, reads robots.txt of every host, sends at most 2 requests per second to one host (all Personio company sites count as one) and retries with backoff on 429 and 5xx answers. It never outputs recruiter, hiring manager or other contact fields, and by default it removes e-mail addresses and phone numbers from descriptions.

### Use cases

- **Recruiting and staffing agencies:** schedule a daily run over your target accounts with `postedWithinDays` set to 1 and call the hiring teams about fresh roles before other agencies do.
- **Sales and market intelligence:** treat hiring as a buying signal. Compare open roles per department across a list of accounts every week, for example data or security roles.
- **Job boards and newsletters:** build a niche job postings dataset, such as remote engineering jobs in Europe, refresh it daily and link readers to `applyUrl`.
- **Analysts and researchers:** follow locations, seniority, workplace type and published pay ranges across many employers and export to CSV, Excel or JSON.
- **Job seekers and career coaches:** one table of open roles at a shortlist of employers, filtered by title, location and remote work.

### Input

Only `companies` is required, one entry per line. Four forms are accepted and can be mixed:

| Form | Examples | What happens |
| --- | --- | --- |
| System and name | `greenhouse:airbnb`, `lever:palantir`, `ashby:notion`, `personio:knime` | The board is fetched directly. |
| Job-board address | `boards.greenhouse.io/airbnb`, `jobs.lever.co/palantir`, `jobs.eu.lever.co/name`, `jobs.ashbyhq.com/notion`, `name.jobs.personio.de` | The company name is read from the address. |
| Plain name | `ramp` | Tried on Greenhouse, Lever, Ashby and Personio in this order; the first board found is used. |
| Careers page | `example.com/careers` | Fetched once and scanned for a supported job board. |

| Field | Default | Description |
| --- | --- | --- |
| `companies` | required | Up to 2,000 entries. The same board is fetched once even when listed twice. |
| `keywords` | none | Keep jobs whose title contains any of these words or phrases. |
| `excludeKeywords` | none | Drop jobs whose title contains any of these words. |
| `searchInDescription` | false | Apply both keyword lists to the description as well as the title. |
| `locations` | none | Keep jobs with a location containing any of these texts: city, country name or code such as `US`. |
| `remoteOnly` | false | Keep fully remote jobs only. |
| `departments` | none | Keep jobs in a department or team whose name contains one of these texts. |
| `postedWithinDays` | none | Keep jobs published in the last N days. Jobs without a date are kept and flagged `dateUnknown`. |
| `maxJobsPerCompany` | 1000 | Newest jobs first. The form is prefilled with 10 so a first test stays cheap. |
| `maxResults` | none | Stop after this many jobs in total. |
| `descriptionFormat` | `text` | `text`, `markdown`, `html` or `none`. Use `none` for a smaller dataset. |
| `stripContactInfo` | true | Replace e-mail addresses and phone numbers in descriptions with a marker. |
| `maxConcurrency` | 5 | Companies in parallel (1 to 20). |

```json
{
  "companies": ["greenhouse:airbnb", "lever:palantir", "ashby:notion", "ramp"],
  "keywords": ["engineer", "data"],
  "locations": ["Berlin", "Germany", "Remote"],
  "postedWithinDays": 30,
  "maxJobsPerCompany": 50,
  "descriptionFormat": "markdown"
}
```

### Output example

One dataset item per job. This is a real posting from a public Ashby board, recorded on 2026-10-10, with the description shortened here:

```json
{
  "type": "job",
  "id": "ashby:notion:1fc309c8-da20-4ff2-84c7-8b863ece2b0a",
  "ats": "ashby",
  "company": "notion",
  "companyName": null,
  "jobId": "1fc309c8-da20-4ff2-84c7-8b863ece2b0a",
  "title": "Software Engineer, Developer Platform",
  "department": "Engineering",
  "team": "Engineering",
  "locations": ["San Francisco, California", "New York, New York"],
  "country": "US",
  "isRemote": false,
  "workplaceType": "hybrid",
  "employmentType": "full-time",
  "seniority": null,
  "salaryMin": 213000,
  "salaryMax": 320000,
  "salaryCurrency": "USD",
  "salaryPeriod": "year",
  "postedAt": "2026-08-24T14:44:49.699Z",
  "updatedAt": null,
  "dateUnknown": false,
  "jobUrl": "https://jobs.ashbyhq.com/notion/1fc309c8-da20-4ff2-84c7-8b863ece2b0a",
  "applyUrl": "https://jobs.ashbyhq.com/notion/1fc309c8-da20-4ff2-84c7-8b863ece2b0a/application",
  "description": "Who We Are\n\nNotion is the collaborative AI workspace where teams and agents think together. ...",
  "descriptionLength": 6294,
  "fetchedAt": "2026-10-10T01:00:00.000Z"
}
```

A company that fails gives a free row such as `{"type": "error", "company": "acme", "ats": null, "error": "No public job board found for \"acme\" on Greenhouse, Lever, Ashby or Personio. ..."}`.

`id` is `system:company:jobId` and stays stable between runs, so you can compare runs. `companyName` is filled when the board provides it. Pay fields are filled only when the board publishes a pay range or the description states one clearly; nothing is estimated. `country` is a two-letter code derived from the location text, and `seniority` is a guess from the job title. The Console table view lists the main columns.

### Pricing

Pay per event. You pay for jobs written to the dataset, never for errors or duplicates.

| Event | Price | When it is charged |
| --- | --- | --- |
| Actor start | $0.005 | Once per run. |
| Job posting | $0.002 | For each job item in the dataset ($2 per 1,000 jobs). |

Examples: 100 jobs cost $0.205, 1,000 jobs $2.005 and 10,000 jobs $20.005. Error rows are free. The Actor never charges beyond the maximum total charge you set for the run: it stops cleanly and says so in the status message. Filters and `maxJobsPerCompany` are applied before charging, so you pay only for the jobs you keep.

### FAQ

**Is it allowed, and does it respect robots.txt?** The Actor only reads public job-board endpoints that the vendors document for this purpose, checks robots.txt on every host, uses an honest user agent and stays at 2 requests per second per host. The postings belong to the companies that publish them, so you remain responsible for how you use the data.

**Which systems are not supported?** SmartRecruiters, Recruitee and Workable. Their public data endpoints are either disallowed for automated access, need a company-issued token or are not documented as public. An entry for one of them returns a free error row that explains this.

**How do I find the name of a board?** Open the company's careers page and look at the address of the job list: `jobs.lever.co/NAME`, `boards.greenhouse.io/NAME` or `jobs.ashbyhq.com/NAME`. You can also paste the careers page itself or just try the plain company name.

**Can I get only new jobs?** Schedule the Actor daily with `postedWithinDays` set to 1. Use `id` to remove jobs you already have.

**Does it collect personal data?** No recruiter, hiring manager, creator or contact fields are read. E-mail addresses and phone numbers in descriptions are replaced by default. A description can still contain a name written in free text, which the Actor cannot detect.

**Is it affiliated with the job-board vendors?** No. Greenhouse, Lever, Ashby and Personio are names of their owners. This Actor is not affiliated with, endorsed by or sponsored by them; the names only say which public job boards it can read.

### Limitations

- Only boards on Greenhouse, Lever, Ashby and Personio are supported. Companies with another system or a custom careers site return an error row.
- Career page detection reads the HTML. A page that builds its job list with JavaScript, or hides the link, is not detected; use the `system:name` form instead.
- Personio feeds have no country and no workplace type, and many descriptions are empty. Lever and Ashby feeds have no display name for the company.
- Country, remote flag, employment type and seniority are heuristic readings of the text the board provides.
- Pay is published only by some companies. It is read from structured fields where they exist and otherwise from a clear pay range in the description.
- Only published postings are visible. Jobs closed by the company disappear from the board.
- A very large board (thousands of jobs with long descriptions) needs more memory than the default 256 MB. If a company fails with a size message, start the run with more memory, for example 1024 MB.
- Job-board vendors may change or restrict their public endpoints.

### Changelog

- **0.1 (October 2026):** first release with Greenhouse, Lever, Ashby and Personio.

# Actor input Schema

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

One company per line. Use a prefix to name the job board system: greenhouse:airbnb, lever:palantir, ashby:notion, personio:company. A job-board address also works (for example https://boards.greenhouse.io/airbnb, https://jobs.lever.co/palantir, https://jobs.eu.lever.co/name, https://jobs.ashbyhq.com/notion, https://name.jobs.personio.de). A plain name such as notion is tried on each supported system in turn and the first board found is used. The address of a company's own careers page is opened once and scanned for a link or embed of a supported job board.

## `keywords` (type: `array`):

Keep only jobs whose title contains at least one of these words or phrases (case-insensitive). Leave empty to keep all jobs. Turn on "Search in description" to match the description too.

## `excludeKeywords` (type: `array`):

Drop jobs whose title (or description, with "Search in description") contains any of these words.

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

Keep only jobs with a location that contains one of these texts, for example Berlin, Germany or Remote. Country names and codes such as US or UK are matched against the country found in the location too.

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

Keep only jobs that are fully remote according to the job board (workplace type, remote flag or a location such as "Remote - US").

## `departments` (type: `array`):

Keep only jobs in a department or team whose name contains one of these texts, for example Engineering or Sales.

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

Keep only jobs published in the last N days. Only applies when the job board gives a date; jobs without a date are kept and flagged with dateUnknown.

## `searchInDescription` (type: `boolean`):

Match keywords and exclude keywords against the full description as well as the title.

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

Keep at most this many jobs per company, newest first. The default is 1000; the form is filled with a small number so that a first test run stays cheap.

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

Stop after this many jobs across all companies. Together with the run's maximum total charge this caps what you pay. Leave empty for no limit.

## `descriptionFormat` (type: `string`):

How the job description is returned. Choose none to leave it out and get a smaller dataset.

## `stripContactInfo` (type: `boolean`):

Replace e-mail addresses and phone numbers in descriptions with a marker. Recommended: job descriptions sometimes name a contact person.

## `maxConcurrency` (type: `integer`):

How many companies are processed in parallel. Requests to one host are always limited to 2 per second.

## Actor input object example

```json
{
  "companies": [
    "greenhouse:airbnb",
    "lever:palantir",
    "ashby:notion"
  ],
  "remoteOnly": false,
  "searchInDescription": false,
  "maxJobsPerCompany": 10,
  "descriptionFormat": "text",
  "stripContactInfo": true,
  "maxConcurrency": 5
}
```

# Actor output Schema

## `results` (type: `string`):

All jobs (and error rows for companies that could not be fetched) as JSON. Use the dataset tabs in the Console for tables, CSV and Excel exports.

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

Per company: job board, jobs found, jobs kept after filters and the error, if any.

# 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": [
        "greenhouse:airbnb",
        "lever:palantir",
        "ashby:notion"
    ],
    "maxJobsPerCompany": 10
};

// Run the Actor and wait for it to finish
const run = await client.actor("ottomath/ats-jobs-aggregator").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": [
        "greenhouse:airbnb",
        "lever:palantir",
        "ashby:notion",
    ],
    "maxJobsPerCompany": 10,
}

# Run the Actor and wait for it to finish
run = client.actor("ottomath/ats-jobs-aggregator").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 '{
  "companies": [
    "greenhouse:airbnb",
    "lever:palantir",
    "ashby:notion"
  ],
  "maxJobsPerCompany": 10
}' |
apify call ottomath/ats-jobs-aggregator --silent --output-dataset

```

## MCP server setup

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

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/gDdawnKegGRox3rjV/builds/rAKfe2RpWt6x0P2gY/openapi.json
