DEV Community

Fast Data
Fast Data

Posted on

How to scrape Workday job postings (myworkdayjobs.com) as JSON: the 2,000-result limit and how to get around it

A large share of big employers (NVIDIA, Salesforce, Wells Fargo and many more) run their careers page on Workday. The URLs look like https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite. If you want those jobs as clean JSON for a job board, a hiring-signal alert or a market analysis, most job APIs won't help you, because they don't cover Workday.

This post covers the do-it-yourself route first: how the endpoint works, the 2,000-result limit, and the facet trick that gets around it. After that I show how to get the same data in one call.

Disclosure: I built the Apify actor shown at the end (Career Site Jobs API). The DIY route below is free and works fine for a handful of companies.

How a Workday career site loads its jobs

Open any *.myworkdayjobs.com site and watch the network tab. The page is a JavaScript app, and the job list comes from a public JSON endpoint:

POST https://<tenant>.wd<N>.myworkdayjobs.com/wday/cxs/<tenant>/<site>/jobs
Content-Type: application/json

{"appliedFacets": {}, "limit": 20, "offset": 0, "searchText": ""}
Enter fullscreen mode Exit fullscreen mode

For NVIDIA, tenant is nvidia, N is 5 and site is NVIDIAExternalCareerSite. All three come straight from the careers URL. You can't guess them from a company name, so you need the URL once.

The response holds:

  • total: the number of matching jobs
  • jobPostings: 20 jobs, each with title, externalPath, locationsText, postedOn ("Posted 3 Days Ago") and sometimes remoteType
  • facets: the filters in the sidebar (job family, location, time type, ...), each with a facetParameter and a list of values with an id and a count

Pagination is plain offset paging in steps of 20. The job ID is the last part of externalPath (..._JR2017966), and GET /wday/cxs/<tenant>/<site><externalPath> returns the full posting with description and all locations.

The 2,000-result limit

This is where most scrapers quietly fail. Workday serves at most 2,000 results per query. On a site with 2,700 open jobs, total stops at 2,000 and paging past offset 2,000 gets you nothing more. You don't get an error. You just get 26% fewer jobs than the site has.

The fix is in the facets block. Every facet value has its own count, and a query filtered to one value has its own 2,000 window. So:

  1. Fetch page 0 without filters and read facets.
  2. Pick a facet where every value has fewer than 2,000 jobs (job family or category usually works).
  3. Page through each value separately via appliedFacets: {"<facetParameter>": ["<value id>"]}.
  4. De-duplicate by externalPath, because some facets let one job appear under two values.

The sum of the facet counts is also the best estimate of how many jobs are really open, which is how you notice you hit the limit in the first place.

A DIY sketch in Python

This is a sketch. My dev sandbox can't reach myworkdayjobs.com, so I haven't run this exact script. The request body, headers and response fields match what the actor below sends and parses, and that part worked in the run shown further down.

import time, requests

BASE = "https://nvidia.wd5.myworkdayjobs.com"
API = f"{BASE}/wday/cxs/nvidia/NVIDIAExternalCareerSite/jobs"
HEADERS = {"Content-Type": "application/json", "Accept": "application/json",
           "Accept-Language": "en-US"}
WINDOW = 2000

def page(offset, facets=None):
    body = {"appliedFacets": facets or {}, "limit": 20,
            "offset": offset, "searchText": ""}
    r = requests.post(API, json=body, headers=HEADERS, timeout=30)
    r.raise_for_status()
    time.sleep(0.5)  # be polite: one request at a time, small pause
    return r.json()

def fetch_all(query=None, total=None):
    first = page(0, query)
    total = min(total or first["total"], WINDOW)
    pages = [first] + [page(o, query) for o in range(20, total, 20)]
    return [p for d in pages for p in d.get("jobPostings", [])]

first = page(0)
jobs = []
if first["total"] < WINDOW:
    jobs = fetch_all()
