# Ashby Jobs Scraper : $1 per 1,000 Jobs (`workware/ashby-jobs-scraper`) Actor

Give it an Ashby board slug, URL, or company and get every live job on that startup's board as clean, normalized JSON: including the employer's own published salary ranges, read in real time from the official Ashby posting API, never from a stale index.

- **URL**: https://apify.com/workware/ashby-jobs-scraper.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

## Ashby Jobs Scraper: Startup Job Boards → JSON

**Give it an Ashby board slug, a board URL, or a company: get every live job on that startup's Ashby
board as clean, normalized JSON, including the employer's own published salary ranges, in seconds,
straight from Ashby.** Not from an index that was built a few hours ago: this Actor reads the employer's
own Ashby posting API 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:** one or more Ashby boards. The fastest path is the board slug: the
  `jobs.ashbyhq.com/{slug}` identifier, but you can also paste a board URL or a company name and let the
  Actor resolve it.
- **What you get back:** one record per live job posting, in a single canonical schema, plus one status
  record per board telling you exactly what happened: `found`, `no_board`, or `error`.
- **The native-salary edge:** Ashby serves structured compensation, so when an employer publishes pay,
  this Actor returns it as the employer stated it: `salary.source: "stated"`, not a re-parsed estimate.
- **Boosted by the Apify platform:** full REST API access, scheduling, monitoring, webhooks and
  integrations, datacenter proxy rotation, and dataset export to JSON, CSV, or Excel.

### What is the Ashby Jobs Scraper and what can it do?

The Ashby Jobs Scraper is a **live Ashby job board API**: you name a board and it returns every open role
on it as structured JSON. Ashby is the applicant tracking system the startup world runs on: Notion, Ramp,
Linear, PostHog, OpenAI, LangChain, Deel, and Vanta all post through it, so this is the tool you want when
you care about startup hiring specifically, or about the accounts and portfolio companies whose Ashby boards
you already know.

