DEV Community

Scott Williams
Scott Williams

Posted on AI-assisted

Reading Workday job postings as JSON, and getting past the 2,000-result ceiling

AI disclosure: this post was drafted with AI assistance, and every request and code sample was run and its output checked on October 29, 2026.

Many large employers run their career sites on Workday. You can recognize them by the host: {company}.wd{N}.myworkdayjobs.com. The page is a JavaScript app, but the job list behind it is a plain JSON endpoint that the page calls itself. No API key, no login, no headless browser.

This post covers that endpoint, three limits that are not obvious until your numbers come out wrong, and a small standard-library Python reader that handles them.

1. From a career site URL to the API

A Workday career site URL has three parts you need:

https://nvidia.wd5.myworkdayjobs.com/en-US/NVIDIAExternalCareerSite
        ^^^^^^ ^^^                   ^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
        tenant pod                   locale site
Enter fullscreen mode Exit fullscreen mode
  • Host: nvidia.wd5.myworkdayjobs.com. The wd5 part varies by company (wd1, wd3, wd5 ...), so keep the host exactly as it appears.
  • Tenant: the first label of the host, nvidia.
  • Site: the first path segment after an optional locale like en-US. One tenant often has several sites (external, internal, students, contractors).

The job list is a POST to:

POST https://{host}/wday/cxs/{tenant}/{site}/jobs
Content-Type: application/json

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

The first page answers with four keys: total, jobPostings, facets and userAuthenticated. Each posting is short:

{
  "title": "Power Methodology and Analysis Engineer",
  "externalPath": "/job/China-Shanghai/Power-Methodology-and-Analysis-Engineer_JR2028881",
  "locationsText": "China, Shanghai",
  "postedOn": "Posted Today",
  "bulletFields": ["JR2028881"]
}
Enter fullscreen mode Exit fullscreen mode

externalPath is the key to everything else. The public job page is https://{host}/{site}{externalPath}, and the full posting is a GET on the API base plus the same path:

GET https://{host}/wday/cxs/{tenant}/{site}{externalPath}
Enter fullscreen mode Exit fullscreen mode

That returns jobPostingInfo with jobDescription (HTML), location, additionalLocations, timeType, jobReqId, startDate, postedOn and a few more, plus a top-level hiringOrganization with the hiring entity's name.

2. Three limits worth knowing

I tested these against NVIDIA, Salesforce, Intel, Accenture and Workday's own career site.

Page size is capped at 20. "limit": 20 works. "limit": 21 and "limit": 100 both returned HTTP 400. An empty body {} also works and gives you 20.

total is only on the first page. With offset: 0, NVIDIA returned total: 2000. With offset: 20, the same query returned total: 0. Read the total once and keep it; do not re-read it per page, or your loop ends after one page.

One query stops at 2,000. This is the one that silently loses data. NVIDIA's first page said total: 2000, but its facet counts (more on those below) added up to 2,719. Accenture's said 2,000 while its job family facet added up to 41,402. Paging past the cap does not help: offsets 2000, 2020, 2500 and 5000 all returned the first page again. If your loop is "fetch until offset >= total", a large employer gives you 2,000 jobs and no error.

Smaller sites are under the cap and page normally: Salesforce reported 1,566, Intel 802 and Workday's own site 395.

3. Getting past the cap with facets

The first page also returns facets, the same filters you see on the career site. Each facet has a facetParameter and a list of values with an id, a descriptor and a count. On NVIDIA, the facets were jobFamilyGroup, workerSubType, timeType and locationMainGroup (that last one is nested and has no counts at the top level).

Filtering by a facet value is just a different appliedFacets:

{"appliedFacets": {"jobFamilyGroup": ["<value id>"]}, "limit": 20, "offset": 0, "searchText": ""}
Enter fullscreen mode Exit fullscreen mode

Each filtered query gets its own 2,000 ceiling. NVIDIA's jobFamilyGroup had 15 values, the biggest being Engineering with 1,730, then Sales with 320 and University Employment with 208. Every value is under 2,000, so paging through each value and merging the results by externalPath covers the whole board.

workerSubType would not work for this board: its "Regular Employee" bucket had 2,240. That is why the reader below looks for a facet whose every bucket is under the cap.

