AI note: this post was drafted with AI assistance. I ran every request and code sample below myself on October 31, 2026 and checked the output.
In an earlier post I went through the public JSON endpoints behind Greenhouse, Lever and Ashby career pages. Reading a board once is easy. The useful version is a feed: check the same boards every morning and see only the postings that appeared since yesterday.
That turns out to be mostly a state problem, not a scraping problem. This post builds the feed in about 80 lines of standard-library Python, runs it on a GitHub Actions schedule, and covers the details that make a daily diff go wrong.
The plan
- A text file lists the boards to watch, one
ats:boardper line. - Each run reads every board in one request.
- Every job gets a key that will not change:
ats:board:id. -
data/jobs.csvholds every key ever seen. A job is new when its key is not in that file. - New jobs go to
data/new_jobs.csvand are appended todata/jobs.csv. - GitHub Actions runs it daily and commits the two CSVs.
The repo keeps its own history, so there is no database and nothing to host.
Pick a key that survives
The tempting key is the job URL. Do not use it for Greenhouse. On the day I checked, all 202 jobs on Airbnb's Greenhouse board had an absolute_url on careers.airbnb.com, not on Greenhouse:
https://careers.airbnb.com/positions/7532824?gh_jid=7532824
A company that moves its careers site, or changes how it links to jobs, changes every URL at once, and your feed reports the whole board as new. The numeric id stays the same.
Titles are not keys either. On the same day, 39 of Airbnb's 202 jobs shared a title with another Airbnb job, and Spotify's Lever board had 89 postings but only 80 distinct titles ("Account Executive - Commercial, Spotify Technology" appeared three times, once per location). Each of those is a separate posting with its own ID.
IDs are only unique within one ATS and one board, so the key carries both: greenhouse:airbnb:7532824, lever:spotify:<uuid>, ashby:ramp:<uuid>.
The script
boards.txt:
greenhouse:airbnb
lever:spotify
ashby:ramp
jobs_feed.py:
import csv, json, sys, time, urllib.error, urllib.request
from datetime import date
from pathlib import Path
UA = "jobs-feed/1.0 (+https://github.com/fecklesspleb/ats-new-jobs-feed)"
SEEN = Path("data/jobs.csv")
NEW = Path("data/new_jobs.csv")
COLS = ["uid", "company", "title", "location", "url", "first_seen"]
def get(url):
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def greenhouse(board):
for j in get(f"https://boards-api.greenhouse.io/v1/boards/{board}/jobs")["jobs"]:
yield f"greenhouse:{board}:{j['id']}", j["title"], j["location"]["name"], j["absolute_url"]
def lever(site):
data = get(f"https://api.lever.co/v0/postings/{site}?mode=json")
if not isinstance(data, list): # unknown sites answer {"ok": false, ...}
raise ValueError(f"lever:{site}: {data}")
for j in data:
yield f"lever:{site}:{j['id']}", j["text"], j["categories"].get("location", ""), j["hostedUrl"]
def ashby(board):
for j in get(f"https://api.ashbyhq.com/posting-api/job-board/{board}")["jobs"]:
if j.get("isListed", True):
yield f"ashby:{board}:{j['id']}", j["title"].strip(), j["location"], j["jobUrl"]
READERS = {"greenhouse": greenhouse, "lever": lever, "ashby": ashby}
def main(boards, keywords):
seen = set()
if SEEN.exists():
with SEEN.open(newline="", encoding="utf-8") as f:
seen = {row["uid"] for row in csv.DictReader(f)}
new, failed = [], []
for entry in boards:
ats, board = entry.split(":", 1)
try:
for uid, title, location, url in READERS[ats](board):
if uid in seen or (keywords and not any(k in title.lower() for k in keywords)):
continue
seen.add(uid)
new.append({"uid": uid, "company": board, "title": title, "location": location,
"url": url, "first_seen": date.today().isoformat()})
except (urllib.error.URLError, ValueError, KeyError) as e:
failed.append(entry)
print(f"skipped {entry}: {e}", file=sys.stderr)
time.sleep(1)
SEEN.parent.mkdir(exist_ok=True)
first = not SEEN.exists()
with SEEN.open("a", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, COLS)
if first:
w.writeheader()
w.writerows(new)
with NEW.open("w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, COLS)
w.writeheader()
w.writerows(new)
print(f"{len(new)} new jobs, {len(failed)} boards failed")
return 1 if failed and len(failed) == len(boards) else 0
if __name__ == "__main__":
boards = [b.strip() for b in open("boards.txt") if b.strip() and not b.startswith("#")]
keywords = [k.lower() for k in sys.argv[1:]]
sys.exit(main(boards, keywords))
Run it with optional title keywords (matched case-insensitively against the title):
python3 jobs_feed.py engineer
python3 jobs_feed.py engineer
Output on October 31, 2026:
128 new jobs, 0 boards failed
0 new jobs, 0 boards failed
The first run found 128 jobs with "engineer" in the title across the three boards (out of 462 listed in total). The second run, a few seconds later, found nothing new, which is the point. data/new_jobs.csv starts like this:
uid,company,title,location,url,first_seen
greenhouse:airbnb:7532824,airbnb,"Engineering Manager, Guest & Host","London, United Kingdom",https://careers.airbnb.com/positions/7532824?gh_jid=7532824,2026-10-31
Substring matching is crude on purpose: "engineer" also matches "Engineering Manager". Use more specific phrases ("data engineer", "backend") if that is too broad.
Details that break a daily diff
A failed board must not look like an empty board. If you ever extend this to report closed jobs ("in yesterday's file, not in today's"), only compare boards that were read successfully. A timeout on one board would otherwise mark every job on it as closed, and the next good run would report them all as new again. The script above tracks failed boards separately for that reason.
404 and an empty list mean different things. An unknown Lever site returns HTTP 404 with {"ok": false, "error": "Document not found"}. A real Lever site with no openings returns 200 and []. Greenhouse and Ashby return 404 for unknown boards too. Companies change ATS more often than you would think: one of the Lever examples I planned to use for this post had moved off Lever and now returns 404. Log 404s loudly, because they mean the board name in your list is stale, not that the company stopped hiring.
skipped lever:plaid: HTTP Error 404: Not Found
skipped ashby:not-a-board-xyz: HTTP Error 404: Not Found
0 new jobs, 2 boards failed
Each board is one request, and some are large. Greenhouse and Ashby return the whole board in one response with no paging. Without descriptions, Airbnb's Greenhouse board was about 155 KB; with ?content=true it was about 2.9 MB. Ramp's Ashby board (171 jobs) was about 3.1 MB because Ashby includes descriptions by default. For a once-a-day diff you only need titles and IDs, so leave content=true off Greenhouse.
Clean the fields you compare. 14 of Ramp's 171 Ashby titles had leading or trailing spaces. That does not affect the key, but it does affect keyword matching and spreadsheet sorting, hence the .strip(). Ashby also has an isListed flag; the script skips jobs where it is false.
Be a polite client. Lever's robots.txt asks for Crawl-delay: 1, so the loop sleeps one second between boards. Send a User-Agent that says what the client is and where to find it. Postings change over hours and days, not minutes, so once or twice a day is plenty for a job feed.
Run it every day on GitHub Actions
.github/workflows/jobs.yml:
name: New jobs
on:
schedule:
- cron: "17 11 * * *"
workflow_dispatch:
permissions:
contents: write
jobs:
feed:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python jobs_feed.py engineer "data scientist"
- run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add data/
git diff --cached --quiet || { git commit -m "New jobs $(date -u +%F)" && git push; }
permissions: contents: write lets the job push the updated CSVs. The git diff --cached --quiet || guard skips the commit on days with nothing new. I linted this file with actionlint and ran the commit step in a local repo: the first pass committed, the second was a no-op.
Two things about scheduled workflows worth knowing before you rely on one:
- Schedules run on the default branch and can start late when GitHub is busy; the docs suggest avoiding the top of the hour, hence minute 17.
- In a public repository, GitHub disables scheduled workflows after 60 days with no repository activity. This workflow only commits on days with new jobs, so a quiet watch list can switch itself off. Check the Actions tab now and then.
Results land in the repo, so data/new_jobs.csv is a one-click view of today's postings, and the commit history doubles as a log of when each job first appeared.
Where this stops working
This covers boards on Greenhouse, Lever and Ashby whose names you already know. It does not cover Workday, which needs a paged POST per tenant (I wrote that up separately), or ATSs whose terms do not allow automated reading. It also only reads what companies publish on their public boards, which is what those endpoints are for.
A tested version of the script, with offline tests against saved responses and an n8n workflow, is available under the MIT license. Its README lists more options.
Code is in my repo: https://github.com/fecklesspleb/ats-new-jobs-feed
This article was created with the help of AI. The endpoints, numbers and code were tested by me before publishing.
Top comments (0)