# Greenhouse, Lever & Ashby Jobs API — 6 ATS, one schema (`crearcode/greenhouse-lever-ashby-jobs-api`) Actor

Scrape jobs from company career pages across 6 ATS platforms — Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee — into one schema. Salary ranges parsed and annualized, remote/hybrid/onsite detection, LATAM eligibility flag, delta feed. No personal data.

- **URL**: https://apify.com/crearcode/greenhouse-lever-ashby-jobs-api.md
- **Developed by:** [Juan Carlos Gutiérrez Huérfano](https://apify.com/crearcode) (community)
- **Categories:** Jobs, Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.60 / 1,000 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/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Job Postings Scraper — Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee

Pulls open roles straight from companies' own applicant tracking systems and returns
**one clean schema** across all six, with salary bands parsed into comparable annual
figures and an eligibility flag for Latin America.

No personal data is collected or emitted — job postings only.

### Supported sources

| ATS | Board URL | Board entry |
|---|---|---|
| Greenhouse | `boards.greenhouse.io/stripe` | `greenhouse:stripe` |
| Lever | `jobs.lever.co/leverdemo` | `lever:leverdemo` |
| Ashby | `jobs.ashbyhq.com/ramp` | `ashby:ramp` |
| Workable | `apply.workable.com/hospitable` | `workable:hospitable` |
| SmartRecruiters | `jobs.smartrecruiters.com/Acme` | `smartrecruiters:Acme` |
| Recruitee | `tether.recruitee.com` | `recruitee:tether` |

### Why this exists

Every ATS already publishes its job board as a public JSON endpoint. The hard part was
never fetching it; it's that each one names things differently, buries compensation in
free prose, and describes location in a dozen incompatible ways. Existing scrapers hand
you the raw payload and leave the normalization to you.

This one does the normalization:

- **Salary parsed and annualized.** Structured fields when the ATS provides them,
  otherwise parsed from the description — English, Spanish and Portuguese, multi-currency,
  with `annualizedMin`/`annualizedMax` so an hourly US rate and a monthly Colombian
  salary sort in the same column. Every band carries the exact `rawText` it came from,
  and a `confidence` of `structured` or `parsed`, so you can audit or discard.
- **Work mode classified** as `remote` / `hybrid` / `onsite` / `unknown` — reading the
  title and body, not just the location string. "Hybrid remote" resolves to hybrid, and
  a named city with no remote signal resolves to onsite; a bare country stays `unknown`
  rather than being guessed at.
- **LATAM eligibility flag.** `latamFriendly` is true when the role sits in a Latin
  American country *or* is remote with a scope that includes the region — "Remote - LATAM",
  "Americas", "must overlap GMT-5", "work from anywhere". US-built job scrapers treat
  those strings as noise; if you hire in the region, that flag is the product.

  It is deliberately conservative. A role anchored to a non-LATAM country and not marked
  remote is never flagged, no matter what the description says — a US-based role whose
  body mentions "our business in the Americas" is a business-scope mention, not a hiring
  signal, and treating it as one would make the flag worthless.
- **Language detection** (`es` / `pt` / `en`) on the posting body.
- **Delta feed.** Pass `postedAfter` with your previous run's timestamp and get only
  what's new, instead of re-pulling every board in full.

### Input

```json
{
  "boards": [
    "greenhouse:stripe",
    "lever:leverdemo",
    "ashby:ramp",
    "workable:hospitable",
    "smartrecruiters:smartrecruiters",
    "recruitee:tether"
  ],
  "postedAfter": "2026-07-01T00:00:00Z",
  "latamOnly": true,
  "withSalaryOnly": false,
  "workMode": ["remote"],
  "titleIncludes": ["backend", "platform"],
  "maxItems": 0,
  "descriptionMaxChars": 5000,
  "failOnAnySourceError": false
}
```

The slug is whatever appears in the board's public URL — see the table above. Only
`boards` is required; every filter is optional.

Set `failOnAnySourceError` to `true` when you would rather have a loud failure
than a partial result — useful if you schedule this and act on the output
automatically. It is off by default, because most callers would rather have five
platforms of data than none.

### Output

One record per posting:

```json
{
  "id": "greenhouse:stripe:7967890",
  "source": "greenhouse",
  "companySlug": "stripe",
  "companyName": "Stripe",
  "title": "Consumer Operations Associate",
  "url": "https://stripe.com/jobs/search?gh_jid=7967890",
  "locationRaw": "Mexico City",
  "countryCode": "MX",
  "latamFriendly": true,
  "workMode": "onsite",
  "department": "Operations",
  "employmentType": "Full-time",
  "postedAt": "2026-07-22T15:16:54-04:00",
  "updatedAt": "2026-07-27T11:17:30-04:00",
  "salary": {
    "min": 120000,
    "max": 160000,
    "currency": "USD",
    "period": "year",
    "annualizedMin": 120000,
    "annualizedMax": 160000,
    "rawText": "$120,000 - $160,000",
    "confidence": "parsed"
  },
  "language": "en",
  "description": "...",
  "scrapedAt": "2026-07-31T18:00:00Z"
}
```

`id` is stable across runs, so you can upsert on it.

A `RUN_SUMMARY` record is written to the key-value store with per-board failures,
postings scanned vs emitted, and the filters applied — so a run that returns fewer
rows than you expected tells you why.

Every run also writes a `SOURCE_STATUS` record: one entry per ATS platform, so you
can tell a genuinely quiet week apart from a source that stopped answering.

```json
{
  "checkedAt": "2026-08-12T12:59:00Z",
  "sources": [
    { "ats": "greenhouse", "status": "ok", "boards": 1, "boardsOk": 1,
      "jobsFound": 412, "jobsEmitted": 412, "errors": [] },
    { "ats": "ashby", "status": "failed", "boards": 1, "boardsOk": 0,
      "jobsFound": 0, "jobsEmitted": 0,
      "errors": ["ashby:ramp: HTTP 404 (board not found)"] }
  ]
}
```

`status` is `ok`, `partial` (some boards answered, others did not) or `failed`. The run
finishes with a status message like `6/6 ATS OK - 1301 postings`, readable from the run
list without opening anything.

### Reliability

These are documented public JSON endpoints with no authentication and no anti-bot
layer, so there is nothing here that silently degrades when a site ships a redesign.
Requests retry three times with exponential backoff; a `404` is treated as a permanent
"no such board" rather than retried. Boards are fetched five at a time — enough to be
fast, not enough to be rude. One failing board does not sink the run; all boards failing
does, loudly.

**The sources are checked every day.** A scheduled run hits one known-good board on
each of the six platforms and alerts on any source that errors or returns nothing. If
an ATS changes its API, that check fails before your run does.

A typical run across six boards reads well over a thousand postings in about ten seconds.

### What this does not do

- **No personal data.** No recruiter or hiring-manager names, no contact details, no
  applicant information. This is a deliberate design constraint, not an oversight.
- **No authenticated scraping** and no credential sharing.
- **No aggregator sites.** It reads each company's own board directly, which is why the
  data is first-hand and the uptime is the ATS's rather than a middleman's.
- **`companyName` is null on Lever.** Lever's public postings API does not expose the
  company's display name, and inventing one from the slug would be a guess. The other
  five sources return it when the ATS publishes it.

### Local development

```bash
npm install
npm run build
npm run test:local    # offline assertions + live endpoint probe
```

`test:local` checks the salary parser and the location, work-mode and LATAM classifiers
against fixtures without needing network — including a regression test for the
"business in the Americas" false positive — then probes the six live endpoints and
reports how many postings each returned.

### Issues

Found a bad parse, or want a field in a different shape? Open an issue on the Actor.
Include the board entry and the posting URL and it is usually a quick fix.

# Actor input Schema

## `boards` (type: `array`):

One entry per board, as "source:slug", where slug is the identifier in the board's public URL. Supported sources: greenhouse, lever, ashby, workable, smartrecruiters, recruitee. Examples: boards.greenhouse.io/stripe → "greenhouse:stripe"; jobs.ashbyhq.com/ramp → "ashby:ramp"; apply.workable.com/acme → "workable:acme"; jobs.smartrecruiters.com/Acme → "smartrecruiters:Acme"; acme.recruitee.com → "recruitee:acme".

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

ISO date. Only returns postings published or updated at or after this moment. Use the previous run's timestamp to poll cheaply instead of re-pulling every board in full.

## `latamOnly` (type: `boolean`):

Keep only roles a candidate based in Latin America could take: located in a LATAM country, or remote with a scope that includes the region (LATAM, Americas, GMT-3..-6, worldwide).

## `withSalaryOnly` (type: `boolean`):

Keep only postings where a salary band was resolved, either from the ATS's structured fields or parsed from the description.

## `workMode` (type: `array`):

Keep only these work modes.

## `titleIncludes` (type: `array`):

Case-insensitive substring match against the job title, e.g. "backend", "java", "platform".

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

Hard cap on returned postings. 0 means no limit.

## `descriptionMaxChars` (type: `integer`):

Truncate the plain-text description to this many characters. Lower it to cut dataset size when you only need metadata.

## `failOnAnySourceError` (type: `boolean`):

Off by default. Turn it on for a scheduled health check: the run fails loudly when an ATS platform stops answering, instead of quietly returning a partial result.

## Actor input object example

```json
{
  "boards": [
    "greenhouse:stripe",
    "ashby:ramp",
    "workable:acme"
  ],
  "latamOnly": false,
  "withSalaryOnly": false,
  "maxItems": 0,
  "descriptionMaxChars": 5000,
  "failOnAnySourceError": false
}
```

# Actor output Schema

## `allJobsJson` (type: `string`):

Every posting from this run, with salary annualized, workMode classified and the latamFriendly flag resolved.

## `allJobsCsv` (type: `string`):

The same postings as CSV, ready to open in a spreadsheet.

## `sourceStatus` (type: `string`):

Per-platform status for this run: which ATS sources answered, how many postings each returned, and the exact error for any that did not.

## `runInConsole` (type: `string`):

Run detail page with the labelled table view, the log and the input used.

# 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 = {
    "boards": [
        "greenhouse:stripe",
        "ashby:ramp"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("crearcode/greenhouse-lever-ashby-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 = { "boards": [
        "greenhouse:stripe",
        "ashby:ramp",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("crearcode/greenhouse-lever-ashby-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 '{
  "boards": [
    "greenhouse:stripe",
    "ashby:ramp"
  ]
}' |
apify call crearcode/greenhouse-lever-ashby-jobs-api --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,crearcode/greenhouse-lever-ashby-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/XQRy6Q8wfZS2YrjaD/builds/SJ4K9o9jQTuDDhHcR/openapi.json
