# ADP Jobs API

> Query jobs from ADP Workforce Now boards through one API with ?source=adp — plus why finding an ADP board is harder than fetching one.

Source: https://hyperjobs.io/ats/adp

ADP is payroll for a very large share of American employers, and Workforce Now
carries a recruiting module with a public career center attached. The API behind
it is unauthenticated, paginated, and returns full descriptions. There are 4,560
ADP postings in this dataset — a number that moves hourly, so read it from
[`/v1/meta`](/docs/api-reference/meta) rather than trusting this sentence.

That number is small next to [Oracle](/ats/oracle) or
[SuccessFactors](/ats/successfactors), and the reason why is the whole story of
this page. It isn't that ADP is hard to scrape. ADP is easy to scrape. It's that
for months, this adapter was finished, tested, working, and returned **nothing at
all** — because nobody could hand it a list of boards.

## The board id is a query parameter

Every other platform on this site gives each customer a subdomain.
`jobs.lever.co/<site>`. `careers-<slug>.icims.com`. `<tenant>.wd5.myworkdayjobs.com`.
That subdomain is a handle: you can crawl for it, pattern-match it in a link,
recognise it in a sitemap, guess it from a company name and check.

ADP doesn't have one. Every customer in the world is on the same host, and the
board is identified by a **`cid` query parameter** that is a bare UUID:

```
https://workforcenow.adp.com/mascsr/default/mdf/recruitment/recruitment.html?cid=9f2b1c04-7d3e-4a88-b115-6c0e2f7a91d4
```

There is nothing in that string to enumerate. `9f2b1c04-…` is not derivable from
the company's name, domain, or anything else about it. It is an opaque identifier
you either find written down somewhere or never learn. There is no directory, no
sitemap of tenants, and no pattern to sweep.

<Note title="Finding boards is the hard part, not fetching them">
  This is the single most-repeated lesson in building this dataset, and ADP is its
  cleanest example. A perfect adapter against a public, unauthenticated,
  well-behaved API is worth exactly zero jobs until someone solves discovery.
  Three platforms here sat in that state at once: ADP, Eightfold, and join.com.
  join.com turned out to have had a 23,548-board list sitting in a public dataset
  the whole time. [Eightfold](/ats/eightfold) is still waiting.
</Note>

### And then the boards were found, and it still returned nothing

The second half is better, because it's the kind of bug that survives code
review. Discovery eventually turned up ADP boards and reported success:

```
adp → 1530 new boards
```

The table got **1**.

A shared guard called `isAggregatorBoardToken` rejects board tokens that look like
bare UUIDs. That rule is correct and load-bearing for [Ashby](/ats/ashby), where a
`jobs-page-<uuid>` token is an *aggregator* board mixing many employers together
and must not be stored as one company. For ADP it was catastrophic, because for
ADP a bare UUID **is** the board id. The same rule that protects one platform
silently deletes another.

The log counted map inserts. The guard rejected at write. So the run reported 1,530
new boards and wrote one, and nothing anywhere returned an error. Between ADP and
Paylocity, which has the same UUID-shaped board id, 4,188 boards were nearly lost
to it.

## Try it

```bash
curl -G https://api.hyperjobs.io/v1/jobs \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "source=adp" \
  --data-urlencode "time_frame=7d" \
  --data-urlencode "limit=2"
```

