# Workday Jobs Scraper — Pay Ranges by Company Name (`mochiboo/workday-jobs-salary-scraper`) Actor

Scrape open roles from any company's Workday career site by typing the company name — no tenant ID, pod or site name needed. Extracts disclosed pay ranges (min, max, currency, hourly vs annual), location, remote type, requisition ID and posting date. For comp benchmarking and hiring intelligence.

- **URL**: https://apify.com/mochiboo/workday-jobs-salary-scraper.md
- **Developed by:** [mochi](https://apify.com/mochiboo) (community)
- **Categories:** Jobs, Automation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Workday Jobs Scraper — Pay Ranges by Company Name

Get every open role from any company's **Workday** career site by typing the company name. No tenant ID, no pod number, no career-site name — and no URL hunting.

Built for **compensation benchmarking** and **hiring intelligence**: alongside the usual job fields it extracts the **disclosed pay range** — minimum, maximum, currency, and whether the figure is hourly or annual — from the posting text, including the multi-band ranges that large US employers now publish under state pay-transparency laws.

***

### Why this is harder than it looks

A Workday career site lives at `https://<tenant>.wd<N>.myworkdayjobs.com/<CareerSite>`. To fetch one company you need all three parts:

| Part | Example | Where you find it |
|---|---|---|
| tenant | `cvshealth` | usually the company slug, but not always |
| pod | `wd1` / `wd5` / `wd12` / `wd103` | which Workday data centre hosts them — no pattern, no registry |
| career site | `CVS_Health_Careers` | freely chosen by the employer |

There is no public directory mapping companies to those values. This actor resolves all three from a plain company name by reading the tenant's own `robots.txt`, which publishes a `Sitemap:` directive for each career site it operates.

You type `CVS Health` — it finds `cvshealth.wd1` / `CVS_Health_Careers`.

You can also paste a career-site URL directly if you already have one.

***

### Pay-range coverage — measured, not claimed

Disclosure is entirely up to the employer, and it varies enormously. These are real numbers from a sweep of **325 live postings across 12 companies**:

| Company | Postings sampled | With a pay range |
|---|---|---|
| CVS Health | 30 | **100%** |
| Target | 30 | **93%** |
| Workday | 30 | 73% |
| Cisco | 30 | 63% |
| Adobe | 30 | 53% |
| NVIDIA | 30 | 43% |
| AstraZeneca | 30 | 17% |
| Pfizer | 30 | 13% |
| Shell | 30 | 10% |
| RBC | 24 | 8% |
| Salesforce | 30 | 7% |
| Unilever | 1 | 0% |
| **Overall** | **325** | **44%** |

Currencies seen: USD, EUR, CAD. Periods: annual and hourly. Roughly **1.65 pay bands per disclosing posting** — because many postings quote more than one (see below).

If you only want rows that carry pay, set **`onlyWithPayRange`**. US-filtered runs land far above the average — a `countryCodes: ["US"]` run on these companies returns close to 100% disclosure, because the disclosure laws are American.

***

### Multi-band ranges — the part most scrapers flatten

A single posting frequently contains several ranges, and they mean different things. Real examples from the captures in `fixtures/`:

**Cisco** publishes a base range plus state-specific ranges in the same posting:

```
starting salary range ................ $145,200.00 to $184,100.00
New York City Metro Area ............. $145,200.00 - $212,100.00
Non-Metro New York state & Washington  $129,100.00 - $188,300.00
```

**NVIDIA** publishes one range per level:

```
base salary range is 60,000 USD - 101,200 USD for Level 3,
                 and 72,000 USD - 120,750 USD for Level 4
```

**Target** publishes a starting range and a full range:

```
The starting pay range for this position per hour is $21.00 - $23.00
The full pay range for this position per hour is $21.00 - $35.70
```

The primary band is reported in the flat `payMin` / `payMax` columns, and **every** band is kept in `payBands`, each with its own `scope` (`starting` / `full`), `level` (e.g. `Level 3`) and an `includesVariablePay` flag for ranges labelled OTE or "cash compensation".

***

### Hourly vs annual

Workday postings do not label the period in a machine-readable field, and the wording is often ambiguous. CVS Health, for example, prints this next to *every* range:

> "This pay range represents the base hourly rate **or** base annual full-time salary…"

That sentence cannot decide the question, so:

- If the surrounding text gives exactly **one** unambiguous period phrase (`per hour`, `per year`, `annually`…), it is used and **`payPeriodInferred` is `false`**.
- If the text is silent or self-contradictory, the period is inferred from the magnitude, and **`payPeriodInferred` is `true`** so you can tell the difference.
- Between roughly 400 and 12,000 in a major currency, an amount could be monthly, weekly or a daily rate. **The period is left `null` rather than guessed.** A benchmark with gaps is more useful than one with invented numbers.

Magnitude inference is only applied to currencies with USD-like magnitudes. A JPY or INR figure never gets a guessed period.

***

### What the parser refuses to do

Every one of these was a real false positive caught against live postings during the build:

- **`Estimated Weekly Hours 40`** — a bare integer is never money, and "Weekly" here is not a pay period.
- **`500-5,000 FTE`**, **`BGP 1-7`** — a trailing capitalised acronym is only a currency if it is a real ISO-4217 code.
- **`$231,000 - $275,000 OTE`** — that *is* a real USD range; `OTE` is recorded as a variable-pay flag, not mistaken for the currency.
- **`a $500 sign-on bonus`** — a lone amount needs both an explicit period phrase and pay context before it counts as pay.
- **`$2,500.00 per hour of transaction volume`** — has a period phrase but no pay context, so it is ignored.
- A decoy number earlier in the description (`a portfolio of 200 to 300 accounts`) does not hide the real range further down.

***

### Output fields

| Field | Notes |
|---|---|
| `company`, `legalEntity`, `tenant`, `pod`, `careerSite`, `requestedAs` | who, and how it was resolved |
| `jobId`, `requisitionId`, `jobPostingId`, `title`, `jobUrl` | the requisition |
| `location`, `requisitionLocation`, `country`, `countryCode`, `additionalLocations`, `locationCount` | ISO alpha-2 country code included |
| `workplaceType`, `isRemote`, `timeType` | as Workday reports them; often unset |
| `postedOnText`, `postedDate`, `daysSincePosted`, `applicationDeadline`, `timeLeftToApply`, `isOpen` | freshness and deadlines |
| `hasPayRange`, `payMin`, `payMax`, `payCurrency`, `payCurrencySymbol` | the primary band |
| `payPeriod`, `payPeriodInferred`, `payIsSingleValue` | `hour` / `year` / `month` / `null` |
| `payIncludesVariablePay`, `payScope`, `payLevel` | OTE flag, starting-vs-full, level label |
| `payRangeRaw` | the exact source text, so you can audit any number |
| `payBandCount`, `payBands` | every band found, not just the first |
| `descriptionText` | optional (`includeDescription`) |
| `source`, `scrapedAt` | provenance |

Pay columns are always present, set to `null` when no range was found, so a CSV export keeps a stable shape.

***

### Input

| Option | Default | What it does |
|---|---|---|
| `companies` | 5 sample employers | Company names or career-site URLs |
| `maxResults` | `20` | Allocated **round-robin**, so every company is represented |
| `searchText` | `""` | Passed to Workday's own search per company |
| `onlyWithPayRange` | `false` | Keep only postings with a disclosed range |
| `titleKeywords` | `[]` | Substring match on the title |
| `locationKeywords` | `[]` | Matches location, requisition location, country, additional locations |
| `countryCodes` | `[]` | **Exact** ISO alpha-2 match — `US` does not also select `AUS` |
| `postedWithinDays` | `0` | Freshness filter; `0` disables |
| `remoteOnly` | `false` | Many employers leave the field unset; those are excluded |
| `includeDescription` | `false` | Adds the full plain-text description |
| `includeSecondarySites` | `false` | Early-careers / campaign sites, which mostly duplicate the main one |
| `includeAllSites` | `false` | Also sites marked internal / private / contractor |

**Round-robin matters.** Filling results company-by-company means the first company consumes the whole quota and every company after it silently returns nothing. Results here are interleaved as they are fetched, so a five-company run at `maxResults: 20` returns four from each.

***

### How it reports problems

Silence is the failure mode that costs you money, so the run log distinguishes:

- **unresolved** — no Workday tenant found for that name (it is never guessed at);
- **empty** — the career site resolved fine but genuinely lists **0** open postings (Dell's Workday instance is a real example);
- **fetch failure** — a request that actually failed.

The counts are also written to the `RUN_SUMMARY` key in the default key-value store.

***

### Source, limits and compliance

- **Source:** public Workday career sites — the same postings any visitor sees, read through the career site's own JSON endpoint.
- **robots.txt is honoured.** The resolver reads each tenant's `robots.txt` at run time, uses its `Sitemap:` directives for discovery, and will not request a path that tenant disallows. If a tenant ever names an AI crawler in `robots.txt`, that company is refused outright rather than fetched.
- **Non-public career sites are skipped** by default, including ones named internal, private, contractor or conversion-only, even where they are technically reachable.
- **PDPA / GDPR: organisation-level data only.** Employer, requisition, location and pay. No candidate or recruiter data is collected.
- **Coverage limits:** the actor only reaches employers who use Workday, and only sites listed in that tenant's `robots.txt`. Pay extraction depends on the employer disclosing in the posting text — 44% of postings in our sample, and 0% at some employers.
- **Rate:** requests are issued one at a time per company with retries and backoff.

# Actor input Schema

## `companies` (type: `array`):

Company names (e.g. "NVIDIA", "CVS Health") or full Workday career-site URLs. A plain name is resolved automatically to the company's Workday tenant, pod and career site by reading that tenant's robots.txt. Names that resolve to no Workday tenant are reported in the run log, never guessed at.

## `maxResults` (type: `integer`):

Maximum postings to write. Results are allocated round-robin across the companies, so every company you list is represented rather than the first one filling the quota.

## `searchText` (type: `string`):

Optional keyword passed to Workday's own job search for each company (e.g. "data engineer"). Leave empty to list all open postings.

## `onlyWithPayRange` (type: `boolean`):

Keep only postings where a pay range was found. Disclosure varies widely by employer — around 40% of postings across the companies we measured, from ~100% at CVS Health to 0% at Salesforce.

## `titleKeywords` (type: `array`):

Keep only postings whose title contains one of these (case-insensitive substring match).

## `locationKeywords` (type: `array`):

Keep only postings whose location, requisition location, country or additional locations contain one of these.

## `countryCodes` (type: `array`):

Keep only postings in these countries, matched exactly against the requisition's ISO alpha-2 code (e.g. "US", "GB", "IN"). Exact match, so "US" does not also select "AUS".

## `postedWithinDays` (type: `integer`):

Keep only postings first posted within this many days. 0 disables the filter.

## `remoteOnly` (type: `boolean`):

Keep only postings whose Workday workplace type is remote. Note that many employers leave this field unset, in which case the posting is excluded.

## `includeDescription` (type: `boolean`):

Add a descriptionText field with the plain-text job description. Makes rows much larger; the pay range is already extracted without it.

## `includeSecondarySites` (type: `boolean`):

Some companies run extra public career sites for early careers, internships or job-board campaigns. Off by default because they largely duplicate the main site.

## `includeAllSites` (type: `boolean`):

Also consider sites whose names mark them as internal, private or contractor-only. Off by default; these are not intended for public browsing even where they are technically reachable.

## Actor input object example

```json
{
  "companies": [
    "cvshealth",
    "nvidia",
    "cisco",
    "target",
    "adobe"
  ],
  "maxResults": 20,
  "searchText": "",
  "onlyWithPayRange": false,
  "titleKeywords": [],
  "locationKeywords": [],
  "countryCodes": [],
  "postedWithinDays": 0,
  "remoteOnly": false,
  "includeDescription": false,
  "includeSecondarySites": false,
  "includeAllSites": false
}
```

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "companies": [
        "cvshealth",
        "nvidia",
        "cisco",
        "target",
        "adobe"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("mochiboo/workday-jobs-salary-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "companies": [
        "cvshealth",
        "nvidia",
        "cisco",
        "target",
        "adobe",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("mochiboo/workday-jobs-salary-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "companies": [
    "cvshealth",
    "nvidia",
    "cisco",
    "target",
    "adobe"
  ]
}' |
apify call mochiboo/workday-jobs-salary-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,mochiboo/workday-jobs-salary-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/tBMGs2xPftddaFtsf/builds/WL5VvT1gSfSetKJGs/openapi.json
