--- name: ashby-jobs-scraper description: Read public Ashby-hosted job boards as structured records via the Apify Actor arman-bd/ashby-jobs-scraper. Returns one row per posting with title, team, location, employment type, the board's own remote flag, apply URL, full plain-text description and the published salary band when a company discloses one. Use when a task needs bulk job postings, salary-transparency data, hiring-signal tracking across a list of startups or a sourcing feed filtered by title keyword and remote status. Not for applicant data, candidate submission, boards on other ATS platforms, or the private side of an Ashby account. --- # Ashby Jobs Scraper Apify Actor `arman-bd/ashby-jobs-scraper`. Give it a list of Ashby org slugs, get one dataset record per published job posting. It takes no credentials of your own. Ashby is one of the few ATS feeds that exposes a posting's compensation band, so this is the Actor to reach for when pay data matters. ## When to use it - A list of companies has to become a table of open roles: title, team, location, pay. - You are building or refreshing a salary-transparency dataset across many startups. - You want a hiring signal: which teams a company is growing, tracked over weeks. - You need a sourcing feed narrowed to a job family and to remote roles only. - Any of the above on a schedule, diffing on `jobId` to spot new and closed postings. ## When not to use it - Boards on Greenhouse, Lever, Workable, Workday, Personio or Teamtailor. Those are separate Actors; this one only reads Ashby. - Applying to a job, or reading anything on the employer side of an Ashby account. Only published postings are returned. - Candidate or applicant data of any kind. None is touched. - A single lookup of a single known posting. This Actor earns its keep on lists. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/ashby-jobs-scraper').call({ boards: ['ramp', 'https://jobs.ashbyhq.com/notion'], includeCompensation: true, searchTerms: ['engineer', 'security'], remoteOnly: false, maxJobsPerBoard: 100, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` 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~ashby-jobs-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"boards":["ramp"],"includeCompensation":true,"maxJobsPerBoard":25}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/ashby-jobs-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `boards` | string[] | yes | | Ashby org slugs (`ramp`) or full board URLs. Mixed forms are fine and are normalised to slugs. Duplicates are removed before any request. | | `includeCompensation` | boolean | no | `true` | Ask for published salary bands. Off blanks `compensationMin`, `compensationMax` and `currency` for every row; it never drops a row. | | `searchTerms` | string[] | no | `[]` | Keep only postings whose **title** contains one of these terms, case-insensitive substring. Empty keeps everything. | | `remoteOnly` | boolean | no | `false` | Keep only postings the board itself flags as remote. | | `maxJobsPerBoard` | integer | no | `0` | Cap saved rows per board, applied **after** filtering. `0` means no limit. Minimum `0`. | **The order of operations is the thing to get right.** Every posting on a board arrives in one response, then `searchTerms` and `remoteOnly` are applied with AND, then `maxJobsPerBoard` truncates what survives. So `searchTerms: ["engineer"]` with `maxJobsPerBoard: 25` gives you 25 engineering roles, not 25 postings of which some happen to be engineering. `includeCompensation` is not a filter and never changes the row count; it only decides whether the three pay fields carry values. ## Output One record per posting that passed the filters. | Field | Type | Notes | |---|---|---| | `board` | string | The org slug this row came from, normalised. Join back to your input list on this. | | `jobId` | string | The posting's UUID. Stable across runs, use it as your primary key. | | `title` | string | Job title, trimmed. | | `team` | string \| null | The posting's team, falling back to its department when a board fills only one. `null` when neither is set. | | `location` | string \| null | Primary location string as published. Free text, not a normalised place. | | `employmentType` | string \| null | `FullTime`, `PartTime`, `Intern`, `Contract`, `Temporary`. | | `isRemote` | boolean | The board's own remote flag, never a keyword guess. Always a boolean, never null. | | `compensationMin` | number \| null | Bottom of the salary band. `null` when pay is not disclosed or `includeCompensation` is off. | | `compensationMax` | number \| null | Top of the salary band, same nulling rules. | | `currency` | string \| null | ISO code for the band, for example `USD`. `null` when there is no band. | | `publishedAt` | string \| null | ISO 8601 timestamp the posting went live. | | `applyUrl` | string \| null | Direct application link, falling back to the posting URL. | | `descriptionPlain` | string | Full description as plain text. The board's own plain rendering where it has one, otherwise an HTML-to-text conversion. Can be several kilobytes. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record, long strings trimmed: ```json { "board": "ramp", "jobId": "34413f8d-26bf-4bbc-8ade-eb309a0e2245", "title": "Security Engineer, Cloud", "team": "Backend", "location": "New York, NY (HQ)", "employmentType": "FullTime", "isRemote": true, "compensationMin": 211400, "compensationMax": 290600, "currency": "USD", "publishedAt": "2026-04-07T17:12:35.753+00:00", "applyUrl": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245/application", "descriptionPlain": "ABOUT RAMP\n\nRamp is building the smart infrastructure for finance teams…", "scrapedAt": "2026-08-06T12:00:00.000Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** It is where a partial run admits that it was partial. ```json { "boardsRequested": 2, "boardsFailed": 0, "failures": [], "jobsSaved": 212, "jobsWithCompensation": 168, "filters": { "searchTerms": [], "remoteOnly": false, "maxJobsPerBoard": 0, "includeCompensation": true }, "finishedAt": "2026-08-06T12:00:04.117Z" } ``` `boardsFailed` above zero means some of your list is missing from the dataset, and `failures` names each slug with its error, usually a typo in the slug. Compare `jobsWithCompensation` against `jobsSaved` before drawing any conclusion about pay: a run of 212 rows with 168 disclosing pay is a different dataset from one with 12. ## Behaviour to plan around - **Compensation is optional per posting, and often absent.** Whole boards publish no bands at all. Rows are never dropped for missing pay; the three pay fields come back `null`. Treat `null` as "not disclosed", never as zero. - **Only the salary component is reported.** A posting may also carry equity, bonus and commission components, none of which have a comparable min and max. When a posting lists both an annual and a monthly salary band, the annual one wins, so `compensationMin` and `compensationMax` are annual figures. - **`searchTerms` matches the title only.** It does not look at `descriptionPlain`, `team` or `location`. A search for `security` misses a role called "Trust Engineer" whose description is entirely about security. - **One bad board never aborts the run.** An unknown slug is recorded in `RUN_SUMMARY.failures` and the run continues. The Actor only throws when every board failed. - **Transient failures are retried** three times with linear backoff. A board that does not exist, or a response in an unexpected shape, fails immediately and is not retried. - **There is no pagination and no cursor.** A board's whole published set arrives in one response, so `maxJobsPerBoard` is a truncation of results, not a way to page through them. Running twice with `maxJobsPerBoard: 50` returns the same 50 rows. - **An empty board is a valid outcome.** A slug with no published roles returns zero rows and is not a failure. The per-board listed, matched and saved counts are in the run log, not in `RUN_SUMMARY`, so an unexpectedly empty result is best diagnosed by re-running with the filters cleared. - **`location` is free text.** "New York, NY (HQ)", "Remote - US" and "London" all come from the employer, in whatever form they typed. Do not parse it into a structured place without a normaliser of your own. - **Boards are read sequentially** with a short politeness gap between them, so wall time grows roughly linearly with the length of `boards`. ## Recipes **Build a salary-transparency dataset.** Sweep a list of boards with pay on, then keep the rows that actually disclose. ```json { "boards": ["ramp", "vanta", "notion"], "includeCompensation": true } ``` Filter the result to `compensationMin !== null` and check `RUN_SUMMARY.jobsWithCompensation` to see what fraction that was. **Track pay bands for one job family over time.** Narrow to a family, run weekly. ```json { "boards": ["ramp", "vanta"], "searchTerms": ["engineer", "engineering manager"], "includeCompensation": true } ``` Diff `compensationMin` and `compensationMax` keyed on `jobId`. A `jobId` that vanishes between runs is a closed or filled role. **Sourcing feed, remote roles only.** Two filters with AND, capped so no single large board floods the list. ```json { "boards": ["ramp", "notion", "vanta"], "remoteOnly": true, "searchTerms": ["designer", "product manager"], "maxJobsPerBoard": 50 } ``` Hand `title`, `location`, `applyUrl` and the pay fields straight to a sourcer. **Hiring-signal snapshot, no filters.** Everything published, smallest payload. ```json { "boards": ["ramp", "vanta", "notion", "linear"], "includeCompensation": false } ``` Group by `board` and `team` to see where headcount is going. Pay fields will be `null` throughout, by design. **Finding a slug.** It is the company segment of the board URL: for `https://jobs.ashbyhq.com/ramp` the slug is `ramp`. You can paste the whole URL and the Actor will extract it.