else:
    # split by a facet whose values all stay below the 2,000 window
    for f in first.get("facets", []):
        vals = [v for v in f.get("values", []) if "id" in v and "count" in v]
        if vals and max(v["count"] for v in vals) < WINDOW:
            for v in vals:
                jobs += fetch_all({f["facetParameter"]: [v["id"]]}, v["count"])
            break

unique = {j["externalPath"]: j for j in jobs}
print(len(unique), "jobs")
Enter fullscreen mode Exit fullscreen mode

Things to know before you run it at scale:

  • Rate limits. Workday tenants throttle aggressive clients. One request at a time with a short pause is plenty for a daily run. The actor never sends more than 5 Workday requests in parallel.
  • Some facets are nested (e.g. location groups contain locations). The sketch skips values without a flat id/count.
  • Dates are relative. "Posted 30+ Days Ago" has no exact date. You need the detail request for that.
  • locationsText can say "5 Locations". The real list is only in the detail response.
  • Be a good citizen. Check the site's terms and robots.txt, use only the public endpoint the career site itself calls, and don't log in, rotate identities or work around a block. If a tenant refuses automated requests, skip it. Job postings are business data, but descriptions sometimes name a recruiter, so don't build person profiles from them.

That's a working base for one company. In practice the maintenance is in everything around it: tenants on myworkdaysite.com with a different URL scheme, sites that need the site ID read from the HTML, 400s for renamed sites, de-duplication, and remembering which jobs you've already seen.

The same thing in one call

The Career Site Jobs API actor does exactly the above: same public endpoint, paging, automatic facet splitting above 2,000 (up to 10,000 jobs per company and run), with no login and no browser. It also covers Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Teamtailor, Recruitee and Personio with the same output schema.

I ran it on 23 October 2026 with a deliberately tiny limit:

{
  "companies": ["https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite"],
  "maxJobsPerCompany": 5
}
Enter fullscreen mode Exit fullscreen mode

The run finished in 5 seconds and returned 5 jobs. The SUMMARY record reported "jobsOpenOnSite": 2719 for NVIDIA, so this site is over the 2,000 window, and a plain offset loop would miss more than 700 jobs. One of the returned items:

{
  "title": "Senior Software Developer, Test and Automation",
  "company": "Nvidia",
  "ats": "workday",
  "location": "US, CA, Santa Clara",
  "remote": false,
  "department": null,
  "employmentType": null,
  "postedAt": "2026-10-23T00:00:00+00:00",
  "url": "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite/job/US-CA-Santa-Clara/Senior-Software-Developer--Test-and-Automation_JR2026347",
  "jobId": "JR2026347"
}
Enter fullscreen mode Exit fullscreen mode

A few honest notes. department is always empty for Workday because the endpoint doesn't publish it. Two of the five items had location: "5 Locations"/"2 Locations". Turn on includeDescription to get the full location list, employment type, exact date and plain-text description, which costs one extra request per job and makes the run slower.

Cost: pricing is per job returned, at $2 per 1,000 jobs (higher Apify plans go down to $1.50 per 1,000). These 5 jobs cost $0.01. Companies that are not found or that block the request return a free failed/not_found item instead of a broken run. All ~2,700 NVIDIA jobs would cost about $5.40.

From Python:

from apify_client import ApifyClient

client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("fast_data_asap/career-site-jobs-api").call(run_input={
    "companies": ["https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite"],
    "keywords": ["engineer"],
    "maxJobsPerCompany": 0,  # 0 = all open jobs
})
jobs = client.dataset(run["defaultDatasetId"]).list_items().items
Enter fullscreen mode Exit fullscreen mode

Only new jobs

For alerts you rarely want the full list every day. Set "onlyNewJobs": true and schedule the actor. The first run stores the job IDs as a baseline, and later runs return (and charge for) only postings that weren't there before. If nothing is new, you get one free no_results item.

Which one should you use?

  • One or two Workday sites, occasionally: use the DIY sketch. It's free and teaches you how the endpoint works.
  • Dozens of companies across several ATS, daily, with delta mode: that's the maintenance the actor saves you.

Either way, remember the 2,000 window. If your Workday scraper always returns exactly 2,000 jobs, it isn't done.

Top comments (0)