Check one more thing: that the buckets add up to the total. Accenture's site also returns a distance facet with five values ("10 miles" up to "Any Distance"), each with a count of 1. Every bucket is "under the cap", but the counts are not job counts, and an earlier version of my reader picked that facet and would have returned a partial list without complaint. Requiring the counts to sum to at least the first page's total rules it out.

4. The reader

Standard library only (I ran it on Python 3.13.5):

import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
import urllib.robotparser

UA = "workday-jobs-reader/1.0 (+you@example.com)"
PAGE = 20      # the largest page size the endpoint accepted in my tests
CAP = 2000     # the most results one query will page through


def parse_site(url):
    """https://nvidia.wd5.myworkdayjobs.com/en-US/NVIDIAExternalCareerSite -> (host, tenant, site)"""
    u = urllib.parse.urlsplit(url)
    m = re.match(r"^([\w-]+)\.wd\d+\.myworkdayjobs\.com$", u.netloc, re.I)
    if not m:
        raise ValueError("not a myworkdayjobs.com URL")
    parts = [p for p in u.path.split("/") if p and not re.fullmatch(r"[a-z]{2}-[A-Z]{2}", p)]
    if not parts:
        raise ValueError("the URL needs the site segment after the host")
    return u.netloc.lower(), m.group(1).lower(), parts[0]


def site_allowed(host, site):
    """Is /<site>/ allowed for us in the tenant's robots.txt?"""
    req = urllib.request.Request(f"https://{host}/robots.txt",
                                 headers={"User-Agent": UA, "Accept": "text/plain"})
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            lines = r.read().decode("utf-8", "replace").splitlines()
    except urllib.error.HTTPError as e:
        if 400 <= e.code < 500:
            return True          # RFC 9309: no robots.txt means no restrictions
        raise                    # 5xx: assume disallowed, so stop here
    rp = urllib.robotparser.RobotFileParser()
    rp.parse(lines)
    return rp.can_fetch(UA, f"https://{host}/{site}/")


class Workday:
    def __init__(self, url, delay=0.25):
        self.host, self.tenant, self.site = parse_site(url)
        self.api = f"https://{self.host}/wday/cxs/{self.tenant}/{self.site}"
        self.delay = delay

    def _call(self, path, body=None):
        time.sleep(self.delay)
        req = urllib.request.Request(
            self.api + path,
            data=json.dumps(body).encode() if body is not None else None,
            headers={"User-Agent": UA, "Accept": "application/json",
                     "Content-Type": "application/json"},
        )
        with urllib.request.urlopen(req, timeout=30) as r:
            return json.load(r)

    def search(self, facets=None, text="", offset=0):
        return self._call("/jobs", {"appliedFacets": facets or {}, "limit": PAGE,
                                    "offset": offset, "searchText": text})

    def page_through(self, facets=None, text=""):
        first = self.search(facets, text)
        total = first["total"]           # only the first page carries the real total
        yield from first["jobPostings"]
        for offset in range(PAGE, min(total, CAP), PAGE):
            yield from self.search(facets, text, offset)["jobPostings"]

    def all_jobs(self):
        self.skipped = []                # list entries with no externalPath (see below)
        first = self.search()
        if first["total"] < CAP:
            queries = [self.page_through()]
        else:
            # Too many to page through: split on a facet whose every bucket is under
            # the cap and whose buckets add up to at least the total (see below).
            for facet in first["facets"]:
                values = facet.get("values") or []
                counts = [v.get("count", CAP) for v in values]
                if values and max(counts) < CAP and sum(counts) >= first["total"]:
                    break
            else:
                raise RuntimeError("no facet splits this board under the cap")
            queries = (self.page_through({facet["facetParameter"]: [v["id"]]}) for v in values)
        seen = set()
        for query in queries:
            for job in query:
                path = job.get("externalPath")
                if not path:             # some entries carry only bulletFields
                    self.skipped.append(job)
                elif path not in seen:
                    seen.add(path)
                    yield job

    def detail(self, job):
        return self._call(job["externalPath"])["jobPostingInfo"]
Enter fullscreen mode Exit fullscreen mode

And the run:

