# Company Jobs Scraper: Greenhouse, Lever & Ashby (`rovnaki/ats-jobs-feed`) Actor

Track jobs from company domains or ATS boards across six providers. Filter roles, return one JSON schema, and schedule new or changed jobs. No login or proxy.

- **URL**: https://apify.com/rovnaki/ats-jobs-feed.md
- **Developed by:** [rovnaki](https://apify.com/rovnaki) (community)
- **Categories:** Jobs, Lead generation
- **Stats:** 2 total users, 1 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 job results

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

## Company Jobs Scraper: Greenhouse, Lever & Ashby

Give it the companies you follow and get their open jobs in one table. Reads six public ATS feeds, with title/location filters and an optional mode that returns only new or changed jobs. Start with the explicit board example below; domains and career URLs are also accepted.

This company jobs scraper reads public ATS APIs for Greenhouse, Lever, Ashby, SmartRecruiters, Workable and Recruitee. Use it as a company careers API or incremental jobs feed for job monitoring. No login or proxy is needed; there is no official affiliation with the ATS providers.

### Quick start

```json
{
  "companies": [
    "greenhouse:stripe",
    "lever:spotify",
    "ashby:ashby"
  ],
  "maxJobsPerCompany": 5,
  "maxChargedResults": 15,
  "includeDescription": false,
  "onlyNewSinceLastRun": false,
  "concurrency": 2
}
```

Up to 15 delivered jobs and three fetched boards cost **$0.021 in custom events**, plus start: **$0.0212 at 4 GB** or **$0.02105 at ≤1 GB**. A zero-match board still incurs a company event. Inspect the default key-value store's **`OUTPUT`** record for company errors, counts and stop reasons.

Illustrative compact output row (the dataset includes additional nullable fields):

```json
{"id":"ashby:ashby:example-job","company":"ashby","ats":"ashby","title":"Engineer","location":"Remote","remote":true,"change_type":"new"}
```

Export JSON, CSV or Excel from the dataset. Use it as a Greenhouse jobs scraper or a Lever jobs API, or to monitor Ashby jobs. It reads the boards you supply; it does not discover a global jobs database or produce closed-job rows.

### Daily watchlist

Schedule this capped input daily, with one run at a time:

```json
{
  "companies": [
    "greenhouse:stripe",
    "lever:spotify",
    "ashby:ashby"
  ],
  "maxJobsPerCompany": 5,
  "maxChargedResults": 15,
  "includeDescription": false,
  "onlyNewSinceLastRun": true,
  "concurrency": 2,
  "filters": {
    "keywords": [
      "engineer"
    ],
    "remoteOnly": true
  }
}
```

Keep the company list, filters and description setting stable to reuse the same state scope. The first run emits a baseline; later runs omit unchanged jobs. Zero matches still cost company events, and no closed-job rows are produced. Inspect `OUTPUT` if a company fails. See incremental monitoring below for state and concurrency limitations.

### Inputs and discovery

You can also use `stripe.com`, `https://www.spotify.com/careers/`, `https://jobs.ashbyhq.com/ashby`, `smartrecruiters:BoschGroup`, `workable:skroutz`, or `recruitee:bunq`. Domain discovery reads HTML, follows up to two careers links, recognizes ATS URLs/embeds and redirects, then probes up to two domain-derived slugs on the six providers. Explicit `ats:slug` inputs avoid discovery ambiguity. Keep slug casing, particularly for SmartRecruiters.

| Input | Behavior |
| --- | --- |
| `companies` | Required array of 1–1,000 domains, URLs or `ats:slug` strings. Duplicate strings and duplicate resolved boards are processed once. |
| `filters.keywords` | Case-insensitive title substring matches, for example `["engineer", "developer"]`. |
| `filters.locations` | Substring match against any published location label, for example `["Singapore", "London"]`. |
| `filters.departments` | Substring match against the published department. Does not substitute team labels. |
| `filters.remoteOnly` | Include only roles explicitly marked remote or with a remote location label. Hybrid and unknown roles are excluded. |
| `filters.postedWithinDays` | Positive integer: include postings published within the last N days. Excludes unknown publication dates. |
| `includeDescription` | Default `false`. Add `description_text` and `description_html`. SmartRecruiters uses extra detail requests when enabled. Workable detail requests are always used for salary and accurate metadata. |
| `onlyNewSinceLastRun` | Default `false`. When enabled, emit new or updated jobs and skip unchanged jobs. |
| `maxJobsPerCompany` | Default 1,000; Store prefill 5. Maximum emitted rows per company after filters and change detection, not a limit on fetched jobs. |
| `maxChargedResults` | Optional run-wide job result cap. Zero skips processing. Company events are priced separately. |
| `concurrency` | Default 2, maximum 4. Per-host request starts are spaced by at least 200 ms; publication and charging are serialized. |

All filter groups must match. Values within a list are alternatives (OR). Omit a filter or use an empty list to disable it. Keyword matching uses titles regardless of `includeDescription`.

### Output example

Illustrative row from the recorded Ashby response; `first_seen_at` depends on when your input scope first delivers the job:

```json
{
  "id": "ashby:ashby:7458d4e9-da2e-47bd-98cb-adfda43d42b2",
  "company": "ashby",
  "ats": "ashby",
  "title": "Engineering Manager - EU",
  "department": "Engineering",
  "team": "EMEA Engineering",
  "location": "Remote - European Union",
  "locations": ["Remote - European Union", "Spain", "Italy", "Germany"],
  "remote": true,
  "employment_type": "FullTime",
  "salary": { "min": 110000, "max": 185000, "currency": "EUR", "interval": "1 YEAR", "text": "€110K - €185K" },
  "posted_at": "2024-03-04T14:29:08.532Z",
  "updated_at": null,
  "url": "https://jobs.ashbyhq.com/ashby/7458d4e9-da2e-47bd-98cb-adfda43d42b2",
  "apply_url": "https://jobs.ashbyhq.com/ashby/7458d4e9-da2e-47bd-98cb-adfda43d42b2/application",
  "first_seen_at": "2026-10-15T00:00:00.000Z",
  "change_type": "new"
}
```

The example abbreviates the locations array. Actual results preserve every distinct location supplied by that posting. Composite IDs include the ATS and board slug, avoiding collisions. Company names fall back to the board slug when the feed has no company name. Missing metadata is `null`; no salary, date, team or remote status is invented. Employment labels retain each provider's vocabulary. Salary uses the first published salary tier/range, with currency and interval when available; text summaries are retained if structured values are absent. Other compensation such as equity is not converted to salary.

The dataset has jobs and details views. Company errors are saved separately in the default key-value store's **`OUTPUT`** record, linked from the Output tab. The summary contains each input's resolved provider, status, fetched/matched/pushed counts, errors, event counts, and any stop reason. A failed company does not crash other companies or add an error row to the jobs dataset. Check `OUTPUT.status`; a run can finish normally with `partial`, `failed`, or `limited` status.

### Incremental monitoring

The named key-value store **`ats-jobs-feed-state`** persists across runs. Keys contain a SHA-256 hash of the sorted company inputs, normalized filters and description setting, followed by a board hash. The comparison stores a content hash and original `first_seen_at` for successfully delivered jobs.

The first run emits matching jobs as `new`. Later runs classify normalized changes as `updated` and identical rows as `unchanged`. With `onlyNewSinceLastRun=true`, unchanged rows are omitted and incur no job-result fee. Company processing still has a fee. Changing the company list, filters or description setting starts a separate baseline. Toggling incremental mode or changing limits/concurrency keeps the baseline.

Unemitted jobs are not marked seen. A capped incremental run can deliver the next unseen jobs on its next run. New and updated classifications compare the last successfully delivered version, including source update timestamps. Removing a job from a board does not emit a closed record; the schema reports open jobs with `new`, `updated`, or `unchanged` only. Missing jobs remain in state so a reappearing identical posting is unchanged. Delete the named store to reset all baselines.

Use one scheduled run at a time per input scope. The named store is not a distributed lock: concurrent runs of the same scope can emit duplicates or overwrite each other's state. State is checkpointed after each company, including partial delivery on a budget stop. A process termination or storage/billing failure between delivery and checkpoint can cause a duplicate on retry; delivery, billing and state are not one transaction.

### Pay-per-event pricing

| Event | USD event price | Charged when |
| --- | ---: | --- |
| `apify-actor-start` | $0.00005/GB, minimum 1 GB | Default platform event, automatically at run start. |
| `company-processed` | $0.002 | One board resolves and its listing is fetched and validated, even with no matching jobs. Duplicate resolved boards are skipped. |
| `job-result` | $0.001 | One normalized job is actually written to the dataset. |

The prefilled run delivers up to 15 jobs across 3 fetched boards: up to $0.021 in custom events, plus the memory-based start event. At 4 GB that is **$0.0212**; at 512 MB (the local default) it is **$0.02105**. The start event costs $0.00005 per GB with a one-GB minimum. Check the effective run memory and Store pricing at execution time, including any separately applicable platform usage.

Event definitions and suggested prices are in **`.actor/pay_per_event.json`**. This is a publishing configuration reference; the CLI does not automatically activate it from `actor.json`. For a future publishing pass, verify that Console matches these events, retain the default start event and keep the synthetic `apify-default-dataset-item` fee disabled. Check Console for the applicable platform-cost allocation; it is not established by this source manifest. The code fails closed on missing PPE events or a positive synthetic dataset fee to prevent incorrect billing.

The Actor calls `Actor.charge()` once per company and once **after** each successful job push. It checks the SDK's event allowance and current prices before charging. It also respects `ACTOR_MAX_TOTAL_CHARGE_USD`, `maxChargedResults`, and the result-cap environment variables `ACTOR_MAX_PAID_DATASET_ITEMS`, `APIFY_MAX_PAID_DATASET_ITEMS`, and `ACTOR_MAX_CHARGED_RESULTS`, using the tightest limit. Existing platform event counts, including the start event, are included in budget checks. Writes and charges are serialized so concurrent company fetches cannot spend the same remaining budget. Exhaustion stops gracefully and records the reason in `OUTPUT`.

Local runs do not bill an account. Supplied local limits are enforced using suggested company/job prices; Apify's synthetic start event is not charged locally.

### FAQ

**Do I need an ATS API key, login, or proxy?** No. Only public job-board APIs are used. Private postings and authenticated recruiting APIs are excluded.

**Why does a company domain fail to resolve?** Board slugs can differ from company domains. Careers pages may render links only with JavaScript, use unsupported ATS providers, or link multiple unrelated boards. Supply the ATS board URL or `ats:slug` to remove ambiguity. Generic empty probe responses are not treated as proof of a company's board; explicit board inputs support zero jobs.

**Is this a global jobs search engine?** No. It reads the company boards you provide. It does not crawl LinkedIn, Indeed, or aggregators, and does not resolve subsidiary relationships or search multiple boards for a single company. Discovery chooses the first valid board it finds.

**Can I use descriptions on my website?** The Actor returns source HTML as data. Sanitize it before rendering and follow the job publisher's terms for reuse. It does not collect applications or candidate records.

**How does it handle errors?** Requests have 15-second timeouts, up to two retries for network errors, HTTP 429 and 5xx responses, exponential backoff with jitter, and a bounded `Retry-After` delay. Responses are limited to 40 MB and redirects to five hops. Company failures are isolated and summarized in `OUTPUT`. Pagination repeats and malformed responses fail visibly instead of silently returning an incomplete board.

### Limitations

- Only the six listed ATS providers and their public listed jobs are supported. Workday, Personio, BambooHR, Teamtailor and authenticated or regional Lever/Greenhouse endpoints are not implemented.
- No browser is used. JavaScript-only discovery and unusual custom embeds may require explicit board slugs. Slug probing is a heuristic, not proof that a domain owns a board.
- Provider metadata varies. Remote unknowns are `null`; hybrid roles are not remote-only matches. Compound location labels remain intact. Dates can be absent or reflect publication/republication; Lever exposes creation time. Time filters exclude unknown dates.
- A source may place salary only in description prose. Structured extraction covers supported salary fields and Ashby/Lever salary summaries; it does not parse arbitrary compensation prose. Multiple salary ranges are reduced to the first salary range/tier.
- The list APIs can download all jobs even when emission limits are small. SmartRecruiters and Lever pagination have a 100,000-job safety bound. Extremely large feeds, rate limiting and detail requests increase run time and cost.
- Live daily smoke coverage uses the three prefills. Workable, Recruitee and SmartRecruiters have recorded-fixture coverage and were verified live during development, but are not in the default daily smoke input.
- Scheduled batch execution is supported; a persistent Standby HTTP server and closed-job notifications are not implemented.

### Supported public sources

These populated endpoints returned HTTP 200 without authentication using `curl` on **2026-10-15**:

| ATS | Verified company | Official public endpoint |
| --- | --- | --- |
| Greenhouse | Stripe | `https://boards-api.greenhouse.io/v1/boards/stripe/jobs?content=true&pay_transparency=true` |
| Lever | Spotify | `https://api.lever.co/v0/postings/spotify?mode=json&limit=2` |
| Ashby | Ashby | `https://api.ashbyhq.com/posting-api/job-board/ashby?includeCompensation=true` |
| SmartRecruiters | Bosch Group | `https://api.smartrecruiters.com/v1/companies/BoschGroup/postings?limit=2` |
| Workable | Skroutz | `https://apply.workable.com/api/v1/widget/accounts/skroutz` |
| Recruitee | bunq | `https://bunq.recruitee.com/api/offers/` |

SmartRecruiters job details at `/v1/companies/BoschGroup/postings/744000155667010` and Workable details at `/api/v2/accounts/skroutz/jobs/DB1A13AC0A` were also verified without authentication. Recorded responses are in `tests/fixtures`; each list fixture keeps its first two jobs.

### Local development and health check

```bash
cd /home/ubuntu/projects/sideIncome/apify/ats-jobs-feed
source ~/.nvm/nvm.sh
nvm use 22
npm ci
npm run build
npm test
npm run smoke
```

`npm run smoke` builds the Actor, launches `node dist/main.js` in an isolated local Apify storage directory using `INPUT.json`, and validates every row against the dataset JSON Schema. It requires each prefilled company to return jobs, checks summary success and duplicate IDs, prints a single JSON result line after npm's script banner, and exits non-zero on failure. It strips inherited Apify platform credentials, pricing and limits from the child process so a health check runs locally without billing. Child logs are captured and included in the failure summary. Storage is retained under `.smoke-storage/<run-id>/` for diagnosis. For a JSON-only cron output, use `npm --silent run smoke`.

To run arbitrary local inputs, set `APIFY_LOCAL_STORAGE_DIR` to a directory inside this project, put your JSON input at `key_value_stores/default/INPUT.json` in that directory, then run `npm start`. Reuse that directory to retain named-store history. No Apify login or push is necessary. The Dockerfile uses `apify/actor-node:22` with a separate build stage.

For API contracts and platform behavior, see [Greenhouse Job Board API](https://developers.greenhouse.io/job-board.html), [Lever Postings API](https://github.com/lever/postings-api), [Ashby public job postings API](https://developers.ashbyhq.com/docs/public-job-posting-api), [SmartRecruiters Posting API](https://developers.smartrecruiters.com/docs/posting-api), and [Apify PPE documentation](https://docs.apify.com/platform/actors/publishing/monetize/pay-per-event).

# Actor input Schema

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

Company domains, careers-page URLs, or ats:slug. Supported prefixes: greenhouse, lever, ashby, smartrecruiters, workable, recruitee. Explicit board slugs are the most reliable option. Three populated example boards verified on 2026-10-15.

## `filters` (type: `object`):

All filter groups must match. Lists use case-insensitive substring OR matching. Keywords match titles; locations match any published location. Jobs with unknown dates are excluded when postedWithinDays is set.

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

Include description_html and description_text. SmartRecruiters requires an extra detail request per candidate. Raw HTML is provided as data; sanitize before rendering.

## `onlyNewSinceLastRun` (type: `boolean`):

Compare normalized results with persistent state in the named ats-jobs-feed-state key-value store. First run emits matching jobs. Unchanged jobs are free of job-result charges. Run overlapping schedules serially.

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

Cap emitted jobs after filtering and incremental comparison. Unemitted jobs remain eligible next run. The full public listing may still be fetched for pagination.

## `maxChargedResults` (type: `integer`):

Optional run-wide cap on delivered job results, including local runs. Zero produces no company or job events. Also respects platform budget and result-cap environment variables. Company events have a separate price.

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

Number of company feeds fetched concurrently. Per-host requests start at least 200 ms apart; dataset writes and charges are serialized.

## Actor input object example

```json
{
  "companies": [
    "greenhouse:stripe",
    "lever:spotify",
    "ashby:ashby"
  ],
  "filters": {
    "keywords": [],
    "locations": [],
    "departments": [],
    "remoteOnly": false
  },
  "includeDescription": false,
  "onlyNewSinceLastRun": false,
  "maxJobsPerCompany": 5,
  "maxChargedResults": 15,
  "concurrency": 2
}
```

# Actor output Schema

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

JSON dataset of emitted jobs. Export to CSV or Excel through the dataset API.

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

OUTPUT record with each company's status, counts, error messages and any budget stop reason.

# 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:stripe",
        "lever:spotify",
        "ashby:ashby"
    ],
    "filters": {
        "keywords": [],
        "locations": [],
        "departments": [],
        "remoteOnly": false
    },
    "includeDescription": false,
    "onlyNewSinceLastRun": false,
    "maxJobsPerCompany": 5,
    "maxChargedResults": 15,
    "concurrency": 2
};

// Run the Actor and wait for it to finish
const run = await client.actor("rovnaki/ats-jobs-feed").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:stripe",
        "lever:spotify",
        "ashby:ashby",
    ],
    "filters": {
        "keywords": [],
        "locations": [],
        "departments": [],
        "remoteOnly": False,
    },
    "includeDescription": False,
    "onlyNewSinceLastRun": False,
    "maxJobsPerCompany": 5,
    "maxChargedResults": 15,
    "concurrency": 2,
}

# Run the Actor and wait for it to finish
run = client.actor("rovnaki/ats-jobs-feed").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:stripe",
    "lever:spotify",
    "ashby:ashby"
  ],
  "filters": {
    "keywords": [],
    "locations": [],
    "departments": [],
    "remoteOnly": false
  },
  "includeDescription": false,
  "onlyNewSinceLastRun": false,
  "maxJobsPerCompany": 5,
  "maxChargedResults": 15,
  "concurrency": 2
}' |
apify call rovnaki/ats-jobs-feed --silent --output-dataset

```

## MCP server setup

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

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/ozZ5KCC8NDKBYVP2z/builds/EU89zkbj7Od7lozfN/openapi.json
