# Paycom Jobs API

> Query jobs from Paycom career portals through one API with ?source=paycom — plus how Paycom's anonymous JWT and its job-posting-previews search really behave.

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

Paycom is a US payroll and HR platform, and its ATS career portals are the
frontline end of this pool: restaurant groups, gyms, clinics, franchisees,
municipal employers. Boards live at
`paycomonline.net/v4/ats/web.php/portal/<clientkey>/career-page` — a React app in
front of a JSON API — and there are 6,960 Paycom postings in this dataset, a
number that moves hourly, so read it from [`/v1/meta`](/docs/api-reference/meta)
rather than trusting this sentence.

## Try it

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

```json title="Illustrative — the shape, not a live response"
{
  "data": [
    {
      "id": "paycom:1563f0a94c7e42b8a10d6ef25b937c8f_278",
      "title": "Line Cook",
      "company": {
        "slug": "sunfire-pizza",
        "name": "Sunfire Pizza",
        "domain": "sunfirepizza.com",
        "website": "https://sunfirepizza.com",
        "logo": "https://media.licdn.com/dms/image/…",
        "industry": "Restaurants",
        "employee_count": 90
      },
      "location": {
        "remote": false,
        "countries": ["United States"],
        "cities": ["Denver"],
        "regions": ["Colorado"],
        "raw": ["Uptown - Denver, CO 80203"]
      },
      "employment_type": ["FULL_TIME"],
      "work_arrangement": "On-site",
      "seniority": null,
      "salary": {
        "min": null,
        "max": null,
        "currency": null,
        "unit": null,
        "annual_min": null,
        "annual_max": null,
        "summary": "Salary"
      },
      "skills": ["food preparation"],
      "taxonomies": ["Hospitality"],
      "apply_url": "https://www.paycomonline.net/v4/ats/web.php/portal/1563f0a94c7e42b8a10d6ef25b937c8f/jobs/278",
      "url": "https://www.paycomonline.net/v4/ats/web.php/portal/1563f0a94c7e42b8a10d6ef25b937c8f/jobs/278",
      "source": "paycom",
      "posted_at": "2026-07-12T00:00:00.000Z",
      "expires_at": null,
      "updated_at": "2026-07-14T08:02:55.000Z"
    }
  ],
  "total": 2,
  "limit": 2,
  "offset": 0
}
```