```json title="Illustrative — the shape, not a live response"
{
  "data": [
    {
      "id": "adp:c41f8a26e9b74d02",
      "title": "Warehouse Associate",
      "company": {
        "slug": "cascade-supply",
        "name": "Cascade Supply Co",
        "domain": "cascadesupply.com",
        "website": "https://cascadesupply.com",
        "logo": "https://media.licdn.com/dms/image/…",
        "industry": "Wholesale",
        "employee_count": 640
      },
      "location": {
        "remote": false,
        "countries": ["United States"],
        "cities": ["Tacoma"],
        "regions": ["Washington"],
        "raw": ["Tacoma, WA, US"]
      },
      "employment_type": ["FULL_TIME"],
      "work_arrangement": "On-site",
      "seniority": null,
      "salary": {
        "min": 19,
        "max": 24,
        "currency": "USD",
        "unit": "HOUR",
        "annual_min": 39520,
        "annual_max": 49920,
        "summary": "19 To 24 (USD) Hourly"
      },
      "skills": ["forklift", "inventory"],
      "taxonomies": ["Operations"],
      "apply_url": "https://workforcenow.adp.com/mascsr/default/mdf/recruitment/recruitment.html?cid=9f2b1c04-7d3e-4a88-b115-6c0e2f7a91d4",
      "url": "https://workforcenow.adp.com/mascsr/default/mdf/recruitment/recruitment.html?cid=9f2b1c04-7d3e-4a88-b115-6c0e2f7a91d4",
      "source": "adp",
      "posted_at": "2026-07-12T00:00:00.000Z",
      "expires_at": null,
      "updated_at": "2026-07-14T05:52:03.000Z"
    }
  ],
  "total": 2,
  "limit": 2,
  "offset": 0
}
```

## How ADP boards actually work

Once you have the `cid`, it's two clean endpoints and OData-style paging:

```bash
# 1. List — $top / $skip, 50 per page
curl "https://workforcenow.adp.com/mascsr/default/careercenter/public/events/staffing/v1/\
job-requisitions?cid=<cid>&\$top=50&\$skip=0"

# 2. Detail — this is where the description lives
curl "https://workforcenow.adp.com/mascsr/default/careercenter/public/events/staffing/v1/\
job-requisitions/<itemID>?cid=<cid>"
```

The detail is worth the N+1 here. It carries `payGradeRange` on roughly **100%**
of postings, a verbatim `SalaryRange` string on about **96%**, and the employer's
own requisition number on about 100%. Descriptions land at **100%**.

`SalaryRange` is prose in a structured field, and it's the good kind: `19 To 24
(USD) Hourly`, `90000.00 To 100000.00 (USD) Annually`. The unit comes from a
`SalaryType` code — `HR` or `AN` — rather than being inferred, though a bound
under 200 is a wage whatever the code says.

## What bites you

<Warning title="There is no per-job apply URL on this source">
  ADP publishes no clean deep link to an individual posting. The list item's
  `links` array is empty, and `recruitment.html` is a single-page app that returns
  `200` for any parameters you give it, so a per-job URL can't be constructed and
  then trusted.

  So on `adp` rows, `url` and `apply_url` are the **board-level** career centre
  link, carrying only the `cid`. The candidate lands on the employer's ADP job
  list rather than on that job. Everywhere else on this site `apply_url` is the
  employer's real form for that posting; here it's as close as the platform
  permits. That's a deliberate limit, not an omission, and it's why title,
  location, description and pay are all complete on this source while the link is
  the weak field.
</Warning>

**Pagination that stops at 200 truncates silently.** The page size is 50 and the
loop has to run to a short page. A cap set for safety turns into a data bug on any
large employer, with a `200` on every request on the way.

**ADP's own item id is not the id in its URLs.** The API's `itemID` and the job id
used by ADP's older recruitment front end differ, so rows collected through the
API don't line up with rows collected from those URLs. Two ids for one job, and
neither is wrong.

## Or use ours

The API is genuinely pleasant. The dataset is the product.

You get 4,560 postings across the ADP boards we've found, with descriptions at
100%, structured pay off `payGradeRange` with the unit read from ADP's own code
rather than guessed, the employer's requisition number, and the [company
join](/docs/objects/company) — a `cid` is a UUID and tells you nothing about who
it belongs to.

Mostly, you get the board list. That's the part that was hard, that's the part
that's still growing, and that's the part you cannot go and fetch, because ADP
publishes no index of the customers running a public career centre on it.

Query it with `?source=adp`.
[Quickstart](/docs/quickstart) · [Pricing](/pricing)
