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

CleanJobData Engineering

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.

On this page

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.

At a Glance

EndpointGET https://workforcenow.adp.com/mascsr/default/careercenter/public/events/staffing/v1/job-requisitions?cid={cid}&ccId={ccId}&lang={lang}&$top=20&$skip=0
Detail endpointGET .../job-requisitions/{itemID}?cid={cid}&ccId={ccId}&lang={lang}
API keyNone
Pagination$skip; about 20 rows per page whatever $top says
Full descriptionDetail call, requisitionDescription
Salary fieldYes, payGradeRange plus a SalaryRange text field
RemoteNo 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:

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:

{
  "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 }
}
FieldWhat it is
itemIDInternal ID, used for the detail call
customFieldGroup.stringFields → ExternalJobIDThe public job number, used in apply links
requisitionTitleJob title
postDatePosting time, with no time zone
requisitionLocations[]address.cityName, address.countrySubdivisionLevel1.codeValue (state), address.postalCode, nameCode.shortName
payGradeRangeminimumRate / maximumRate, each with amountValue and currencyCode
customFieldGroup.stringFields → SalaryRangeADP's readable pay text, including the period
workLevelCode.shortNameEmployment type, for example "Regular Full-Time"
meta.totalNumberTotal 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:

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:

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)

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 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, and compare with BambooHR and Workday.

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 or see the API Documentation.

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.

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.

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.