How to Scrape Workday Job Listings
Workday powers career sites for a large share of large enterprises, and its underlying API (internally called CXS — Candidate Experience System) is genuinely harder to scrape correctly than Greenhouse, Lever, Ashby, or Workable. It's POST-based instead of GET, paginated with explicit offsets, returns relative date strings instead of timestamps in the list view, and needs a second request per job to get data you'd expect in the first one. Here's what that actually looks like.
Finding the Tenant Host and Career Site
A Workday-hosted careers page URL looks like https://hp.wd5.myworkdayjobs.com/ExternalCareerSite (or a company's custom domain, if they've configured one, fronting the same tenant). Two pieces matter, and both are visible directly in that URL:
- Tenant host — the subdomain, e.g.
hp.wd5.myworkdayjobs.com. - Career site — the path segment after the host, e.g.
ExternalCareerSite. Larger companies sometimes run more than one career site on the same tenant (an internal one and an external one, or separate sites per business unit) — check the URL of the specific careers page you're targeting.
As with every ATS covered in this series, this only works for a company whose careers URL you already have — there's no public directory mapping every enterprise to its Workday tenant, and finding those at scale across thousands of employers is a separate, much harder problem than fetching one board you already have the address for.
The List Endpoint Is POST, Not GET
This trips people up if they've scraped Greenhouse or Lever first and expect the same pattern:
curl -X POST "https://hp.wd5.myworkdayjobs.com/wday/cxs/hp/ExternalCareerSite/jobs" \
-H "Content-Type: application/json" \
-d '{"appliedFacets": {}, "limit": 20, "offset": 0, "searchText": ""}'limit and offset control pagination — Workday's own list UI paginates in pages of 20, and the public API follows the same convention. appliedFacets is how the site's own filter sidebar works (department, location, etc.); leave it as {} to get everything. Loop, incrementing offset by your limit, until offset >= total (returned in the response).
The List Response Shape
{
"total": 143,
"jobPostings": [
{
"title": "Senior Backend Engineer",
"externalPath": "/job/Fort-Collins/ACS-Quality-Engineer_3164651-2",
"locationsText": "2 Locations",
"postedOn": "Posted 5 Days Ago",
"bulletFields": ["R-3164651"],
"remoteType": "Hybrid",
"timeType": "Full time"
}
]
}This list response is intentionally thin, and three fields are worth flagging before you rely on any of them:
postedOnis a relative string ("Posted Today","Posted 5 Days Ago","Posted 30+ Days Ago") — not a timestamp. You can approximate a date by parsing it, but"30+ Days Ago"is a floor, not an exact value — a job posted 90 days ago and one posted 31 days ago both say the same thing. Treat this as good enough for a coarse freshness filter, not for anything needing a real date.bulletFields[0]looks like a stable requisition ID ("R-3164651"above), but it isn't reliable across all tenants — some put a location string there instead of a req ID, and others omitbulletFieldsentirely. Don't use it as your primary dedup key without a fallback.locationsTextis often just a vague count ("2 Locations") with no resolvable place name at all for multi-location postings — the list view doesn't give you the actual location strings, only a hint that there's more than one.
None of this is enough to build a real listing from. You need the detail endpoint.
The Detail Endpoint — And Why You Need It
Take the last path segment of externalPath (ACS-Quality-Engineer_3164651-2 in the example above) and request:
curl "https://hp.wd5.myworkdayjobs.com/wday/cxs/hp/ExternalCareerSite/job/ACS-Quality-Engineer_3164651-2"{
"jobPostingInfo": {
"title": "Senior Backend Engineer",
"jobReqId": "R-3164651",
"jobPostingId": "ACS-Quality-Engineer_3164651-2",
"jobDescription": "<p>We're looking for...</p>",
"startDate": "2026-07-01",
"location": "Fort Collins, Colorado, United States of America",
"additionalLocations": ["Denver, Colorado, United States of America"],
"timeType": "Full time",
"jobRequisitionLocation": {
"country": { "alpha2Code": "US" }
}
}
}This is where the real data lives:
startDate— an exact ISO date, the correct source of truth for publish date. Prefer this over the list view's relativepostedOnstring whenever you can afford the extra request.location— a clean, already-assembled string ("Fort Collins, Colorado, United States of America"). Better than the list view, but still just text — no city/state/country IDs or coordinates.additionalLocations— the actual per-location strings for multi-location postings, which the list view only hinted at with"2 Locations". If you only readlocation, you'll drop every other office a multi-location posting is open at.jobRequisitionLocation.country.alpha2Code— a genuine ISO country code, when present. Useful, but it's the only structured geographic field in the whole response — no equivalent code for state or city.jobReqIdvsjobPostingId— usejobReqIdas your primary stable ID when it's present (it's the human-facing requisition number, e.g."R-3164651"), but fall back tojobPostingId(the URL slug) when it's missing — some tenants genuinely omitjobReqIdon certain postings (particularly ones mirrored across multiple career sites for the same tenant), andjobPostingIdis always present since it's required to address the job at all.
Gotchas Specific to Workday
Location strings often carry internal facility codes. Expect raw locality text like "FTC03 - Ft. Collins, CO B-3 (FTC03)" — the leading code and trailing parenthetical are Workday's internal facility identifiers, not part of the actual place name. Strip both before treating this as a display-ready location string.
Some tenant subdomains silently swap underscores for hyphens. A tenant slug like vhr_otsuka can't appear in a DNS-safe subdomain, so the visible URL uses vhr-otsuka.wd1.myworkdayjobs.com — but the API's internal company path segment sometimes still expects the underscore version. If a POST to the jobs endpoint returns a 422, and the tenant slug in your URL contains a hyphen, try again with underscores substituted for hyphens before giving up on that tenant.
Workday's edge appears to rate-limit by source IP across all tenants combined, not per tenant. If you're scraping multiple Workday-hosted companies from the same IP, a 429 on one tenant can be a signal about your overall request volume across all of them, not that specific company's limit. Back off globally, not just for the tenant that returned the error, and honor a Retry-After header if one is present.
No salary data anywhere in either response. Same gap as Greenhouse and Workable — nothing structured, and Workday's job description templates don't consistently include it as parseable text either.
When This Is Enough
For a handful of large enterprises you're tracking individually, this two-phase list-then-detail pattern is workable — it's just meaningfully more request-heavy than Greenhouse or Lever, since you can't get real dates, full locations, or reliable IDs from the list call alone.
When It Isn't
Workday tenants can run thousands of open postings at once for a large employer, and the detail-fetch-per-job requirement multiplies your request count accordingly — combine that with shared cross-tenant rate limiting and relative date strings that need re-verification against the detail response's exact startDate, and a from-scratch Workday scraper is a meaningfully bigger build than the other ATS platforms in this series. That complexity, handled once and normalized alongside Greenhouse, Lever, Ashby, and Workable into one consistent schema, is exactly what a data API like JobsDataAPI exists to absorb.