Many startups and tech companies host their careers pages on Greenhouse, Lever or Ashby. All three publish a free, public job-board API: the JSON their own careers pages load. So you don't need to scrape HTML to watch a company's openings. You need to:
- find each company's board,
- call the right endpoint,
- remember what you've seen, so you're alerted only to new jobs.
We'll do it by hand first, then with Hapol Data's Greenhouse, Lever & Ashby Jobs API actor on Apify.
Disclosure: I build these actors as Hapol Data.
Who this is for: job seekers ("new roles at my favourite companies, every morning"), recruiters and sourcers, job boards and newsletters, and sales teams that treat new openings as a buying signal.
Step 1: Find the company's board (the "slug")
Open the company's careers page and click a job. The URL tells you the ATS and the board name:
| URL looks like… | ATS | Slug |
|---|---|---|
boards.greenhouse.io/gitlab or job-boards.greenhouse.io/gitlab
|
Greenhouse | gitlab |
jobs.lever.co/palantir |
Lever | palantir |
jobs.ashbyhq.com/ashby |
Ashby | ashby |
If the board is embedded on the company's own domain, look for greenhouse, lever or ashbyhq in the page source or the "Apply" link.
Step 2: Call the public APIs yourself
curl -s "https://boards-api.greenhouse.io/v1/boards/gitlab/jobs"
curl -s "https://api.lever.co/v0/postings/palantir?mode=json" # EU boards: api.eu.lever.co
curl -s "https://api.ashbyhq.com/posting-api/job-board/ashby?includeCompensation=true"
When we ran these on Oct 9, 2026, they returned 221, 309 and 68 jobs. Each call returns the whole board. But the three responses look nothing alike. These are the top-level fields of one job from each:
Greenhouse: absolute_url, company_name, first_published, id, location, title, updated_at, ...
Lever: applyUrl, categories, country, createdAt, hostedUrl, id, text, workplaceType, ...
Ashby: applyUrl, compensation, department, isRemote, jobUrl, location, publishedAt, team, title, ...
The title is title in two APIs and text in Lever. Dates are ISO strings in Greenhouse and Ashby, and epoch milliseconds in Lever. Salary exists only in Ashby (and in some Lever boards). To get new jobs, you also have to store every ID and diff on each run. That's easy for one company and a chore for fifty.
Step 3: One schema across all three, without code
The Jobs API actor calls the same official endpoints and normalizes the results. Here's the input we used. Note the deliberately fake board at the end:
{
"boards": ["greenhouse:gitlab", "https://jobs.lever.co/palantir", "ashby:ashby", "notarealboard-xyz123"],
"onlyNew": true,
"stateKey": "article-demo",
"maxJobs": 2000
}
The run log:
Board greenhouse:gitlab: 221 jobs
Board lever:palantir: 309 jobs
Board ashby:ashby: 68 jobs
WARN Board notarealboard-xyz123: no Greenhouse, Lever or Ashby board named "notarealboard-xyz123"
Done: 598 jobs saved from 3/4 boards (598 matched filters, 598 new since last run).
The missing board appears in the Board status output (BOARDS) with every ATS it tried, and it costs nothing:
{ "input": "notarealboard-xyz123", "exists": false, "triedAts": ["greenhouse", "lever", "ashby"] }
Here's the same job shape from each ATS. These are real rows, trimmed only where marked:
{
"jobId": "greenhouse:gitlab:8860302002",
"ats": "greenhouse", "board": "gitlab", "company": "GitLab",
"title": "Account Executive - France",
"location": "Remote, France", "workplaceType": "remote", "isRemote": true,
"postedAt": "2026-10-02T15:31:50.000Z",
"jobUrl": "https://job-boards.greenhouse.io/gitlab/jobs/8860302002",
"isNew": true
}
{
"jobId": "lever:palantir:6ed76ce8-4156-4b60-b120-403538bd66cd",
"ats": "lever", "board": "palantir",
"title": "Administrative Business Partner",
"team": "Administrative",
"location": "Singapore, Singapore", "country": "SG",
"workplaceType": "hybrid", "isRemote": false, "employmentType": "Full-time",
"postedAt": "2026-08-11T17:38:11.368Z",
"jobUrl": "https://jobs.lever.co/palantir/6ed76ce8-4156-4b60-b120-403538bd66cd",
"applyUrl": "https://jobs.lever.co/palantir/6ed76ce8-4156-4b60-b120-403538bd66cd/apply",
"isNew": true
}
{
"jobId": "ashby:ashby:7458d4e9-da2e-47bd-98cb-adfda43d42b2",
"ats": "ashby", "board": "ashby",
"title": "Engineering Manager - EU",
"department": "Engineering", "team": "EMEA Engineering",
"location": "Remote - European Union",
"locations": ["Remote - European Union", "Spain", "Italy", "Germany", "… 16 more"],
"workplaceType": "remote", "isRemote": true, "employmentType": "FullTime",
"postedAt": "2024-03-04T14:29:08.532Z",
"compensationSummary": "€110K – €185K • Offers Equity • Offers Bonus",
"salaryMin": 110000, "salaryMax": 185000, "salaryCurrency": "EUR", "salaryInterval": "1 YEAR",
"jobUrl": "https://jobs.ashbyhq.com/ashby/7458d4e9-da2e-47bd-98cb-adfda43d42b2",
"isNew": true
}
Things to notice:
-
companyis filled only for Greenhouse, because Lever's and Ashby's public APIs don't return a company name. Useboardas the identifier. - Salary showed up only on the Ashby rows (all 68 in this run). Greenhouse's public jobs list has no pay, and Lever has it only when a company publishes a range. Palantir's board had none.
-
postedAtis each ATS's own date. Some postings stay open a long time; the Ashby example above was first published in March 2024.
Step 4: New jobs only (the alert part)
We ran the exact same input again a few seconds later:
New-since-last-run: 598 jobs seen before (state "seen-article-demo", ...).
Done: 0 jobs saved from 3/4 boards (598 matched filters, 0 new since last run).
That's the behavior you want from a daily alert. The first run records everything; later runs output (and bill) only jobs that weren't there before. Each job carries isNew. If a run stops early (max jobs or your spending cap), the saved state isn't advanced, so nothing gets skipped. Use a different stateKey for each separate watchlist. The state lives in a key-value store named job-boards-state in your own Apify account.
Step 5: Narrow it down with filters
A second real run, with bare company names (the actor tries Greenhouse, then Lever, then Ashby) and filters:
{
"boards": ["gitlab", "palantir", "ashby"],
"titleKeywords": ["engineer"],
"remoteOnly": true,
"postedWithinDays": 30,
"includeDescription": true
}
Result: 30 jobs out of 598 fetched. 28 came from GitLab and 2 from Ashby. Palantir contributed none, because all 309 of its Lever postings were marked isRemote: false. A few of the matches:
GitLab | Backend Engineer, Create: Repository Management | Remote, United Kingdom | 2026-09-24
GitLab | Engineering Manager, Dedicated Infrastructure | Remote, Canada; Remote, United States | 2026-10-05
GitLab | Manager, Support Engineering (AMER) | Remote, Canada; Remote, United States | 2026-10-08
Ashby | Product Support Engineer - NAMER ($75K – $140K) | Remote - US | 2026-10-06
With includeDescription, each row also gets descriptionHtml and a clean descriptionText.
Step 6: Schedule it and get notified
In Apify Console, create a Schedule (for example, daily at 07:00) that runs the actor with your saved input and onlyNew: true. To be notified, add an integration on the actor (Console → the actor → Integrations; options include Slack, email, Zapier, Make, n8n and webhooks; check the list in your Console) so each finished run passes on its new rows.
Or start a run and read the results with the Python API client (pip install apify-client). Note that this starts a billed run on your account:
from apify_client import ApifyClient
client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("hapol/job-boards").call(run_input={
"boards": ["greenhouse:gitlab", "lever:palantir", "ashby:ashby"],
"onlyNew": True,
})
for job in client.dataset(run["defaultDatasetId"]).iterate_items():
# Lever/Ashby rows have no "company" field; fall back to the board slug
print(job.get("company", job["board"]), "|", job["title"], "|", job["jobUrl"])
What it costs
The actor charges per job saved, with an optional add-on charge for full descriptions. Missing boards, filtered-out jobs and already-seen jobs are free, so a repeat run with onlyNew that finds nothing new adds no job charges. Check current pricing on the actor page.
Limits and good manners
- It supports only Greenhouse, Lever and Ashby boards. Workday, iCIMS and SmartRecruiters aren't supported.
- Job descriptions can contain people's names (one Ashby posting in our run opens with the hiring manager introducing herself). If you store descriptions, handle them under the privacy laws that apply to you.
- If you republish postings, link back to the original
jobUrl/applyUrl, don't present them as your own, and remove them once they close. Check each ATS's terms for your use case.
Summary
Greenhouse, Lever and Ashby already give the data away. The work is in finding the boards, reconciling three formats and remembering what you've seen. Do it with three curl calls and a small database, or schedule Hapol Data's Jobs API actor with onlyNew and get a clean daily list of new roles.
Hapol Data: Web Data. Automated.
Top comments (3)
Good call leading with the job-board JSON instead of HTML. It is the step people skip, and then the scraper breaks every month. For sales teams there is a natural second half to this feed: the opening says a function is being staffed, and the hire that lands a few weeks later says who got the seat and where they came from, which is the part that decides whether the account is warm.
I wrote that half up here, including why the most recent one or two months are thin in profile data and how to size the window around it: dev.to/kevin_hall/who-joined-your-...
Hope it is useful as a companion to the openings feed.
Thanks for reading, Kevin, and for the thoughtful point about pairing job openings with later hire signals for sales teams. Glad the job-board JSON approach resonated with you.
Thanks. The n8n version you posted yesterday makes the pair complete: openings every morning from one account list, hires a few weeks later from the same list. Readers who want both now have both.