# Cornerstone OnDemand Jobs API

> Query jobs from Cornerstone OnDemand boards through one API with ?source=cornerstone — plus how Cornerstone's scraped-JWT handshake really behaves.

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

Cornerstone OnDemand is a talent-management suite built around learning and
compliance as much as hiring, which is the kind of thing large, regulated
employers buy. Its career sites live on `csod.com`, and there are 12,463
Cornerstone postings in this dataset — a number that moves hourly, so read it from
[`/v1/meta`](/docs/api-reference/meta) rather than trusting this sentence.

Cornerstone is unusual in a good way: its descriptions arrive **in the listing**,
so there's no detail call per posting. It's unusual in a less good way too, which
is the handshake.

## Try it

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

```json title="Illustrative — the shape, not a live response"
{
  "data": [
    {
      "id": "cornerstone:req-88104",
      "title": "Registered Nurse, Intensive Care",
      "company": {
        "slug": "st-aubin-health",
        "name": "St Aubin Health System",
        "domain": "staubinhealth.org",
        "website": "https://staubinhealth.org",
        "logo": "https://media.licdn.com/dms/image/…",
        "industry": "Hospitals and Health Care",
        "employee_count": 12400
      },
      "location": {
        "remote": false,
        "countries": ["United States"],
        "cities": ["Portland"],
        "regions": ["Oregon"],
        "raw": ["Portland, Oregon, United States"]
      },
      "employment_type": ["FULL_TIME"],
      "work_arrangement": "On-site",
      "seniority": null,
      "salary": null,
      "skills": ["icu", "acls"],
      "taxonomies": ["Healthcare"],
      "apply_url": "https://staubin.csod.com/ux/ats/careersite/4/job/88104?c=staubin",
      "url": "https://staubin.csod.com/ux/ats/careersite/4/job/88104?c=staubin",
      "source": "cornerstone",
      "posted_at": "2026-07-10T00:00:00.000Z",
      "expires_at": "2026-08-09T00:00:00.000Z",
      "updated_at": "2026-07-14T08:33:19.000Z"
    }
  ],
  "total": 2,
  "limit": 2,
  "offset": 0
}
```

## How Cornerstone boards actually work

Two steps, and the first one exists only to get a token:

```bash
# 1. GET the career site HTML — scrape the JWT and the regional API host out of it
curl "https://<tenant>.csod.com/ux/ats/careersite/<siteId>/home?c=<slug>"

# 2. POST the search, carrying that JWT — descriptions included
curl -X POST "https://na.api.csod.com/rec-job-search/external/jobs" \
  -H "Authorization: Bearer <token>" \
  -H "content-type: application/json" \
  -d '{"careerSiteId":4,"careerSitePageId":4,"pageNumber":1,"pageSize":50,
       "cultureId":1,"cultureName":"en-US"}'
```

### The JWT is in the page, and it isn't a login

The career-site HTML carries `csod.context.token = '<jwt>'`. There's no account,
no client credentials and no OAuth dance — the token is a public context token
that the tenant's own front end uses, and the API rejects you without it. So the
"auth" is really a scrape: fetch the HTML, pull the token, use it before it
expires.

**The API host is in that HTML too, and it's regional.** `na.api.csod.com`,
`eu-fra.api.csod.com`, `uk.api.csod.com`. Hardcode the North American one and
every European tenant fails, which looks like a dead board rather than a wrong
host.

### The description is in the listing

The `externalDescription` field on each requisition is the full body. No N+1, no
detail fetch, one POST per 50 postings. This is why Cornerstone descriptions land
at **99.7%** and why the platform is cheap to keep fresh despite being enterprise.

## What bites you

<Warning title="Unpublished drafts ship with the real jobs">
  Cornerstone returns template and draft requisitions through the same public
  endpoint as live ones, and they announce themselves in the title:

  ```
  <<ENTER DISPLAY JOB TITLE>>
  // ENTER DISPLAY JOB TITLE \\
  ```

  They parse perfectly. Nothing about the response says they're not real. Store
  them and your dataset has jobs whose title is a placeholder an admin never
  filled in.
</Warning>

**"Please upload the job description" is a description.** As far as any fill-rate
check is concerned, anyway. A subset of postings carry a placeholder body —
`please upload the job description`, `no description available`, `see job
description` — which is a populated string of plausible length. It counts as a
hit on every naive coverage metric and is worth nothing to a reader. We null it
rather than serve it.

**A raw job URL is not a board.** Same shape of trap as
[Oracle](/ats/oracle): the careers URL and a link to one posting differ by a
`/requisition/{id}` suffix, so storing whatever URL discovery found turns every
requisition into its own board. Canonicalise to host, site id and slug first.

**`country` is an ISO-2 code.** `MG`, not `Madagascar`. The location string won't
resolve until it's expanded, and a two-letter token is exactly the kind of thing a
geocoder will cheerfully match to something else.

## Or use ours

The listing-side descriptions genuinely are the easy part. The rest isn't.

You get the JWT handshake and the regional host resolved per tenant, draft
requisitions and placeholder bodies filtered out rather than counted, ISO-2
countries expanded so locations resolve deterministically, real posting expiry
from `postingExpirationDate` rather than a synthetic default, and the [company
join](/docs/objects/company). And you get the board list, which is the part that
took the time: Cornerstone publishes no index of its customers, so every tenant
behind these 12,463 postings was found individually.

Cornerstone sits in the heavy refresh tier, attempted every third cycle — see
[coverage](/docs/reference/coverage).

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