# Job Postings Scraper API : $1 per 1,000 Jobs (`workware/job-postings-api`) Actor

Fetch every live job posting from any company's career board (Greenhouse, Lever, Ashby, Workable) as clean, normalized JSON: real-time, not from a stale index.

- **URL**: https://apify.com/workware/job-postings-api.md
- **Developed by:** [Workware](https://apify.com/workware) (community)
- **Categories:** Jobs, Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 50.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 job posting returneds

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 API: All Jobs at Any Company

**Give it a company: get every live job on that company's official career board as clean, normalized
JSON, in seconds, straight from the ATS.** Not from an index that was built a few hours ago: this Actor
reads the employer's own Greenhouse, Lever, Ashby, or Workable board at the moment you ask, so what you
get back is what a candidate would see on the careers page right now.

- **What you put in:** a list of companies, each given as a name, a website domain, or a board URL.
  Up to 500 per run, looked up one after another.
- **What you get back:** one record per live job posting, in a single canonical schema no matter which
  ATS it came from, plus one status record per company telling you exactly what happened to it.
- **Why this one:** the alternatives sell you a pre-built index with an indexing delay and a per-run job
  cap. An index cannot answer "what is on company X's board *right now*". This can, and it is roughly
  four times cheaper per job.
- **Boosted by the Apify platform:** full REST API access, scheduling, monitoring, webhooks and
  integrations, proxy rotation, and dataset export to JSON, CSV, or Excel.

### What is the Job Postings API and what can it do?

The Job Postings API is a **company-first jobs API**: you ask about employers, not about keywords. Point
it at a company and it works out which applicant tracking system that company uses, reads the public
board, and returns every open role on it as structured JSON. It is the tool you want when you already
know whose jobs you care about (the accounts in your CRM, the portfolio companies in your fund, the
competitors you track) and you want their current openings on demand or on a schedule.

Supported applicant tracking systems: **Greenhouse, Lever, Ashby, and Workable**. Together these cover a
large share of software, fintech, biotech, and growth-stage employers worldwide, including Lever's
EU-hosted cluster. Every one of them is read through its official, public, no-authentication JSON API:
the most stable class of source that exists for jobs data, and the reason this Actor needs no browser, no
residential proxy, and no anti-bot workarounds.

What it deliberately does **not** do: it does not search across companies by keyword, it does not
maintain an index or a history, and it does not enrich postings with AI-inferred salaries, skills, or
taxonomies. Those are separate concerns, and inferred fields are exactly where indexed competitors
accumulate quiet errors. This Actor's promise is narrower and testable: *the live board, correctly
normalized, honestly labelled.*

How a company gets resolved, in order: if you supply a board URL, it is used directly. Otherwise the
Actor reads the company's own careers page and looks for a board link: evidence straight from the
employer. If that finds nothing, it probes each ATS with candidate slugs derived from the name and
domain, and a similarity guard rejects near-misses so `acme` never resolves to `acme-health`. If every
step misses, you get a definitive `no_board` answer, which is a real result: some employers genuinely
have no public board.

### What data does it extract? (output fields)

Every record is a canonical `JobPosting`: a stable id, the source and its native id, the posting URL and
apply URL, raw and normalized titles, company, structured location, department, employment type, salary,
description in HTML and plain text, and the dates the role was posted and observed.

Two fields deserve a note because they are where jobs data usually goes wrong:

- **`salary`** carries a `source` label: `stated` only when the employer set a structured salary the ATS
  returns as data (Ashby compensation, Lever `salaryRange`), and `parsed` when it was read out of a
  description. If a role's pay cannot be established honestly, the field is absent. It is never guessed.
- **`location.remoteScope`** is the geography a remote candidate must be eligible to work in: ISO
  country codes and macro-region labels such as `["US","CA"]` or `["EU"]`, populated when the employer
  states a restriction and absent when they state none. Absent means unstated, never "worldwide".

Source-specific fields that do not fit the canonical schema (Greenhouse offices and departments, Ashby
compensation tiers) are preserved under `extras.<source>` rather than dropped or forced into a field
where they do not belong. Personal contact details are dropped at a single normalization chokepoint and
never reach the dataset.

Alongside the job records, each company produces one `company-status` row: `{ company, resolvedSource,
boardToken, jobCount, status }`, where status is `found`, `no_board`, or `error`. This is what makes an
empty result unambiguous: you always know whether a company has no jobs, no board, or simply could not
be reached.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | Always | Stable id: `sha1(source + sourceId)` (computed by normalizers/actors, not here). |
| `source` | `greenhouse` | `lever` | `ashby` | `workable` | `wellfound` | `seek` | `stepstone` | `dice` | `ziprecruiter` | `bayt` | `xing` | `reed` | Always | This Actor reads Greenhouse, Lever, Ashby and Workable; the other values are reserved for sibling Actors. |
| `sourceId` | `string` | Always | Native id at the source. |
| `url` | `string` | Always | Canonical public posting URL. |
| `applyUrl` | `string` | Optional | Direct apply link when distinct from `url`. |
| `title` | `string` | Always | Raw title as posted. |
| `titleNormalized` | `string` | Always | Cleaned title (no req-ids, no ALL CAPS, trimmed seniority tags): normalized in M1b. |
| `company.name` | `string` | Always | Display name. |
| `company.website` | `string` | Optional | Company website when known. |
| `company.sourceSlug` | `string` | Always | Source-native company slug/identifier. |
| `location.raw` | `string` | Always | Raw location string as provided by the source. |
| `location.city` | `string` | Optional |  |
| `location.region` | `string` | Optional |  |
| `location.country` | `string` | Optional | ISO-3166 country code when resolvable. |
| `location.remote` | `full` | `hybrid` | `onsite` | `unknown` | Always | Remote-work posture; `unknown` when it cannot be determined. |
| `location.remoteScope` | `string`\[] | Optional | For remote roles: the geography a remote candidate must be eligible to work in (visa / right-to-work), e.g. `['US','CA']`, `['EU']`, `['GLOBAL']`. Values are ISO-3166-1 alpha-2 country codes and/or canonical macro-region labels (`GLOBAL\|EU\|EMEA\|APAC\|LATAM\|MENA\|ANZ\|DACH\| AMERICAS\|NORAM`). Absent when the source states no restriction: never assumed global. |
| `department` | `string` | Optional | Source-provided team/department. |
| `employmentType` | `full_time` | `part_time` | `contract` | `intern` | `temporary` | `unknown` | Optional |  |
| `salary.raw` | `string` | Optional | Original salary display string when present. |
| `salary.min` | `number` | Optional |  |
| `salary.max` | `number` | Optional |  |
| `salary.currency` | `string` | Optional | ISO-4217 currency code when known. |
| `salary.period` | `year` | `month` | `day` | `hour` | Optional |  |
| `salary.source` | `stated` | `parsed` | Optional |  |
| `descriptionHtml` | `string` | Optional | Sanitized HTML description when available. |
| `descriptionText` | `string` | Always | Plain-text description. |
| `postedAt` | `string` | Optional | When the role was published at the source (not scrape time). |
| `firstSeenAt` | `string` | Always | Our observation window. In live-fetch actors both are simply the run's scrape time: do NOT build cross-run observation tracking here; that is new-job-postings-api's (#18) concern. |
| `lastSeenAt` | `string` | Always |  |
| `extras` | `object` | Optional | Source-specific fields, namespaced under `extras.<source>.*`; never breaks the schema. |

### How to use the Job Postings API (tutorial)

1. Click **Start** with the example input already filled in: one company, no options.
2. Watch the run finish (a single company typically completes in a few seconds).
3. Open the **Dataset** tab. You will see the job records plus one `company-status` row.
4. Export as JSON, CSV, or Excel, or call the run from your own code through the Apify API.

To run it on your own list, replace the `companies` array. Each entry needs at least one of `name`,
`domain`, or `atsUrl`; supplying `domain` resolves most reliably, and supplying `atsUrl` skips detection
entirely and is the fastest path if you already know the board.

Companies in a batch are processed **one after another**, at a deliberately polite request rate to the
ATSs: a single company typically takes a few seconds, and a long list takes roughly that per company.
If you are polling on a schedule, prefer several small runs over one very large one.

### How much does it cost?

Pay-per-event, with two events:

- **$0.005 per company processed**: charged once per company that reached a definitive answer, whether
  that answer is "here is the board" or "this company has no public board". The detection work has value
  either way.
- **$0.001 per job posting returned**: charged per validated record.

**Failed lookups are free.** A company whose status is `error` is never charged, and neither are the
records of a company whose fetch failed part-way through: they are discarded rather than delivered. We
charge for answers, not for our own failures.

A worked example: 100 companies averaging 40 open roles each costs `100 × $0.005 + 4,000 × $0.001 =
$4.50`. The comparable indexed API charges $4.00 per 1,000 jobs, which would be $16.00 for the same
4,000 postings. Platform usage costs are absorbed, not passed through.

| Event | What it is | Charged for | Price |
|---|---|---|---|
| `company-processed` | Company processed (definitive answer) | each run | $5.00 / 1,000 |
| `job-result` | Job posting returned | each record delivered | $1.00 / 1,000 |

### Input and output examples

Input: the simplest useful run:

```json
{ "companies": [{ "name": "Anthropic" }] }
```

Input: a filtered batch:

```json
{
  "companies": [
    { "domain": "stripe.com" },
    { "atsUrl": "https://jobs.ashbyhq.com/ramp" }
  ],
  "titleFilter": "engineer",
  "remoteOnly": true,
  "maxJobsPerCompany": 25
}
```

Output: one job record (trimmed) and its company's status row:

```json
{
  "id": "8f2c…",
  "source": "greenhouse",
  "url": "https://job-boards.greenhouse.io/acme/jobs/4001",
  "title": "Senior Backend Engineer",
  "titleNormalized": "Senior Backend Engineer",
  "company": { "name": "Acme", "sourceSlug": "acme" },
  "location": { "raw": "Remote — US", "country": "US", "remote": "full", "remoteScope": ["US"] },
  "employmentType": "full_time",
  "salary": { "min": 180000, "max": 220000, "currency": "USD", "period": "year", "source": "parsed" },
  "postedAt": "2026-06-20T10:00:00Z"
}
```

```json
{ "type": "company-status", "company": { "domain": "acme.com" }, "resolvedSource": "greenhouse",
  "boardToken": "acme", "jobCount": 42, "status": "found" }
```

`titleFilter` is a plain, case-insensitive substring match, not a regular expression. `remoteOnly` keeps
roles whose posture is remote or hybrid; it does not filter on eligibility geography, so a remote role
restricted to one country is still returned and you can filter on `location.remoteScope` yourself.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `companies` | `array` | Yes | - | The companies to look up: one run handles the whole batch, one company at a time. Each entry needs at least one of `name`, `domain`, or `atsUrl`. `domain` resolves most reliably; `atsUrl` skips detection altogether. |
| `includeDescription` | `boolean` | No | `true` | Include the full job description in each record. Descriptions are always FETCHED (salary parsing and normalization read them): turning this off strips them from the output to keep datasets small. |
| `remoteOnly` | `boolean` | No | `false` | Return only roles whose remote posture is remote or hybrid. This filters the posture, not the eligibility geography: a remote role open only to US residents is still returned. |
| `titleFilter` | `string` | No | - | Keep only roles whose title contains this text, case-insensitively. Plain substring matching: not a regular expression. |
| `maxJobsPerCompany` | `integer` | No | `0` | Caps the jobs returned per company. 0 means no cap: return the whole board. |

### Other Actors you might need

| Actor | Use it when |
|---|---|
| [Greenhouse Jobs Scraper](https://apify.com/workware/greenhouse-jobs-scraper) | You only care about Greenhouse and already have board tokens. |
| [Ashby Jobs Scraper](https://apify.com/workware/ashby-jobs-scraper) | You only care about Ashby boards. |

If you do not know which ATS a company uses, or your list spans several of them, this Actor is the one
you want: resolving the board is the part it does for you.

### FAQ, legality, and support

#### How is this different from indexed job APIs?

An indexed API crawls many career sites on its own schedule and serves you a copy. That is the right
shape for "show me every Python job posted this week", and the wrong shape for "what is on this
company's board right now": the copy is as fresh as the last crawl, and crawls run hours apart. This
Actor holds no index. It reads the employer's board when you ask, which means no indexing delay and no
per-run cap on how much of a board you can retrieve.

#### Which applicant tracking systems are supported?

Greenhouse, Lever (including the EU cluster), Ashby, and Workable, all through their official public
JSON APIs. If a company uses an ATS outside this list, you get a `no_board` status rather than a silent
empty result.

#### What if a company has no public board?

You get a `company-status` row with `status: "no_board"`, and that counts as a definitive answer. Many
employers post only to their own bespoke careers page or to job boards rather than running a public ATS
board; "this company has no public ATS board" is genuinely useful information, and it is priced as one
processed company with no job results.

#### What happens when a source changes its API?

Every response is shape-checked before it is normalized. A change that breaks the shape fails the
affected company loudly instead of emitting silently wrong records, and a scheduled smoke test runs
against known-stable boards so we usually find out before you do.

#### Is this legal to use?

The data is public, employer-published job advertising, retrieved through the ATS vendors' own public
APIs. Job postings are not personal data, attribution and apply URLs are preserved on every record, and
recruiter and contact details are dropped rather than collected. As always, how you use the data is your
responsibility.

#### Can I get a custom variant?

Yes: open an issue on the Actor or contact us through the Apify platform. Cross-company keyword search,
per-posting detail enrichment, and new-jobs-only change tracking are all being built as separate Actors.

# Actor input Schema

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

The companies to look up: one run handles the whole batch, one company at a time. Each entry needs at least one of `name`, `domain`, or `atsUrl`. `domain` resolves most reliably; `atsUrl` skips detection altogether.

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

Include the full job description in each record. Descriptions are always FETCHED (salary parsing and normalization read them): turning this off strips them from the output to keep datasets small.

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

Return only roles whose remote posture is remote or hybrid. This filters the posture, not the eligibility geography: a remote role open only to US residents is still returned.

## `titleFilter` (type: `string`):

Keep only roles whose title contains this text, case-insensitively. Plain substring matching: not a regular expression.

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

Caps the jobs returned per company. 0 means no cap: return the whole board.

## Actor input object example

```json
{
  "companies": [
    {
      "name": "Anthropic"
    }
  ],
  "includeDescription": true,
  "remoteOnly": false,
  "titleFilter": "engineer",
  "maxJobsPerCompany": 0
}
```

# Actor output Schema

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

Every record this run produced, as JSON. Also available as CSV, Excel and XML.

# 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": [
        {
            "name": "Anthropic"
        }
    ],
    "titleFilter": "engineer"
};

// Run the Actor and wait for it to finish
const run = await client.actor("workware/job-postings-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": [{ "name": "Anthropic" }],
    "titleFilter": "engineer",
}

# Run the Actor and wait for it to finish
run = client.actor("workware/job-postings-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": [
    {
      "name": "Anthropic"
    }
  ],
  "titleFilter": "engineer"
}' |
apify call workware/job-postings-api --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,workware/job-postings-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/Yi98JObBJxmC1gOcK/builds/fZzrJ3YVUKLzocOHk/openapi.json
