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.
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#
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"{
"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:
# 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.
`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