DEV Community

Fikri Marhan
Fikri Marhan

Posted on

Which ATS does a company use? Detect it from the domain, then build a new-jobs alert (JS + Python)

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:

  1. Recognising each ATS from a board URL
  2. Detecting the ATS and slug from a company's domain (JavaScript)
  3. Building a daily new-jobs alert from a CSV of boards (Python)
  4. 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:

  1. If the input is already a board URL, match it against the patterns and skip to step 4.
  2. Fetch /careers, /jobs and / on the company's domain, if robots.txt allows it. Search the HTML for board links and embeds.
  3. 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.
  4. 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)));
}
Enter fullscreen mode Exit fullscreen mode
node detect-ats.mjs https://jobs.lever.co/megaport eightsleep.com coinbase.com 1000heads.com 11bitstudios.com anduril.com
Enter fullscreen mode Exit fullscreen mode

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":[]}
Enter fullscreen mode Exit fullscreen mode

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 propel shows. 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 jobs list for the slug coinbase. That's why confirm() requires at least one job.
  • The detector doesn't run JavaScript. If a page adds the board link in the browser, a plain fetch doesn'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, Workable or Recruitee
  • 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:

  1. Download the sample CSV:
curl -sO https://raw.githubusercontent.com/neorhapsody/ats-company-directory-sample/main/sample-50.csv
Enter fullscreen mode Exit fullscreen mode
  1. Save the script below as new_jobs.py in 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
            if (e.code == 429 or e.code >= 500) and attempt < tries:
                retry_after = e.headers.get("Retry-After") or ""
                time.sleep(int(retry_after) if retry_after.isdigit() else 2 ** attempt)
                continue
            raise

def main(csv_path):
    state = json.loads(STATE.read_text()) if STATE.exists() else {}
    new_jobs, unchanged = [], 0
    with open(csv_path, newline="", encoding="utf-8") as f:
        rows = list(csv.DictReader(f))
    for row in rows:
        key = f'{row["ats_platform"]}:{row["board_slug"]}'
        list_key, read = FIELDS[row["ats_platform"]]
        old = state.get(key)
        try:
            status, etag, data = fetch(row["api_endpoint"], old and old.get("etag"))
            if status == 200:
                raw = data if list_key is None else data[list_key]
                # Ashby can return unlisted jobs; skip them like its own board does.
                jobs = [read(j) for j in raw if j.get("isListed", True)]
        except Exception as e:  # one broken board or odd response must not stop the run
            print(f"skip {key}: {e}", file=sys.stderr)
            continue
        finally:
            time.sleep(1)  # be polite: about one request per second
        if status == 304:
            unchanged += 1  # nothing changed since the last run, and nothing was downloaded
            continue
        if status == 404:
            print(f"gone {key}: the board no longer exists", file=sys.stderr)
            continue
        ids = {str(job_id) for job_id, *_ in jobs}
        if old is not None:  # the first run for each board only records a baseline
            seen = set(old["ids"])
            for job_id, title, location, url in jobs:
                if str(job_id) not in seen:
                    new_jobs.append({"company": row["company_name"], "ats": row["ats_platform"],
                                     "title": title.strip(), "location": location, "url": url})
        state[key] = {"etag": etag, "ids": sorted(ids)}
    STATE.write_text(json.dumps(state))
    for job in new_jobs:
        print(json.dumps(job))
    print(f"{len(rows)} boards checked, {unchanged} unchanged (304), {len(new_jobs)} new jobs", file=sys.stderr)

if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "sample-50.csv")
Enter fullscreen mode Exit fullscreen mode
  1. Run python3 new_jobs.py sample-50.csv once to record a baseline.
  2. Schedule the script to run daily, as shown at the end of this section.

The script keeps its memory in seen.json. For each board, the file stores the job IDs from the last successful fetch and the response's ETag header. An ETag is a version tag that the server attaches to a response.

On the next run, the script sends that tag back in an If-None-Match header. A request with this header is called a conditional request. If the board hasn't changed, the server replies 304 Not Modified with no body. A 304 saves both your script and the API from transferring the whole board again.

The first time the script sees a board, it records that board's jobs without reporting them. This also applies to boards you add to the CSV later.

What the runs looked like

First run. The script recorded a baseline of 5,677 open jobs across the 50 boards and reported nothing, by design:

50 boards checked, 0 unchanged (304), 0 new jobs
Enter fullscreen mode Exit fullscreen mode

