# Startup Jobs Aggregator - 3 ATS in One (`renzomacar/startup-jobs-aggregator`) Actor

Give it company handles; it finds each one on Greenhouse, Ashby or Lever and returns every open role in one deduplicated, normalised schema. No API key, no login, no proxy.

- **URL**: https://apify.com/renzomacar/startup-jobs-aggregator.md
- **Developed by:** [Renzo Madueno](https://apify.com/renzomacar) (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 $4.00 / 1,000 job scrapeds

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?

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

## Startup Jobs Aggregator — Greenhouse, Ashby & Lever in One Run

Give it company names. It works out which applicant tracking system each one uses, pulls every open role, and returns them all in **one normalised, deduplicated schema**. No API key, no login, no cookies, no proxy, no browser.

```json
{ "companies": ["stripe", "ramp", "spotify", "notion", "linear"] }
```

That is the whole input. Stripe resolves on Greenhouse, Ramp and Notion and Linear on Ashby, Spotify on Lever — and you get one dataset where `salaryMin` means the same thing in every row.

***

### The problem this solves

Scraping job boards means knowing which ATS a company uses before you can ask for its jobs. That is a research task per company, and it changes when companies migrate. Then each ATS returns a different shape:

- Lever calls the job title `text`, not `title`
- Greenhouse nests location as `location.name`; Ashby uses a flat `location`; Lever buries it in `categories.location`
- Ashby's employment type is `FullTime`; Lever's is `Full-time`; Greenhouse has none at all
- Ashby publishes structured salary numbers; the other two do not
- Ashby has a real `isRemote` boolean; Greenhouse has nothing of the kind
- Dates arrive as ISO strings, as epoch milliseconds, and as different field names

This Actor probes all three platforms per handle, then flattens everything into one schema with normalised enums, so a downstream filter like `employmentType === "FULL_TIME" && isRemote` works identically across all three sources.

### How the discovery works

A plain handle like `ramp` is tried against every enabled ATS, because a company's Greenhouse token, Ashby board name and Lever slug are almost always the same word. A full ATS URL pins the handle to that one platform and skips the probing.

By default the Actor **stops at the first ATS that returns jobs** (`stopAtFirstMatch: true`), because a company almost always lives on exactly one. Turn it off to catch companies mid-migration that still have roles live on two — the cross-ATS deduplication below is built for exactly that case.

Every company that matches nothing is reported as `no_ats_match` in `FAILURES`, and a `COMPANY_MAP` record tells you which platform each handle resolved to:

```json
{
  "stripe":  { "jobs": 10, "foundOn": ["greenhouse"] },
  "ramp":    { "jobs": 10, "foundOn": ["ashby"] },
  "spotify": { "jobs": 10, "foundOn": ["lever"] },
  "zzznotarealcompany": { "jobs": 0, "foundOn": [] }
}
```

That map is worth keeping. Run it once over your company list and you have an ATS census you never have to rebuild.

### Deduplication across platforms

Two layers:

1. **Hard** — same source, company and job id.
2. **Soft** — same company, title and location, even when the ids differ and the platforms differ.

The soft layer is what catches a company that migrated from Lever to Ashby and left the same twelve roles live on both. Without it you would silently double-count. `duplicatesDropped` in the `FAILURES` record tells you how many were removed, and you are never charged for a dropped duplicate.

***

### What you get, with the fill rate actually measured

Fill rates depend on which ATS a row came from, and pretending otherwise would be dishonest. Measured across **4,316 live postings** — 2,628 on Greenhouse (9 boards), 1,234 on Ashby (7 boards), 454 on Lever (3 sites):

| Field | Greenhouse | Ashby | Lever |
|---|---|---|---|
| `title`, `jobId`, `location`, `locations[]` | 100% | 100% | 100% |
| `jobUrl`, `applyUrl`, `boardUrl` | 100% | 100% | 100% |
| `postedAt` | 100% | 100% | 100% |
| `descriptionText` | 100% | 100% | 100% |
| `department` | 100% | 100% | 100% |
| `team` | — | 100% | 100% |
| `employmentType` | — | 100% | 98.7% |
| `country` | — | 99.8% | 100% |
| `workplaceType` | 25.1% | 77.6% | 100% |
| `isRemote` from the ATS itself | — | 76.7% | 100% |
| **`salaryMin`** | 57.0% | **79.9%** | 64.3% |
| — of which structured | 0% | **71.8%** | 0% |

Read the last two rows carefully, because they are the whole story of this category:

- **Ashby is the only one of the three with a structured pay field.** 71.8% of its postings carry exact `minValue`/`maxValue`/`currencyCode` numbers the company typed into their ATS.
- **Greenhouse's public board API exposes no salary field at all.** We checked `pay_input_ranges` on all 2,628 postings: populated on zero. The 57% figure is parsed out of the description text.
- **Lever has a `salaryRange` field that nobody populates** — 0% across 454 postings. Its 64.3% is also parsed from text.

Every row carries `salarySource`, so you always know which kind you have: `ats_structured`, `ats_summary` or `description`. Filter on it if your use case needs certainty. Set `salaryOnly: true` to drop rows with no range.

Fields absent for a given source are absent, not null-filled — Greenhouse genuinely has no employment type or country to give.

### Remote flags, and what we refuse to guess

`isRemote` is on every row, with `remoteSource` recording how we know:

- `ats_field` — the platform's own answer. 100% of Lever rows, 76.7% of Ashby rows, none on Greenhouse.
- `inferred_location` — the location text says so (`"US-Remote"`, `"Remote in the US"`). 27% of Greenhouse rows.
- `unknown` — returned as `false`.

We do **not** infer remoteness from the description body. Measured on 2,245 Greenhouse postings, that method flagged 95 roles as remote whose location was Singapore, Dublin or Bengaluru, because it was matching company boilerplate about being "remote-friendly". That describes the employer, not the job. The inference was removed rather than shipped with a caveat.

***

### Input

```json
{
  "companies": ["stripe", "ramp", "spotify", "notion", "linear"],
  "sources": ["greenhouse", "ashby", "lever"],
  "maxItems": 1000,
  "salaryOnly": false,
  "remoteOnly": false,
  "titleKeywords": ["engineer"],
  "stopAtFirstMatch": true
}
```

#### Accepted company formats

```
ramp                                          probed on all enabled ATSs
https://job-boards.greenhouse.io/stripe       pinned to Greenhouse
https://jobs.ashbyhq.com/ramp                 pinned to Ashby
https://jobs.lever.co/spotify                 pinned to Lever
```

#### Input aliases

- companies: `companies`, `company`, `boards`, `companyUrls`, `startUrls`, `tokens`
- limit: `maxItems`, `maxResults`, `limit`, `maxJobs`
- sources: `sources`, `source`, `ats`, `platforms`
- keywords: `titleKeywords`, `keywords`, `searchTitle`
- locations: `locationKeywords`, `locations`, `location`
- departments: `departmentKeywords`, `departments`, `department`
- salary filter: `salaryOnly`, `onlyWithSalary`
- descriptions: `includeDescription`, `includeContent`, `fullDescription`

#### Filters, applied identically across all three sources

| Option | What it does |
|---|---|
| `maxItems` | Hard ceiling on rows written across every company and ATS. **This is your spend cap.** |
| `maxJobsPerCompany` | Keeps a 798-posting Databricks board from consuming a 20-company run |
| `sources` | Restrict to `greenhouse`, `ashby`, `lever` — fewer probes, faster and cheaper |
| `stopAtFirstMatch` | On by default; off catches mid-migration companies |
| `salaryOnly` | Only rows with a populated `salaryMin` |
| `remoteOnly` | Only `isRemote: true` |
| `employmentTypes` | Normalised across all three: `FULL_TIME`, `PART_TIME`, `INTERNSHIP`, `CONTRACT` |
| `titleKeywords` / `locationKeywords` / `departmentKeywords` | Substring matching |
| `postedAfter` | ISO date |
| `dedupe` | Cross-ATS deduplication, on by default |
| `concurrency` | Companies probed in parallel, 1–15, default 6 |

***

### Output sample

Every row has the same keys regardless of which platform it came from:

```json
{
  "source": "ashby",
  "companyToken": "ramp",
  "companyName": "ramp",
  "jobId": "34413f8d-26bf-4bbc-8ade-eb309a0e2245",
  "title": "Security Engineer, Cloud",
  "department": "Engineering",
  "departments": ["Engineering", "Backend"],
  "team": "Backend",
  "employmentType": "FULL_TIME",
  "location": "New York, NY (HQ)",
  "locations": ["New York, NY (HQ)", "Remote (Canada)", "Remote (US)", "Miami, FL"],
  "country": "USA",
  "isRemote": true,
  "workplaceType": "HYBRID",
  "remoteSource": "ats_field",
  "salaryMin": 211400,
  "salaryMax": 290600,
  "salaryCurrency": "USD",
  "salaryInterval": "YEAR",
  "salaryText": "$211.4K – $290.6K • Offers Equity",
  "salarySource": "ats_structured",
  "postedAt": "2026-04-07T17:12:35.753+00:00",
  "jobUrl": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245",
  "applyUrl": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245/application",
  "boardUrl": "https://jobs.ashbyhq.com/ramp",
  "descriptionText": "ABOUT RAMP ...",
  "scrapedAt": "2026-08-22T04:16:40.117Z"
}
```

Four saved dataset views ship with the Actor: **Job overview**, **Salary ranges**, **Remote & locations** and **By ATS source**.

***

### What you can build with this

**A startup job board.** One run over a few hundred portfolio companies gives you every open role with apply links, refreshed on a schedule.

**A compensation dataset.** Set `salaryOnly: true` and filter `salarySource === "ats_structured"` for a clean corpus of company-published ranges with currency and interval attached.

**Recruiting and competitive intelligence.** Run the same list weekly and diff on `jobId`. New ids are new reqs; disappeared ids are filled or pulled. Hiring velocity by department, without a vendor.

**An ATS census.** The `COMPANY_MAP` record tells you which platform each company uses — useful in its own right if you sell to recruiting teams.

**Lead lists.** `department`, `team` and `location` tell you how a company is structured and where it is growing.

***

### How errors are handled

**Errors never enter your dataset.** Charging for a row that says "this failed" is charging for an error message. Everything goes to the `FAILURES` record in the run's key-value store:

```json
{
  "runFailed": false,
  "companiesRequested": 5,
  "companiesMatched": 4,
  "companiesUnmatched": 1,
  "jobsDelivered": 40,
  "jobsBySource": { "greenhouse": 10, "ashby": 20, "lever": 10 },
  "duplicatesDropped": 0,
  "byReason": { "no_ats_match": 1 }
}
```

Reasons:

- `no_ats_match` — not found on any enabled platform. Either the company uses a different ATS (Workday, SmartRecruiters, Taleo) or its handle differs from its name. **This is the normal outcome for a chunk of any real company list, not a bug.**
- `board_empty` — found on that ATS, but zero open roles right now. Distinct from not being found at all.
- `unresolvable_input` — could not derive a handle.
- `budget_exhausted` — `maxItems` was reached before this company was written. Nothing is skipped silently.
- `fetch_failed` — network or upstream error after retries.

**If nothing was delivered, the run ends FAILED** with the reason breakdown in the message.

***

### Pricing and the free tier

Pay per event:

- **Actor start** — one small charge per gigabyte of memory
- **Job scraped** — charged **after** the row is written to the dataset

Rows are pushed in batches and the per-job event fires only for rows that landed. **Probing a company that turns out not to be on any of the three platforms costs you nothing** — no row, no charge. Same for filtered-out postings and deduplicated repeats.

**The free tier returns real data.** No API key, no proxy, no credentials of any kind — all three upstreams are public JSON endpoints. A free account running `{"companies": ["ramp"], "maxItems": 10}` gets ten real jobs with real structured salary ranges. Nothing in the code path throws because you lack a paid feature.

***

### Speed

Each company costs at most one HTTP request per enabled ATS, and with `stopAtFirstMatch` on it is usually one or two. All requests are gzip-compressed and run with bounded concurrency. Measured: **1,234 Ashby postings across 7 boards in 1.4 seconds**, and a mixed 5-company probe across all three platforms in about 3 seconds.

If you are running hundreds of companies with descriptions on, raise memory to 2 GB and consider `includeDescription: false` when you only need the metadata.

***

### Common questions

**What if a company is on Workday or SmartRecruiters?** It comes back `no_ats_match`. This Actor covers the three startup ATSs. For enterprise Workday sites, use the **Workday Jobs Scraper** in this same fleet.

**Should I use this or the single-ATS Actors?** Use this when you have company names and do not know their ATS. Use a single-ATS Actor when you already know, or when you want a platform-specific field this one does not normalise — Greenhouse's `educationRequirement`, Lever's structured `requirementLists`, Ashby's `compensationPublic`.

**Why did a company match nothing when I know it uses Greenhouse?** Its board token probably differs from its name. Pass the full board URL instead of the bare handle.

**Can a company appear on two platforms?** Yes, during a migration. Set `stopAtFirstMatch: false` to collect both; soft deduplication removes the roles that are genuinely the same.

**Are the enums really consistent?** Yes. `employmentType` normalises `FullTime` and `Full-time` to `FULL_TIME`; `workplaceType` is always `REMOTE`, `HYBRID` or `ONSITE`; `salaryInterval` is always `YEAR`, `MONTH`, `WEEK`, `DAY` or `HOUR`. Non-standard Lever labels like `PERMANENT` are passed through uppercased rather than forced into a bucket where they would be wrong.

**How fresh is the data?** Live on every run. Nothing is cached.

***

### Related Actors in this fleet

- **Greenhouse Jobs Scraper** — plus `educationRequirement` and `internalJobId`
- **Ashby Jobs Scraper** — plus `compensationPublic` and unlisted pipelines
- **Lever Jobs Scraper** — plus structured `requirementLists`
- **Workday Jobs Scraper** — the enterprise side, by career-site URL

### Legal

This Actor reads public, unauthenticated JSON endpoints that Greenhouse, Ashby and Lever each publish so job boards and aggregators can syndicate postings. It sends no credentials, solves no challenges and bypasses no access control. You are responsible for how you use the data, including any applicable data-protection rules.

# Actor input Schema

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

One per line. A plain handle ("ramp") is probed against every enabled ATS, because a company's Greenhouse token, Ashby board name and Lever slug are usually the same word. A full ATS URL pins it to that platform. Aliases: company, boards, companyUrls, tokens.

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

Any of: greenhouse, ashby, lever. Empty means all three.

## `stopAtFirstMatch` (type: `boolean`):

On by default: one company almost always lives on exactly one ATS. Turn it off to catch companies mid-migration that still have roles on two.

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

Hard ceiling on rows written, across every company and ATS. Aliases: maxResults, limit, maxJobs.

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

Max jobs per company.

## `titleKeywords` (type: `array`):

Keep only postings whose title contains one of these (case-insensitive). Leave empty for all. Aliases accepted in raw input: keywords, searchTitle.

## `locationKeywords` (type: `array`):

Keep only postings whose location or office list contains one of these. Example: "New York", "Remote", "London".

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

Keep only postings flagged remote. Check the remoteSource field to see whether the flag came from the ATS or was inferred.

## `postedAfter` (type: `string`):

ISO date, e.g. 2026-07-01. Drops anything published before it.

## `departmentKeywords` (type: `array`):

Department or team keywords.

## `employmentTypes` (type: `array`):

Full-time, Part-time, Intern, Contract. Normalised across all three ATSs.

## `salaryOnly` (type: `boolean`):

Keeps only rows where salaryMin is populated. Most of these come from Ashby, which is the one ATS of the three with a structured pay field.

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

Plain-text job description. Turn it off for a much smaller dataset.

## `includeHtmlDescription` (type: `boolean`):

Adds the original HTML alongside the text version. Off by default because it roughly triples dataset size.

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

Drops repeats by job id, and repeats of the same company + title + location published twice.

## `concurrency` (type: `integer`):

How many requests to run at the same time. Lower it if you hit rate limits.

## Actor input object example

```json
{
  "companies": [
    "stripe",
    "ramp",
    "spotify",
    "notion",
    "linear"
  ],
  "sources": [
    "greenhouse",
    "ashby",
    "lever"
  ],
  "stopAtFirstMatch": true,
  "maxItems": 1000,
  "maxJobsPerCompany": 100000,
  "titleKeywords": [],
  "locationKeywords": [],
  "remoteOnly": false,
  "departmentKeywords": [],
  "employmentTypes": [],
  "salaryOnly": false,
  "includeDescription": true,
  "includeHtmlDescription": false,
  "dedupe": true,
  "concurrency": 6
}
```

# Actor output Schema

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

One row per open role across Greenhouse, Lever and Ashby, deduplicated into a single dataset.

# 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": [
        "stripe",
        "ramp",
        "spotify",
        "notion",
        "linear"
    ],
    "sources": [
        "greenhouse",
        "ashby",
        "lever"
    ],
    "titleKeywords": [],
    "locationKeywords": [],
    "departmentKeywords": [],
    "employmentTypes": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("renzomacar/startup-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": [
        "stripe",
        "ramp",
        "spotify",
        "notion",
        "linear",
    ],
    "sources": [
        "greenhouse",
        "ashby",
        "lever",
    ],
    "titleKeywords": [],
    "locationKeywords": [],
    "departmentKeywords": [],
    "employmentTypes": [],
}

# Run the Actor and wait for it to finish
run = client.actor("renzomacar/startup-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": [
    "stripe",
    "ramp",
    "spotify",
    "notion",
    "linear"
  ],
  "sources": [
    "greenhouse",
    "ashby",
    "lever"
  ],
  "titleKeywords": [],
  "locationKeywords": [],
  "departmentKeywords": [],
  "employmentTypes": []
}' |
apify call renzomacar/startup-jobs-aggregator --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,renzomacar/startup-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/54qnysCp9LHsde9Lg/builds/NUd5JFgU9M2abcnbl/openapi.json
