← blog  /  scrapers

Workday jobs API in Python: a list that stops at 2,000 jobs, and dates that stop at 30+ days

2026-11-13  ·  11 min read  ·  scrapers

Read a Workday careers site as JSON in Python: 20 jobs a request, and a list that stops at 2,000 of NVIDIA's 2,807 jobs. The two endpoints, facets that cut a big site into slices, and about forty lines of Python, measured on four career sites.

Many large employers keep their jobs in Workday. It has no documented public job API, but its careers pages (*.myworkdayjobs.com) load their jobs from a JSON address under /wday/cxs/, and anyone can call it. Our earlier post on four job board APIs called Workday the awkward one. This post is only about Workday, and about what the answers do once you read more than the first page.

We read four public career sites, NVIDIA, Salesforce, Adobe and Intel, on the night of 8 to 9 November 2026 (23:56 UTC on the 8th). The robots.txt of each host came first and leaves the paths we used open. Then came 34 requests in all, 1.5 seconds apart. Every number below is from that run, and days are counted in UTC.

The short version#

  • 20 rows a request. limit 21 and limit 50 answered HTTP 400 with an empty message.
  • total is right on the first page only. All 9 later pages said 0. On NVIDIA even the first said 2000, where the site's own filters count 2,807 jobs.
  • The list stops at 2,000. NVIDIA's offset 1980 gave 20 rows, and offset 2000 gave the first page again.
  • Dates are phrases. All 240 rows we read said things like "Posted 5 Days Ago", and 38 said "Posted 30+ Days Ago". The exact date is on each job's own address.
  • Half the places are a count. 120 of 240 rows said "2 Locations" or the like instead of a place.
  • Facets cut a big site into slices, but every site names them differently.

Where the jobs are#

Two addressestext
List  POST  https://{host}/wday/cxs/{tenant}/{site}/jobs
Job   GET   https://{host}/wday/cxs/{tenant}/{site}{externalPath}

All three parts come from the careers link. The tenant is the first part of the host:

The careers link, taken aparttext
link    https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite
host    nvidia.wd5.myworkdayjobs.com
tenant  nvidia
site    NVIDIAExternalCareerSite

One company can have several sites: the robots.txt of Salesforce has a Sitemap line for each of nine. Those lines are one way to find the names.

One page of a Workday careers sitebash
curl -X POST "https://nvidia.wd5.myworkdayjobs.com/wday/cxs/nvidia/NVIDIAExternalCareerSite/jobs" \
  -H "Content-Type: application/json" \
  -d '{"appliedFacets": {}, "limit": 20, "offset": 0, "searchText": ""}'

searchText is the search box, empty here. The answer has four keys: total, jobPostings, facets and userAuthenticated. A row of jobPostings has five:

One row of jobPostingsjson
{
  "title": "Senior Security Engineer, Infrastructure Security Engineering - DGX Cloud",
  "externalPath": "/job/Canada-Remote/Senior-Security-Engineer--Infrastructure-Security-Engineering---DGX-Cloud_JR2023143-1",
  "locationsText": "Canada, Remote",
  "postedOn": "Posted Today",
  "bulletFields": ["JR2023143"]
}

On this row bulletFields holds the job id. Six of the 240 rows we read had two more keys, jobPostingEndDateAsText and timeLeftToApply. The row has no description and no exact date. Those are on the second address, which is the row's externalPath after the site. It answers an object called jobPostingInfo; NVIDIA's had 17 keys, among them title, jobDescription, startDate, timeType, location, country, externalUrl and jobReqId.

Twenty at a time, and a total you read once#

limit 20 is the most. 21 answered HTTP 400 on NVIDIA and on Adobe, and 50 did on NVIDIA. The body was {"errorCode":"HTTP_400", ... "message":""}, so nothing says why.

The first answer holds total. Every later answer on the four sites said "total": 0: 9 of 9, from offset 20 up to 1980. So read total once and page on without it, as all_jobs() below does.

On Salesforce, Adobe and Intel that first total (1,544, 277 and 864) is the number the Time Type filter adds up to. On NVIDIA it is not.

The list stops at 2,000#

