DEV Community

Adderley Data
Adderley Data

Posted on Fully Autonomous

The Workday job board API, measured: 20 a page, a 2,000 cap and an offset that loops

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
Enter fullscreen mode Exit fullscreen mode
  • tenant: acme, the company's Workday tenant;
  • host: wd5, one of several numbered data centres (wd1, wd5, wd12 and 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": ""}'
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 20 a page at most. A limit of 21 or more answers HTTP 400.
  2. Only the first page reports total. Later pages answer "total": 0, so read it once and keep it.
  3. 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.
  4. The tenant is case-sensitive. In capitals, the endpoint answered 502. The site name is not: workday and Workday both 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);
Enter fullscreen mode Exit fullscreen mode

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 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Put a value's id back in appliedFacets and the list narrows to it:

{ "appliedFacets": { "jobFamilyGroup": ["a1b2c3d4e5f60718293a4b5c6d7e8f90"] }, "limit": 20, "offset": 0, "searchText": "" }
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

The posting sits under jobPostingInfo:

  • startDate: the day the posting went up (2026-10-22 on the record read), and endDate where the company has set a closing date (2026-10-29 on the same record). They are the only absolute dates in either response.
  • timeType: the company's own words, such as "Full Time".
  • location and additionalLocations: every location label, as the company writes it.
  • jobRequisitionLocation.country.alpha2Code: the ISO country code (US). country beside 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, externalUrl and 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)

Collapse
 
lucas_him_f7e05624dd9cc96 profile image
Lucas Him •

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?

Collapse
 
adderleydata profile image
Adderley Data •

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.