# Company Career Jobs API: Greenhouse, Lever, Ashby & more (`volanta-ai/company-career-jobs-api`) Actor

- **URL**: https://apify.com/volanta-ai/company-career-jobs-api.md
- **Developed by:** [Richard Hutton](https://apify.com/volanta-ai) (community)
- **Categories:** Jobs, Lead generation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.80 / 1,000 job delivereds

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 Career Jobs API: Greenhouse, Lever, Ashby, Workable, Recruitee, SmartRecruiters and Personio jobs

**Company Career Jobs API** keeps a recurring feed of open jobs for the companies you track, across **Greenhouse, Lever, Ashby, Workable, Recruitee, SmartRecruiters and Personio**, so you don't have to maintain seven API clients, a seen-jobs store or failure handling yourself.

- **Input:** board URLs, domains or company names. The Actor auto-detects the ATS and reads the public job-board API that ATS publishes for careers pages, with no logins or proxies.
- **Output:** one normalized JSON record per job: title, department, team, locations, workplace type, employment type, published salary, dates, job and apply URLs, and description.
- **Monitoring:** with `onlyNewSinceLastRun`, each scheduled run returns only jobs added since the previous run, lists removed postings, and explains every company in a `COMPANY_STATUS` record.

Use it as a careers-page jobs API, a hiring-signal feed for sales and recruiting, or a source for job boards and AI agents. It works from the Apify Console, the API, schedules, webhooks, Make, Zapier, n8n and the Apify MCP server.

### Quickstart: a three-board feed in two runs

[Open the ready-to-run example](https://apify.com/volanta-ai/company-career-jobs-api/examples/collect-company-career-jobs) for a guided single run.

1. Start the Actor with this input in the **JSON** tab:

   ```json
   {
     "companies": [
       "https://boards.greenhouse.io/stripe",
       "https://jobs.lever.co/palantir",
       "https://jobs.ashbyhq.com/ramp"
     ],
     "onlyNewSinceLastRun": true,
     "stateStoreName": "my-three-board-feed",
     "maxJobsPerCompany": 3,
     "includeDescription": "none"
   }
   ```

   `stateStoreName` names the key-value store that remembers which jobs this feed has already seen. Apify keeps named stores until you delete them. Give every independent feed its own name. If you save the input as a **Task**, its history is also kept separate from other tasks.
2. **Run 1 is the baseline.** It delivers up to 3 jobs per board, at most 9, and records every job currently on each board as seen. Event charges: at most 3 × $0.002 + 9 × $0.0008 = **$0.0132**.
3. **Run 2: start the same input again.** It returns only jobs that were not on the boards during run 1. If the boards haven't changed, which is common for runs minutes apart, the dataset is empty and every company shows `newJobs: 0`. That run costs 3 × $0.002 = **$0.006**.
4. **Where to look:**
   - The **Output** tab lists delivered jobs.
   - In the run's **Storage → Key-value store**, `COMPANY_STATUS` explains each company: ATS, jobs on the board, new jobs, delivered jobs and a message. On run 1 the message says "First run for this board: baseline saved".
   - `REMOVED_JOBS` lists postings that disappeared since the previous run.
   - `RUN_SUMMARY` holds the totals.
5. **Platform usage is included in the price;** you pay only the event charges. To keep the feed running, save the input as a Task and add a schedule (see [Monitoring new jobs on a schedule](#monitoring-new-jobs-on-a-schedule)).

**Continue beyond the Free demo.** Free runs check only the first 10 companies and return at most 100 jobs per company. For a larger feed, if your Apify account is still Free, go to [Billing → Subscription → Upgrade](https://docs.apify.com/account/subscriptions), choose a paid plan, and rerun your saved Task. If you already have a paid plan, simply rerun it. Paid plans include prepaid usage, and this Actor's listed event charges consume that available usage. Keep the `maxTotalChargeUsd` cap for API runs.

> **Maintainer QA, not a customer result:** on 20 Oct 2026 the owner ran this input pattern on build 0.1.5 with its own state store. Run 1 returned 9 jobs from 3/3 companies in about 4 s, and the repeat returned 0 jobs from 3/3 companies in about 3 s.

#### Run it from the API (curl or Python)

Keep your Apify API token (Apify Console → Settings → API & Integrations) in the `APIFY_TOKEN` environment variable. The examples send it only in the `Authorization` header, never in the URL. `maxTotalChargeUsd` caps what a run can be charged, and `timeout` limits the run to 120 seconds. The synchronous endpoint waits at most 300 seconds and answers **HTTP 408** if the run takes longer. For big lists, start the run with `POST /v2/acts/Hd56tFCgqOeIdJUjV/runs` and read the dataset when it finishes. Other errors: **400** invalid input, **401** missing or wrong token, **402** usage limit or credits exhausted, **403** insufficient permissions. If the connection times out or drops, the run may still exist. **Do not blindly repeat the POST**: first check the Actor's Runs tab and retrieve the existing result if it completed; use the asynchronous endpoint when you need a run ID for reliable recovery.

```bash
curl -sS --fail-with-body -X POST \
  "https://api.apify.com/v2/acts/Hd56tFCgqOeIdJUjV/run-sync-get-dataset-items?maxTotalChargeUsd=0.05&timeout=120" \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"companies": ["https://boards.greenhouse.io/stripe"], "maxJobsPerCompany": 5, "includeDescription": "none"}'
```

```python
import json
import os
import urllib.error
import urllib.request

ACTOR_ID = "Hd56tFCgqOeIdJUjV"  # volanta-ai/company-career-jobs-api
URL = (f"https://api.apify.com/v2/acts/{ACTOR_ID}/run-sync-get-dataset-items"
       "?maxTotalChargeUsd=0.05&timeout=120")


def fetch_jobs(companies, max_jobs_per_company=5):
    body = json.dumps({
        "companies": companies,
        "maxJobsPerCompany": max_jobs_per_company,
        "includeDescription": "none",
    }).encode()
    request = urllib.request.Request(URL, data=body, method="POST", headers={
        "Authorization": f"Bearer {os.environ['APIFY_TOKEN']}",
        "Content-Type": "application/json",
    })
    try:
        with urllib.request.urlopen(request, timeout=320) as response:
            return json.load(response)
    except urllib.error.HTTPError as error:
        if error.code == 408:
            raise RuntimeError("The run took longer than 300 s; use the asynchronous /runs endpoint.") from error
        detail = error.read().decode("utf-8", "replace")[:300]
        raise RuntimeError(f"Apify API error {error.code}: {detail}") from error


if __name__ == "__main__":
    for job in fetch_jobs(["https://boards.greenhouse.io/stripe"]):
        print(job["companyName"], "|", job["title"], "|", job["jobUrl"])
```

### What it does

- **7 ATS in one Actor**: Greenhouse, Lever (US and EU), Ashby, Workable, Recruitee, SmartRecruiters, Personio (.de and .com).
- **Auto-detection**: paste a board URL (`https://jobs.lever.co/palantir`), a domain (`ramp.com`), a slug (`stripe`) or a name (`Hugging Face`). Known URL patterns are parsed directly; otherwise at most 3 candidate slugs are checked against each ATS API (stopping at the first board with jobs), and as a fallback the company website is read once to find the ATS link. Detections are cached between runs. Links to job sites and social networks (a LinkedIn company page, an Indeed listing...) and unsupported ATS (Workday, iCIMS, Taleo...) are rejected with a clear message instead of guessed.
- **One normalized schema** for all ATS: title, department, team, locations, workplace type (remote / hybrid / onsite), employment type, salary range when the employer publishes it, posting and update dates, job and apply URLs, description as plain text and/or HTML.
- **Filters**: include and exclude title keywords, locations, remote only, departments or teams, posted within N days, max jobs per company.
- **New-jobs monitoring**: `onlyNewSinceLastRun` remembers seen jobs per job board, returns only new postings on each scheduled run and lists removed (closed) jobs.
- **Per-company status**: a `COMPANY_STATUS` record tells you, for every input, which ATS was detected, how many jobs were found and delivered, and why a company returned nothing.
- **Contact-detail redaction (best effort)**: recruiter and hiring-manager fields from the ATS APIs are not included in the output, and email addresses, phone numbers and contact-person names that follow labels such as "Contact person:" or "Ansprechpartnerin:" are replaced in descriptions. Redaction is pattern based, so free-text descriptions can still contain personal information.

### Who it is for

- **Sales and recruiting teams** tracking hiring signals: which target accounts open roles in Engineering, Sales or a new country.
- **Job boards, newsletters and aggregators** that need fresh, structured jobs straight from company career pages.
- **Market and talent researchers** analysing hiring volume, locations, remote share and published salary ranges.
- **Developers and AI agents** that need a reliable "get open jobs for company X" tool with a stable output schema.

### Supported ATS and accepted inputs

| ATS | Example input | Salary data | Notes |
|---|---|---|---|
| Greenhouse | `https://boards.greenhouse.io/stripe`, `https://job-boards.greenhouse.io/stripe`, embed `?for=stripe` | No (not in the public list API) | Department from the board |
| Lever | `https://jobs.lever.co/palantir`, `https://jobs.eu.lever.co/<company>` | Yes, when published (`salaryRange`) | US and EU hosts; company name derived from the slug |
| Ashby | `https://jobs.ashbyhq.com/ramp` | Yes, when published | Department, team, workplace type |
| Workable | `https://apply.workable.com/huggingface`, `https://<company>.workable.com` | No | Remote flag |
| Recruitee | `https://bunq.recruitee.com` | Yes, when published | Remote / hybrid / on-site flags |
| SmartRecruiters | `https://jobs.smartrecruiters.com/Ubisoft2`, `https://careers.smartrecruiters.com/<company>` | No | Descriptions need one extra request per job |
| Personio | `https://tado.jobs.personio.de`, `https://<company>.jobs.personio.com` | Yes, when published | XML feed; legal entity as company name |

Also accepted: a careers page URL (`https://example.com/careers`), a domain (`ramp.com`), a slug (`stripe`) or a company name (`Hugging Face`). Board URLs are the fastest and most precise input.

Not supported: Workday, iCIMS, Taleo, SuccessFactors and other systems without an official public job-board API.

### Input example

```json
{
    "companies": [
        "https://boards.greenhouse.io/stripe",
        "https://jobs.lever.co/palantir",
        "ramp.com",
        "Hugging Face"
    ],
    "titleKeywords": ["engineer", "data scientist"],
    "excludeKeywords": ["intern"],
    "locations": ["London", "Remote"],
    "postedWithinDays": 30,
    "includeDescription": "text",
    "maxJobsPerCompany": 100,
    "onlyNewSinceLastRun": false
}
```

| Field | Type | Default | Description |
|---|---|---|---|
| `companies` | array of strings | required | Board URLs, careers page URLs, domains, slugs or names. Max 1,000 per run, 500 characters per entry. |
| `atsHint` | string | `auto` | Force one ATS (`greenhouse`, `lever`, `ashby`, `workable`, `recruitee`, `smartrecruiters`, `personio`) for slug/name/domain inputs. |
| `titleKeywords` | array | `[]` | Keep titles containing any keyword. Whole-word, case-insensitive; `engineer*` = prefix match. |
| `excludeKeywords` | array | `[]` | Drop titles containing any keyword (same rules). |
| `locations` | array | `[]` | Keep jobs whose location contains any text (substring). `remote` also matches remote jobs. |
| `remoteOnly` | boolean | `false` | Keep only remote jobs. |
| `departments` | array | `[]` | Keep jobs whose department or team contains any text. |
| `postedWithinDays` | integer | `0` | Keep jobs posted in the last N days (0 = no limit). |
| `includeDescription` | string | `text` | `text`, `html`, `both` or `none`. |
| `maxJobsPerCompany` | integer | `0` | Max jobs delivered per company, newest first (0 = no limit; Free plan demo: 100). |
| `onlyNewSinceLastRun` | boolean | `false` | Return only jobs not seen in the previous run; list removed jobs. |
| `stateStoreName` | string | `ats-jobs-feed-state` | Named key-value store for seen jobs and detection cache. Runs from a saved task already keep their own history; set a different name only for independent monitors that run without a task. |
| `scanCareersPage` | boolean | `true` | Read the company website once (respecting robots.txt) when slug detection fails. |

### Output example

One dataset item per job (a real record from a live run, description shortened; `includeDescription: "text"`):

```json
{
    "jobId": "ashby:190c265a-ca4f-48b7-b2a4-13412299249d",
    "ats": "ashby",
    "nativeId": "190c265a-ca4f-48b7-b2a4-13412299249d",
    "companySlug": "ramp",
    "companyName": "Ramp",
    "title": "Manager, Account Manager | Growth",
    "department": "Sales",
    "team": "Account Manager",
    "locations": ["New York, NY (HQ)", "San Francisco, CA"],
    "workplaceType": "hybrid",
    "employmentType": "full-time",
    "employmentTypeRaw": "FullTime",
    "salaryMin": 180000,
    "salaryMax": 247000,
    "salaryCurrency": "USD",
    "salaryPeriod": "year",
    "postedAt": "2026-10-13T15:07:59.917Z",
    "updatedAt": null,
    "jobUrl": "https://jobs.ashbyhq.com/ramp/190c265a-ca4f-48b7-b2a4-13412299249d",
    "applyUrl": "https://jobs.ashbyhq.com/ramp/190c265a-ca4f-48b7-b2a4-13412299249d/application",
    "descriptionText": "About Ramp\n\nRamp is building the smart infrastructure for finance teams, embedded in the transaction flow of every dollar a business spends. ...",
    "descriptionHtml": null,
    "isNew": true,
    "firstSeenAt": "2026-10-14T14:11:48.308Z",
    "fetchedAt": "2026-10-14T14:11:48.308Z",
    "companyInput": "ramp.com"
}
```

Every field is always present; unknown values are `null`. Field notes:

- `jobId` is stable across runs: `<ats>:<native id>`. Use it to deduplicate.
- `workplaceType`: `remote`, `hybrid`, `onsite` or `null`. Taken from the ATS when it has a field for it, otherwise inferred from "Remote" or "Hybrid" in the location or title, otherwise `null`.
- `employmentType`: `full-time`, `part-time`, `contract`, `temporary`, `internship`, `apprenticeship`, `freelance`, `volunteer` or `null`; the ATS value is kept in `employmentTypeRaw`.
- `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryPeriod` (`year`, `month`, `week`, `day`, `hour`) are filled only when the employer publishes pay in the ATS.
- `salaryMin` / `salaryMax` are copied as published by the employer, without corrections.
- `isNew` / `firstSeenAt`: whether the job was absent in the previous run for this board (in the same task), and when this Actor first saw it. On the first run every job is new.
- `descriptionText` / `descriptionHtml`: filled according to `includeDescription`; emails, phone numbers and labelled contact-person names are replaced, on a best-effort basis, with `[email removed]`, `[phone removed]` and `[name removed]`. `descriptionHtml` is lightly sanitized (scripts, iframes, forms, event handlers and `javascript:`/`mailto:` links removed); run it through your own HTML sanitizer before rendering it on a website.

Default key-value store records:

- `COMPANY_STATUS`: one entry per input with `status` (`ok`, `not_found`, `error`, `duplicate`, `skipped`), `ats`, `boardUrl`, `detectionMethod` (`url`, `probe`, `hint`, `careers-page`, `cache`), `jobsOnBoard`, `jobsMatched`, `newJobs`, `jobsPushed`, `removedJobsCount`, `removedJobs`, `message` and the detection `attempts`.
- `REMOVED_JOBS`: jobs that were on a board in the previous run and are gone now (`jobId`, `title`, `jobUrl`, `firstSeenAt`, company).
- `RUN_SUMMARY`: totals for the run.

Example `COMPANY_STATUS` entry (real, from a run with `maxJobsPerCompany: 5`):

```json
{
    "input": "ramp.com",
    "status": "ok",
    "ats": "ashby",
    "companySlug": "ramp",
    "companyName": "Ramp",
    "boardUrl": "https://jobs.ashbyhq.com/ramp",
    "detectionMethod": "probe",
    "jobsOnBoard": 162,
    "jobsMatched": 162,
    "newJobs": null,
    "jobsPushed": 5,
    "removedJobsCount": null,
    "removedJobs": [],
    "charged": true,
    "message": "First run for this board: baseline saved, all jobs count as new. 157 more matching jobs were not delivered because of maxJobsPerCompany.",
    "attempts": [
        "recruitee:ramp (not found)",
        "smartrecruiters:ramp (not found)",
        "workable:ramp (found, 0 jobs)",
        "personio:ramp (not found)",
        "greenhouse:ramp (not found)",
        "ashby:ramp (found, 162 jobs)",
        "lever:ramp (not found)"
    ],
    "checkedAt": "2026-10-14T14:11:48.323Z"
}
```

### Monitoring new jobs on a schedule

1. Save your input with `onlyNewSinceLastRun: true` as a task and run it once to set the baseline (see the Quickstart).
2. Add a schedule, for example daily. Each run returns only new jobs, and `REMOVED_JOBS` lists closed postings.
3. Connect a webhook, Slack, email, Google Sheets, Make, Zapier or n8n integration to act on new jobs.

Seen jobs are stored per job board in the named key-value store set by `stateStoreName` (default `ats-jobs-feed-state`), so two spellings of the same company share one history. Per-run records (`COMPANY_STATUS`, `REMOVED_JOBS`, `RUN_SUMMARY`) live in each run's own key-value store, which Apify deletes after your plan's retention period. A job that disappears is reported in `REMOVED_JOBS` once; if it comes back within 30 days (for example after a temporary unpublish), it is not delivered or charged as new again. Each saved task keeps its own history, so two monitoring tasks, or a monitoring task and a quick test run from the Console, never use up each other's new jobs. Runs started without a task (for example directly from the API) share one history per `stateStoreName`; give independent API monitors different `stateStoreName` values. The first run saves the whole board as the baseline, including jobs it did not deliver because of `maxJobsPerCompany` or a limit, so later runs only return jobs that were really added after it. From then on, a new job that matched your filters but was not delivered (because of `maxJobsPerCompany`, the Free plan demo limit or your spending limit) is not marked as seen, so it is delivered in the next run.

### Pricing

This Actor uses pay-per-event pricing. Platform usage is included, so you pay only for these events:

| Event | Price (USD) | When it is charged |
|---|---|---|
| Company checked (`company-checked`) | $0.002 | Once per company whose job board was found and read, including boards that currently list no jobs (you still get its status and removed jobs). Not charged for companies that are not found, API outages, duplicates, or companies skipped by the demo cap. |
| Job delivered (`job`) | $0.0008 | Once per job record in the dataset, after your filters. Filtered-out jobs are free. |

Examples:

- 10 companies, 50 matching jobs each: 10 x $0.002 + 500 x $0.0008 = **$0.42**.
- Daily monitor of 100 companies with `onlyNewSinceLastRun`, about 3 new matching jobs per company per day: $0.20 + $0.24 = **$0.44 per day**.
- Checking 1,000 companies with `maxJobsPerCompany: 5`: $2 + up to $4 = **at most $6**.

The run stops cleanly when your maximum charge per run is reached: no company or job is started or pushed that would exceed it, and every job in the dataset is charged exactly once. Companies that were not reached are listed as `skipped` in `COMPANY_STATUS`.

#### Free plan demo

On the Apify Free plan, each run is a demo: it checks the **first 10 companies** of your list and returns **up to 100 jobs per company**. Skipped companies are listed as `skipped` in `COMPANY_STATUS`, and the run log explains the limit. Any paid Apify plan removes both limits.

### FAQ

**Why did a company return no jobs?**
Open the `COMPANY_STATUS` record. `not_found` means no public board was found on the seven supported ATS for that input (paste the board URL from the company's careers page, or set `atsHint`), or that the input was a job site such as LinkedIn or an unsupported ATS such as Workday (the message says which). `ok` with 0 jobs means the board exists but lists no open roles or none matched your filters (`jobsOnBoard` vs `jobsMatched`).

**I have two scheduled monitors. Do they interfere?**
No, as long as each runs from its own saved task: every task keeps its own seen-jobs history. For monitors started directly through the API without a task, set a different `stateStoreName` per monitor.

**The wrong ATS or company was detected.**
Slugs such as `apple` can exist on several systems. Paste the exact board URL, or set `atsHint`. Board URLs are never guessed. For multi-word names, the shortest guess (the first word, e.g. `acme` for "Acme Robotics") is only accepted when the ATS reports a matching company name.

**How fresh is the data?**
Every run reads the live ATS API. There is no intermediate database or cache of job data.

**Does it return salaries?**
Yes, when the employer publishes pay ranges in Ashby, Lever, Recruitee or Personio. Greenhouse, Workable and SmartRecruiters do not expose pay in their public list APIs, so those fields are `null`.

**Can I get Workday, iCIMS or LinkedIn jobs?**
No. This Actor reads the public job-board APIs that ATS vendors provide for displaying jobs on careers pages. Workday, iCIMS and LinkedIn do not offer an equivalent public jobs API, so they are not supported.

**How do AI agents use it?**
Through the Apify MCP server or the Apify API: call the Actor with `companies` (and optional filters) and read the dataset. The output schema is fixed and documented above, so agents can rely on field names and types.

**How long does a run take?**
A board URL needs one request (SmartRecruiters: one per 100 postings). Domains and names are checked against up to 9 ATS API endpoints in parallel for each candidate slug (at most 3 slugs), stopping at the first board with jobs. SmartRecruiters descriptions need one extra request per delivered job. Typical runs of a few companies finish in seconds.

### Limits

- Up to 1,000 companies per run; split bigger lists into several runs or tasks.
- SmartRecruiters: up to 10,000 postings per company (auto-detection reads only the first page; the full listing is loaded once the board is chosen); descriptions add one request per delivered job, so use `maxJobsPerCompany` or `includeDescription: "none"` for very large boards. SmartRecruiters answers unknown company identifiers with an empty list, so a mistyped board URL shows up as a board with 0 jobs (the status message says so).
- Lever's and Ashby's public APIs do not return the company name; it is taken from your input name or derived from the board slug (`palantir` becomes `Palantir`).
- Greenhouse EU-hosted boards (`job-boards.eu.greenhouse.io`) are tried on the global API first, then the EU API.
- Descriptions are returned in the language the employer published them in.
- Detection by name or domain is best effort. Board URLs are always exact.

### Legal and data

- **Data sources**: Greenhouse Job Board API, Lever Postings API, Ashby Job Postings API, Workable job widget API, Recruitee Careers Site API, SmartRecruiters Posting API and the Personio XML job feed. These endpoints are published by the ATS vendors so that job postings can be displayed and syndicated outside their own sites. For detection by domain or name, the Actor may also read the company's own careers page once.
- **Access**: no logins, CAPTCHA solving or bot-detection workarounds are used. The optional careers-page read happens only when robots.txt allows it.
- Requests identify themselves with the User-Agent `VolantaActor/0.1 (+mailto:richard@volanta.ai)`, use timeouts and back off on rate limits.
- **Personal information (best effort)**: recruiter, hiring-manager and contact fields from the ATS APIs are not included in the output (for example the SmartRecruiters `creator` or the Recruitee `mailbox_email`). Email addresses, phone numbers and names that follow a contact label are redacted from job descriptions. Redaction is pattern based, so descriptions written by employers can still contain personal information; review them before republishing.
- Job texts remain the property of the employers. You are responsible for using the data in line with applicable law and the employers' terms, for example when republishing descriptions.

### Integrations and API

Run it from the Apify API, JavaScript or Python client, the Apify CLI, schedules, webhooks, or integrations such as Make, Zapier, n8n, Google Sheets and Slack. Results can be downloaded as JSON, CSV, Excel, XML or RSS.

### Support

Built and maintained by **Volanta** (Volanta S.R.L., Romania). Questions, bugs or a missing ATS: **richard@volanta.ai**, or open an issue on the Actor's Issues tab.

### For maintainers

No API keys or secrets are required: all data sources are public APIs. Environment variables:

| Variable | Purpose |
|---|---|
| `APIFY_USER_IS_PAYING` | Set by the Apify platform (`1` = paying user). Anything else on the platform enables the Free plan demo limits (10 companies per run, 100 jobs per company; constants in `src/main.js`). Locally the limits apply only if you set it to something other than `1`. |
| `ACTOR_TASK_ID` | Set by the platform for task runs; scopes the seen-jobs history to the task. |
| `ATS_FEED_OFFLINE=1` | Mock mode: serve recorded fixtures from `test/fixtures` instead of calling the ATS APIs. Uses the state store `<stateStoreName>-offline`, so it never touches a live baseline. |
| `CRAWLEE_STORAGE_DIR` | Local storage folder (default `./storage`); point it to a temp folder for throwaway runs. |
| `NODE_USE_ENV_PROXY=1` | Local only, when your network requires an HTTP(S) proxy. |
| `APIFY_ACTOR_PRICING_INFO`, `APIFY_CHARGED_ACTOR_EVENT_COUNTS`, `ACTOR_MAX_TOTAL_CHARGE_USD`, `ACTOR_USE_CHARGING_LOG_DATASET=1` | Local PPE simulation (charges are logged to the `charging_log` dataset). Locally the SDK counts every event as $1 against the limit but adds real prices to the total, so budget stops are not realistic; `test/billing.test.js` checks budget handling with the SDK's real `ChargingManager` in platform mode instead. |

Commands:

```bash
npm install
npm test                                   # unit tests (no network, recorded fixtures)
ATS_FEED_OFFLINE=1 npm start               # offline end-to-end run (input in storage/key_value_stores/default/INPUT.json)
apify run                                  # live local run
node scripts/record-fixtures.mjs           # re-record trimmed fixtures from the live APIs
node scripts/validate-schemas.mjs [items.json]   # validate .actor schemas (and dataset items) with Apify's validators
```

Pricing events are defined in `.actor/pay_per_event.json` and charged in `src/billing.js`. **When you set up pay-per-event pricing in the Console, delete the synthetic `apify-default-dataset-item` event that Apify adds by default**: the `job` event already charges once per dataset item, so keeping it would make users pay twice per job (the code would still keep the total within the user's max charge, but every job would cost more than the price table above). The seen-jobs state and detection cache live in the named key-value store from `stateStoreName`; if the Actor runs with limited permissions and cannot open it, the run continues without new-job tracking and logs a warning.

# Actor input Schema

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

One company per line (up to 1,000 per run; the Apify Free plan demo checks the first 10). Accepted forms: an ATS board URL (https://boards.greenhouse.io/stripe, https://jobs.lever.co/palantir, https://jobs.ashbyhq.com/ramp, https://apply.workable.com/huggingface, https://bunq.recruitee.com, https://jobs.smartrecruiters.com/Ubisoft2, https://tado.jobs.personio.de), a careers page URL, a company domain (ramp.com), or a slug/name (stripe, Hugging Face). Board URLs are the fastest and most precise; domains and names are auto-detected.

## `atsHint` (type: `string`):

Force one ATS when your entries are slugs, names or domains. Leave on auto-detect unless a company is detected on the wrong system. Ignored for ATS board URLs.

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

Keep jobs whose title contains ANY of these words or phrases (case-insensitive, whole words: "engineer" matches "Senior Engineer" but not "Engineering Manager"). End a keyword with \* for prefix matching ("engineer\*"). Leave empty to keep all titles.

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

Drop jobs whose title contains ANY of these words or phrases (same matching rules as the include list), e.g. "intern", "senior".

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

Keep jobs where any job location contains ANY of these texts (case-insensitive substring), e.g. "Berlin", "United Kingdom", "NY". The value "remote" also matches jobs whose workplace type is remote.

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

Keep only jobs marked remote by the ATS or with "Remote" in a location.

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

Keep jobs whose department or team contains ANY of these texts (case-insensitive substring), e.g. "Engineering", "Sales".

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

Keep jobs first published in the last N days (by the ATS posting date). 0 = no limit. Jobs without a known posting date are dropped when this is set.

## `includeDescription` (type: `string`):

Include the job description as plain text, sanitized HTML, both, or not at all (smaller, faster output). Emails and phone numbers are redacted from descriptions on a best-effort basis.

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

Deliver at most this many matching jobs per company (most recently posted first). 0 = no limit. On the Apify Free plan the demo returns at most 100 jobs per company.

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

For scheduled monitoring: return only jobs that were not on the board during the previous run (the first run returns the matching jobs, up to the max per company, and saves the whole board as the baseline). Jobs that disappeared are listed in the REMOVED_JOBS record and in each company's status. Seen jobs are remembered per job board in the named key-value store below.

## `stateStoreName` (type: `string`):

Name of the key-value store that remembers seen jobs and detected job boards between runs. Use a different name per independent monitor (e.g. one per schedule). Letters, digits and hyphens only.

## `scanCareersPage` (type: `boolean`):

If a domain or careers-page URL cannot be matched by slug, fetch that page once (respecting robots.txt) and look for links to a supported ATS board.

## Actor input object example

```json
{
  "companies": [
    "https://jobs.lever.co/palantir",
    "ramp.com",
    "bunq",
    "Hugging Face"
  ],
  "atsHint": "auto",
  "remoteOnly": false,
  "postedWithinDays": 0,
  "includeDescription": "text",
  "maxJobsPerCompany": 20,
  "onlyNewSinceLastRun": false,
  "stateStoreName": "ats-jobs-feed-state",
  "scanCareersPage": true
}
```

# Actor output Schema

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

Normalized job postings (one item per job).

## `companyStatus` (type: `string`):

One record per input company: detected ATS, board URL, jobs found/matched/delivered, new and removed jobs, and why a company returned nothing.

## `removedJobs` (type: `string`):

Jobs that were on a board in the previous run but are gone now (closed or unpublished).

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

Totals for the run (companies found, not found, jobs delivered, limits applied).

# 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": [
        "https://boards.greenhouse.io/stripe",
        "ramp.com",
        "huggingface"
    ],
    "maxJobsPerCompany": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("volanta-ai/company-career-jobs-api").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": [
        "https://boards.greenhouse.io/stripe",
        "ramp.com",
        "huggingface",
    ],
    "maxJobsPerCompany": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("volanta-ai/company-career-jobs-api").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": [
    "https://boards.greenhouse.io/stripe",
    "ramp.com",
    "huggingface"
  ],
  "maxJobsPerCompany": 20
}' |
apify call volanta-ai/company-career-jobs-api --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,volanta-ai/company-career-jobs-api"
        }
    }
}
```

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/Hd56tFCgqOeIdJUjV/builds/rQ1RbAxB6BPdUWcKb/openapi.json
