# Ashby Jobs API & Workable Jobs Scraper (Unofficial) (`graylabs/ats-jobs-feed`) Actor

Ashby jobs and Workable jobs from public ATS job boards and careers pages in one schema. Each run flags new, changed and closed jobs; output only the changes. Ashby salary ranges. Filter by title, location, department and remote. Job board API, no login. Not affiliated with Ashby or Workable.

- **URL**: https://apify.com/graylabs/ats-jobs-feed.md
- **Developed by:** [Gray Labs](https://apify.com/graylabs) (community)
- **Categories:** Jobs, Automation
- **Stats:** 2 total users, 1 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.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

## Ashby Jobs API & Workable Jobs Scraper (Unofficial)

Get jobs from public **Ashby job boards and Workable careers pages (ATS job boards)** in one clean schema, and see **what changed since your last run**. Every row is marked `new`, `changed` or `unchanged`, and jobs that were taken down are reported once as `closed`.

Give it a list of companies (a board name or the careers page URL), then schedule it. No account, login or API key is needed.

- **Change tracking built in.** Turn on `onlyChanges` to get only the new, changed and closed jobs each day. Skipped unchanged jobs are not charged.
- **Pay ranges from Ashby** when a company shows them on its postings (`salaryMin`, `salaryMax`, `salaryCurrency`, `salaryInterval`).
- **Filters:** title keywords, location, department or team, and workplace type (remote, hybrid, on-site).
- **A bad board never fails the run.** It is listed in `RUN_SUMMARY` with a plain-language reason, and every other board is still saved.
- **Company and job data only.** No recruiter or hiring-manager fields. Full descriptions are off by default; when you turn them on, emails, phone numbers, profile links and cued names are removed.

> Unofficial. Not affiliated with, endorsed by or sponsored by Ashby, Inc. or Workable Technology Limited.

### What data can you extract?

Company and job fields only. There are no recruiter, hiring-manager or other person fields.

| Field                                                        | What it is                                                                                                                                                     |
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `jobKey`                                                     | Stable unique key: `ashby:<board>:<id>` or `workable:<account>:<shortcode>`                                                                                    |
| `source`, `sourceName`, `board`                              | `ashby` or `workable`, the source's name, and the board or account name                                                                                        |
| `companyName`                                                | Company name as shown on the Workable account (Ashby's job board API does not provide one)                                                                     |
| `jobId`, `requisitionId`                                     | The posting's id and, where published, the company's requisition code (Workable)                                                                               |
| `title`, `departments`, `team`                               | Job title, department and team (team: Ashby)                                                                                                                   |
| `location`, `allLocations`, `country`                        | Location text, all listed locations, country code (Workable)                                                                                                   |
| `workplaceType`, `isRemote`                                  | `remote`, `hybrid` or `on-site` (Ashby); Workable flags remote jobs only                                                                                       |
| `employmentType`                                             | e.g. Full-time, Contract                                                                                                                                       |
| `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryInterval` | Pay range when the company shows one on its Ashby postings                                                                                                     |
| `postedAt`                                                   | When the job was (last) published, as given by the source                                                                                                      |
| `updatedAt`, `applicationDeadline`, `language`               | Kept for a stable schema; neither source publishes them, so they are `null`                                                                                    |
| `jobUrl`, `applyUrl`                                         | Link to the original job page and the application page                                                                                                         |
| `description`, `descriptionRedactions`                       | Full description, only when **Include full job descriptions** is on (otherwise `null`), with personal details removed (see Privacy), and how many were removed |
| `changeStatus`, `firstSeenAt`, `lastSeenAt`, `closedAt`      | Change tracking (see below)                                                                                                                                    |
| `retrievedAt`                                                | When this run read the job                                                                                                                                     |

### Change tracking

With `trackChanges` on (default), the Actor remembers each board's jobs in a named key-value store in your account (`ats-jobs-feed-state`). History is kept **per feed**, so separate feeds never interfere:

- Run from a saved **task**: the task is the feed. Two tasks that watch the same board each get every job as `new` the first time, and each keeps its own history.
- Run directly (API or Console, no task): the feed is the combination of boards and filters (keywords, locations, departments, workplace types; order and letter case don't matter). Changing the boards or filters starts a new feed, whose first run reports every job as `new`. Changing `onlyChanges`, `includeClosed`, `includeDescriptions`, `maxJobs` or `maxRequests` keeps the history.

`RUN_SUMMARY.stateFeedId` shows which feed a run used. On the next run of the same feed:

- `new`: the job was not on the board last time.
- `changed`: title, location, department, pay or another job field changed (Ashby: also the description).
- `unchanged`: same as last time (skip these with `onlyChanges`; skipped rows are not charged).
- `closed`: the job was on the board last time and is gone now. It is reported once with its last known details and `closedAt`, then forgotten.

Safety rules:

- A board that fails in a run never marks its jobs as closed.
- A board whose rows were not all saved (for example because `maxJobs` or your maximum cost per run was reached) keeps its previous state, so nothing is missed next time.
- A posting that is still listed but can't be read this run (for example a missing title) is not reported as closed. If a posting has no readable id at all, no job on that board is marked closed in that run.
- `firstSeenAt` is the first run of the feed that saw the job.

### Reliability

- Each board is fetched and processed in isolation. Errors are recorded per board in `RUN_SUMMARY` with a reason such as "says this board does not exist (HTTP 404)".
- The run only fails if **every** board fails. If some fail, the run succeeds with status `partial`.
- Temporary errors (timeouts, 5xx) are retried with exponential backoff. HTTP 429 waits for the time the API asks for.
- Requests are paced per source (Ashby: at least 0.5 seconds apart; Workable: at least 1.5 seconds apart).
- `maxRequests` caps HTTP calls per run, retries included; `maxJobs` caps saved rows.
- Every row is validated against the output schema; postings without a title or link are skipped and counted.

### Use cases

- Daily feed of new openings at a list of startups and scale-ups, for a job board, newsletter or community.
- Labour-market and hiring-trend research: how many roles a set of companies opens and closes per week, by department or location.
- Tracking published pay ranges for a role across companies (Ashby boards).
- Keeping your own careers aggregator in sync, including removing closed roles.

### How to use it

1. Add companies under **Ashby job boards** (e.g. `acme` or `jobs.ashbyhq.com/acme`) and/or **Workable accounts** (e.g. `acme`, `apply.workable.com/acme` or `acme.workable.com`).
2. Optionally filter by title keywords, locations, departments or workplace type.
3. Run it. Schedule it daily and turn on **Only output changes** for a change feed.
4. Read the jobs from the dataset and the per-board results from `RUN_SUMMARY`.

Finding the board name: open the company's careers page. If job links go to `jobs.ashbyhq.com/<name>` or `apply.workable.com/<name>`, that `<name>` is what you enter. You can also paste the URL.

### Input examples

These are also in the `examples/` folder.

Daily change feed for a few companies (schedule it daily; only new, changed and closed jobs are saved) (`examples/daily-change-feed.json`):

```json
{
    "ashbyBoards": ["linear", "ramp", "jobs.ashbyhq.com/notion", "supabase"],
    "workableAccounts": ["huggingface", "apply.workable.com/getresponse"],
    "onlyChanges": true,
    "maxJobs": 2000
}
```

Remote engineering roles at a few companies (`examples/remote-engineering.json`):

```json
{
    "ashbyBoards": ["linear", "supabase"],
    "workableAccounts": ["huggingface"],
    "keywords": ["engineer", "developer", "sre"],
    "workplaceTypes": ["remote"],
    "maxJobs": 500
}
```

One company's engineering roles in one city, with full descriptions, without change tracking (`examples/one-company-with-descriptions.json`):

```json
{
    "ashbyBoards": ["openai"],
    "keywords": ["engineer"],
    "locations": ["San Francisco"],
    "includeDescriptions": true,
    "trackChanges": false,
    "maxJobs": 100
}
```

### Output example

Real sample: 3 rows from a test run on October 25, 2026, with the default input and change tracking off (links shown without the protocol; the dataset has full links; one long location list shortened). With **Include full job descriptions** on, `description` holds the cleaned text, for example `"Hi 👋 I’m [name removed] [profile link removed] Head of EMEA Engineering. ..."` (from a real Ashby posting), and `descriptionRedactions` counts the removals.

```json
[
    {
        "jobKey": "workable:huggingface:81B46579FE",
        "source": "workable",
        "sourceName": "Workable public jobs",
        "board": "huggingface",
        "companyName": "Hugging Face",
        "jobId": "81B46579FE",
        "title": "Open-Source Machine Learning Engineer - EMEA Remote",
        "departments": ["Open Source"],
        "team": null,
        "location": "Paris, Île-de-France, France",
        "allLocations": ["Paris, Île-de-France, France"],
        "country": "FR",
        "workplaceType": "remote",
        "isRemote": true,
        "employmentType": "Full-time",
        "salaryMin": null,
        "salaryMax": null,
        "salaryCurrency": null,
        "salaryInterval": null,
        "requisitionId": null,
        "language": null,
        "postedAt": "2026-05-29T00:00:00.000Z",
        "updatedAt": null,
        "applicationDeadline": null,
        "jobUrl": "apply.workable.com/j/81B46579FE",
        "applyUrl": "apply.workable.com/j/81B46579FE/apply",
        "description": null,
        "descriptionRedactions": null,
        "changeStatus": null,
        "firstSeenAt": null,
        "lastSeenAt": null,
        "closedAt": null,
        "retrievedAt": "2026-10-25T15:41:49.321Z"
    },
    {
        "jobKey": "ashby:ashby:7458d4e9-da2e-47bd-98cb-adfda43d42b2",
        "source": "ashby",
        "sourceName": "Ashby public job board",
        "board": "ashby",
        "companyName": null,
        "jobId": "7458d4e9-da2e-47bd-98cb-adfda43d42b2",
        "title": "Engineering Manager - EU",
        "departments": ["Engineering"],
        "team": "EMEA Engineering",
        "location": "Remote - European Union",
        "allLocations": ["Remote - European Union", "Spain", "Italy", "Germany", "…"],
        "country": null,
        "workplaceType": "remote",
        "isRemote": true,
        "employmentType": "Full-time",
        "salaryMin": 150000,
        "salaryMax": 250000,
        "salaryCurrency": "EUR",
        "salaryInterval": "1 YEAR",
        "requisitionId": null,
        "language": null,
        "postedAt": "2024-03-04T14:29:08.532+00:00",
        "updatedAt": null,
        "applicationDeadline": null,
        "jobUrl": "jobs.ashbyhq.com/ashby/7458d4e9-da2e-47bd-98cb-adfda43d42b2",
        "applyUrl": "jobs.ashbyhq.com/ashby/7458d4e9-da2e-47bd-98cb-adfda43d42b2/application",
        "description": null,
        "descriptionRedactions": null,
        "changeStatus": null,
        "firstSeenAt": null,
        "lastSeenAt": null,
        "closedAt": null,
        "retrievedAt": "2026-10-25T15:41:49.592Z"
    },
    {
        "jobKey": "ashby:ashby:86a60834-ba64-484d-9658-afa1bc97a957",
        "source": "ashby",
        "sourceName": "Ashby public job board",
        "board": "ashby",
        "companyName": null,
        "jobId": "86a60834-ba64-484d-9658-afa1bc97a957",
        "title": "Mid Market Account Executive - EMEA (French Speaking)",
        "departments": ["Sales"],
        "team": "Sales",
        "location": "France",
        "allLocations": [
            "France",
            "United Kingdom",
            "Belgium",
            "Germany",
            "Switzerland",
            "Ireland",
            "Austria",
            "Spain",
            "Stuttgart",
            "Berlin"
        ],
        "country": null,
        "workplaceType": "remote",
        "isRemote": true,
        "employmentType": "Full-time",
        "salaryMin": 110000,
        "salaryMax": 195500,
        "salaryCurrency": "EUR",
        "salaryInterval": "1 YEAR",
        "requisitionId": null,
        "language": null,
        "postedAt": "2026-09-08T16:27:26.694+00:00",
        "updatedAt": null,
        "applicationDeadline": null,
        "jobUrl": "jobs.ashbyhq.com/ashby/86a60834-ba64-484d-9658-afa1bc97a957",
        "applyUrl": "jobs.ashbyhq.com/ashby/86a60834-ba64-484d-9658-afa1bc97a957/application",
        "description": null,
        "descriptionRedactions": null,
        "changeStatus": null,
        "firstSeenAt": null,
        "lastSeenAt": null,
        "closedAt": null,
        "retrievedAt": "2026-10-25T15:41:49.592Z"
    }
]
```

### How much does it cost?

Pay per event, with no subscription:

- **$0.01** per run start,
- **$2.00 per 1,000 jobs** saved ($0.002 per row, `closed` rows included; unchanged rows skipped by `onlyChanges` are free). Failed boards cost nothing.

Example: a full run that saves 1,000 jobs costs $0.01 + $2.00 = $2.01. A daily `onlyChanges` run usually costs much less than a full run, because only changes are saved. Set a maximum cost per run in the run options and the Actor stops cleanly when it is reached.

### Privacy: company and job data only

- The output has no person fields. Any other fields in the source data are dropped.
- Full job descriptions are **off by default**. Rows still have the title, company, location, department, links and dates. Descriptions are each employer's own text; turn them on only if you need them.
- With **Include full job descriptions** on, descriptions are plain text with **email addresses, phone numbers, profile links (LinkedIn, Calendly, X/Twitter), names right before a profile link, and names after cues** such as "contact", "recruiter", "hiring manager", "reports to", "created by", "I'm" or "my name is" replaced with placeholders like `[name removed]`.
- Limits: a name written with no such cue (for example a bare "Alex will onboard you") can't be told apart from other capitalised words and may remain. Leave **Include full job descriptions** off (the default) if you need zero free text.
- Ashby postings that a company marks as not listed (reachable by direct link only) are left out.
- Use the data for job-related purposes. Don't use it to contact people. Workable's robots.txt asks that its content not be used to train AI models; please respect that.

### Limits

- Ashby's job board API does not include the company name, so `companyName` is empty for Ashby rows (`board` holds the company's Ashby name).
- Neither endpoint supports server-side filtering, so filters are applied after download: each board is still read in full.
- Workplace type: Ashby gives remote, hybrid or on-site. Workable's public endpoint only says whether a job is remote, so non-remote Workable jobs have `workplaceType` `null` and `isRemote` `false`.
- Workable descriptions are only downloaded when **Include full job descriptions** is on, so a Workable job whose description alone was edited is not marked `changed`. Ashby description edits are detected either way.
- The sources do not publish rate limits for these endpoints. The Actor paces itself and stops a board after repeated rate-limit responses.
- Change tracking remembers up to 100,000 jobs per board per feed. Very large boards (several thousand jobs with long descriptions) may need more memory than the 256 MB default.

### Integrations and API

Use the Apify API, webhooks, or integrations (Zapier, Make, Google Sheets, Slack and others) to send each run's new and changed jobs wherever you need them. The per-board results are in the `RUN_SUMMARY` record of the run's default key-value store.

### FAQ

**Does it need an Ashby or Workable account?** No. It reads public job board data that needs no login or key.

**Why did a board fail?** See `RUN_SUMMARY.boards`. The most common reason is a wrong board name or a company that no longer uses that job board (HTTP 404).

**How do I reset change tracking?** Delete the `ats-jobs-feed-state` key-value store in your account (this resets every feed), or start a new task.

**Is it official?** No. It is an independent tool, not affiliated with Ashby or Workable.

**How do I find a company's Ashby board name?** Open its careers page. If job links go to `jobs.ashbyhq.com/<name>`, enter `<name>` or paste the URL. For Workable, it's `apply.workable.com/<name>` or `<name>.workable.com`.

**Does it show salaries?** Yes for Ashby, when the company publishes a pay range on the posting. Workable's public endpoint has no pay fields.

**Does it cover SmartRecruiters, Greenhouse or Lever?** Not SmartRecruiters. Greenhouse and Lever are covered by our separate Greenhouse Jobs API & Lever Jobs Scraper actor on the Apify Store.

**Why is `companyName` empty on Ashby rows?** Ashby's job board API doesn't return a company name. `board` holds the company's Ashby name.

**What does it cost?** $0.01 per run plus $0.002 per saved job ($2.00 per 1,000). Closed-job rows count as saved; unchanged jobs skipped with `onlyChanges` are free.

**How do I get only new jobs each day?** Turn on `onlyChanges` and schedule the Actor daily. You get new, changed and closed jobs; unchanged jobs are skipped and not charged.

# Changelog

This Actor's version history is a separate document: https://apify.com/graylabs/ats-jobs-feed/changelog.md

# Actor input Schema

## `ashbyBoards` (type: `array`):

Job board names or URLs, e.g. "acme" or "https://jobs.ashbyhq.com/acme". The name is the last part of the company's Ashby job board address.

## `workableAccounts` (type: `array`):

Account names or careers-page URLs, e.g. "acme", "https://apply.workable.com/acme" or "https://acme.workable.com".

## `keywords` (type: `array`):

Keep only jobs whose title contains any of these words (case-insensitive). Empty keeps all.

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

Keep only jobs whose location contains any of these (case-insensitive), e.g. "London", "Germany", "Remote".

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

Keep only jobs whose department or team contains any of these (case-insensitive), e.g. "Engineering".

## `workplaceTypes` (type: `array`):

Keep only these workplace types. Ashby reports remote, hybrid or on-site; Workable only flags remote jobs.

## `includeDescriptions` (type: `boolean`):

Off by default: rows have the title, company, location, department, links and dates, and `description` is null. Turn on to add each job's full description as plain text. Email addresses, phone numbers, profile links and person names after cues like "contact" or "recruiter" are removed from the text. Descriptions are written by each employer.

## `trackChanges` (type: `boolean`):

Remembers each board's jobs in a named key-value store in your account so every row says whether it is new, changed or unchanged since your last run, and jobs that disappeared are reported as closed.

## `onlyChanges` (type: `boolean`):

Output only new, changed and closed jobs (needs change tracking). Unchanged jobs are skipped and not charged.

## `includeClosed` (type: `boolean`):

Add a row with changeStatus "closed" for each job that was on a board last run but is gone now.

## `maxJobs` (type: `integer`):

Stop after saving this many rows (closed rows included).

## `maxRequests` (type: `integer`):

Safety cap on HTTP requests per run, retries included. Each board or account takes one request.

## Actor input object example

```json
{
  "ashbyBoards": [
    "ashby"
  ],
  "workableAccounts": [
    "huggingface"
  ],
  "includeDescriptions": false,
  "trackChanges": true,
  "onlyChanges": false,
  "includeClosed": true,
  "maxJobs": 20,
  "maxRequests": 300
}
```

# Actor output Schema

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

No description

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

No description

# 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 = {
    "ashbyBoards": [
        "ashby"
    ],
    "workableAccounts": [
        "huggingface"
    ],
    "maxJobs": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("graylabs/ats-jobs-feed").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 = {
    "ashbyBoards": ["ashby"],
    "workableAccounts": ["huggingface"],
    "maxJobs": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("graylabs/ats-jobs-feed").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 '{
  "ashbyBoards": [
    "ashby"
  ],
  "workableAccounts": [
    "huggingface"
  ],
  "maxJobs": 20
}' |
apify call graylabs/ats-jobs-feed --silent --output-dataset

```

## MCP server setup

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

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/o2CjbX5ljyq2ITuPV/builds/uUUnWBzVWh8ZLracb/openapi.json
