DEV Community

UDANINN
UDANINN

Posted on

You can tell which ATS a company uses by looking at its careers URL

Every job-data project starts with the same question: this company has a careers
page, but what is actually behind it?

You do not need to guess. Seven of the most common applicant tracking systems put
their identity directly in the URL, and each one has a public JSON endpoint you
can derive from that same URL. Here is the mapping, plus the parts that bite.

The lookup table

You see ATS Public JSON
boards.greenhouse.io/acme or job-boards.greenhouse.io/acme Greenhouse boards-api.greenhouse.io/v1/boards/acme/jobs
jobs.lever.co/acme Lever api.lever.co/v0/postings/acme?mode=json
jobs.ashbyhq.com/acme Ashby api.ashbyhq.com/posting-api/job-board/acme
apply.workable.com/acme Workable apply.workable.com/api/v1/widget/accounts/acme
acme.recruitee.com Recruitee acme.recruitee.com/api/offers/
careers.smartrecruiters.com/acme SmartRecruiters api.smartrecruiters.com/v1/companies/acme/postings
acme.wd5.myworkdayjobs.com/Careers Workday see below - this one is different

Six of the seven are a straight substitution: pull the company token out of the
URL, drop it into the API pattern, get JSON. No key, no proxy, no browser.

Workday is the one that breaks the pattern

Every other ATS needs one identifier. Workday needs three, and only one of them
is guessable:

https://acme.wd5.myworkdayjobs.com/External_Career_Site
         ^^^^  ^^^                  ^^^^^^^^^^^^^^^^^^^
       tenant  shard                site
Enter fullscreen mode Exit fullscreen mode
  • tenant is usually the company name, so you might guess it
  • shard is wd1 through wd12 or higher, assigned when the account was provisioned. There is no pattern
  • site is free text chosen by whoever set it up. External_Career_Site, NVIDIAExternalCareerSite, Careers are all real

This is why "just build the Workday URL from the company name" does not work.
You need a real link from the company's careers site - any link, a single job
posting is enough, because all three parts are in it.

The endpoint itself is:

POST https://{tenant}.{shard}.myworkdayjobs.com/wday/cxs/{tenant}/{site}/jobs
Content-Type: application/json

{"appliedFacets": {}, "limit": 20, "offset": 0, "searchText": ""}
Enter fullscreen mode Exit fullscreen mode

Three things that will waste your afternoon

Workday pages at exactly 20. Ask for limit: 21 and you get HTTP 200 with an
empty list and total still filled in. That looks identical to a company with no
openings, so you conclude the board is empty and move on. Always page at 20.

Workday's postedOn is a display string, not a date. It returns
"Posted Today", "Posted 3 Days Ago", "Posted 30+ Days Ago" - and it returns
them in whatever locale the endpoint feels like. Pin Accept-Language: en-US on
every request or your date parsing silently rots. Note also that 30+ carries no
date at all, so anything older than a month has no usable timestamp.

Greenhouse's jobs list has no departments. /jobs gives you titles and
locations; departments live on a separate endpoint. If you need both you have to
join them yourself, which is the single most common thing people miss.

Detecting it in code

import re

PATTERNS = [
    ("greenhouse",      r"(?:job-)?boards\.greenhouse\.io/([\w-]+)"),
    ("lever",           r"jobs\.lever\.co/([\w-]+)"),
    ("ashby",           r"jobs\.ashbyhq\.com/([\w-]+)"),
    ("workable",        r"apply\.workable\.com/([\w-]+)"),
    ("recruitee",       r"([\w-]+)\.recruitee\.com"),
    ("smartrecruiters", r"careers\.smartrecruiters\.com/([\w-]+)"),
]

WORKDAY = re.compile(r"([\w-]+)\.(wd\d+)\.myworkdayjobs\.com(?:/([^?#\s]*))?", re.I)
LOCALE = re.compile(r"^[a-z]{2}(-[A-Za-z]{2,4})?$")


def detect(url: str):
    m = WORKDAY.search(url)
    if m:
        tenant, shard = m.group(1).lower(), m.group(2).lower()
        segs = [s for s in (m.group(3) or "").split("/") if s]
        site = None
        # The cxs API URL carries the site one segment after the tenant.
        if len(segs) >= 4 and segs[0].lower() == "wday" and segs[1].lower() == "cxs":
            site = segs[3]
        else:
            for seg in segs:            # skip locale segments like en-US
                if LOCALE.match(seg):
                    continue
                site = seg
                break
        return {"ats": "workday", "tenant": tenant, "shard": shard, "site": site}

    for ats, pattern in PATTERNS:
        m = re.search(pattern, url, re.I)
        if m:
            return {"ats": ats, "token": m.group(1)}

    return None
Enter fullscreen mode Exit fullscreen mode

That locale loop matters more than it looks. Workday URLs shared from a
non-English browser carry /en-US/ or /fr-CA/ before the site name, and if you
take the first path segment blindly you get site="en-US" and an endpoint that
404s on every request. Walking past locale segments also means a deep link to a
single posting works as input, which is usually the only link you can get someone
to send you.

Verified against these:

nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite
  -> nvidia / wd5 / NVIDIAExternalCareerSite
acme.wd3.myworkdayjobs.com/fr-CA/External_Career_Site/job/Montreal/Dev_JR1
  -> acme / wd3 / External_Career_Site
salesforce.wd12.myworkdayjobs.com/wday/cxs/salesforce/External_Career_Site/jobs
  -> salesforce / wd12 / External_Career_Site
Enter fullscreen mode Exit fullscreen mode

Why bother

Once you can identify the ATS from a URL, "monitor hiring at these 200 companies"
stops being 200 scrapers and becomes one dispatcher plus seven thin clients. A
company that is hiring is a company that is spending, which is why this data is
worth having in the first place - headcount changes are one of the earliest
public signals that a budget exists.

I packaged each of these as a scraper on Apify, one per platform plus a combined
one that auto-detects:

The endpoints above are public either way - if you only need one board, the curl
is genuinely all it takes.

Earlier posts with the full field-by-field breakdown:
the six open JSON boards
and Workday's hidden API.

Top comments (0)