DEV Community

Datawright Labs
Datawright Labs

Posted on

Greenhouse, Lever, Ashby, Workable & SmartRecruiters job APIs: endpoints, fields and one normalized feed (2026)

If you're building a niche job board, a hiring-signal alert, or just tracking a list of target companies, you don't need to scrape HTML. Most tech companies post jobs through an applicant tracking system (ATS), and the big five all expose the same public JSON feed that powers the company's own careers page. No login, no API key, no headless browser.

I checked all five this morning (October 2026). Here's what each one returns, the gotchas, and a small Python script that merges them into one schema.

The five endpoints

ATS Careers page looks like JSON endpoint
Greenhouse boards.greenhouse.io/figma https://boards-api.greenhouse.io/v1/boards/{slug}/jobs
Lever jobs.lever.co/spotify https://api.lever.co/v0/postings/{slug}?mode=json
Ashby jobs.ashbyhq.com/ramp https://api.ashbyhq.com/posting-api/job-board/{slug}?includeCompensation=true
Workable apply.workable.com/huggingface https://apply.workable.com/api/v1/widget/accounts/{slug}
SmartRecruiters careers.smartrecruiters.com/Ubisoft2 https://api.smartrecruiters.com/v1/companies/{slug}/postings?limit=100&offset=0

The slug is just the last part of the careers-page URL.

Test run just now: Figma 141 jobs (0.26 s), Spotify 81 (1.1 s), Ramp 167 (0.16 s), Hugging Face 6 (0.17 s), Ubisoft 329 across 4 pages. That's 724 jobs from five companies in about 3.5 seconds.

Quirks worth knowing

  • Greenhouse: the list endpoint has title, location.name, absolute_url, updated_at and first_published. Add ?content=true if you need the HTML description and departments in the same call (much bigger response).
  • Lever: returns a bare JSON array. createdAt is epoch milliseconds, not ISO. EU-hosted companies live on api.eu.lever.co; the US host answers 404 for them, so try both. Plain-text descriptions come for free (descriptionPlain).
  • Ashby: includeCompensation=true adds structured salary ranges when the company publishes them. You also get isRemote and workplaceType directly, which is the cleanest remote flag of the five.
  • Workable: the widget endpoint is light; pass ?details=true to get descriptions. Remote is a boolean called telecommuting.
  • SmartRecruiters: paginated with limit (max 100) and offset; stop when offset >= totalFound. The list has no description; fetch /postings/{id} per job if you need it, and be polite about rate.

One normalized feed in Python

import requests

UA = {"User-Agent": "job-feed-tutorial/1.0"}

def greenhouse(slug):
    r = requests.get(f"https://boards-api.greenhouse.io/v1/boards/{slug}/jobs", headers=UA, timeout=30)
    for j in r.json()["jobs"]:
        yield {"title": j["title"], "location": j["location"]["name"],
               "url": j["absolute_url"], "posted": j.get("first_published") or j["updated_at"]}

def lever(slug):
    r = requests.get(f"https://api.lever.co/v0/postings/{slug}?mode=json", headers=UA, timeout=30)
    if r.status_code == 404:  # EU-hosted boards live on a different host
        r = requests.get(f"https://api.eu.lever.co/v0/postings/{slug}?mode=json", headers=UA, timeout=30)
    for j in r.json():
        yield {"title": j["text"], "location": j["categories"].get("location"),
               "url": j["hostedUrl"], "posted": j["createdAt"]}  # createdAt is epoch ms

def ashby(slug):
    r = requests.get(f"https://api.ashbyhq.com/posting-api/job-board/{slug}?includeCompensation=true", headers=UA, timeout=30)
    for j in r.json()["jobs"]:
        yield {"title": j["title"], "location": j["location"],
               "url": j["jobUrl"], "posted": j["publishedAt"]}

def workable(slug):
    r = requests.get(f"https://apply.workable.com/api/v1/widget/accounts/{slug}", headers=UA, timeout=30)
    for j in r.json()["jobs"]:
        loc = ", ".join(x for x in (j.get("city"), j.get("country")) if x)
        yield {"title": j["title"], "location": loc,
               "url": j["url"], "posted": j.get("published_on")}

def smartrecruiters(slug):
    offset = 0
    while True:
        r = requests.get(f"https://api.smartrecruiters.com/v1/companies/{slug}/postings",
                         params={"limit": 100, "offset": offset}, headers=UA, timeout=30).json()
        for j in r["content"]:
            yield {"title": j["name"], "location": j["location"].get("fullLocation") or j["location"].get("city"),
                   "url": f"https://jobs.smartrecruiters.com/{slug}/{j['id']}", "posted": j["releasedDate"]}
        offset += 100
        if offset >= r["totalFound"]:
            break

PLATFORMS = {"greenhouse": greenhouse, "lever": lever, "ashby": ashby,
             "workable": workable, "smartrecruiters": smartrecruiters}
Enter fullscreen mode Exit fullscreen mode

Use it like this:

companies = ["greenhouse:figma", "lever:spotify", "ashby:ramp",
             "workable:huggingface", "smartrecruiters:Ubisoft2"]
feed = []
for c in companies:
    platform, slug = c.split(":")
    feed += [dict(j, company=slug, platform=platform) for j in PLATFORMS[platform](slug)]
print(len(feed))  # 724 this morning
Enter fullscreen mode Exit fullscreen mode

Detecting new and closed jobs

The interesting signal is usually the change: a company that opens ten data-engineering roles in a week is hiring a team. Keep the last run's job URLs and diff:

import json, pathlib

state = pathlib.Path("seen.json")
before = set(json.loads(state.read_text())) if state.exists() else set()
now = {j["url"] for j in feed}

new_jobs = [j for j in feed if j["url"] not in before]
closed = before - now
state.write_text(json.dumps(sorted(now)))
print(f"{len(new_jobs)} new, {len(closed)} closed")
Enter fullscreen mode Exit fullscreen mode

Run it on a cron and push new_jobs to Slack, a sheet, or your board.

Pitfalls

  • Finding the slug: companies with a custom careers domain often still embed boards.greenhouse.io/embed/job_board?for=<slug> or a jobs.lever.co/<slug> link. View source and search for the ATS name.
  • Bad slugs fail differently: Greenhouse, Lever, Ashby and Workable return 404, but SmartRecruiters returns 200 with an empty list (totalFound: 0), so check the count too. Log per company instead of crashing the whole run.
  • Be a good citizen: these feeds are public, but one request per company per few hours is plenty. Set a real User-Agent.
  • Postings only: none of these endpoints expose recruiter personal data, and you don't need it.

If you'd rather not maintain it

I packaged the same approach (all five platforms, one schema, title/location/remote/date filters, salary where published, and new/closed detection across scheduled runs) as a hosted tool: ATS Jobs Scraper on Apify. It's pay-per-result at $1 per 1,000 jobs, and Apify's free monthly credit covers small watch-lists. You can also call it as an MCP tool from an AI agent.

But honestly, for a handful of companies the script above is all you need. Happy building!

Top comments (0)