Here is a compact Node.js client for the Ashby Jobs Scraper, aimed at developers who put startup job data inside a product: a niche job board, a hiring-signal feed in a sales tool, a chat bot that announces new roles. Ashby-hosted employers publish their openings at jobs.ashbyhq.com/<org>. This ES module sends a list of those org identifiers to the Actor on Apify, waits for the run, and hands your code an array of postings with team, locations, workplace type, employment type, pay band and an apply URL. Run it from the command line, or lift the call into an Express handler, a queue consumer or a serverless function.
Actor: Ashby Jobs Scraper Β· Publisher: Devil Scrapes Β· Also in Python: ashby-jobs-scraper-python
- A whole board per request: Ashby answers a single GraphQL query with every live posting an organization has, so a large board costs one round trip rather than a loop over pages.
- Readable team names: a posting only carries a team id; the Actor looks it up in the board's own team list and writes the name to
team_name, ornullwhen the role sits under no team. - Pay bands where employers disclose them:
compensation_summaryholds Ashby's summary string with its currency and any equity note; boards that keep pay private givenull. - Every office, not only the first:
location_nameis the primary site,secondary_locationslists the others, and the optionallocationFilterchecks both. - Slug or URL, your choice: send
openaiorhttps://jobs.ashbyhq.com/openai; when the host is present, the path segment after it becomes the slug. - Apply links at no extra cost:
apply_urlis assembled from the slug and the posting id, so it adds no requests on top of the board fetch. - Failures stay with one organization: rate limits and server errors are retried with backoff, and an org that still fails is counted in the status message while the rest of the batch is written.
Sync a curated list of Ashby employers into your database every few hours, keyed on posting_id. Insert the new ids, delete the ones that disappeared, and your listings stay in step with the source without a crawler of your own to maintain.
Teams selling to fast-growing startups can turn a burst of new engineering or go-to-market roles into a CRM task. A scheduled function compares counts per org_slug and team_name against the previous run and pushes only the jumps.
Keep the previous set of posting ids, diff on every run and post the newcomers to Slack, Discord or Teams. The recipe further down does exactly that with nothing but Node built-ins.
Expose an endpoint that takes a city name and forwards it as locationFilter. The match runs inside the Actor across primary and secondary locations, so your service only relays the result and pays for fewer rows.
You need an Apify account and an API token from Console β Settings β Integrations. Apify's free plan includes monthly usage credit; this Actor's event prices are listed under Pricing below.
git clone https://github.com/devilscrapes/ashby-jobs-scraper-javascript.git
cd ashby-jobs-scraper-javascript
npm install
export APIFY_TOKEN=your_token_here
node index.js # uses input.json
node index.js my-input.json --out data --max-charge 2The script starts the Ashby Jobs Scraper, waits for it to finish, prints a link to the run in Apify Console and saves every result to results.json and results.csv. It exits with code 1 and the run's status message if the run fails.
Everything else in index.js is argument parsing and file output. The part worth copying into a service is below: call() resolves once the run has finished, and listItems() returns its dataset rows.
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: 'YOUR_APIFY_TOKEN' });
const run = await client.actor('DevilScrapes/ashby-jobs-scraper').call({"orgSlugs": ["openai"], "maxResultsPerOrg": 10, "proxyConfiguration": {"useApifyProxy": true}});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items);The bundled input.json points at OpenAI's board with a ten-row cap; at today's event prices that is roughly $0.22 per test. In production you will usually build this object in code: an array of slugs from your database, a maxResultsPerOrg that suits your page size, and locationFilter when the request arrived with a city.
{
"orgSlugs": [
"openai"
],
"maxResultsPerOrg": 10,
"proxyConfiguration": {
"useApifyProxy": true
}
}| Field | Type | Required | Default | Allowed values |
|---|---|---|---|---|
orgSlugs |
array | yes | ["openai"] |
β |
maxResultsPerOrg |
integer | no | 500 |
β |
locationFilter |
string | no | β | β |
proxyConfiguration |
object | no | {"useApifyProxy": true} |
β |
Rows are flat JSON objects, so they map directly onto a table or a document store. posting_id stays the same across runs and makes a good primary key, secondary_locations is always an array that may be empty, and team_name, workplace_type, employment_type and compensation_summary can each be null.
| Field | Type | Always present |
|---|---|---|
posting_id |
string | yes |
title |
string | yes |
org_slug |
string | yes |
team_name |
string or null | no |
location_name |
string | yes |
workplace_type |
string or null | no |
employment_type |
string or null | no |
secondary_locations |
array | yes |
compensation_summary |
string or null | no |
apply_url |
string | yes |
scraped_at |
string | yes |
| Event | Price (USD) |
|---|---|
| Actor start | $0.2 |
| Result emitted | $0.0015 |
Prices as configured on 2026-08-20. The Actor page always shows the current price. Runs are billed to the Apify account that owns the token. Use --max-charge to cap what a single run can cost.
This script turns consecutive runs into an alert. It remembers which posting ids it has seen in seen-postings.json, prints the new ones, and sends them to the URL in WEBHOOK_URL when you set that variable. Global fetch ships with Node 18 and later, so there is nothing to install.
import { readFile, writeFile } from 'node:fs/promises';
const rows = JSON.parse(await readFile('results.json', 'utf8'));
let seen = new Set();
try {
seen = new Set(JSON.parse(await readFile('seen-postings.json', 'utf8')));
} catch {
// first run: every posting counts as new
}
const fresh = rows.filter((row) => !seen.has(row.posting_id));
const lines = fresh.map((row) => {
const where = [row.location_name, ...row.secondary_locations].join(' / ');
const pay = row.compensation_summary ?? 'pay not listed';
return `${row.org_slug}: ${row.title} (${row.team_name ?? 'no team'}, ${where}, ${pay}) ${row.apply_url}`;
});
console.log(lines.length ? lines.join('\n') : 'No new postings since the last run.');
if (fresh.length && process.env.WEBHOOK_URL) {
await fetch(process.env.WEBHOOK_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: lines.join('\n') }),
});
}
await writeFile('seen-postings.json', JSON.stringify(rows.map((row) => row.posting_id)));Yes. Import the client call into the handler and await it. For long slug lists, start the Actor with start() instead of call(), return right away, and let an Apify webhook tell your endpoint when the dataset is ready.
No. It reads the board Ashby serves to candidates at the moment of the run, so a role that closed yesterday is simply absent. History is something you build by storing each run's rows.
Move filtering into the input. locationFilter and maxResultsPerOrg take effect before rows are written, and charges are per written row, so a filtered run is cheaper than filtering in your own code afterwards.
Its slug starts returning no rows. Track row counts per org_slug in your service; a board that drops to zero overnight means either a hiring freeze or a move to a different applicant tracking system, and both are worth a look.
Set APIFY_TOKEN first: export the token in the same shell, or put it in a local.envfile your shell loads. Never commit it.- Run finished with
FAILEDorABORTED: open the printed Console link; the run log says why. Input validation errors name the field. - Fewer rows than expected: a
--max-chargecap also limits how many results can be charged. Raise the cap or lower the limits in your input. - Something looks wrong in the data: open an issue on the Actor page so the maintainers see it.
- Ashby Jobs Scraper on Apify Store
- Python version of this example
- All Devil Scrapes Actors
- Apify web scraping cookbook
Related Actors:
- Lever Jobs Scraper β the same per-company board export for employers that hire through Lever.
- Workday Jobs Scraper β enterprise career sites on myworkdayjobs.com, for the larger end of the market.
- Wellfound Startup Jobs Scraper β startup roles searched by keyword and location across Wellfound, with funding stage.
MIT licensed example code. The Actor itself runs on Apify and is maintained by Devil Scrapes. Not affiliated with Ashby.