DEV Community

Zahar Baranenko
Zahar Baranenko

Posted on

How to pull every open job from Greenhouse, Lever, Ashby and SmartRecruiters with public APIs (and monitor changes)

Job postings are one of the most underrated public data sources on the internet. Recruiters use them to spot placement opportunities, B2B teams read them as buying signals (a new Head of Data means data-tooling budget), and job seekers want to apply on day one — not when a posting finally reaches the aggregators.

The usual instinct is to scrape career pages. Don't. Most tech companies host their careers page on one of a handful of Applicant Tracking Systems (ATS), and the four biggest ones — Greenhouse, Lever, Ashby and SmartRecruiters — all expose public, documented JSON APIs. No auth. No proxies. No brittle HTML selectors. The career page itself loads the same JSON you're about to fetch.

In this tutorial we'll build a single-file Python tool that:

  1. fetches every open job for a company from any of the four ATS,
  2. auto-detects which ATS a company uses,
  3. normalizes everything into one clean schema,
  4. monitors changes — run it on a schedule and get only new / removed / changed postings.

The four endpoints

ATS Endpoint
Greenhouse GET https://boards-api.greenhouse.io/v1/boards/{slug}/jobs?content=true
Lever GET https://api.lever.co/v0/postings/{slug}?mode=json
Ashby GET https://api.ashbyhq.com/posting-api/job-board/{slug}
SmartRecruiters GET https://api.smartrecruiters.com/v1/companies/{slug}/postings (paginated)

The {slug} is the company identifier you see in career-page URLs: boards.greenhouse.io/stripestripe, jobs.lever.co/spotifyspotify, jobs.ashbyhq.com/linearlinear, careers.smartrecruiters.com/VisaVisa.

Try one right now — no API key needed:

curl -s "https://api.ashbyhq.com/posting-api/job-board/linear" | head -c 400
Enter fullscreen mode Exit fullscreen mode

Step 1 — fetchers, one per ATS

Each API returns a different shape, so we normalize as we fetch. Here are all four (Python 3, only requests):

import requests

UA = {"User-Agent": "ats-jobs-tutorial/1.0"}

def get_json(url, params=None):
    r = requests.get(url, params=params, headers=UA, timeout=30)
    r.raise_for_status()
    return r.json()

def fetch_greenhouse(slug):
    d = get_json(f"https://boards-api.greenhouse.io/v1/boards/{slug}/jobs",
                 {"content": "true"})
    return [{
        "job_id": str(j["id"]),
        "title": j.get("title"),
        "location": (j.get("location") or {}).get("name"),
        "url": j.get("absolute_url"),
        "published_at": j.get("first_published"),
    } for j in d.get("jobs", [])]

def fetch_lever(slug):
    d = get_json(f"https://api.lever.co/v0/postings/{slug}", {"mode": "json"})
    return [{
        "job_id": str(j["id"]),
        "title": j.get("text"),
        "location": (j.get("categories") or {}).get("location"),
        "url": j.get("hostedUrl"),
        "published_at": j.get("createdAt"),  # milliseconds since epoch!
    } for j in d]

def fetch_ashby(slug):
    d = get_json(f"https://api.ashbyhq.com/posting-api/job-board/{slug}")
    return [{
        "job_id": str(j["id"]),
        "title": j.get("title"),
        "location": j.get("location"),
        "url": j.get("jobUrl") or j.get("applyUrl"),
        "published_at": j.get("publishedAt"),
    } for j in d.get("jobs", []) if j.get("isListed") is not False]

def fetch_smartrecruiters(slug):
    out, offset = [], 0
    while True:
        d = get_json(f"https://api.smartrecruiters.com/v1/companies/{slug}/postings",
                     {"limit": 100, "offset": offset})
        items = d.get("content", [])
        for j in items:
            loc = j.get("location") or {}
            out.append({
                "job_id": str(j["id"]),
                "title": j.get("name"),
                "location": ", ".join(filter(None, [loc.get("city"), loc.get("country")])) or None,
                "url": f"https://jobs.smartrecruiters.com/{slug}/{j['id']}",
                "published_at": j.get("releasedDate"),
            })
        offset += len(items)
        if not items or offset >= d.get("totalFound", 0):
            break
    return out

FETCHERS = {
    "greenhouse": fetch_greenhouse,
    "lever": fetch_lever,
    "ashby": fetch_ashby,
    "smartrecruiters": fetch_smartrecruiters,
}
Enter fullscreen mode Exit fullscreen mode

