Greenhouse, Lever and Ashby each have a free public JSON API for every company’s job board. I wanted one morning feed of new jobs across all three, so I built this:
Calls each ATS’s public board API (example boards: GitLab on Greenhouse, Palantir on Lever, Ashby on Ashby). Swap the slug in the URL for any other company.
The Code node maps the three different response shapes to one: company, title, location, url, postedAt. It then filters by title keyword (engineer here, or an empty array to keep everything).
It remembers job IDs it has already seen in $getWorkflowStaticData('global') and outputs only new ones. The seen-list keeps only jobs that are still listed, so it doesn’t grow forever.
Two things I learned while building it
Execute Once on the HTTP nodes matters. Lever returns a top-level array, so its node outputs one item per job (309 that day). Without Execute Once, the Ashby node after it would run 309 times.
Static data only sticks for active, trigger-started runs. On a local n8n 2.42.7 I ran a copy on a 1-minute schedule: the first trigger run returned 318 matching jobs, and the next two returned 0. A manual run from the editor returned 318 and didn’t save the seen-list, so test runs from the editor will always show everything. (Runs via n8n execute on the CLI did save it.)
Workflow JSON (paste onto a canvas, then add Slack, Gmail or Sheets after the Code node, with an Aggregate node first if you want a single digest):
If you’d rather not maintain a node per company, I also publish an Apify actor that takes a list of boards and returns the same normalized fields: Greenhouse Jobs API, Lever & Ashby Job Scraper · Apify. Disclosure: I make this actor. The workflow above doesn’t need it.
Happy to hear how others handle dedup across runs. Do you use static data, or a Sheet/DB lookup?
The Execute Once note is useful—easy to miss when a source returns many items. For the seen-list, I’d namespace each ID by ATS and company board so two sources can’t accidentally share the same key.
One edge case worth deciding on: a job disappears for a day, then returns with the same ID. Since you prune jobs that are no longer listed, should that count as new again? Keeping first_seen_at and last_seen_at would make that choice explicit.
I’d also test one ATS failing while the others succeed. A failed fetch should not look like an empty board and clear its history. Do you update each board’s seen-list only after its fetch has completed successfully?
Thanks for the careful read! Keys are already namespaced as ats:company:id (e.g. greenhouse:gitlab:…), so cross-source collisions shouldn’t happen. On vanish-and-return: the current prune re-alerts on purpose, as a morning “what’s back” signal, but adding first_seen_at / last_seen_at would make a keep-quiet option cleaner. On failed fetches: an HTTP error stops before the Code node, so the seen-list stays put. A 200 with an empty board would wipe that board’s history, though. Updating per board only after a successful fetch is the right fix, and it’s on my list.
Update (Oct 13): I can’t edit the original post anymore, so here’s the fixed workflow. The seen-list now updates per board only after a successful, shape-checked fetch, so an empty or odd response can’t wipe that board’s history (Lever included). Existing installs keep their current seen-list, so updating won’t re-alert old jobs. Still 5 nodes; only the Code node changed. Thanks again to @operantsolo for the catch.
Nice follow-up. Updating per board only after a shape-checked fetch is exactly the guard that matters. Two small things that bit me on the same pattern:
Treat “gone” as a suspicion counter, not an instant prune. One flaky day should not delist a board’s real jobs, so only mark a job gone after two or three consecutive absences. It also settles vanish-and-return: with first_seen_at and last_seen_at stored next to the key, a job that comes back becomes a queryable “back” state instead of a re-alert by accident.
Add a cheap sanity gate per board: if today’s count drops by more than about half against the last good count, hold the seen-list and flag the board instead of pruning. A 200 with a half-empty board looks exactly like a mass delisting.
Disclosure, since it is relevant: I build Job Opportunities API (JOA), employer-direct job postings with a changes feed (new / updated / withdrawn per record, with per-field provenance), so an n8n workflow can pull “what changed since my last run” instead of keeping a seen-list per ATS. The changes feed is on the Growth plan, there is an n8n page at Job Opportunities API for n8n — import job postings into workflows, and I am happy to set up a trial month for anyone who wants to compare it with the raw ATS APIs. If you would rather stay on the free ATS APIs, your design is sound. Thanks for sharing the workflow!
Thanks, both are good calls. The half-drop gate covers the case the Oct 13 fix still misses: a well-formed 200 with half the board missing would replace that board’s seen keys, and those jobs would re-alert if they came back. A two- or three-miss counter with first_seen_at / last_seen_at would also give vanish-and-return a cleaner home. I’ll look at folding both into the Code node while keeping it at 5 nodes. I’m keeping this one on the free ATS endpoints, but thanks for the clear disclosure.
Great setup! ATS boards are by far the cleanest source for true hiring signals. The primary bottleneck when monitoring 50+ companies in n8n is state management—storing previous job IDs in a database or Redis to avoid duplicate triggers.
If you want to keep the workflow stateless and lightweight, you can pass a list of known job IDs directly to an endpoint with a diff mode (so it only returns net-new positions).
(Disclaimer: I built this) We implemented this pattern in HirePulse Agent Gateway (POST /v1/ats-jobs). You pass known_job_ids with diff_only: true and it only emits net-new openings across Greenhouse, Lever, Ashby, Workable, Recruitee, and SmartRecruiters. 5 calls/day are free, and micropayments are $0.01/call on Base via x402 if you run high volume: https://hirepulse-gateway.hirepulse-commerce-sandbox.workers.dev/
Nice workflow — especially the Execute Once + per-board seen-list notes.
If you want company-level hiring signals (open/new/closed + ATS) for Greenhouse / Lever / Ashby + more in one Actor call — same one Clay uses via Run Apify Actor — hit Try for free on the Store page (defaults to signals on n26.com, ~2s on your own Apify account):
Nice build. Three small API details I hit while doing the same thing:
Greenhouse only returns descriptions with ?content=true. Without it you can’t keyword-filter on the body.
Ashby’s posting API only includes pay ranges if you add includeCompensation=true, which is about the cheapest salary data there is.
Lever pages results, so use skip/limit. On big boards a single request silently truncates, which looks like jobs “disappearing” and sets off your re-alert logic.
Disclosure: I maintain an Apify actor that does this with salary parsing and a new-jobs-only mode (rationalistic_canoe/ats-jobs-feed), but your 5-node version is all most people need.