Watching a company's hiring week to week: snapshot and diff Greenhouse, Lever and Ashby job boards
Disclosure: I built the Apify Actor mentioned in the "Skipping the plumbing" section. Everything before it uses only free public endpoints and needs no account.
"Spotify has 82 open jobs" is a weak signal. "Spotify opened 17 roles and closed 14 in ten days" is a much stronger one, and you can't get it from a careers page. You get it by taking snapshots and diffing them. Here's a small, dependency-light way to do that for companies on Greenhouse, Lever or Ashby.
Step 1: one snapshot function for three ATSs
All three ATSs publish public JSON job boards. For a diff you only need a stable key per posting (its public URL) and something human-readable (the title):
| ATS | Endpoint | URL field | Title field |
|---|---|---|---|
| Greenhouse | boards-api.greenhouse.io/v1/boards/{slug}/jobs | absolute_url | title |
| Lever | api.lever.co/v0/postings/{slug}?mode=json | hostedUrl | text |
| Ashby | api.ashbyhq.com/posting-api/job-board/{slug} | jobUrl | title |
The slug is the board name in the careers URL (jobs.lever.co/spotify → spotify).
"""Save today's open jobs for one company board as {job_url: title}.""" import datetime, json, pathlib, sys import requests FETCH = { "greenhouse": lambda s: [(j["absolute_url"], j["title"]) for j in requests.get(f"https://boards-api.greenhouse.io/v1/boards/{s}/jobs", timeout=30).json()["jobs"]], "lever": lambda s: [(j["hostedUrl"], j["text"]) for j in requests.get(f"https://api.lever.co/v0/postings/{s}", params={"mode": "json"}, timeout=30).json()], "ashby": lambda s: [(j["jobUrl"], j["title"]) for j in requests.get(f"https://api.ashbyhq.com/posting-api/job-board/{s}", timeout=30).json()["jobs"]], } def snapshot(ats, slug, folder="snapshots"): jobs = dict(FETCH[ats](slug)) path = pathlib.Path(folder) / f"{ats}-{slug}-{datetime.date.today()}.json" path.parent.mkdir(exist_ok=True) path.write_text(json.dumps(jobs, indent=1, ensure_ascii=False)) return path, len(jobs) if __name__ == "__main__": print(*snapshot(sys.argv[1], sys.argv[2])) Running it on 19 Oct 2026:
$ python snapshot.py lever spotify snapshots/lever-spotify-2026-10-19.json 82 $ python snapshot.py greenhouse stripe snapshots/greenhouse-stripe-2026-10-19.json 729 $ python snapshot.py ashby openai snapshots/ashby-openai-2026-10-19.json 814 Run it from cron (or GitHub Actions) once a day. Each snapshot is a small JSON file, so a year of daily snapshots per company is easy to keep in git.
Step 2: diff two snapshots
"""Compare two snapshots: which postings opened, which closed.""" import json, sys old, new = (json.load(open(p)) for p in sys.argv[1:3]) opened = sorted(set(new) - set(old), key=new.get) closed = sorted(set(old) - set(new), key=old.get) print(f"{len(old)} -> {len(new)} open jobs | +{len(opened)} opened | -{len(closed)} closed") for url in opened[:5]: print(" +", new[url]) for url in closed[:5]: print(" -", old[url]) I had a real Spotify baseline from 9 October (all 79 postings on its Lever board that day), so here is the 10-day diff:
$ python diff.py baseline-lever-spotify-2026-10-09.json snapshots/lever-spotify-2026-10-19.json 79 -> 82 open jobs | +17 opened | -14 closed + AV Engineer - Platform + Art Director - Mexico + Audiobook Licensing & Partnerships Manager - Japan + Client Partner ANZ + Client Partner, Emerging & Scaled - Android Engineer - Experience - C++ Engineer - Experience - Client Partner - Emerging & Scaled, Independent Agency (UK) - Director, Portfolio & Monetization Architecture - Growth Manager What the net number hides
The headline count moved by +3. Underneath, 31 postings changed: 17 opened and 14 closed, so roughly 40% of a 79-job board turned over in ten days. If you only track counts, you'd conclude nothing happened.
I also counted the same diff by title instead of URL. It gives 18 titles opened and 15 closed (some titles appear more than once on the board), so the churn isn't just the same job re-posted under a new URL.
A few practical notes from building this:
- Key on the URL, not the title. Companies often run several postings with the same title in different cities. Title-keyed dicts silently collapse them.
- "Closed" means "no longer on the board". That could mean filled, paused or moved. Treat it as a signal, not a fact.
- Lever dates are epoch milliseconds (
createdAt); Greenhouse and Ashby use ISO 8601. The diff approach sidesteps dates entirely, which is part of why I like it. - Be polite. These are companies' public career feeds. Once a day is plenty, and cache everything.
Skipping the plumbing (optional, paid)
If you'd rather not maintain per-ATS fetchers (or need Workable, SmartRecruiters or Recruitee too), my Apify Actor ats-jobs-by-company returns one normalized schema and can filter to recently posted jobs. It costs $0.001 per saved job and needs an Apify account (there's a free tier). pip install "apify-client>=3":
import os from apify_client import ApifyClient # pip install "apify-client>=3" client = ApifyClient(os.environ["APIFY_TOKEN"]) run = client.actor("clearpath-data/ats-jobs-by-company").call(run_input={ "companies": ["https://jobs.lever.co/spotify"], "postedSince": "7 days", "maxResults": 20, "descriptionMode": "none", }) rows = client.dataset(run.default_dataset_id).list_items().items print(len(rows), "Spotify jobs posted in the last 7 days") for r in rows[:3]: print(r["postedAt"][:10], "|", r["title"]) Output (19 Oct 2026):
6 Spotify jobs posted in the last 7 days 2026-10-15 | Audiobook Licensing & Partnerships Manager - Japan 2026-10-15 | Client Partner ANZ 2026-10-14 | Senior Machine Learning Engineer, Personalization Note the difference: postedSince tells you what's new, while the snapshot diff also tells you what disappeared. For hiring-trend work you usually want both.
All the code is in github.com/clearpath-data/ats-jobs-api.
Drafted with AI assistance, reviewed by me.