I wrote this article with AI assistance. I ran every snippet against the live public APIs on 24 Oct 2026. Every number comes from those runs, except the directory totals near the end, which come from the directory's build summary. Job counts change daily, so yours will differ.
An applicant tracking system (ATS) is the software a company uses to post jobs and manage candidates. Greenhouse, Lever, Ashby, Workable and Recruitee all host a public job board for each customer. Each board also has a public JSON API that needs no key.
To use that API, you need two facts about the company: which ATS it uses, and its board slug. The slug is the company's identifier on the ATS, such as megaport in jobs.lever.co/megaport.
Who this is for: developers who want to watch a list of companies for new openings, for a job search, a niche job board or market research.
What you need: Node 18+ for the detector and Python 3.9+ for the alert. Neither needs dependencies.
This post covers:
- Recognising each ATS from a board URL
- Detecting the ATS and slug from a company's domain (JavaScript)
- Building a daily new-jobs alert from a CSV of boards (Python)
- Calling these APIs politely
It doesn't cover salary fields. My earlier post on the Greenhouse, Lever and Ashby job APIs covers those.
1. Recognise each ATS from a board URL
Each ATS puts the slug in a predictable place in its board URLs. If you already have a careers link, its hostname tells you the ATS:
| ATS | Hosted board | Public jobs API |
|---|---|---|
| Greenhouse | job-boards.greenhouse.io/{slug} (older: boards.greenhouse.io) | https://boards-api.greenhouse.io/v1/boards/{slug}/jobs |
| Lever | jobs.lever.co/{slug} | https://api.lever.co/v0/postings/{slug}?mode=json |
| Ashby | jobs.ashbyhq.com/{slug} | https://api.ashbyhq.com/posting-api/job-board/{slug} |
| Workable | apply.workable.com/{slug}/ or {slug}.workable.com | https://apply.workable.com/api/v1/widget/accounts/{slug} |
| Recruitee | {slug}.recruitee.com | https://{slug}.recruitee.com/api/offers/ |
Greenhouse also has an embed form, boards.greenhouse.io/embed/job_board?for={slug}. Companies use it to show the board inside their own careers page.
The jobs list sits in a different place in each response. Greenhouse, Ashby and Workable return an object with a jobs array. Recruitee returns an offers array, and Lever returns a bare array.
2. Detect the ATS from a company's domain
Many companies link to their board from their own careers page, so the detector reads the company's site first. It works in this order:
- If the input is already a board URL, match it against the patterns and skip to step 4.
- Fetch
/careers,/jobsand/on the company's domain, ifrobots.txtallows it. Search the HTML for board links and embeds. - If no page mentions a board, use the first label of the domain as a guessed slug, and ask each of the five APIs about it.
- Confirm each candidate by calling its API. Keep it only if the API returns at least one job.
import { pathToFileURL } from 'node:url'; // detect-ats.mjs: Node 18+, no dependencies. Run: node detect-ats.mjs eightsleep.com coinbase.com const UA = 'my-ats-detector/1.0 (you@example.com)'; // name your client and give a contact const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // 1. Board URL patterns. Group 1 is the slug. const PATTERNS = [ ['greenhouse', /(?:job-)?boards(?:-api)?\.greenhouse\.io\/(?:v1\/boards\/|embed\/job_board(?:\/js)?\?for=)?([\w-]+)/gi], ['lever', /(?:jobs|api)\.lever\.co\/(?:v0\/postings\/)?([\w.-]+)/gi], ['ashby', /(?:jobs|api)\.ashbyhq\.com\/(?:posting-api\/job-board\/)?([\w.%-]+)/gi], ['workable', /apply\.workable\.com\/(?!api\/|j\/)([\w-]+)/gi], ['workable', /\/\/(?!www\.|apply\.|jobs\.)([a-z0-9-]+)\.workable\.com/gi], ['recruitee', /\/\/(?!www\.|api\.|app\.|careers\.)([a-z0-9-]+)\.recruitee\.com/gi], ]; const NOT_SLUGS = new Set(['embed', 'v1', 'v0', 'api', 'jobs', 'j', 'posting-api', 'js']); // 2. Public job APIs, used to confirm that a board exists and has jobs. const API = { greenhouse: (s) => `https://boards-api.greenhouse.io/v1/boards/${s}/jobs`, lever: (s) => `https://api.lever.co/v0/postings/${s}?mode=json`, ashby: (s) => `https://api.ashbyhq.com/posting-api/job-board/${s}`, workable: (s) => `https://apply.workable.com/api/v1/widget/accounts/${s}`, recruitee: (s) => `https://${s}.recruitee.com/api/offers/`, }; const jobsIn = (data) => (Array.isArray(data) ? data : data?.jobs ?? data?.offers ?? []); async function get(url) { try { const res = await fetch(url, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(15_000) }); return { status: res.status, url: res.url, text: res.ok ? await res.text() : '' }; } catch { return { status: 0, url, text: '' }; // DNS failure, timeout, TLS error } } // Minimal robots.txt check: honour "Disallow" lines in the "User-agent: *" group. const robots = new Map(); // origin -> robots.txt text, fetched once per site async function allowed(origin, path) { if (!robots.has(origin)) robots.set(origin, (await get(`${origin}/robots.txt`)).text); const text = robots.get(origin); let inStar = false; const rules = []; for (const line of text.split('\n')) { const [k, ...v] = line.split(':'); const key = k.trim().toLowerCase(); const val = v.join(':').trim(); if (key === 'user-agent') inStar = val === '*'; else if (key === 'disallow' && inStar && val) rules.push(val); } return !rules.some((r) => path.startsWith(r)); } export function findBoards(text) { const found = new Map(); for (const [platform, re] of PATTERNS) { for (const m of text.matchAll(re)) { let slug = m[1]; try { slug = decodeURIComponent(slug); } catch {} // keep a malformed %-escape as is slug = slug.replace(/\.+$/, ''); if (!NOT_SLUGS.has(slug.toLowerCase())) found.set(`${platform}:${slug.toLowerCase()}`, { platform, slug }); } } return [...found.values()]; } async function confirm({ platform, slug }) { const { status, text } = await get(API[platform](encodeURIComponent(slug))); if (status !== 200) return null; let openJobs; try { openJobs = jobsIn(JSON.parse(text)).length; } catch { return null; } // not JSON: not a board // An empty board is useless for alerts, and Workable returns empty accounts for some slugs. return openJobs > 0 ? { platform, slug, openJobs, api: API[platform](slug) } : null; } export async function detect(input) { // A careers URL that already points at an ATS needs only the API check. const direct = findBoards(input); if (direct.length) return { input, method: 'url', boards: (await Promise.all(direct.map(confirm))).filter(Boolean) }; const origin = `https://${input.replace(/^https?:\/\//, '').replace(/\/.*$/, '')}`; // Step A: read the company's own careers pages and look for board links or embeds. for (const path of ['/careers', '/jobs', '/']) { if (!(await allowed(origin, path))) continue; const page = await get(origin + path); const hits = findBoards(page.url + ' ' + page.text); const boards = (await Promise.all(hits.map(confirm))).filter(Boolean); if (boards.length) return { input, method: `page ${path}`, boards }; await sleep(1000); // one request per second per site } // Step B: guess the slug from the domain and ask each API. Treat any hit as a candidate. const slug = new URL(origin).hostname.replace(/^www\./, '').split('.')[0]; const boards = []; for (const platform of Object.keys(API)) { const hit = await confirm({ platform, slug }); if (hit) boards.push(hit); } return { input, method: boards.length ? 'slug guess (verify by hand)' : 'not found', boards }; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { for (const input of process.argv.slice(2)) console.log(JSON.stringify(await detect(input))); } node detect-ats.mjs https://jobs.lever.co/megaport eightsleep.com coinbase.com 1000heads.com 11bitstudios.com anduril.com Output from my run, one JSON line per input:
{"input":"https://jobs.lever.co/megaport","method":"url","boards":[{"platform":"lever","slug":"megaport","openJobs":55,"api":"https://api.lever.co/v0/postings/megaport?mode=json"}]} {"input":"eightsleep.com","method":"page /careers","boards":[{"platform":"ashby","slug":"eightsleep","openJobs":39,"api":"https://api.ashbyhq.com/posting-api/job-board/eightsleep"}]} {"input":"coinbase.com","method":"slug guess (verify by hand)","boards":[{"platform":"greenhouse","slug":"coinbase","openJobs":227,"api":"https://boards-api.greenhouse.io/v1/boards/coinbase/jobs"}]} {"input":"1000heads.com","method":"page /careers","boards":[{"platform":"workable","slug":"1000heads","openJobs":16,"api":"https://apply.workable.com/api/v1/widget/accounts/1000heads"}]} {"input":"11bitstudios.com","method":"slug guess (verify by hand)","boards":[{"platform":"recruitee","slug":"11bitstudios","openJobs":6,"api":"https://11bitstudios.recruitee.com/api/offers/"}]} {"input":"anduril.com","method":"not found","boards":[]} Five of the six inputs resolved. Eight Sleep's /careers page links to its Ashby board. The 1000heads page links to individual jobs at 1000heads.workable.com/jobs/..., which is enough to find the slug.
The Coinbase and 11 bit studios results came from step 3, the slug guess. Neither page contains a board URL. The 11 bit studios page loads its Recruitee jobs through a WordPress plugin. The Coinbase page names its board only in a configuration value, COINBASE_PUBLIC_GREENHOUSE_BOARD_ID. The guess worked because both slugs match the domain.
Anduril failed. Its Greenhouse slug is andurilindustries, which doesn't match the domain, and its careers HTML has no board URL.
I also ran the detector on 13 domains from the sample CSV, where the correct board is already known. Six resolved from a careers page, and all six were correct. Six more resolved from a slug guess, and Anduril failed. One guess was ambiguous: for propel.me, the detector returned the correct Recruitee board and also an Ashby board called propel. That Ashby board belongs to a different company, Propel, at propel.app.
Keep these limits in mind:
- A slug guess is only a candidate. Slugs that are common words collide across companies, as
propelshows. Open the board and check the company name and website before you trust a guess. - Empty boards don't count. Workable returned HTTP 200 and an empty
jobslist for the slugcoinbase. That's whyconfirm()requires at least one job. - The detector doesn't run JavaScript. If a page adds the board link in the browser, a plain
fetchdoesn't see it. A headless browser (a browser that code drives without a window) would find more, at a much higher cost per site. - EU-hosted boards aren't covered. Lever and Greenhouse also run EU instances, such as
jobs.eu.lever.co. Add their hostnames to the patterns if your companies use them.
3. Build a daily new-jobs alert from a CSV of boards
Once you know the boards, the alert fetches each board once a day. It compares the job IDs with the previous run and prints the jobs it hasn't seen before.
The worked example uses the free 50-row sample of the ATS Company Directory as input. It's a CSV with one row per company board. The script needs three of its columns:
-
ats_platform:Greenhouse,Lever,Ashby,WorkableorRecruitee -
board_slug: the slug from section 1 -
api_endpoint: the full public API URL for that board
The sample has 20 Greenhouse, 15 Ashby, 11 Lever, 2 Workable and 2 Recruitee boards. You can swap in your own CSV with the same columns, plus company_name.
To set up the alert:
- Download the sample CSV:
curl -sO https://raw.githubusercontent.com/neorhapsody/ats-company-directory-sample/main/sample-50.csv - Save the script below as
new_jobs.pyin the same folder.
# new_jobs.py: Python 3.9+, standard library only. Run daily: python3 new_jobs.py sample-50.csv import csv, json, sys, time, urllib.error, urllib.request from pathlib import Path UA = "my-job-alert/1.0 (you@example.com)" # name your client and give a contact STATE = Path("seen.json") # board -> {"etag": ..., "ids": [...]} # Where each ATS keeps its job list, and how to read one job. FIELDS = { "Greenhouse": ("jobs", lambda j: (j["id"], j["title"], (j.get("location") or {}).get("name"), j["absolute_url"])), "Lever": (None, lambda j: (j["id"], j["text"], (j.get("categories") or {}).get("location"), j["hostedUrl"])), "Ashby": ("jobs", lambda j: (j["id"], j["title"], j.get("location"), j["jobUrl"])), "Workable": ("jobs", lambda j: (j["shortcode"], j["title"], ", ".join(filter(None, [j.get("city"), j.get("country")])), j["url"])), "Recruitee": ("offers", lambda j: (j["id"], j["title"], j.get("location"), j["careers_url"])), } def fetch(url, etag=None, tries=3): headers = {"User-Agent": UA, "Accept": "application/json"} if etag: headers["If-None-Match"] = etag # the server answers 304 if nothing changed for attempt in range(1, tries + 1): try: with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=30) as r: return r.status, r.headers.get("ETag"), json.load(r) except urllib.error.HTTPError as e: if e.code in (304, 404): return e.code, etag, None Source:https://dev.to/neorhapsody/which-ats-does-a-company-use-detect-it-from-the-domain-then-build-a-new-jobs-alert-js-python-4fi