That `salary` object is deliberate and it is the honest output. It's non-null and
carries no number, because Paycom published the word "Salary" and no period —
see [below](#the-pay-period-exists-only-in-the-text).

## How Paycom boards actually work

Three steps, and the first one is not optional:

```bash
# 1. Bootstrap — the shell embeds an ANONYMOUS session JWT + the regional API host
curl "https://www.paycomonline.net/v4/ats/web.php/portal/<clientkey>/career-page"

# 2. List — POST; the Authorization header is BARE, not "Bearer"
curl -X POST "https://<mantle-host>/api/ats/job-posting-previews/search" \
  -H "Authorization: <jwt>" \
  -d '{"skip":0,"take":2000,"filtersForQuery":{"sortOption":"", …}}'

# 3. Detail — the body lives here
curl -H "Authorization: <jwt>" "https://<mantle-host>/api/ats/job-postings/<jobId>"
```

The JWT is minted **anonymously** — `sub: 0`, no login, a two-hour TTL — and it
**is the tenant selector**. No clientkey is ever sent to the API; the token's own
claim pins it to one tenant, and tenant A's JWT 404s on tenant B's job. So the
bootstrap fetch is mandatory once per board, which is also convenient: the shell
is where the regional API host comes from, rather than being hardcoded.

`take` is uncapped. One request returned all 445 postings of a 445-job board, a
set identical to the paged union crawled to exhaustion, and `skip` is honest —
pages at `take=20` had zero id overlap.

<Warning title="`sortOption` is the only required key, and omitting it returns an empty board">
  Drop it and the API answers **HTTP 200** with
  `{"jobPostingPreviews":[],"jobPostingPreviewsCount":0}` — indistinguishable
  from a board with no open roles.

  This was measured by dropping each key of the request body in turn against a
  3-job board: every other key can go and you still get 3. Drop `sortOption`
  alone and you get 0. `{"sortOption":""}` on its own gets you 3. An empty
  string, in a filter object, is the difference between a working scrape and a
  silently dead one.
</Warning>

## What bites you

**The list's `description` is a hard 153-character teaser.** It looks inline and
complete — a `description` field, populated, on every row. It's a truncation: 440
of 445 rows on one board were *exactly* 153 characters. The detail call is
mandatory for the real body, which runs to a median of about 4.9k characters and
up to 18.6k.

**`qualifications` is a separate block and it is not inside `description`.** It's
frequently the longer half — a requirements list that reads as the substance of
the posting. Joining it moved bodies on a real board from a 4,385-character median
to 7,461. Fetch the detail, take only `description`, and you ship two thirds of
each job while believing you shipped all of it.

<Warning title="`jobId` is per-tenant, and the collisions are real">
  Six turned up across just 140 boards. `jobId` **278** is "Uptown -
  Cook/Counter" at a Denver pizza restaurant **and** "Dishwasher (55)" at a
  Wisconsin hospitality group. A key of `paycom:278` collapses two employers'
  postings into one row, silently.

  Paycom's own `identifier` is `J{clientCode}{jobId}` — the platform telling you
  the bare id was never unique. Ours is `paycom:<clientkey>_<jobId>`, lowercased,
  because the **clientkey is case-insensitive** at source: the same board answers
  on `1563…6CF` and `1563…6cf`, so two casings of one key would register as two
  boards and double every job on it.
</Warning>

**`validThrough` is PHP's zero-date on most rows, and it parses.**
`"-0001-11-30"` does not fail — a naive ISO conversion turns it into
`2030-01-10T23:00:00.000Z`, a perfectly plausible future expiry, on 79 of 80 rows
of one board. So the choice is a guarded parse or a dataset where four fifths of
Paycom postings claim to expire in 2030. Guarded, `expires_at` is null where the
tenant left it unset and real where they set it.

**The location label is a trap.** `location` is `{site label} - {City, ST ZIP}`,
and the label is a building, a code or a department: `Legal Address - Vineland, NJ
08360`, `TEMP - Temple, TX 76501`, `HOUS - Houston…`. Passed to a location parser
verbatim it stores **city = "Legal Address"**, **city = "TEMP"**, **city =
"HOUS"**. The structured address in the posting's JSON-LD block is the ground
truth, and it's present on every job that has a place at all — the rest are
genuinely "Fully Remote".

### The pay period exists only in the text

`baseSalary.value.unitText` is the empty string on 216 of 216 rows. The JSON-LD
carries clean numbers and **no period**. The only place a period ever appears is
the free-text `salaryRange` field, whose vocabulary is
`Hourly | Salary | Commission | Base+Commission`, optionally with `/year` or
`/month` — and only some of those state a period at all.

A bare `"Salary"` on that platform spans $200 to $250,000. It is not safely
annual, it is not safely anything, so we emit no number and pass the text through
verbatim as [`salary.summary`](/docs/objects/job#salary). That's why a Paycom
posting can return a non-null `salary` with nothing usable in it — which is true
of the dataset generally, and most visible here.

**`positionType` and `educationLevel` are tenant free text**, not the enum the
platform's own filter UI suggests. `positionType` really contains `Cashier` (a job
title) and `FT1` (an internal code) beside `Full Time`; `educationLevel` contains
`None` and `Not Specified`. Both are filtered rather than trusted.

<Note title="Description fill here is tenant-dependent, not board health">
  Across three tenants the bodies came in at 99%, 100% and **63%**. The 63% is
  not an adapter gap: those 211 postings carry `description: ""` **and**
  `qualifications: ""` at source, at HTTP 200, on a real record — a franchise that
  simply never wrote its "Team Member" posts, while a control job on the same
  board returns 792 + 1,275 characters. A board-level description percentage is
  not a health signal on Paycom. It's a fact about that employer.
</Note>

## Or use ours

Paycom is cheap to fetch and unusually easy to get wrong in ways that return 200
the whole time: the empty `sortOption` that zeroes a board, the 153-character
teaser that looks like a body, the missing `qualifications` half, the zero-date
that becomes a 2030 expiry, the site label that becomes a city.

You get the body joined from both blocks rather than the shorter one, the
tenant-scoped and case-folded ids that stop one restaurant's `278` from clobbering
another's, expiry guarded against the zero-date, the city from the structured
address instead of `TEMP`, pay left unstructured rather than annualised from a
word, and the [company join](/docs/objects/company) — a Paycom board tells you a
32-hex clientkey and nothing else about who that is.

Refresh cadence varies by [source](/docs/reference/sources); the uncapped list is
cheap and the per-posting detail is not. See [coverage](/docs/reference/coverage),
and don't assume uniform latency across sources.

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