Disclosure: I make the Apify actor linked at the end, as Hapol Data. The workflow below doesn't need it.
Greenhouse, Lever and Ashby each publish a free, public JSON API for every company job board: the same data their own careers pages load. This post builds an n8n workflow that runs every morning, calls all three, puts the jobs into one shape, and returns only jobs that are new since the last run. You can add Slack, email or Google Sheets at the end.
Everything below was tested in a self-hosted n8n 2.42.7 on Oct 12, 2026; the numbers are from those runs.
1. The three endpoints
curl -s "https://boards-api.greenhouse.io/v1/boards/gitlab/jobs"
curl -s "https://api.lever.co/v0/postings/palantir?mode=json"
curl -s "https://api.ashbyhq.com/posting-api/job-board/ashby"
On Oct 12 these returned 221 (GitLab), 309 (Palantir) and 68 (Ashby) jobs. You don't need a key for any of them. To use another company, change the slug, which is the part after greenhouse.io/, jobs.lever.co/ or jobs.ashbyhq.com/ in its careers URL.
The three responses use different shapes:
| Greenhouse | Lever | Ashby | |
|---|---|---|---|
| Jobs live in |
jobs array |
top-level array |
jobs array |
| Title | title |
text |
title |
| Link | absolute_url |
hostedUrl |
jobUrl |
| Location | location.name |
categories.location |
location |
| Posted |
first_published (ISO) |
createdAt (epoch ms) |
publishedAt (ISO) |
2. The workflow
Schedule Trigger → Greenhouse jobs → Lever jobs → Ashby jobs → Code
- Each HTTP Request node is a plain GET with Execute Once turned on. That setting matters: Lever returns a top-level array, so n8n turns it into 309 items, and without Execute Once the Ashby node would run 309 times. In our runs, with Execute Once on, the Ashby node ran once and output 1 item.
- The Code node reads all three responses with
$('Node name'), normalizes them, filters by title keyword, and uses$getWorkflowStaticData('global')to remember the job IDs it has already seen.
The Code node:
// Normalize Greenhouse, Lever and Ashby responses into one job list,
// then keep only jobs not seen in a previous production run.
const KEYWORDS = ['engineer']; // empty array = keep all titles
const jobs = [];
for (const j of $('Greenhouse jobs').first().json.jobs ?? []) {
jobs.push({ key: `greenhouse:gitlab:${j.id}`, company: 'GitLab', title: j.title,
location: j.location?.name ?? '', url: j.absolute_url, postedAt: j.first_published ?? null });
}
for (const item of $('Lever jobs').all()) {
const j = item.json;
if (!j.id) continue;
jobs.push({ key: `lever:palantir:${j.id}`, company: 'Palantir', title: j.text,
location: j.categories?.location ?? '', url: j.hostedUrl,
postedAt: j.createdAt ? new Date(j.createdAt).toISOString() : null });
}
for (const j of $('Ashby jobs').first().json.jobs ?? []) {
jobs.push({ key: `ashby:ashby:${j.id}`, company: 'Ashby', title: j.title,
location: j.location ?? '', url: j.jobUrl, postedAt: j.publishedAt ?? null });
}
const matches = jobs.filter(j => !KEYWORDS.length ||
KEYWORDS.some(k => (j.title ?? '').toLowerCase().includes(k.toLowerCase())));
// Static data is only saved for production (active, trigger-started) executions.
const state = $getWorkflowStaticData('global');
const seen = new Set(state.seen ?? []);
const fresh = matches.filter(j => !seen.has(j.key));
state.seen = matches.map(j => j.key); // only remember jobs still listed
return fresh.map(j => ({ json: j }));
The seen-list keeps only jobs that are still listed, so it doesn't grow forever.
3. Import it
Copy this JSON and paste it onto an empty n8n canvas (Ctrl/Cmd+V). On self-hosted n8n you can also save it as a file and run n8n import:workflow --input=workflow.json, which is how we tested it:
{
"name": "Daily Greenhouse/Lever/Ashby job alerts",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"triggerAtHour": 8
}
]
}
},
"name": "Every day at 8:00",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
0
],
"id": "trigger"
},
{
"parameters": {
"url": "https://boards-api.greenhouse.io/v1/boards/gitlab/jobs",
"options": {}
},
"name": "Greenhouse jobs",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
220,
0
],
"executeOnce": true,
"id": "greenhouse-jobs"
},
{
"parameters": {
"url": "https://api.lever.co/v0/postings/palantir?mode=json",
"options": {}
},
"name": "Lever jobs",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
440,
0
],
"executeOnce": true,
"id": "lever-jobs"
},
{
"parameters": {
"url": "https://api.ashbyhq.com/posting-api/job-board/ashby",
"options": {}
},
"name": "Ashby jobs",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
660,
0
],
"executeOnce": true,
"id": "ashby-jobs"
},
{
"parameters": {
"jsCode": "// Normalize Greenhouse, Lever and Ashby responses into one job list,\n// then keep only jobs not seen in a previous production run.\nconst KEYWORDS = ['engineer']; // empty array = keep all titles\nconst jobs = [];\n\nfor (const j of $('Greenhouse jobs').first().json.jobs ?? []) {\n jobs.push({ key: `greenhouse:gitlab:${j.id}`, company: 'GitLab', title: j.title,\n location: j.location?.name ?? '', url: j.absolute_url, postedAt: j.first_published ?? null });\n}\nfor (const item of $('Lever jobs').all()) {\n const j = item.json;\n if (!j.id) continue;\n jobs.push({ key: `lever:palantir:${j.id}`, company: 'Palantir', title: j.text,\n location: j.categories?.location ?? '', url: j.hostedUrl,\n postedAt: j.createdAt ? new Date(j.createdAt).toISOString() : null });\n}\nfor (const j of $('Ashby jobs').first().json.jobs ?? []) {\n jobs.push({ key: `ashby:ashby:${j.id}`, company: 'Ashby', title: j.title,\n location: j.location ?? '', url: j.jobUrl, postedAt: j.publishedAt ?? null });\n}\n\nconst matches = jobs.filter(j => !KEYWORDS.length ||\n KEYWORDS.some(k => (j.title ?? '').toLowerCase().includes(k.toLowerCase())));\n\n// Static data is only saved for production (active, trigger-started) executions.\nconst state = $getWorkflowStaticData('global');\nconst seen = new Set(state.seen ?? []);\nconst fresh = matches.filter(j => !seen.has(j.key));\nstate.seen = matches.map(j => j.key); // only remember jobs still listed\n\nreturn fresh.map(j => ({ json: j }));\n"
},
"name": "Normalize and keep new",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
0
],
"id": "code"
}
],
"connections": {
"Every day at 8:00": {
"main": [
[
{
"node": "Greenhouse jobs",
"type": "main",
"index": 0
}
]
]
},
"Greenhouse jobs": {
"main": [
[
{
"node": "Lever jobs",
"type": "main",
"index": 0
}
]
]
},
"Lever jobs": {
"main": [
[
{
"node": "Ashby jobs",
"type": "main",
"index": 0
}
]
]
},
"Ashby jobs": {
"main": [
[
{
"node": "Normalize and keep new",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
Then activate (publish) the workflow. The seen-list is workflow static data, and n8n does not save it when you run the workflow manually from the editor, so test runs from the editor keep returning every match. Once the workflow is active and started by its schedule, the seen-list sticks between runs (see the test below).
4. What we actually tested
We imported the JSON above with n8n import:workflow into a local n8n 2.42.7 and ran it. For the schedule test we used a copy whose trigger fired every minute instead of daily at 8:00; nothing else changed. Output of the last node, per execution:
execution how it started "Normalize and keep new" items
1 n8n execute (CLI) 318
2 n8n execute (CLI) 0
3 manual run (editor, seen-list cleared first) 318, seen-list not saved
4 schedule trigger (active) 318
5 schedule trigger, 1 min later 0
6 schedule trigger, 2 min later 0
318 is the number of the 598 jobs with "engineer" in the title that day. So: the first production run returns everything that matches, later runs return only new jobs, and editor test runs don't touch the seen-list. One thing we didn't expect: runs started with n8n execute from the CLI do save it (execution 2 returned 0).
The first item it returned:
{"key":"greenhouse:gitlab:8843512002","company":"GitLab","title":"Backend Engineer, Create: Repository Management","location":"Remote, United Kingdom","url":"https://job-boards.greenhouse.io/gitlab/jobs/8843512002","postedAt":"2026-09-24T17:59:06-04:00"}
5. Send the alert
Connect a Slack, Gmail or Google Sheets node after the Code node. Each new job arrives as one item, with {{ $json.title }}, {{ $json.company }}, {{ $json.location }} and {{ $json.url }} available. If you want a single digest message, put an Aggregate node in front.
6. When you have many companies
Adding a company means adding a node and a few lines of mapping, and you need to know which ATS it uses. If you'd rather not maintain that, I also publish a Greenhouse, Lever & Ashby Jobs API actor on Apify that takes a list of boards and returns one normalized schema. (Disclosure: I make this actor.) The workflow above works fine without it.
Hapol Data: Web Data. Automated.
Top comments (1)
The five-node shape here is the same one that works for the hires side of the signal: schedule, a Code node with the account list, the fetch, a dedupe against the last run, and the output. The dedupe is the part most people skip, and it is the reason a daily alert stays readable after week two.
One thing I would add from running the hires version: key the "seen" store on something stable (job id, person id), not on title plus company. Titles get edited, and then everything looks new again on the next run.