Workday does not publish a public job board API. Every careers site on myworkdayjobs.com still fills its job list from JSON: the visitor's browser sends a POST to an endpoint under /wday/cxs/, and that endpoint is what people find when they search for a Workday jobs API.
We read it every day, so here is how it behaves: the request, the paging rules, the filters, the posting record and the errors. Unless a line gives another date, each behaviour below was checked against Workday's own careers site on 23 October 2026.
Three names from the careers link
Everything comes from the link to a company's careers site:
https://acme.wd5.myworkdayjobs.com/External
-
tenant:
acme, the company's Workday tenant; -
host:
wd5, one of several numbered data centres (wd1,wd5,wd12and others); -
site:
External, the name the company gave this careers site. A company can run several.
Links often carry a language code, such as /en-US/External. Leave it out of the API path: with it, the endpoint answers HTTP 405.
There is no public lookup from a company's name to these three, so you start from the link on its careers page.
The list request
curl -X POST "https://acme.wd5.myworkdayjobs.com/wday/cxs/acme/External/jobs" \
-H "Content-Type: application/json" \
-d '{"appliedFacets": {}, "limit": 20, "offset": 0, "searchText": ""}'
The answer has four keys: total, jobPostings, facets and userAuthenticated. A posting in the list is thin, six keys and nothing more:
{
"title": "Data Engineer",
"externalPath": "/job/Melbourne/Data-Engineer_JR-0001042",
"locationsText": "Melbourne",
"postedOn": "Posted 3 Days Ago",
"remoteType": "Flex",
"bulletFields": ["JR-0001042"]
}
postedOn is relative text that stops at "Posted 30+ Days Ago", and locationsText turns into a count ("2 Locations") when a role has several. The date, the country, the time type and the text are in each posting's own record, further down.
searchText is the careers page's own search box; an empty string returns everything.
The paging rules
Four things to know before you write the loop:
-
20 a page at most. A
limitof 21 or more answers HTTP 400. -
Only the first page reports
total. Later pages answer"total": 0, so read it once and keep it. - An offset past the end loops back. It does not return an empty page: on a site listing 387 roles, offset 400 returned the same 20 postings as offset 0. A loop that waits for an empty page never ends, so stop at the first page's total.
-
The tenant is case-sensitive. In capitals, the endpoint answered 502. The site name is not:
workdayandWorkdayboth returned 387.
A script that follows those rules, for Node 18 or later and nothing to install:
// list-workday-jobs.mjs: every open role on one Workday careers site (Node 18 or later)
// node list-workday-jobs.mjs https://acme.wd5.myworkdayjobs.com/External
const careersUrl = new URL(process.argv[2]);
const tenant = careersUrl.hostname.split('.')[0];
// The site name is the last part of the path, after any language code such as en-US.
const site = careersUrl.pathname.split('/').filter((part) => part && !/^[a-z]{2}-[A-Z]{2}$/.test(part)).pop();
const listUrl = `https://${careersUrl.hostname}/wday/cxs/${tenant}/${site}/jobs`;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function listPage(offset) {
const response = await fetch(listUrl, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ appliedFacets: {}, limit: 20, offset, searchText: '' }),
});
if (!response.ok) throw new Error(`HTTP ${response.status} for offset ${offset}`);
return response.json();
}
const first = await listPage(0);
const total = first.total; // only the first page says how many there are
if (total >= 2000) console.warn('This board is at the 2,000 cap: read it one filter value at a time.');
const postings = [...first.jobPostings];
for (let offset = 20; offset < Math.min(total, 2000); offset += 20) {
await sleep(700); // one request at a time, under 90 a minute
postings.push(...(await listPage(offset)).jobPostings);
}
console.log(`${postings.length} postings, total ${total}`);
for (const posting of postings.slice(0, 3)) console.log(posting.title, '|', posting.locationsText, '|', posting.externalPath);
Run against Workday's own careers site, with and without /en-US/ in the link, it printed 387 postings, total 387 both times, in about a minute. Keep the pause: these are a company's careers pages, and one request at a time is plenty.
The 2,000 cap, and reading past it
total stops at 2,000. On 24 September 2026, a board whose filter counts added up to 2,616 reported a total of 2,000, and paging cannot reach the rest.
The way past it is the careers page's own filters. Each entry in facets lists values with an id and a count:
{
"facetParameter": "jobFamilyGroup",
"descriptor": "Job Category",
"values": [
{ "descriptor": "Engineering", "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "count": 112 }
]
}
Put a value's id back in appliedFacets and the list narrows to it:
{ "appliedFacets": { "jobFamilyGroup": ["a1b2c3d4e5f60718293a4b5c6d7e8f90"] }, "limit": 20, "offset": 0, "searchText": "" }
On Workday's own site, the country filter for the United States narrowed 387 roles to 188, which is its count. Two things to watch when you split a large board:
- Pick a filter whose values each sit under 2,000, and split a value again if one does not.
-
Some filters overlap. Workday's own site has seven: remote type, country, job category, state, time type, distance and a location group. Its job category counts add up to the total (387); its country counts add up to 416, because a role in several countries is counted under each. Split by country and you must remove duplicates by
externalPath.
The posting record
Each posting's externalPath gives its own record with a GET:
curl "https://acme.wd5.myworkdayjobs.com/wday/cxs/acme/External/job/Melbourne/Data-Engineer_JR-0001042"
The posting sits under jobPostingInfo:
-
startDate: the day the posting went up (2026-10-22on the record read), andendDatewhere the company has set a closing date (2026-10-29on the same record). They are the only absolute dates in either response. -
timeType: the company's own words, such as "Full Time". -
locationandadditionalLocations: every location label, as the company writes it. -
jobRequisitionLocation.country.alpha2Code: the ISO country code (US).countrybeside it has only a name and Workday's internal id. -
jobDescription: the whole posting as HTML; the record read on 23 October ran to 11,236 characters. -
jobReqId,remoteType,externalUrland Workday's internal ids.
Beside jobPostingInfo, hiringOrganization.name names the employing entity, such as "Workday, Inc.". Two things are absent: there is no pay field (where a company states pay, it is inside jobDescription, in its own words), and neither response names a recruiter.
The errors you will meet
| You sent | Workday answered |
|---|---|
limit of 21 or more |
HTTP 400 |
a language code in the path (/wday/cxs/acme/en-US/External/jobs) |
HTTP 405 |
the wrong wd host, or a tenant that does not exist |
HTTP 422 |
| a site name that does not exist | HTTP 404, "errorCode": "S21", "not found: Job_Posting_Site_ID=…"
|
| the tenant in capitals | HTTP 502 |
All five were checked on 23 October 2026. The 502 is the one that misleads, because it reads like an outage: check the tenant's case before you retry.
What a whole board costs
One list page per 20 postings, plus one record per posting for anything beyond title, location label and relative date. On 1 October 2026, Workday's own careers site listed 362 roles in 24 countries. Reading all of it took 381 requests (19 list pages and 362 records), every one answered at the first attempt, in 4 minutes 21 seconds at 90 requests a minute. 235 of the 362 stated a pay range in their text.
Where a script stops
Reading one board once is the script above plus a loop over the records. Watching boards over time is where the work is:
- Every full read costs a request per posting, because the list holds no date, country or text.
- Nothing says what changed. You keep yesterday's postings, compare, and decide what counts as new, changed or gone. An edit to a posting's text alone does not show in the list.
- Large boards need splitting past the cap, and the filters overlap.
We publish a Workday jobs scraper on the Apify Store that does this from the careers link: it pages and splits as above and, in incremental mode, reads a record only for a role the list shows is new or changed, then returns, and charges for, only the roles that changed since its last run. The full reference page has the measured run behind the cost figures.
Disclosure: Adderley Data, who wrote this, builds and sells that Actor; neither link carries a referral or affiliate code. We are not affiliated with Workday, and "Workday" is used only to say which careers sites this describes. The endpoint names no recruiter, and the Actor has no field for a person's name, email or phone.
Top comments (2)
The note that description-only edits don't appear in the list raises a question about incremental mode. Do you periodically recheck unchanged IDs, or document those edits as outside the changed-role guarantee?
Good question. It's the second: we document it rather than recheck. Change detection runs on what Workday's list returns (title, location as the list shows it, remote type and requisition id) before any posting's own record is fetched, which is what lets an unchanged posting cost no request and no charge. An edit only to the description doesn't change any of those, so it isn't reported as UPDATED; the README says so under incremental mode. Rechecking every unchanged posting would take the same requests as a full read, so if you need text edits, a periodic full run (incremental off, or a new state key) picks them up.