# ADP Jobs API: Get Workforce Now Job Postings as JSON (No Key)

> ADP Workforce Now career centers serve job requisitions as JSON with no key. Find cid, ccId and lang, page with $skip, and read pay ranges and gotchas.

Source: https://cleanjobdata.com/articles/how-to-scrape-adp-job-listings
Published: 2026-11-01

TL;DR: ADP Workforce Now career centers load jobs from a keyless JSON endpoint at workforcenow.adp.com, identified by the cid, ccId and lang values in the career page URL. The list returns titles, locations, dates and pay ranges about 20 rows at a time whatever $top says, so advance $skip by the rows you received. Each job's description comes from a detail call by itemID. Pay is in payGradeRange plus a readable SalaryRange string, and there's no employer name or remote flag in the data.

To get a company's jobs from ADP Workforce Now, call `https://workforcenow.adp.com/mascsr/default/careercenter/public/events/staffing/v1/job-requisitions?cid={cid}&ccId={ccId}&lang={lang}&$top=20&$skip=0` with no API key. The list returns titles, locations, dates and pay ranges, and each job's description comes from `/job-requisitions/{itemID}` with the same query parameters.

That's the DIY route, and it works for a handful of companies you already know. If you'd rather not build and maintain an ADP scraper, CleanJobData already ingests ATS platforms and company career sites and serves the jobs normalized through one REST API — see the [Jobs API docs](https://cleanjobdata.com/docs/api/jobs).

## At a Glance

| | |
|---|---|
| Endpoint | `GET https://workforcenow.adp.com/mascsr/default/careercenter/public/events/staffing/v1/job-requisitions?cid={cid}&ccId={ccId}&lang={lang}&$top=20&$skip=0` |
| Detail endpoint | `GET .../job-requisitions/{itemID}?cid={cid}&ccId={ccId}&lang={lang}` |
| API key | None |
| Pagination | `$skip`; about 20 rows per page whatever `$top` says |
| Full description | Detail call, `requisitionDescription` |
| Salary field | Yes, `payGradeRange` plus a `SalaryRange` text field |
| Remote | No flag; infer from title and location |

## Finding cid, ccId and lang

ADP career centers have URLs like this:

```
https://workforcenow.adp.com/mascsr/default/mdf/recruitment/recruitment.html?cid=11111111-2222-3333-4444-555555555555&ccId=19000101_000001&lang=en_US
```

Copy all three values:

- **`cid`** is the career center's GUID, one per employer.
- **`ccId`** was `19000101_000001` on every career center we've looked at, but it's a required parameter, so read it from the URL rather than hard-coding it.
- **`lang`** decides which jobs come back. A Canadian employer returned an empty list for `en_US` and its jobs for `en_CA`. Use the value in the employer's own URL.

Companies often link to the career center from their own site, so look for the "Careers" or "Apply" link that leads to `workforcenow.adp.com`.

## The List Request

`$top` and `$skip` start with a dollar sign, so wrap the URL in single quotes in the shell:

```bash
curl 'https://workforcenow.adp.com/mascsr/default/careercenter/public/events/staffing/v1/job-requisitions?cid=11111111-2222-3333-4444-555555555555&ccId=19000101_000001&lang=en_US&$top=20&$skip=0'
```

A trimmed response:

```json
{
  "jobRequisitions": [
    {
      "itemID": "9200000000000_1",
      "requisitionTitle": "Group Leader",
      "postDate": "2026-10-30T00:34:00",
      "requisitionLocations": [
        {
          "address": {
            "cityName": "Brooklyn",
            "countrySubdivisionLevel1": { "codeValue": "NY" },
            "postalCode": "11206"
          },
          "nameCode": { "shortName": " Brooklyn, NY, US" }
        }
      ],
      "payGradeRange": {
        "minimumRate": { "amountValue": 0.0, "currencyCode": "USD" },
        "maximumRate": { "amountValue": 20.0, "currencyCode": "USD" }
      },
      "workLevelCode": { "shortName": "Temporary Part-Time" },
      "customFieldGroup": {
        "stringFields": [
          { "nameCode": { "codeValue": "ExternalJobID" }, "stringValue": "1001" },
          { "nameCode": { "codeValue": "SalaryRange" }, "stringValue": " Up to 20 (USD) Hourly" }
        ]
      }
    }
  ],
  "meta": { "totalNumber": 113 }
}
```

| Field | What it is |
|---|---|
| `itemID` | Internal ID, used for the detail call |
| `customFieldGroup.stringFields` → `ExternalJobID` | The public job number, used in apply links |
| `requisitionTitle` | Job title |
| `postDate` | Posting time, with no time zone |
| `requisitionLocations[]` | `address.cityName`, `address.countrySubdivisionLevel1.codeValue` (state), `address.postalCode`, `nameCode.shortName` |
| `payGradeRange` | `minimumRate` / `maximumRate`, each with `amountValue` and `currencyCode` |
| `customFieldGroup.stringFields` → `SalaryRange` | ADP's readable pay text, including the period |
| `workLevelCode.shortName` | Employment type, for example "Regular Full-Time" |
| `meta.totalNumber` | Total jobs, sometimes missing |

`customFieldGroup` holds arrays called `stringFields`, `numberFields`, `dateFields`, `indicatorFields` and `codeFields`. Each entry is keyed by `nameCode.codeValue`, so you look values up by name:

```python
def custom_string(job: dict, key: str) -> str | None:
    for field in (job.get("customFieldGroup") or {}).get("stringFields") or []:
        if (field.get("nameCode") or {}).get("codeValue") == key:
            return field.get("stringValue")
    return None
```

## Pagination

ADP ignores `$top`. Asking for 50 returned 19, 20 and 20 rows across three pages of an 87-job board. Two things follow:

- Advance `$skip` by the number of rows you actually received, not by `$top`. A fixed step skips jobs.
- Don't stop because a page came back shorter than `$top`, or you'll stop after the first page. Stop on an empty `jobRequisitions` array, and use `meta.totalNumber` as a second check when it's present.

## The Detail Request

The description lives in a second call per job, keyed by `itemID`:

```bash
curl 'https://workforcenow.adp.com/mascsr/default/careercenter/public/events/staffing/v1/job-requisitions/9200000000000_1?cid=11111111-2222-3333-4444-555555555555&ccId=19000101_000001&lang=en_US'
```

The detail response repeats the list fields and adds `requisitionDescription`, the HTML description. Since the list already has everything else, you only need the detail call for jobs you haven't stored yet.

## Fetching Jobs (Python)

```python
import requests

API = "https://workforcenow.adp.com/mascsr/default/careercenter/public/events/staffing/v1/job-requisitions"

def fetch_adp_jobs(cid: str, ccid: str = "19000101_000001", lang: str = "en_US") -> list[dict]:
    base_params = {"cid": cid, "ccId": ccid, "lang": lang}
    rows, skip = [], 0
    while True:
        resp = requests.get(API, params={**base_params, "$top": 20, "$skip": skip}, timeout=20)
        resp.raise_for_status()
        data = resp.json()
        page = data.get("jobRequisitions") or []
        if not page:
            break
        rows.extend(page)
        skip += len(page)
        total = (data.get("meta") or {}).get("totalNumber") or 0
        if total and skip >= total:
            break

    jobs = []
    for row in rows:
        detail = requests.get(f"{API}/{row['itemID']}", params=base_params, timeout=15).json()
        if not detail.get("itemID"):
            continue  # the job is gone; ADP answers 200 with a stub
        ext_id = custom_string(row, "ExternalJobID") or row["itemID"]
        pay = detail.get("payGradeRange") or {}
        jobs.append({
            "id": ext_id,
            "title": detail.get("requisitionTitle"),
            "posted": detail.get("postDate"),
            "description_html": detail.get("requisitionDescription"),
            "locations": [
                (loc.get("nameCode") or {}).get("shortName", "").strip()
                for loc in detail.get("requisitionLocations") or []
            ],
            "pay_min": (pay.get("minimumRate") or {}).get("amountValue"),
            "pay_max": (pay.get("maximumRate") or {}).get("amountValue"),
            "currency": (pay.get("minimumRate") or pay.get("maximumRate") or {}).get("currencyCode"),
            "pay_text": custom_string(detail, "SalaryRange"),
            "url": (
                "https://workforcenow.adp.com/mascsr/default/mdf/recruitment/recruitment.html"
                f"?cid={cid}&ccId={ccid}&type=MP&lang={lang}&jobId={ext_id}"
            ),
        })
    return jobs
```

The API has no apply link, so the example builds the career center deep link. Its `jobId` is the `ExternalJobID`, not the `itemID`.

## Gotchas

**Country only appears in `shortName`.** `nameCode.shortName` reads `" Brooklyn, NY, US"`, with a leading space and an ISO alpha-2 country at the end. The `address` object has the city and a state code but no country. Trim the string and take the last part as the country.

**Some locations are empty.** A job can carry a second `requisitionLocations` entry with every field blank. Skip entries with no city, state or country.

**A zero minimum usually means "up to".** One posting had `minimumRate` of `0.0`, `maximumRate` of `20.0` and a `SalaryRange` of `" Up to 20 (USD) Hourly"`. Read `SalaryRange` for the period (other jobs say things like `"50000.00 to 55000.00 (USD) Annually"`) and don't store 0 as a real minimum. The [salary extraction guide](https://cleanjobdata.com/guides/salary-data-extraction) covers annualizing.

**`postDate` has no time zone.** Values look like `2026-10-30T00:34:00`. Decide which zone you assume and apply it consistently.

**Gone jobs return 200.** The detail call for a closed job answers 200 with a near-empty stub that has no `itemID`. Check for `itemID` rather than the status code.

**No employer name.** Nothing in the list or detail payload names the company, so you supply it yourself.

**No remote flag.** Check the title and location text for "remote" or "hybrid".

**Edits are easy to miss.** If you only fetch details for new jobs, you won't see a changed description on a job you already stored.

## Career Center Endpoint vs ADP's Official APIs

ADP's documented APIs are for ADP clients and approved partners, and they need credentials tied to the employer's ADP account. They cover payroll and HR data as well as recruiting. The career center endpoint used here is the one ADP's public job pages call, so it only returns published jobs and needs no account, but it's undocumented and can change.

## When This Is Enough

For one employer whose career center URL you have, the list plus a detail call for new jobs is simple, and the structured pay data is a bonus most platforms don't give you.

## When It Isn't

Once you need many employers, you also need each one's `cid` and correct `lang`, and ADP is only one of the platforms employers use. See [which ATS have a public job postings API](https://cleanjobdata.com/articles/ats-public-job-postings-api-list), and compare with [BambooHR](https://cleanjobdata.com/articles/how-to-scrape-bamboohr-job-listings) and [Workday](https://cleanjobdata.com/articles/how-to-scrape-workday-job-listings).

CleanJobData reads ADP career centers alongside other platforms and returns one schema, with parsed salary ranges, locations resolved to cities, states and countries, and a remote type. Try it in the [Playground](https://cleanjobdata.com/playground) or see the [API Documentation](https://cleanjobdata.com/docs).

## Skip the scraper: get this data from the CleanJobData API

If you'd rather not maintain an ADP collector, you can query normalized jobs from ATS platforms and company career sites with a single request. The response has structured city/state/country locations, parsed salary when the employer publishes one, a remote type, and company metadata, and you page through results with the cursor in `pagination.next_page`.

```bash
curl "https://api.cleanjobdata.com/jobs?title=manager&domain=workforcenow.adp.com&limit=20" \
  -H "Authorization: Bearer $CLEANJOBDATA_API_KEY"
```

The `domain` filter is an exact match on the host of each job's `application_url`, so it only returns jobs whose apply link resolves to `workforcenow.adp.com`; use `company_website_url` or `employer_id` to scope to one employer instead. Coverage depends on which employers are in the dataset, so check that the companies you care about appear before you rely on it.

- [Jobs API reference](https://cleanjobdata.com/docs/api/jobs) for every filter and response field
- [Playground](https://cleanjobdata.com/playground) to run queries without writing code
- [Pricing](https://cleanjobdata.com/pricing) — the Trial is free with no card required

## FAQ

### Does ADP Workforce Now have a public jobs API?

Its career centers load jobs from a JSON endpoint at `workforcenow.adp.com/mascsr/default/careercenter/public/events/staffing/v1/job-requisitions` that needs no API key. You pass the employer's `cid`, `ccId` and `lang` from the career center URL.

### Why does the ADP jobs API return only 20 jobs?

ADP returns about 20 rows per page whatever `$top` you send. Increase `$skip` by the number of rows each page returned and keep going until a page is empty.

### Does the ADP jobs API include salary?

Often, yes. `payGradeRange` has a minimum and maximum with a currency code, and the `SalaryRange` custom field has ADP's readable text, including whether the rate is hourly or annual.

### Why does the ADP API return no jobs for a company that is hiring?

Usually the `lang` value is wrong. Some employers only return jobs for their own language code, such as `en_CA`, so copy `lang` from the employer's career center URL.