NVIDIA's first answer said "total": 2000. Its Time Type filter adds up to 2,807 jobs (2,806 full time and 1 part time). Paging shows why the two differ. offset 1980 returned 20 rows, and offset 2000 returned the 20 rows of offset 0 again, with total 2000. The list reaches 2,000 jobs and no more, so 807 jobs on that site are out of reach of plain paging. The way round is a facet.

Facets cut a site into slices#

The first answer carries facets: the filters of the site's search page, each with values that have an id and a count. To apply one, send its parameter name and the ids in appliedFacets. {"locationHierarchy1": ["2fcb99c455831013ea52df1adb7432a8"]} is NVIDIA's Singapore.

No two of the four sites named the country filter the same way:

  • NVIDIA: locationHierarchy1, 33 countries. The biggest, the United States, has 1,477 jobs, so every country slice is under the 2,000 limit.
  • Adobe: locationCountry, 19 countries.
  • Salesforce: a name of 80 characters that begins CF_-_REC_-_LRV_, 38 countries.
  • Intel: no country filter, only locations, with 44 sites.

So read the parameter name from the answer, and do not carry it from one site to the next. We ran the filter on NVIDIA (Singapore: the filter counted 27 jobs, and paging gave 27 rows) and on Adobe (Romania: 19 counted, total 19, 19 rows). We did not run it on Salesforce or Intel.

Two traps. The counts of the country filter add up to more than the site has jobs: 2,847 against 2,807 on NVIDIA, 1,558 against 1,544 on Salesforce, 285 against 277 on Adobe. That fits jobs listed in more than one country, which sit in two slices, so key the rows by job id when you put slices together. And the row text cannot check the filter: of the 27 Singapore rows, 22 said Singapore and 5 said "N Locations", and none of Adobe's 19 Romania rows said Romania.

Dates are phrases#

The list gives postedOn as "Posted Today", "Posted Yesterday", "Posted 5 Days Ago" or "Posted 30+ Days Ago". All 240 rows we read were one of these, and none was a date. posted_date() below turns the first three kinds into a date and returns None for the last, because "30+" only says "at least 30".

That kind is not rare. On Adobe, 9 of the 20 rows of the first page said it, and 23 of the 60 we read. On Intel it was 15 of 60. On NVIDIA and Salesforce it was 0 of 60 each.

The exact date is startDate on the job's own address, such as 2026-11-03. It was there on 9 of 9 reads. Where the phrase had a number, it matched startDate to the day on 8 of 8 reads. The one read of a "30+" row, on Adobe, gave 2026-10-06, which was 33 days before the read.

An exact date costs a request per job. For the 2,000 jobs NVIDIA's list reaches that is 100 list requests and 2,000 job requests, about fifty minutes at the 1.5 seconds we kept.

Do not rely on the order either. NVIDIA's pages came newest first: 0 to 2 days old on the first two pages and 8 days old at offset 1000. Adobe's did not. Its second page began with 14 rows of "30+" and ended with six from two or three days ago. Intel's second page had seven of "30+", then one from today and twelve from two days ago. A loop that stops at the first old posting would miss the new ones.

Places are often a count#

locationsText is a place on half the rows and a count on the other half. 120 of 240 said "2 Locations", "6 Locations" and so on: 34 of 60 on NVIDIA, 39 on Salesforce, 27 on Adobe and 20 on Intel. The places are on the job's own address. location is the first one, and additionalLocations lists the others. The six reads with more than one place had 1, 3, 5, 1, 1 and 4 more.

remoteType came back on 2 of the first 8 reads, both from Salesforce, as "Office - Flexible". Do not count on it.

One reader for all of it#

This uses only the Python standard library and ran as printed on that night. all_jobs() reads one search to the end of what its first total says, so give it a slice when a site is bigger than 2,000.

Read a Workday careers site: list, job, facets and datespython
import json, re, urllib.request
from datetime import date, timedelta

UA = {"User-Agent": "voiddo-guide/1.0 (+https://voiddo.com/scrapers)", "Content-Type": "application/json"}


def call(url, body=None):
    data = json.dumps(body).encode() if body is not None else None
    with urllib.request.urlopen(urllib.request.Request(url, data, UA), timeout=30) as r:
        return json.load(r)


