All platforms

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=paycomsource reference

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 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"
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.

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 anonymouslysub: 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.

`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.

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.

`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.

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. 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.

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.

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 — a Paycom board tells you a 32-hex clientkey and nothing else about who that is.

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

Query it with ?source=paycom. Quickstart · Pricing

Every source above normalises onto the same schema, with the hiring company already joined onto each posting.

Get an API key