It reads Ashby 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. It is Ashby-focused on purpose: a narrow, testable promise beats a broad, flaky one. If you
need Greenhouse, Lever, or Workable too, or you do not know which system a company uses, the
[Job Postings API](https://apify.com/workware/job-postings-api) flagship resolves the board for you across
all four.

**Live vs. indexed: why freshness and native salary are the whole point:**

| | This Actor (live) | Indexed job APIs (fantastic-jobs, jobo.world) |
|---|---|---|
| Where the data comes from | The employer's Ashby board, read when you ask | A crawled copy, refreshed on the vendor's schedule |
| Freshness | Real time: the board as it is right now | An indexing delay (often twice-hourly at best); expired jobs purged only daily |
| Coverage window | The whole current board | Commonly a rolling 6-month window |
| Per-run limits | None, retrieve the entire board | Per-run job caps (e.g. 5,000) are common |
| Salary | The employer's **native published** range, as stated | A parsed or USD-normalized estimate |
| "What is on board X right now?" | Answered directly | Structurally cannot answer it |

What it deliberately does **not** do: it does not search across companies by keyword, it does not maintain
an index or a history, it does not fetch per-posting application questions (Ashby has no public per-posting
endpoint, and the whole board already carries full descriptions), and it does not normalize salary to USD or
invent one with AI. Inferred fields are exactly where indexed competitors accumulate quiet errors. This
Actor's promise is narrower and honest: *the live board, with the employer's own salary, correctly
normalized.*

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

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

Two things are worth calling out:

- **`salary`** is Ashby's differentiator. When an employer opts to display compensation, the native range is
  returned with `source: "stated"`: the widest published salary band, in the employer's own currency and
  period, with equity kept separate rather than folded in. When a board does not publish pay, a figure is
  read out of the description if the employer states one (`source: "parsed"`), and when pay cannot be
  established honestly the field is simply absent: never guessed. Native salary is mapped even if you switch
  descriptions off in the output.
- **Ashby-specific fields** that do not fit the canonical schema: the raw `compensation` tiers (all tiers
  plus equity), `secondaryLocations`, `team`, `isListed`, and `shouldDisplayCompensationOnJobPostings`, are
  preserved under `extras.ashby` rather than dropped or forced into a field where they do not belong.

Alongside the job records, each input board produces one `board-status` row: `{ board, company?, jobCount,
status }`, where status is `found`, `no_board`, or `error`. This is what makes an empty result unambiguous:
you always know whether a board has no jobs, does not exist, 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 | Always `ashby` from this Actor; 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 Ashby Jobs Scraper (tutorial)

1. Click **Start** with the example input already filled in: one board, no options.
2. Watch the run finish (a single board typically completes in a few seconds).
3. Open the **Dataset** tab. You will see the job records plus one `board-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 boards, replace the `boards` array. Each entry needs one of `slug`, `boardUrl`, or
`company`:

- **`slug`**: the `jobs.ashbyhq.com/{slug}` identifier, e.g. `Ramp`. This is the fastest, zero-guess path.
  (Ashby slugs are case-insensitive.)
- **`boardUrl`**: a board URL to parse the slug from; both the board form `jobs.ashbyhq.com/{slug}` and a
  job-detail link `jobs.ashbyhq.com/{slug}/{jobId}` are understood.
- **`company`**: a company name or slug; the Actor tries it directly as a slug, and, when it looks like a
  domain, reads that company's careers page for an Ashby link (with a similarity guard so it never resolves
  the wrong board).

Boards in a batch are processed **one after another**, at a deliberately polite request rate to Ashby. If
you are polling on a schedule, prefer several small runs over one very large one.

### How much does it cost to scrape Ashby?

Pay-per-event, with two events:

- **$0.002 per board processed**: charged once per board that reached a definitive answer, whether that
  answer is "here are the jobs" or "there is no such Ashby board". The resolve-and-fetch work has value
  either way. This is lower than the flagship's fixed fee because there is no cross-ATS detection: one
  fetch, no pagination.
- **$0.001 per job posting returned**: charged per validated record ($1 per 1,000 jobs).

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

A worked example: 10 boards averaging 60 open roles each costs `10 × $0.002 + 600 × $0.001 = $0.62`. The
comparable live-side APIs charge $1.20–$1.50 per 1,000 jobs; this undercuts them on the marginal price while
shipping the employer's native salary they parse or normalize away. Platform usage costs are absorbed, not
passed through.

### Input and output examples

Input: the simplest useful run (Ramp: 126 live roles, all with native compensation):

```json
{ "boards": [{ "company": "Ramp" }] }
```

Input: a filtered batch by slug and URL:

```json
{
  "boards": [
    { "slug": "Ramp" },
    { "boardUrl": "https://jobs.ashbyhq.com/linear" }
  ],
  "titleFilter": "engineer",
  "remoteOnly": true,
  "maxJobsPerBoard": 25
}
```

Output: one job record (trimmed) with a native salary, and its board's status row:

```json
{
  "id": "9a1f…",
  "source": "ashby",
  "url": "https://jobs.ashbyhq.com/ramp/a1b2c3",
  "title": "Senior Platform Engineer",
  "titleNormalized": "Senior Platform Engineer",
  "company": { "name": "Ramp", "sourceSlug": "ramp" },
  "location": { "raw": "San Francisco, CA", "city": "San Francisco", "remote": "unknown" },
  "department": "Engineering",
  "salary": { "min": 211400, "max": 290600, "currency": "USD", "period": "year", "source": "stated" },
  "extras": { "ashby": { "team": "Platform", "shouldDisplayCompensationOnJobPostings": true } }
}
```

```json
{ "type": "board-status", "board": "ramp", "company": "Ramp", "jobCount": 126, "status": "found" }
```

`titleFilter`, `departmentFilter`, and `locationFilter` are plain, case-insensitive substring matches, not
regular expressions. `departmentFilter` matches an Ashby role's department **or** its team. `remoteOnly`
keeps every role that is not clearly on-site, so a role whose posture cannot be determined is kept rather
than silently dropped.

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

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `boards` | `array` | Yes | - | The Ashby boards to scrape: one run handles the whole batch, one board at a time. Each entry needs exactly one of `slug`, `boardUrl`, or `company`. `slug` is the fastest, zero-guess path. |
| `includeDescription` | `boolean` | No | `true` | Include the full job description in each record. Descriptions are always FETCHED (Ashby always returns them, and salary parsing reads them as a fallback): turning this off strips them from the output to keep datasets small. Native published salary is still mapped. |
| `remoteOnly` | `boolean` | No | `false` | Return only roles whose remote posture is not on-site. Ashby marks remote roles structurally (isRemote / workplaceType), so a role whose posture cannot be determined is kept: only a role clearly marked on-site is dropped. |
| `titleFilter` | `string` | No | - | Keep only roles whose title contains this text, case-insensitively. Plain substring matching: not a regular expression. |
| `departmentFilter` | `string` | No | - | Keep only roles whose department or team contains this text, case-insensitively. Plain substring matching: not a regular expression. |
| `locationFilter` | `string` | No | - | Keep only roles whose location contains this text, case-insensitively. Matched against the raw location string. Plain substring matching: not a regular expression. |
| `maxJobsPerBoard` | `integer` | No | `0` | Caps the jobs returned per board. 0 means no cap: return the whole board. Ashby returns the whole board in one response, so this is a client-side truncation. |

### Other Actors you might need

| Actor | Use it when |
|---|---|
| [Job Postings API](https://apify.com/workware/job-postings-api) | You need Greenhouse, Lever, or Workable too, or you don't know which ATS a company uses: the flagship resolves the board across all four. |
| [Greenhouse Jobs Scraper](https://apify.com/workware/greenhouse-jobs-scraper) | You only care about Greenhouse boards. |

If your list spans several applicant tracking systems, start with the flagship: resolving the board is the
part it does for you.

### FAQ, legality, and support

#### How do I find a company's Ashby board slug?

The slug is the identifier in the board URL. If a company's careers page links to `jobs.ashbyhq.com/ramp`,
the slug is `ramp`. You can paste that slug, paste the whole URL, or just give the company name and let the
Actor resolve it. Ashby slugs are case-insensitive, so `Ramp` and `ramp` reach the same board.

#### Does it return salary?

Yes, and this is the reason to use it. Ashby serves structured compensation, so when an employer publishes
pay, the record carries the native range with `source: "stated"` in the employer's own currency and period.
When a board does not publish pay, a stated figure in the description is read out as `source: "parsed"`; when
none can be established honestly, `salary` is absent rather than guessed. Equity is preserved raw in
`extras.ashby` and never counted as base salary.

#### How is this different from an indexed jobs API?

An indexed API crawls many career sites on its own schedule and serves you a copy, usually with a
parsed-or-normalized salary. That is the right shape for "show me every engineering job posted this week",
and the wrong shape for "what is on this board right now": the copy is only as fresh as the last crawl, and
the salary is an estimate. This Actor holds no index. It reads the board when you ask and returns the
employer's own numbers.

#### Can I scrape multiple companies' Ashby boards at once?

Yes. Put several entries in the `boards` array: up to 500 per run, mixing slugs, URLs, and company names
freely. They are processed one after another at a polite request rate, and each gets its own `board-status`
row.

#### What if a board does not exist?

You get a `board-status` row with `status: "no_board"`, and the run still succeeds. "There is no such Ashby
board" is a genuine, useful answer, and it is priced as one processed board with no job results.

#### What happens when Ashby changes its API?

Every response is shape-checked before it is normalized. A change that breaks the shape fails the affected
board 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 Ashby's own public posting API.
Job postings are not personal data, attribution and apply URLs are preserved on every record, and native
compensation is employer-published and intentionally public. As always, how you use the data is your
responsibility.

# Actor input Schema

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

The Ashby boards to scrape: one run handles the whole batch, one board at a time. Each entry needs exactly one of `slug`, `boardUrl`, or `company`. `slug` is the fastest, zero-guess path.

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

Include the full job description in each record. Descriptions are always FETCHED (Ashby always returns them, and salary parsing reads them as a fallback): turning this off strips them from the output to keep datasets small. Native published salary is still mapped.

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

Return only roles whose remote posture is not on-site. Ashby marks remote roles structurally (isRemote / workplaceType), so a role whose posture cannot be determined is kept: only a role clearly marked on-site is dropped.

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

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

## `departmentFilter` (type: `string`):

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

## `locationFilter` (type: `string`):

Keep only roles whose location contains this text, case-insensitively. Matched against the raw location string. Plain substring matching: not a regular expression.

## `maxJobsPerBoard` (type: `integer`):

Caps the jobs returned per board. 0 means no cap: return the whole board. Ashby returns the whole board in one response, so this is a client-side truncation.

## Actor input object example

```json
{
  "boards": [
    {
      "company": "Ramp"
    }
  ],
  "includeDescription": true,
  "remoteOnly": false,
  "titleFilter": "engineer",
  "maxJobsPerBoard": 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 = {
    "boards": [
        {
            "company": "Ramp"
        }
    ],
    "titleFilter": "engineer"
};

// Run the Actor and wait for it to finish
const run = await client.actor("workware/ashby-jobs-scraper").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": [{ "company": "Ramp" }],
    "titleFilter": "engineer",
}

# Run the Actor and wait for it to finish
run = client.actor("workware/ashby-jobs-scraper").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": [
    {
      "company": "Ramp"
    }
  ],
  "titleFilter": "engineer"
}' |
apify call workware/ashby-jobs-scraper --silent --output-dataset

```

## MCP server setup

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

```

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/uD5txgzBqtmIXdBR7/builds/sFHOaFh6wuVQmgh0m/openapi.json