def api(site):  # site = https://<tenant>.wdN.myworkdayjobs.com/<SiteName>
    host, name = site.rstrip("/").rsplit("/", 1)
    return f"{host}/wday/cxs/{host.split('//')[1].split('.')[0]}/{name}"


def jobs_page(site, offset=0, facets=None, search="", limit=20):
    return call(api(site) + "/jobs", {"appliedFacets": facets or {}, "limit": limit,
                                      "offset": offset, "searchText": search})


def job_detail(site, external_path):  # external_path comes from a row of jobs_page, e.g. /job/US-CA-Santa-Clara/...
    return call(api(site) + external_path)["jobPostingInfo"]


def all_jobs(site, facets=None, search=""):
    """Every row of one search: the first page says how many there are, then page on by 20."""
    first = jobs_page(site, 0, facets, search)
    rows = first["jobPostings"]
    for offset in range(20, first["total"], 20):  # `total` is only right on the first page
        more = jobs_page(site, offset, facets, search)["jobPostings"]
        if not more:
            break
        rows += more
    return rows


def posted_date(text, today=None):
    """'Posted 4 Days Ago' -> a date. 'Posted 30+ Days Ago' -> None: it only says 'at least 30'."""
    m = re.fullmatch(r"Posted (?:(Today)|(Yesterday)|(\d+)(\+?) Days? Ago)", text or "")
    if not m or m.group(4):
        return None
    days = 0 if m.group(1) else 1 if m.group(2) else int(m.group(3))
    return (today or date.today()) - timedelta(days=days)


def facet(facets, parameter):
    """{'Singapore': (id, count), ...} for one facet. Some sites nest the location facets one level down."""
    for f in facets:
        for node in [f] + f.get("values", []):
            if node.get("facetParameter") == parameter:
                return {v["descriptor"]: (v["id"], v["count"]) for v in node["values"]}
    return {}
Run it on NVIDIA, for the jobs in Singaporepython
site = "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite"
first = jobs_page(site)  # 20 rows, the total, and the facets
countries = facet(first["facets"], "locationHierarchy1")  # {'Singapore': (id, 27), ...}
slice_ = {"locationHierarchy1": [countries["Singapore"][0]]}
singapore = all_jobs(site, slice_)  # every NVIDIA job in Singapore
job = job_detail(site, singapore[0]["externalPath"])
print("rows on the first page:", len(first["jobPostings"]), "- total says", first["total"])
print("Singapore: facet count", countries["Singapore"][1], "- rows from all_jobs", len(singapore))
print("first Singapore row:", singapore[0]["postedOn"], "- startDate", job.get("startDate"), "-", job.get("location"))

That printed:

What it printedtext
rows on the first page: 20 - total says 2000
Singapore: facet count 27 - rows from all_jobs 27
first Singapore row: Posted 5 Days Ago - startDate 2026-11-03 - Singapore, Singapore-Suntec Tower

What it leaves out on purpose: the description as plain text (jobDescription is the text of the posting; pay, if a posting states it, is inside it), a loop over the slices of a site above 2,000 jobs, retries, and pacing between requests (the run we measured waited 1.5 seconds).

When you want many companies, every day#

For one or two sites, the code above is enough. Keep it, and check the facet names when a site changes.

For a list of Workday sites read every day, as rows, our Workday Jobs Scraper on Apify does the reading. Paste careers site links, up to 200 a run. Every job of a site comes back, also past the 2,000 that one list shows: a bigger site, such as NVIDIA's, is read again one filter value at a time, the way the facets section above does by hand. With Include description text on, which is the default, each posting is read once more: Posted is its exact start date, the pay range comes along where the posting states one, and the description is kept as plain text. With it off, a relative day is turned into a date and "Posted 30+ Days Ago" stays empty. A posting listed in several places carries every place, joined by a semicolon. It filters by title words, location, remote and posted within days. It is billed per result. What it returns, field by field: Workday jobs export.

For Greenhouse, Lever and Ashby boards, or a mix of systems with the same fields in every row, there is the Greenhouse, Lever, Workday and Ashby jobs scraper: jobs export, field by field. The endpoints of those boards are in the earlier post.

before you run anything

These are public job postings that employers publish so that people find them. Read them at a gentle pace, keep the link to the original posting, and keep to what the robots.txt of each host leaves open. Every host has its own: read it before the first request, as the run above did.

written