--- name: workable-jobs-scraper description: Pull every public opening from Workable-hosted careers pages as structured records via the Apify Actor arman-bd/workable-jobs-scraper. Each row carries the account slug, company name, Workable shortcode, title, department, composed location plus city and country, employment type, remote flag, publish date, direct apply link and the full plain-text description. Use when a task needs job-board aggregation across small and mid-size employers, departmental hiring tracking at private companies, lead generation from hiring signals or labour-market research by country and role. Not for salary data, applicant or candidate information, enterprise boards on other ATS platforms, or historical postings that have already closed. --- # Workable Jobs Scraper Apify Actor `arman-bd/workable-jobs-scraper`. Give it Workable account slugs, get one dataset record per open vacancy. A whole board is one request. It runs without credentials. ## When to use it - Aggregating openings across many small and mid-size employers, which is where Workable's customer base sits. - Refreshing a niche or regional job board on a schedule. - Tracking where a private company is adding headcount, by `department` and `publishedAt`. - Lead generation: a company hiring for a role is a company buying for it. - Labour-market counts by country, department or employment type over time. ## When not to use it - Salary. The public feed carries no structured salary field, and the Actor does not invent one. If a figure exists at all it is inside `description`. - Candidates, applications, pipelines or anything on the hiring side of the account. - Companies on Greenhouse, Lever, Ashby, Workday, Personio or Teamtailor. Each has its own Actor; this one only reads Workable accounts. - Closed or historical postings. You get what is open at the moment of the run, so build history by scheduling and diffing. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/workable-jobs-scraper').call({ accounts: ['azumo', 'weetabix'], searchTerms: ['engineer'], remoteOnly: true, includeDescription: false, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); if (summary.failures.length) console.warn(summary.failures); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~workable-jobs-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"accounts":["azumo"],"searchTerms":["engineer"],"maxJobsPerAccount":25}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/workable-jobs-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `accounts` | string[] | yes | | Account slugs (`azumo`), full careers-page URLs, or a `{slug}.workable.com` host. All normalise to a lowercase slug. Duplicates removed. | | `searchTerms` | string[] | no | `[]` | Keep only titles containing one of these, case-insensitive substring. Terms are ORed. Empty keeps every title. | | `departmentFilter` | string[] | no | `[]` | Same matching against `department`. Empty keeps every department. | | `remoteOnly` | boolean | no | `false` | Keep only roles the board flags as remote. | | `maxJobsPerAccount` | integer | no | `0` | Cap per account, applied **after** filtering. `0` means no limit. | | `includeDescription` | boolean | no | `true` | Include the full plain-text description. `false` drops the `description` key entirely and shrinks the dataset a lot. | **`departmentFilter` is the one that quietly returns nothing.** Departments are optional in Workable and plenty of smaller accounts never fill them in, so a job with no department fails a non-empty `departmentFilter` and is dropped. Start with `searchTerms` alone, look at how many rows have a non-null `department`, and only add the department filter on boards that populate it. The other decision is `includeDescription`: leave it on when the text is the deliverable, turn it off for scheduled change detection, where `shortcode` and `publishedAt` are all you diff. ## Output One record per vacancy saved. | Field | Type | Notes | |---|---|---| | `account` | string | The normalised slug used. Join back to your input on this. | | `company` | string | Company name as the board reports it, falling back to the slug. | | `shortcode` | string \| null | Workable's stable per-job code, also the last segment of the apply URL. The diff key. | | `title` | string | Job title. | | `department` | string \| null | `null` on boards that do not use departments, which is common. | | `location` | string \| null | Composed `city, region, country`, with duplicate parts removed. First location only, see below. | | `country` | string \| null | Structured country for that same first location. | | `city` | string \| null | Structured city. | | `employmentType` | string \| null | `Full-time`, `Part-time`, `Contract`, `Temporary`, `Internship`. | | `isRemote` | boolean | The board's own telecommuting flag, set by the employer. | | `publishedAt` | string \| null | Publish date, `YYYY-MM-DD`, falling back to the creation date. Not a timestamp. | | `applyUrl` | string \| null | Direct apply link. | | `description` | string | Full description as plain text, HTML stripped and entities decoded. Key is absent when `includeDescription` is `false`. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record, description trimmed: ```json { "account": "weetabix", "company": "Weetabix", "shortcode": "20459A707F", "title": "Compliance Engineer", "department": "Engineering", "location": "Burton Latimer, England, United Kingdom", "country": "United Kingdom", "city": "Burton Latimer", "employmentType": "Full-time", "isRemote": false, "publishedAt": "2026-07-15", "applyUrl": "https://apply.workable.com/j/20459A707F/apply", "description": "We are Weetabix. Home to the UK's favourite breakfast cereal …", "scrapedAt": "2026-08-06T12:00:00.000Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** An account that returned nothing and an account that does not exist look the same in the dataset. ```json { "accountsRequested": 2, "accountsFailed": 1, "failures": [ { "account": "notarealcompany", "error": "account \"notarealcompany\" not found (404)" } ], "jobsSaved": 37, "filters": { "searchTerms": ["engineer"], "departmentFilter": [], "remoteOnly": true, "maxJobsPerAccount": 0 }, "finishedAt": "2026-08-06T12:00:04.118Z" } ``` A not-found error means the slug is wrong, not that the company has no openings. An account with zero matches leaves no entry in `failures` at all, so `accountsRequested` minus `accountsFailed` is the number of boards actually read, and a `jobsSaved` of zero across those is a filter problem. `filters` echoes your input verbatim, before normalisation, and does not include `includeDescription`. ## Behaviour to plan around - **A multi-location role is one record, and it names one location.** The feed repeats a job once per location, so a role open in five countries arrives five times under one `shortcode`. The Actor collapses those on `shortcode`, keeping the entry with the fullest location list, but `location`, `city` and `country` still describe only the first of them. Never read a single `country` as "this role is only open there". - **`department` is `null` far more often than you would expect**, and a non-empty `departmentFilter` silently drops every one of those rows. - **`maxJobsPerAccount` cuts after filtering and keeps feed order.** It is not a ranking: the rows you get are the first N the board listed, not the newest or the most relevant. Sort on `publishedAt` yourself if recency matters. - **`isRemote` is the employer's flag, not an inference.** Hybrid roles are flagged inconsistently across boards, and `remoteOnly: true` trusts the flag exactly. - **`publishedAt` is a date, not a timestamp**, and it falls back to the creation date when the board publishes no explicit publish date. Two jobs on the same day are unordered. - **`description` is absent, not `null`, when `includeDescription` is `false`.** Test for the key. - **Descriptions are decoded before and after tag stripping.** Authors paste already-encoded markup into the editor, so you get real text rather than literal `<p>`, but the formatting is flattened to plain text with bullet characters. - **One bad account never aborts the run.** An unknown slug is recorded and the next account starts. The Actor throws only when every account failed. - **Transient errors are retried** three times with exponential backoff. Not-found is final for that account. ## Recipes **Cheap change detection on a schedule.** No descriptions, no filters, whole boards. ```json { "accounts": ["azumo", "weetabix"], "includeDescription": false } ``` Diff `shortcode` per `account` against the previous run: new codes are new roles, disappeared codes are filled or withdrawn. **Remote engineering roles across a watchlist.** Filters AND together, so a job must match a title term and be flagged remote. ```json { "accounts": ["azumo", "https://apply.workable.com/weetabix/"], "searchTerms": ["engineer", "developer"], "remoteOnly": true, "maxJobsPerAccount": 50 } ``` Read `applyUrl` straight into your board, and keep `shortcode` as the primary key. **Hiring signal for lead generation.** Full descriptions, so you can search them for the tools and vendors a company is buying for. ```json { "accounts": ["azumo"], "searchTerms": ["sales", "marketing", "revenue"], "includeDescription": true } ``` Group by `company` and `publishedAt` to see which teams started growing this month.