# ATS Job Board API — Greenhouse & Lever Jobs for AI Agents (`samgov-tools/ats-jobs`) Actor

Get current job postings from the official public Greenhouse and Lever job-board APIs. Pass company board tokens or careers URLs; get normalized JSON with title, location, remote, department, dates and original posting link. Built for AI agents and Apify MCP. $3 per 1,000 jobs.

- **URL**: https://apify.com/samgov-tools/ats-jobs.md
- **Developed by:** [AgentReady Tools](https://apify.com/samgov-tools) (community)
- **Categories:** Jobs, AI, Developer tools
- **Features:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 1,000 jobs

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

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

## How to integrate an Actor?

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

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

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

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

# README

## ATS Job Board API — Greenhouse & Lever Jobs for AI Agents

Fetch currently published jobs directly from official public Greenhouse and Lever APIs. One predictable JSON shape makes it easy to build job alerts, recruiting research, and agent tools without integrating two different ATS formats. No source API key is required.

### Disclaimer

- Unofficial tool. Not affiliated with, endorsed by, or sponsored by Greenhouse Software, Inc. or Lever, Inc. "Greenhouse" and "Lever" are trademarks of their respective owners, used only to describe compatibility.
- Uses only public, unauthenticated GET endpoints (boards-api.greenhouse.io/v1/boards/{token}/jobs and /v1/boards/{token}; api.lever.co/v0/postings/{site} and api.eu.lever.co) plus each host's robots.txt. No login, no API keys, no access controls bypassed, no application (POST) endpoints.
- Polite by design: max 1 request per second per host (honours Lever's robots.txt Crawl-delay: 1), respects robots.txt and HTTP 429/Retry-After, no proxy rotation to evade blocking.
- Only jobs the employer has published publicly. Each result links to the original posting (`url`); the original posting is authoritative.
- Personal data: emails and common phone-number formats found in job text are removed by default. You are responsible for your own use of the output, including compliance with GDPR and other data-protection, copyright, database-right and anti-spam laws, and with the source sites' terms. Do not use output for profiling individuals or unsolicited messaging.
- No AI training: Lever's job site declares ai-train=no (an opt-out under Art. 4 of Directive (EU) 2019/790). Do not use Lever-sourced output to train or fine-tune AI models.
- Job descriptions remain the property of the publishing employers. Use metadata-only mode (the default, includeDescription=false) if you don't need full text.
- On demand: the Actor fetches only the companies you list (or the documented defaults for an omitted/empty list), at run time. It does not build or keep a cumulative jobs database.
- No warranty: data is provided "as is", may be incomplete or outdated, and may change if the source APIs change.
- Removal requests: employers or individuals can request exclusion of a board or data via this Actor's Issues tab; we honour verified requests promptly by maintaining the local denylist described below.

### For AI agents

Apify MCP: `https://mcp.apify.com?tools=samgov-tools/ats-jobs`

Example prompt: “Use samgov-tools/ats-jobs to find up to 20 remote engineering jobs at greenhouse:discord and lever:leverdemo. Include posting links; omit descriptions.”

Start with `{}` for a small default run (up to 10 jobs), or provide `companies` and `maxItems`. Use explicit provider prefixes when known. A bare slug tries Greenhouse first and tries Lever only after a Greenhouse 404. Lever global 404 falls back once to its EU API; an EU board URL starts directly in the EU.

To find a token, follow the company's careers link to its hosted board: `job-boards.greenhouse.io/discord` gives `greenhouse:discord`; `jobs.lever.co/leverdemo` gives `lever:leverdemo`. The token is the first path segment, not the job identifier. Embedded careers pages may link to these boards; this Actor does not discover tokens or scrape careers pages.

Use metadata-only mode (default): `includeDescription=false`, with `descriptionText` always null. Greenhouse requests omit `content=true` unless descriptions, department details, or keyword matching in descriptions require it. Filtering can fetch content while keeping output `descriptionText` null. Keywords search titles and descriptions. Prefer a location or department filter for precision. Use `maxItemsPerCompany` to keep the first company from filling the entire run.

Read `OUTPUT` for company errors and the stop reason. Dataset items contain only jobs. No records are emitted for failures. Results follow provider order, not date order.

#### Paying agents without an Apify account (x402 / MPP)

An agent without an Apify account can buy a prepaid Apify API token from Apify AGI (`agi.apify.com`) with x402 (USDC on Base or Solana), MPP (Tempo or Solana) or Skyfire, then use it as the Apify API token in this README's REST example (Actor `samgov-tools~ats-jobs`) or as the bearer token for the Apify MCP server. This Actor uses limited permissions, which prepaid tokens require. Apify's current terms: minimum $1, the balance is a hard spending cap, the token expires 14 days after purchase, unused balance is non-refundable, and the feature is experimental. Check `agi.apify.com` for current terms before paying.

The token pays this Actor's normal pay-per-event prices: `job`: $0.003 per stored job, `company-board`: $0.001 per fetched board, `apify-actor-start`: $0.00005 per run. See [Let your AI agent pay for Actor runs](https://docs.apify.com/agents/payments), [x402](https://docs.apify.com/platform/integrations/x402) and [MPP](https://docs.apify.com/integrations/mpp).

### Quick start

```json
{
  "companies": ["greenhouse:discord", "lever:leverdemo"],
  "keywords": "engineer, data",
  "remoteOnly": true,
  "maxItems": 20,
  "maxItemsPerCompany": 10
}
```

REST synchronous run and dataset items (requires an Apify API token and a published Actor):

```bash
curl --request POST \
  --url 'https://api.apify.com/v2/acts/samgov-tools~ats-jobs/run-sync-get-dataset-items' \
  --header "Authorization: Bearer $APIFY_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{"companies":["greenhouse:discord"],"maxItems":10}'
```

JavaScript (`npm install apify-client`):

```js
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('samgov-tools/ats-jobs').call({
  companies: ['greenhouse:discord', 'lever:leverdemo'],
  maxItems: 20,
  maxItemsPerCompany: 10,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
const summary = await client.keyValueStore(run.defaultKeyValueStoreId).getRecord('OUTPUT');
console.log(items, summary?.value);
```

### Input

| Field | Default | Meaning |
| --- | --- | --- |
| `companies` | `["greenhouse:discord","lever:leverdemo"]` | Up to 50 strings. Provider prefixes, supported board URLs, or bare slugs. Missing/empty list uses defaults. |
| `keywords` | none | Comma-separated ANY match, case-insensitive, in title or fetched description. Example: `engineer, data`. |
| `location` | none | Substring across primary/all locations and Lever country code. Example: `London`. |
| `remoteOnly` | `false` | Lever remote classification or “remote” in location/office text. |
| `department` | none | Department substring. Enables Greenhouse content. |
| `postedWithinDays` | none | Integer 1–365. Greenhouse first publication (fallback update), Lever creation time. |
| `includeDescription` | `false` | Include plain `descriptionText`, up to 20,000 characters; otherwise null. |
| `stripContactInfo` | `true` | Remove emails and common phone formats from `descriptionText` for GDPR data minimization. If false, you take responsibility for personal data. Titles are unchanged. |
| `maxItems` | `10` | Total stored jobs, integer 1–5000. Platform paid-item ceiling can lower this to zero. |
| `maxItemsPerCompany` | none | Per-company stored-job cap, integer 1–5000. |

Supported URL hosts: `boards.greenhouse.io`, `job-boards.greenhouse.io`, `job-boards.eu.greenhouse.io`, `boards-api.greenhouse.io` (the `/v1/boards/<token>` path), `jobs.lever.co`, and `jobs.eu.lever.co`. Job-specific URLs on these boards also work. URL query parameters are ignored. Tokens are trimmed, lowercased and deduplicated; invalid inputs and lists longer than 50 are rejected before network access.

### Output

Illustrative dataset item, with `includeDescription: true`:

```json
{
  "source": "lever",
  "company": "example",
  "companyToken": "example",
  "jobId": "abc-123",
  "title": "Data Engineer",
  "location": "London, UK",
  "allLocations": ["London, UK", "Remote Europe"],
  "remote": true,
  "workplaceType": "remote",
  "department": "Engineering",
  "team": "Data Platform",
  "employmentType": "Full-time",
  "url": "<original posting URL>",
  "applyUrl": "<original application URL>",
  "postedAt": "2026-11-12T10:00:00.000Z",
  "updatedAt": null,
  "descriptionText": "Build reliable data systems.",
  "fetchedAt": "2026-11-14T10:00:00.000Z"
}
```

Field order is fixed. `descriptionText` is always present: null unless requested, also null when empty. Contact stripping runs before the 20,000-character cap; common international/US formats are recognized, but this is not a guarantee of complete anonymization. Other unavailable optional fields are null, with an empty array for unavailable locations. Descriptions decode HTML entities, strip tags/scripts/styles and collapse whitespace. They are plain text, not sanitized HTML for rendering. URLs come from the API; missing original posting URLs cause a job to be skipped, counted as `jobsSkippedMissingUrl` per company and in totals. Missing titles become empty strings. No raw description or list HTML is included in other fields. `jobId` is the public posting ID, not Greenhouse `internal_job_id`.

Greenhouse employer names use `company_name`, then board metadata, then the token if metadata is unavailable. Lever uses the site slug. Greenhouse department/office details may be absent without content; `team` and `employmentType` are null. Greenhouse `workplaceType` is `remote` only when inferred from location/office text, otherwise null. Lever `unspecified` becomes null. Country participates in filtering but is not an extra output field. Lever `updatedAt` is always null.

Remote detection is a simple source/location signal, not a guarantee of work-from-anywhere eligibility. A hybrid classification alone does not pass `remoteOnly`. Missing dates fail the recency filter. Updated dates can make Greenhouse jobs look recent when first publication is unavailable.

#### OUTPUT summary

The default key-value store record `OUTPUT` is also linked in the Actor output schema:

```json
{
  "companies": [
    {
      "input": "greenhouse:discord",
      "source": "greenhouse",
      "token": "discord",
      "status": "ok",
      "jobsFound": 100,
      "jobsStored": 10,
      "jobsSkippedMissingUrl": 0,
      "error": null
    }
  ],
  "totals": {
    "companiesAttempted": 1,
    "companiesOk": 1,
    "companiesNotFound": 0,
    "companiesError": 0,
    "jobsFound": 100,
    "jobsStored": 10,
    "jobsSkippedMissingUrl": 0,
    "bytesDownloaded": 50000,
    "requests": 2,
    "stopReason": "max_items"
  }
}
```

Counts above are illustrative. `jobsFound` counts fetched postings before filters. `jobsStored` counts accepted job pushes. Statuses are `ok`, `not_found`, `error`, `blocked_by_robots`, `robots_unavailable`, `rate_limited`, and `excluded`. All non-ok statuses are free, with zero found/stored counts, and a safe error message; no failure dataset item is pushed. Source can be null for an unsuccessful bare-slug lookup. Summaries cover attempted distinct boards only; companies beyond a budget or total-item stop are not fetched. A fetched board remains `ok` even if budget prevents its charge or storage; `stopReason` explains the stop. Duplicate resolved provider/token boards are not charged twice. `bytesDownloaded` measures consumed successful-response body bytes after decompression, not HTTP wire bytes; `requests` includes robots.txt, retries and optional metadata requests. Policy statuses are listed per company; `companiesError` counts only generic `error`, not policy outcomes. Stop reasons are `completed`, `max_items`, or `budget`.

### Pricing

Pay per event. The Pricing tab on the Actor page is authoritative.

| Event | Charged when | Price |
| --- | --- | --- |
| `job` | One normalized job stored in the dataset, with its original posting link | $0.003 ($3 per 1,000 jobs) |
| `company-board` | One company board successfully fetched from the official Greenhouse or Lever API | $0.001 |
| `apify-actor-start` | Automatically by Apify once per run (per GB of memory) | $0.00005 |

Example: 100 jobs from 2 companies in one run cost $0.30 + $0.002 + $0.00005 = **$0.30205**. The default empty-input run (10 jobs, usually 1 board) costs about $0.031.

A successful board is charged once even if it is empty or filters match no jobs. Unknown, excluded, robots-blocked, rate-limited or failed boards are free. All Lever pages must succeed before the board is charged. The Actor checks your run's maximum charge before every charge and stops cleanly when the budget is reached; `ACTOR_MAX_PAID_DATASET_ITEMS` also caps stored jobs. No source API credentials are used.

### Data sources & terms

Only the official public [Greenhouse Job Board API](https://developers.greenhouse.io/job-board.html) and [Lever Postings API](https://github.com/lever/postings-api) are used.

See the Disclaimer for ownership, reuse, personal-data responsibilities and Lever's AI training opt-out. Public API availability does not grant unrestricted reuse rights.

Verified removal requests are implemented in `src/denylist.ts`: maintain lowercase provider/token entries in `DENYLIST` (empty by default). Checks run before fetching or charging. Bare slugs are conservatively excluded if either provider/token is listed. This is a source-maintained exclusion list, not a persistent jobs database. The maintainer updates it after verifying requests submitted via the Issues tab.

### Limits and operational behavior

- At most 50 company entries and 5000 stored jobs per run. Company processing is sequential; the first company can consume the total cap.
- All HTTP request starts are spaced at least 1000 ms apart per host, including robots.txt, retries, metadata, pagination and fallback. Hosts have independent limiters; a larger selected robots Crawl-delay increases the interval.
- Before the first API request to each host, fetch its robots.txt with a 10-second timeout and cache its final result (including an exhausted failure) per host only for this run; later companies on that host reuse it without another fetch. Select `ats-jobs-apify-actor` user-agent groups, falling back to `*`. Longest Allow/Disallow match wins, Allow wins ties; `*` and terminal `$` patterns are supported. Disallowed paths stop the company with `blocked_by_robots`. Robots 4xx except 429 allows all; 5xx, network failures, redirects, timeouts or unreadable responses fail closed with `robots_unavailable` after retries are exhausted. Robots uses the same retry policy as API requests, including 429 handling.
- API requests have a 30-second timeout. Both API and robots requests have at most two retries for 429/5xx/network failures. Honour Retry-After seconds or HTTP-date; otherwise wait 2 s, then 4 s. Retry waits are capped at 60 s: a Retry-After over 60 s stops the company as `rate_limited` without waiting. The per-host limiter still applies to every retry. API 404s are not retried.
- Requests use a named User-Agent, allow only the three API hosts, and reject redirects. Input URLs never become arbitrary outbound requests.
- Responses are bounded at 25 MiB per request, using header and streamed-body checks. Lever uses pages of 100 until a short page, rejects repeated pages, and has a safety limit of 1000 pages per board.
- A complete board is fetched before billing/filtering. `maxItems` limits stored jobs, not upstream board size. Large description boards use more memory and bandwidth. The default Actor memory is 512 MiB; choose more for unusually large boards.
- Unknown boards, network failures and API errors are isolated per company. Storage/billing infrastructure failures fail the run instead of silently losing records. Optional Greenhouse metadata API errors use the token as company name; robots and excessive Retry-After policy failures stop the company without charge.
- No sorting, application submissions, employer discovery, Ashby support or job-history tracking. Results are a snapshot, not a hiring-status guarantee.

### Local development

Requires Node.js 22+; the Docker image targets Node 22. The SDK entry point is `src/main.ts`; `src/run.ts` is an injectable pipeline usable without Apify initialization. All code is clean-room project code using the documented public response shapes.

```bash
npm ci
npm run build
npm test
## Optional network-only smoke; does not initialize Apify or charge real events:
RUN_LIVE_TESTS=1 npm test
## Actual public API measurements with no-op billing/storage:
npm run measure
## Recreate the icon (Python 3 with Pillow) and schemas:
python3 scripts/make-icon.py
python3 scripts/make-schemas.py
```

Tests use `node:test` via `tsx --test`, mocked fetch and injected clocks/sleep. The live test is skipped unless explicitly enabled. Measurement cases: default input; five requested boards with descriptions and maxItems 5000; the same boards without descriptions and maxItems 10. Each prints wall time, sampled peak RSS, process lifetime maxRSS, downloaded bytes, and found/stored counts. Later cases share the process and memory allocator, so RSS is not isolated per case. The small case stops after reaching ten jobs and may not fetch all five boards.

### FAQ

**Why did I get no jobs?** Check `OUTPUT`: the board may be empty, no filters matched, the token may be unknown, or the budget may have stopped storage. An unknown company does not fail other companies.

**Why is the company a slug?** Lever has no reliable employer display name in these responses. Greenhouse falls back to its token when neither postings nor metadata provide a name.

**Why are some departments or remote jobs missing?** Greenhouse lightweight responses omit departments/offices. Set a department filter or `includeDescription: true` to retrieve those fields; published location text can still be incomplete.

**Can I apply to jobs with this Actor?** It returns public job/application links. It never submits applications.

**Can agents use the results?** Yes, for job lookup and research subject to source rights and policies. Do not use the output for AI model training.

# Actor input Schema

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

Up to 50 company boards. Use greenhouse:discord, lever:leverdemo, a supported board URL, or a bare slug (tries Greenhouse then Lever). Missing or empty lists use the defaults. Tokens are trimmed, lowercased and deduplicated.

## `keywords` (type: `string`):

Comma-separated terms matching ANY title or fetched description, ignoring case. Example: engineer, data. Greenhouse descriptions are fetched for includeDescription, department, or keyword description matching.

## `location` (type: `string`):

Case-insensitive substring of the location, all locations, or Lever country code. Example: London. Omit this field to include jobs in every location.

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

Keep Lever jobs marked remote, or jobs whose location or Greenhouse office text contains remote. Hybrid alone does not qualify. Default is false.

## `department` (type: `string`):

Case-insensitive substring of a department name. Example: Engineering. This filter enables Greenhouse content=true to retrieve department details.

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

Keep jobs posted in the last 1–365 days using Greenhouse first_published (fallback updated_at) or Lever createdAt. Missing dates do not match.

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

Add descriptionText as plain text, capped at 20,000 characters. Enables larger Greenhouse content responses; use only when descriptions are needed.

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

Remove emails and common phone formats from descriptionText by default to reduce GDPR personal-data exposure. If false, you take responsibility for personal data in the output.

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

Maximum total jobs stored across all companies, from 1 to 5000. Default 10 keeps agent calls small. The platform paid-item limit can reduce this value.

## `maxItemsPerCompany` (type: `integer`):

Optional maximum jobs stored from each company, from 1 to 5000. Combine with maxItems to spread results across boards; API order is preserved.

## Actor input object example

```json
{
  "companies": [
    "greenhouse:discord",
    "lever:leverdemo"
  ],
  "remoteOnly": false,
  "includeDescription": false,
  "stripContactInfo": true,
  "maxItems": 10
}
```

# Actor output Schema

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

Default dataset containing normalized public postings from successfully matched Greenhouse and Lever company boards.

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

OUTPUT contains statuses ok, not_found, error, blocked_by_robots, robots_unavailable, rate_limited, excluded; jobsSkippedMissingUrl per company and in totals, found/stored counts and 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:discord",
        "lever:leverdemo"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("samgov-tools/ats-jobs").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:discord",
        "lever:leverdemo",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("samgov-tools/ats-jobs").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:discord",
    "lever:leverdemo"
  ]
}' |
apify call samgov-tools/ats-jobs --silent --output-dataset

```

## MCP server setup

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

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/k3t6ZDrHAkRDS4JfW/builds/u3n4HdwrD3LHF6Z1I/openapi.json