Second run, right after the first. 45 boards answered 304. Greenhouse, Lever and Ashby sent an ETag for all 46 of their boards. Workable and Recruitee sent none, so the script downloaded their four boards in full again. Rocket Lab's Greenhouse board returned a new ETag but the same 562 job IDs, so the script reported nothing for it. A changed ETag doesn't always mean a new job.

50 boards checked, 45 unchanged (304), 0 new jobs
Enter fullscreen mode Exit fullscreen mode

Simulated new jobs. No real job appeared between the runs, so I removed two job IDs and the ETag from the Coinbase and Megaport entries in seen.json. The third run reported those four jobs as new, one JSON object per line on standard output:

{"company": "Coinbase", "ats": "Greenhouse", "title": "Complaints Analyst III", "location": "Hybrid - Luxembourg", "url": "https://www.coinbase.com/careers/positions/7182060?gh_jid=7182060"}
{"company": "Coinbase", "ats": "Greenhouse", "title": "Staff Software Engineer, EAA CX", "location": "Remote - USA", "url": "https://www.coinbase.com/careers/positions/6784618?gh_jid=6784618"}
{"company": "Megaport", "ats": "Lever", "title": "Security Operations Analyst", "location": "Sofia", "url": "https://jobs.lever.co/megaport/0677e2df-915e-43f7-9295-02a98641f779"}
{"company": "Megaport", "ats": "Lever", "title": "Support Engineer (L1)", "location": "Gurugram", "url": "https://jobs.lever.co/megaport/0361293f-7fca-4d30-a819-8f0e8c654f04"}
Enter fullscreen mode Exit fullscreen mode

The summary line goes to standard error, so you can redirect the jobs to a file and still see the summary:

50 boards checked, 43 unchanged (304), 4 new jobs
Enter fullscreen mode Exit fullscreen mode

To run the script every morning at 08:00, add a cron job. This example assumes the script and CSV are in ~/job-alert. It appends new jobs to new-jobs.jsonl and the summary lines to alert.log:

0 8 * * * cd ~/job-alert && python3 new_jobs.py sample-50.csv >> new-jobs.jsonl 2>> alert.log
Enter fullscreen mode Exit fullscreen mode

From there, you can filter new-jobs.jsonl by title or location, or send each line to a chat webhook. With 50 boards and a one-second pause after each request, each of my runs took under two minutes.

4. Call these APIs politely

These APIs are free and unauthenticated. Their owners can rate-limit or block clients that send too many requests. These practices lower that risk:

  • Send a descriptive User-Agent with a way to reach you. Both scripts set one. Ashby returns 403 to Python's default Python-urllib User-Agent, as I found when writing the earlier post.
  • Go slowly. The alert pauses one second after each board. The detector pauses one second between pages on the same site. One request per board per day is enough for an alert.
  • Use conditional requests. Send If-None-Match with the stored ETag, and skip the board on a 304.
  • Honour Retry-After. The alert retries HTTP 429 and 5xx responses after the delay the server asks for, or backs off when there is none.
  • Expect stricter limits from cloud IPs. When I called Workable from Cloudflare Workers, it answered with HTTP 429 even at low volume. The same requests from an ordinary machine worked. If you run the alert as a serverless function, test Workable boards first.
  • Respect robots.txt on company sites. The detector reads it before it fetches a careers page. The check is minimal: it only reads Disallow lines for User-agent: *.
  • Store only what you need. The alert keeps job IDs, not descriptions.

Data sources and disclosure

The sample's list of boards builds on two open datasets: LastRound AI's hiring data (CC BY 4.0) and Outscal OpenJobs (MIT). I re-verified every board against its ATS's public API. The 50-row sample is shared under CC BY 4.0.

The sample is part of the ATS Company Directory, which is my own product. The full directory has 7,977 company boards and 269,858 open jobs, all checked live on 24 Oct 2026 (UTC). By ATS, it has 3,867 Greenhouse, 2,319 Ashby, 1,691 Lever, 72 Workable and 28 Recruitee boards. It's available as a one-time snapshot for $15 or as a membership that's updated monthly, for $29 a year. The scripts in this post work the same with the free sample or with a CSV you build yourself.

Summary

To find a company's ATS, look for a board link on its careers page first. If there is none, guess the slug and confirm it against the APIs, then check the guess by hand. To watch a list of boards, store job IDs and ETags, and report only the IDs you haven't seen. The two scripts above do both in about 170 lines with no dependencies.

If you know a reliable way to detect a board that's hidden behind client-side rendering, please share it in the comments.

Top comments (0)