Three real-world quirks worth knowing (each cost me a debugging session):

  • Lever returns timestamps in milliseconds (createdAt: 1721900000000), not ISO strings. Divide by 1000 before datetime.fromtimestamp.
  • Ashby includes unlisted postings — filter out isListed: false or you'll "discover" jobs the company never published.
  • SmartRecruiters answers 200 OK with an empty list for any slug, even one that doesn't exist. An empty SmartRecruiters response is not proof the company uses SmartRecruiters.

Step 2 — auto-detect the ATS

You usually don't know (or care) which ATS a company uses. Probe all four in order and keep the first that answers with jobs:

def detect_and_fetch(slug):
    for ats, fetch in FETCHERS.items():
        try:
            jobs = fetch(slug)
        except Exception:
            continue
        # SmartRecruiters gives 200 + [] for any slug — an empty board
        # during detection means "not found", not "no openings".
        if ats == "smartrecruiters" and not jobs:
            continue
        return ats, jobs
    return None, []
Enter fullscreen mode Exit fullscreen mode
>>> detect_and_fetch("linear")
('ashby', [{'job_id': '...', 'title': 'Senior / Staff Fullstack Engineer', ...}])
Enter fullscreen mode Exit fullscreen mode

In production you'd cache the detected ATS per company so you don't re-probe on every run — four HTTP calls when one is enough.

Step 3 — monitor changes

Pulling all jobs is a one-liner now. The genuinely useful part is knowing what changed: which postings appeared today, which quietly disappeared, which got edited. That's a diff against the previous state.

Fingerprint each job, store {job_id: fingerprint} between runs, compare:

import hashlib, json, pathlib

STATE = pathlib.Path("state.json")

def fingerprint(job):
    src = "|".join(str(job.get(f, "")) for f in ("title", "location", "url"))
    return hashlib.sha1(src.encode()).hexdigest()[:16]

def diff(prev, jobs):
    cur = {j["job_id"]: j for j in jobs}
    changes = []
    for jid, job in cur.items():
        if jid not in prev:
            changes.append({"change": "new", **job})
        elif prev[jid]["fp"] != fingerprint(job):
            changes.append({"change": "changed", **job})
    for jid, snap in prev.items():
        if jid not in cur:
            changes.append({"change": "removed", "job_id": jid, "title": snap["title"]})
    return changes

def monitor(companies):
    state = json.loads(STATE.read_text()) if STATE.exists() else {}
    for slug in companies:
        ats, jobs = detect_and_fetch(slug)
        if not ats:
            print(f"{slug}: no supported board found")
            continue
        prev = state.get(slug)
        if prev is None:
            print(f"{slug} [{ats}]: baseline saved, {len(jobs)} jobs")
        else:
            for c in diff(prev, jobs):
                print(f"{slug}: {c['change'].upper()}{c.get('title')}")
        state[slug] = {j["job_id"]: {"fp": fingerprint(j), "title": j.get("title")}
                       for j in jobs}
    STATE.write_text(json.dumps(state))

monitor(["stripe", "linear", "spotify"])
Enter fullscreen mode Exit fullscreen mode

First run saves a baseline. Every later run prints only the delta:

stripe: NEW — Backend Engineer, Payments
linear: REMOVED — Account Executive, Growth
Enter fullscreen mode Exit fullscreen mode

Put it on cron (or GitHub Actions on a schedule) and pipe the output into Slack, a spreadsheet, or an n8n/Make webhook — you'll know a company is hiring the day the posting goes live.

What it takes to run this seriously

The 100-line version above works. Running it reliably for a real watchlist grows the usual operational tail: state storage that survives machines, retry/backoff when an API hiccups, distinguishing "board is gone" from "request failed" (so you don't fire 200 false REMOVED alerts), department/salary/remote fields where each ATS hides them differently, caching ATS detection, and a scheduler that doesn't silently die.

All of that is maintenance, not insight. If you'd rather not own it, I packaged this exact pipeline as an Apify Actor — ATS Jobs Scraper & Change Monitor:

  • paste slugs or career-page URLs, get the normalized dataset (department, salary where exposed, remote flags included),
  • monitor mode with hosted state and per-change pricing — watching 100 companies daily costs a few dollars a month,
  • native @apify scheduling, dataset exports (CSV/JSON), webhooks, and it's callable by AI agents via Apify MCP.

The DIY script above gets you 80% of the way for $0 — start there (full runnable version on GitHub). When babysitting it stops being fun, the Actor is the same logic with the ops solved.

Questions about a specific ATS or an edge case? Drop a comment — I've probably hit it.

Top comments (0)