# Workable Jobs Scraper - apply.workable.com Careers, No API Key (`maydit/workable-jobs-scraper`) Actor

Scrape published job postings from any apply.workable.com careers page: title, department, location, remote flag, dates, links, optional full description. New-postings-only mode. No API key.

- **URL**: https://apify.com/maydit/workable-jobs-scraper.md
- **Developed by:** [Brandt May](https://apify.com/maydit) (community)
- **Categories:** Jobs, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.60 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## Workable Jobs Scraper

Scrape published job postings from any **Workable careers page** (`apply.workable.com/<slug>`) and get them back as flat CSV, JSON or Excel rows. Give it an account slug (`blueground`) or the careers-page URL (`https://apply.workable.com/blueground/`) and this Workable scraper returns every posting the employer has published there: job title, department, employment type, city / region / country, the employer's remote flag, published date, posting and apply links, and - when you ask for it - the **full job description converted from Workable's HTML into clean plain text**.

Built for recruiters, sourcers, job-board operators and anyone tracking hiring at a list of companies. It reads the public JSON endpoint behind Workable's embeddable jobs widget, so there is **no API key, no login and no Workable account** involved. Pass one company or a few dozen.

This Actor covers **Workable careers pages only**. It does not read `jobs.workable.com` (Workable's cross-company job search, which its robots.txt disallows), and a company that does not host its careers page on Workable cannot be scraped here.

### What you get

One row per published job posting. Fill rates below were measured on 678 rows across three real runs on 2026-09-25; a blank means the employer left that field empty in Workable.

| Field | What it is | Filled |
| --- | --- | --- |
| `company` | Company name as Workable publishes it (for example `Skroutz S.A`). Falls back to the slug if the account has no name. | 100% |
| `slug` | The Workable account the row came from, so multi-company runs stay separable. | 100% |
| `jobId` | Workable's shortcode for the posting (for example `186545F8C1`) - the id in the posting URL. Stable; use it to dedupe across runs. | 100% |
| `requisitionCode` | The employer's own requisition code, if they filled one in. | 54% |
| `title` | Job title. | 100% |
| `department` | Department the employer filed the role under. | 98% |
| `employmentType` | `Full-time`, `Part-time`, `Contract` and so on, as set by the employer. | 76% |
| `function` | Job function the employer picked from Workable's list (for example `Engineering`). | 50% |
| `industry` | Industry the employer picked (for example `Computer Software`). | 96% |
| `experienceLevel` | `Entry level`, `Associate`, `Mid-Senior level`... as set by the employer. Workable's literal `Unspecified` is returned as blank. | 57% |
| `educationLevel` | `Bachelor's Degree`, `Master's Degree`, `Vocational`... as set by the employer. `Unspecified` is returned as blank. | 44% |
| `city`, `region`, `country` | The posting's location as three separate fields. `country` was filled on every row measured; `city` and `region` are blank on postings the employer published as country-only (often remote roles). | 83% / 83% / 100% |
| `countryCode` | ISO 3166-1 alpha-2 code for the country (`GR`, `CY`, `GB`). | 100% |
| `location` | The three location parts joined, for example `Athens, Attica, Greece`. | 100% |
| `remote` | `true`/`false`: the employer's own remote ("telecommuting") flag in Workable. | 100% |
| `workplaceType` | `Remote` when `remote` is true, otherwise blank. Workable's widget exposes no hybrid or on-site flag, so this Actor does not invent one - see the limits section. | 42% (by design) |
| `publishedAt` | Date the posting was published, as a calendar date (`2026-09-14`). Workable publishes a date only, not a time, so none is invented. Rows are sorted newest-published first across all companies. | 100% |
| `createdAt` | Date the posting was created in Workable, same format. | 100% |
| `url` | Public link to the posting (`https://apply.workable.com/j/<jobId>`). | 100% |
| `applyUrl` | Link straight to the application form. | 100% |
| `descriptionText` | The whole job description as clean plain text: tags removed, entities decoded, list items kept as `- ` bullets, capped at 20,000 characters. Only when `includeDetails` is on; blank otherwise. | 100% with `includeDetails` |
| `scrapedAt` | ISO 8601 UTC timestamp of the run. | 100% |

A `SUMMARY` record is written to the run's key-value store with a **board-health report** for every company requested (`ok` with jobs, `empty`, `not-found`, `rate-limited`, `error`, `not-reached`, each with the company name and how many postings were published and saved), which companies failed and why, the total postings published, and department and country breakdowns (top 25 of each with counts).

### Input

| Field | Type | Default | What it does |
| --- | --- | --- | --- |
| `companies` | string list | empty | Account slugs or careers-page URLs, one per company. Accepts `blueground`, `https://apply.workable.com/blueground/`, `https://apply.workable.com/blueground/j/186545F8C1/` and `https://blueground.workable.com/`. A bare job link (`https://apply.workable.com/j/<code>`) has no account in it and is skipped with a warning. |
| `preset` | select | `verified-sample` | Used only when `companies` is empty. `verified-sample` is 18 Workable accounts that each published at least 3 jobs when checked on 2026-09-25 (Blueground, Skroutz, Epignosis, Novibet, Viva.com, Spotawheel, Welcome Pickups, Printec, Orfium, European Dynamics, Grecotel, payabl., Costa Navarino, Uni Systems, Eightcap, EXUS, Hellas Direct, Stoiximan). `none` means only the companies you list; if that list is also empty the sample is used anyway so the run returns something. |
| `includeDetails` | boolean | `false` | Fetch with `details=true` and keep `descriptionText`. Every other field is unaffected. |
| `titleContains` | string | empty | Keep only postings whose title contains this text (case-insensitive, partial). |
| `remoteOnly` | boolean | `false` | Keep only postings the employer flagged as remote. |
| `maxResultsPerCompany` | integer | `100` | Row cap per company, newest-published first. |
| `maxResults` | integer | `300` | Row cap for the whole run, applied after sorting newest-published first across companies. |
| `onlyNewSinceLastRun` | boolean | `false` | Save only postings this Actor has not seen before. See the FAQ for exactly how the first run behaves. |
| `maxRunSeconds` | integer | `240` | Wall-clock budget. When it is reached the run stops cleanly, keeps everything already collected, and names the companies it did not reach. |

### Example output

A real row from a run with `includeDetails` on (description truncated here for readability):

```json
{
  "company": "EUROPEAN DYNAMICS",
  "slug": "european-dynamics",
  "jobId": "47DDC6EF15",
  "requisitionCode": "DSE/09/26",
  "title": "DevSecOps Engineer",
  "department": "PRB",
  "employmentType": null,
  "function": null,
  "industry": "Computer Software",
  "experienceLevel": null,
  "educationLevel": null,
  "city": "Athens",
  "region": "Attica",
  "country": "Greece",
  "countryCode": "GR",
  "location": "Athens, Attica, Greece",
  "remote": true,
  "workplaceType": "Remote",
  "publishedAt": "2026-09-14",
  "createdAt": "2026-09-14",
  "url": "https://apply.workable.com/j/47DDC6EF15",
  "applyUrl": "https://apply.workable.com/j/47DDC6EF15/apply",
  "descriptionText": "We are looking for a DevSecOps Engineer to help us build, automate, secure, and operate reliable software delivery platforms ...",
  "scrapedAt": "2026-09-25T17:34:59.865Z"
}
```

### FAQ

**Where do I find a company's slug?**
Open the company's careers page. If it is hosted on Workable the address looks like `https://apply.workable.com/<slug>/` - the slug is that path segment (`blueground`, `european-dynamics`, `uni-systems`). You can paste the whole URL, or a link to one of its job postings, into `companies` and the Actor extracts the slug. A slug with no careers page returns HTTP 404 and is reported for that company only; the rest of the run continues. The slug `workable` is not an account (Workable's own openings are at `careers`).

**The company exists but I got no rows.**
That is a real answer, not a failure: plenty of Workable accounts resolve but currently publish zero jobs (in the accounts checked while building this Actor, more than half were in that state). The run finishes successfully, logs a warning, and the company shows up in `SUMMARY.boardHealth` as `empty`. Check the page in a browser at `https://apply.workable.com/<slug>/` if you expected postings.

**What exactly does "only postings new since the last run" do?**
With the option on, the Actor records the `jobId` of every published posting on each careers page it successfully fetched, in a named key-value store called `workable-jobs-scraper-state`. On the **first** run for a company there is no baseline to compare against, so you get **every** posting and the baseline is written - the log says so. From the second run on you get only ids that were not there before. A run with nothing new finishes successfully with an empty dataset and a warning - that is the expected result, not a failure. The baseline is only written on runs where the option is on, and it records every published posting, including any the caps kept out of the dataset. The store is shared by every run of this Actor on your account, so two schedules watching the *same* company will each consume the other's new postings; different company lists are safe, the baseline is kept per slug.

**Is the description really plain text?**
Yes. Workable returns descriptions as HTML. This Actor strips the tags, decodes the entities and keeps list items as `- ` bullets. On the 335-row run with `includeDetails` on that produced the example above, 0 rows contained leftover HTML tags or entities; descriptions averaged about 4,700 characters.

**Why is there no hybrid / on-site value, and no salary?**
Because the widget endpoint does not publish them. The only workplace signal it carries is the employer's boolean remote flag, so `remote` is that flag and `workplaceType` is `Remote` or blank - nothing is inferred from the text. There is no structured salary either; pay ranges appear inside `descriptionText` when the employer wrote them into the posting. No made-up columns.

**How fast is it?**
Workable returns an entire careers page in a single request, with no pagination. Measured locally on 2026-09-25, end to end including writing the rows: the default run (18 companies, 584 postings published, 300 saved) took about 8 seconds; four companies with 43 postings took under 3 seconds; five companies with 335 postings and full descriptions took under 5 seconds. Platform runs add the time to write results over Apify Storage. After the first 25 companies in a run the Actor deliberately slows to one request every 2.5 seconds - see the limits section for why.

### Data source, access and limits

- **Source:** the JSON endpoint behind Workable's embeddable jobs widget, `https://apply.workable.com/api/v1/widget/accounts/{slug}?details=false|true`. It is **public but undocumented** - it needs no key, login, account or cookies, but Workable does not describe it in its developer documentation, so its shape could change without notice. Only postings the employer has published on their careers page are returned.
- **Crawling is allowed; training is not.** `https://apply.workable.com/robots.txt` (read 2026-09-25) is `User-agent: *` / `Disallow:` (nothing disallowed) with `Content-Signal: search=yes, ai-input=yes, ai-train=no`. So: use the data for recruiting, job aggregation, market research and monitoring; **do not use it to train AI models**. `jobs.workable.com`, Workable's cross-company search, is disallowed to crawlers and this Actor never touches it.
- **Rate limit.** Workable's edge (Cloudflare) rate-limits this endpoint, and it is keyed to *how many different pages* one client asks for, not to raw request count: on 2026-09-25, 150 requests for the same careers page in five seconds went through untouched, while 50 requests for 50 different (unknown) slugs in two seconds got HTTP 429 (an HTML error page). The first three blocks that day carried `retry-after: 0` and cleared within twenty seconds to five minutes. After a fourth burst inside the same hour the 429 came back with `retry-after: 85836` (about 24 hours); over the following half hour that block was enforced inconsistently - some requests from the same client went through while others were refused with the same expiry - so treat a long `retry-after` as real. A run is one request per company, so a list of a few dozen companies is fine (30 companies in one run did not trip it); past 25 the Actor paces itself to one request every 2.5 seconds. A 429 with no `retry-after` is retried four times over 45 seconds; a short `retry-after` (up to 45 seconds) is waited out once and the request tried once more; a longer `retry-after` is treated as the block it is - the Actor does not retry it. Either way the run stops there, keeps what it has, writes the board-health report, and lists the companies it did not reach in `SUMMARY.boardHealth` as `not-reached` (the refused one as `rate-limited`, with `retryAfterSec`) so you can re-run just those later. Spreading scheduled runs apart helps, and so does not passing long lists of guessed slugs - unknown slugs count too. Apify runs share outbound IPs with other users, so a block caused by someone else's traffic is possible; it shows up as `rate-limited` in the board-health report, never as "no jobs".
- **`remote` is the employer's flag, not an inference.** A role that is remote only in its description text reads `false`.
- **Everything else is passed through as published.** Titles, departments, functions, industries, levels, locations and dates come from the employer's own Workable data, including its gaps - the fill rates above are what employers actually fill in.
- **No personal data is extracted.** Every row describes a *job posting*, not a person: there is no recruiter name, email or phone field, and none is inferred. `descriptionText` is the employer's own published description reproduced as text. Nothing is collected from candidates or applications.
- **Failures are explicit.** Companies that 404 or error are named in the log and in `SUMMARY.failedSources`, and the run still saves everything from the companies that worked. If *every* company that was fetched fails, the run fails with a message naming the likely cause instead of returning an empty dataset that looks like "no jobs". The `SUMMARY` record, with the board-health report, is written before the run fails, so you can still see which companies were refused and which were never reached.
- **Billing:** this Actor is billed per result on Apify - the current rate is on the Pricing tab of this Actor's page. There is nothing else to buy; the data source itself needs no key.
- **Not affiliated with Workable.** Workable is a trademark of Workable Software Limited; this Actor is an independent tool that reads publicly available careers-page data.

# Actor input Schema

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

One entry per company: the account slug or the careers-page URL, both work. The slug is the path segment in https://apply.workable.com/<slug>/ (for example 'blueground'); job links such as https://apply.workable.com/blueground/j/186545F8C1/ are parsed to the same slug. A bare job link (https://apply.workable.com/j/<code>) has no account in it and is skipped with a warning. Slugs are case-insensitive. Leave empty to run the preset below. A slug with no careers page returns HTTP 404 and is reported for that company only; an account that exists but publishes no jobs is reported as 'empty' in the run's SUMMARY, not treated as an error.

## `preset` (type: `string`):

Used only when 'companies' is empty. 'verified-sample' is 18 Workable accounts that each published at least 3 jobs when checked on 2026-09-25 (Blueground, Skroutz, Epignosis, Novibet, Viva.com, Spotawheel, Welcome Pickups, Printec, Orfium, European Dynamics, Grecotel, payabl., Costa Navarino, Uni Systems, Eightcap, EXUS, Hellas Direct, Stoiximan). Boards change, so the run's SUMMARY reports which preset accounts still publish jobs. 'none' means run only the companies you list; if that list is also empty the sample is used anyway so the run returns something.

## `includeDetails` (type: `boolean`):

Fetch each careers page with details=true and keep the full job description as clean plain text in descriptionText (HTML tags removed, entities decoded, list items kept as '- ' bullets, capped at 20,000 characters). Off by default: it makes each response roughly 5-10x larger. Every other field is the same either way.

## `titleContains` (type: `string`):

Optional. Keep only postings whose title contains this text (case-insensitive, partial match), for example 'engineer' or 'sales'. Leave empty for every posting.

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

Keep only postings the employer flagged as remote in Workable (the 'telecommuting' flag). Workable has no hybrid or on-site flag, so a role that is remote only in its description text is not matched.

## `maxResultsPerCompany` (type: `integer`):

Upper bound on rows kept for each company. Postings are sorted newest-published first, so a low cap keeps the freshest roles. Workable returns a whole careers page in one request, so raising this costs no extra requests.

## `maxResults` (type: `integer`):

Upper bound on rows saved for the whole run, applied after all companies are fetched and sorted newest-published first across companies.

## `onlyNewSinceLastRun` (type: `boolean`):

Save only postings this Actor has not seen before. The job ids of every published posting on each successfully fetched careers page are recorded in a named key-value store ('workable-jobs-scraper-state') and compared on the next run with this option on. The FIRST run for a company has no baseline, so it returns every posting and records the baseline; from the second run on you get only new ones. A run with nothing new finishes successfully with an empty dataset and a warning in the log. Leave this off for one-off scrapes.

## `maxRunSeconds` (type: `integer`):

Wall-clock budget for the run. When it is reached the Actor stops cleanly, saves everything already collected, and lists in the log and SUMMARY which companies it did not reach - instead of being killed with a failed run. A careers page takes well under a second, so the default covers hundreds of companies.

## Actor input object example

```json
{
  "companies": [],
  "preset": "verified-sample",
  "includeDetails": false,
  "remoteOnly": false,
  "maxResultsPerCompany": 100,
  "maxResults": 300,
  "onlyNewSinceLastRun": false,
  "maxRunSeconds": 240
}
```

# Actor output Schema

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

One row per published job posting from each Workable careers page, with department, location, remote flag, dates, links and an optional clean-text description.

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

Totals for the run, a per-company board-health report (with jobs, empty, not found, rate-limited, errored, not reached), which companies failed and why, department and country breakdowns, and whether the run stopped early on its time budget.

# 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": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("maydit/workable-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 = { "companies": [] }

# Run the Actor and wait for it to finish
run = client.actor("maydit/workable-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 '{
  "companies": []
}' |
apify call maydit/workable-jobs-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,maydit/workable-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/b5ilHf9KxjyCefPRD/builds/YWDfLpffX2IGaorLr/openapi.json
