Last checked

How to scrape Workday job listings

Public Workday career sites load their listings from a JSON endpoint on the same host. POST {"appliedFacets": {}, "limit": 20, "offset": 0, "searchText": ""} to https://{host}/wday/cxs/{tenant}/{site}/jobs. The host, tenant, and site name all come from the public careers URL. Keep limit at 20. A higher limit often returns an empty jobPostings array and no error, which looks exactly like the end of the list.

This is the endpoint the public career site uses to draw its own pages. It is not a way into Workday HCM, and it does not accept an employee login.

In short

  1. From the public URL, take the host, the tenant, and the site name. Drop the locale such as en-US.
  2. POST JSON with limit 20, offset 0, and an empty appliedFacets object to /wday/cxs/{tenant}/{site}/jobs.
  3. Increase offset by 20 until you pass total. A limit above 20 often returns an empty list with no error.
  4. GET /wday/cxs/{tenant}/{site} plus the externalPath from the list to read jobPostingInfo.
  5. Sleep between pages, set a max, and skip boards that require a login.

Read the careers URL

A public board looks like this:

https://nvidia.wd5.myworkdayjobs.com/en-US/NVIDIAExternalCareerSite

Split it into three parts:

PartExample
Hostnvidia.wd5.myworkdayjobs.com
Tenantnvidia (the first label of the host)
SiteNVIDIAExternalCareerSite

Ignore the locale segment en-US. The site name is case-sensitive. Copy it from the URL rather than lowercasing it.

The list endpoint is then:

https://nvidia.wd5.myworkdayjobs.com/wday/cxs/nvidia/NVIDIAExternalCareerSite/jobs

Request the first page

The list is a POST. A GET will not give you the postings. The body below is the one checked against that NVIDIA board on 12 October 2026. The response contained total, jobPostings, and a first page of 20 items. Each item had title, externalPath, locationsText, and postedOn.

import json
from urllib.request import Request, urlopen

host = "nvidia.wd5.myworkdayjobs.com"
tenant = "nvidia"
site = "NVIDIAExternalCareerSite"
url = f"https://{host}/wday/cxs/{tenant}/{site}/jobs"
body = json.dumps({
    "appliedFacets": {},
    "limit": 20,
    "offset": 0,
    "searchText": "",
}).encode()

request = Request(
    url,
    data=body,
    headers={
        "Content-Type": "application/json",
        "Accept": "application/json",
        "User-Agent": "workday-jobs-example/1.0",
    },
)

with urlopen(request, timeout=30) as response:
    payload = json.load(response)

print(payload["total"], len(payload["jobPostings"]))
print(payload["jobPostings"][0]["title"])
const host = "nvidia.wd5.myworkdayjobs.com";
const tenant = "nvidia";
const site = "NVIDIAExternalCareerSite";
const url = `https://${host}/wday/cxs/${tenant}/${site}/jobs`;

const response = await fetch(url, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Accept: "application/json",
    "User-Agent": "workday-jobs-example/1.0",
  },
  body: JSON.stringify({
    appliedFacets: {},
    limit: 20,
    offset: 0,
    searchText: "",
  }),
});

if (!response.ok) throw new Error(`Workday responded ${response.status}`);
const payload = await response.json();
console.log(payload.total, payload.jobPostings.length);
console.log(payload.jobPostings[0].title);

postedOn is often a phrase such as “Posted Today”, not a timestamp. Do not parse it as a date. The detail response has startDate when you need a real day.

Page without the empty-list trap

Ask for 20 rows. Add 20 to offset after each page. Stop when offset reaches total, when a page comes back empty, or when you hit a maximum you chose. Put a pause between requests. A large board can be thousands of postings, and you rarely need all of them in one sitting.

import json
import time
from urllib.request import Request, urlopen

def fetch_page(url, offset):
    body = json.dumps({
        "appliedFacets": {},
        "limit": 20,
        "offset": offset,
        "searchText": "",
    }).encode()
    request = Request(
        url,
        data=body,
        headers={
            "Content-Type": "application/json",
            "Accept": "application/json",
            "User-Agent": "workday-jobs-example/1.0",
        },
    )
    with urlopen(request, timeout=30) as response:
        return json.load(response)

def list_jobs(url, max_jobs=40):
    jobs = []
    offset = 0
    total = None
    while len(jobs) < max_jobs:
        payload = fetch_page(url, offset)
        total = payload.get("total", 0)
        batch = payload.get("jobPostings") or []
        if not batch:
            break
        jobs.extend(batch)
        offset += 20
        if offset >= total:
            break
        time.sleep(1)
    return jobs[:max_jobs]

If you set limit to 100 and the array is empty, you have not reached the end of the board. You asked for a page size Workday does not serve. Go back to 20.

Check robots.txt on the host before you automate more than a sample, and read the career site’s terms. Stop if the host answers 403 or 429. Backing off is the right response. Rotating addresses to get around that response is not.

Fetch one description

The list does not include the full description. Each posting has an externalPath such as /job/Austin/Some-Role_JR1001. The public detail endpoint is the list URL’s prefix plus that path.

Checked on 12 October 2026, a GET to the NVIDIA detail URL for one externalPath returned jobPostingInfo with title, location, jobDescription, startDate, timeType, jobReqId, and externalUrl.

import json
from urllib.request import Request, urlopen

def fetch_detail(host, tenant, site, external_path):
    url = f"https://{host}/wday/cxs/{tenant}/{site}{external_path}"
    request = Request(url, headers={"Accept": "application/json", "User-Agent": "workday-jobs-example/1.0"})
    with urlopen(request, timeout=30) as response:
        info = json.load(response)["jobPostingInfo"]
    return {
        "title": info.get("title"),
        "location": info.get("location"),
        "startDate": info.get("startDate"),
        "requisitionId": info.get("jobReqId"),
        "url": info.get("externalUrl"),
        "descriptionHtml": info.get("jobDescription"),
    }

jobDescription is HTML. Strip it to text before you store it as plain text, and do not render it unsanitized. One detail call per job is a lot of traffic. Skip descriptions unless you need them, and keep the same one-second pause.

The public posting link for a person is https://{host}/en-US/{site}{externalPath}, or use externalUrl from the detail payload when it is present.

When to stop writing this yourself

The URL split, the page size of 20, and the extra description request are the whole trick, and they are also the parts that break a quick script. Boards differ in site-name casing. A limit of 21 silently returns nothing. Descriptions multiply the number of requests.

The Workday Jobs Scraper takes the public career URL, pages the list, and can include descriptions. It does not log into Workday. The price is $0.90 per 1,000 results, billed on Apify. For several hiring systems at once, use the New Jobs Feed instead of a Workday-only script.

Written with AI assistance and checked by the Lepidata team.