wd = Workday("https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite")
print("robots.txt allows the site:", site_allowed(wd.host, wd.site))
print("first page total:", wd.search()["total"])
jobs = list(wd.all_jobs())
print("jobs after facet split:", len(jobs))
print("entries without externalPath:", wd.skipped)
info = wd.detail(jobs[0])
print(info["title"], "|", info["jobReqId"], "|", info["postedOn"], "|", info["startDate"])
Enter fullscreen mode Exit fullscreen mode
robots.txt allows the site: True
first page total: 2000
jobs after facet split: 2718
entries without externalPath: [{'bulletFields': ['JR2024815']}]
Power Methodology and Analysis Engineer | JR2028881 | Posted Today | 2026-10-29
Enter fullscreen mode Exit fullscreen mode

That took 147 list requests (146 of them inside all_jobs) and about 2 minutes 40 seconds with a quarter-second pause between requests. The full descriptions are one more request per job, so fetch details only for the jobs that pass your title or location filter.

5. Things that bite

  • Dates in the list are relative. postedOn is "Posted Today", "Posted Yesterday", "Posted 6 Days Ago" or "Posted 30+ Days Ago". The detail response has startDate as an ISO date, which is the one to store. If you only have the list, an approximation is fine for "posted in the last week" filters:
  import re
  from datetime import date, timedelta


  def approx_posted(posted_on, today=None):
      """'Posted 3 Days Ago' -> a date. '30+ Days Ago' only says 'at least 30 days'."""
      today = today or date.today()
      t = (posted_on or "").lower()
      if "today" in t:
          return today
      if "yesterday" in t:
          return today - timedelta(days=1)
      m = re.search(r"(\d+)\+?\s+days?\s+ago", t)
      return today - timedelta(days=int(m.group(1))) if m else None
Enter fullscreen mode Exit fullscreen mode

Two caveats from my runs. "30+ Days Ago" is a floor, not a date: one such job had a startDate 31 days back. And the label does not say which time zone "today" is in, so near midnight an estimate from your own clock can be a day off. When the exact day matters, store startDate from the detail response.

  • locationsText can be a count. Multi-location jobs say "3 Locations" or similar. The real list is location plus additionalLocations in the detail response.
  • The request ID is in bulletFields. On the sites I tried, bulletFields[0] was the requisition ID (JR2028881), and the detail response has it as jobReqId. Use it, or externalPath, as the stable ID for "new since last run" checks.
  • A wrong site name is a 404. A misspelled site returned HTTP 404 from the jobs endpoint, so a typo fails loudly. Copy the site segment from the browser rather than guessing it.
  • Some list entries have no externalPath. In the NVIDIA crawl, one entry in the Engineering bucket was just {"bulletFields": ["JR2024815"]}. My first version indexed job["externalPath"] directly and crashed with a KeyError a minute in. The reader now sets such entries aside in wd.skipped so you can see how many there were.
  • The sitemap is not a shortcut. Tenants list a siteMap.xml per site in robots.txt. NVIDIA's had 100 URLs, against 2,718 open jobs.
  • Very large tenants need two facets. On Accenture's site, the biggest job family bucket alone had 19,364 jobs and no facet split the board under 2,000 (the reader above raises its error there). There you have to combine two facets, such as job family and location, in one appliedFacets.

6. Be a polite client

Workday tenants publish a robots.txt, and it is specific about which sites are public. Pfizer's, for example:

User-agent: *
Allow: /PfizerCareers/
Allow: /ContractorConversions/
Allow: /Canada_Only_External/
Allow: /Costa_Rica_External/
Disallow: /InternalJobs/
Disallow: /refreshFacet/
Enter fullscreen mode Exit fullscreen mode

The API path (/wday/cxs/...) is not listed, so a plain robots check on the API URL says "allowed" even for the internal site. The useful check is on the site path, /{site}/, which is what site_allowed does. For Pfizer it returns False for InternalJobs and True for PfizerCareers.

One quirk: ask for robots.txt with Accept: text/plain. When I asked for it with an Accept header that preferred JSON or HTML, the tenant answered with the HTML app shell instead of the robots file, and a parser reading that would find no rules and allow everything.

Beyond robots.txt:

  • Send a descriptive User-Agent with a contact address.
  • Keep it slow. One request at a time with a short pause is plenty; a full NVIDIA crawl was 146 list requests.
  • Once a day is enough for almost any job-tracking use. Fetch descriptions only for the jobs you will keep.
  • Some employers' own site terms say more than robots.txt does. Read them for the companies you track, and only collect what is already public on the external career site.

Top comments (0)