All platforms

Paycor Jobs API.

Pull structured job titles, locations, and full descriptions from company career boards hosted on Paycor's recruiting (gnewton) platform at recruitingbypaycor.com.

Get API access
Paycor
Live
<3haverage discovery time
1hrefresh interval
Developer tools

Try the API.

Test Jobs and Feed endpoints against https://connect.jobo.world with live request/response examples, then copy ready-to-use curl commands.

What's in every response.

Data fields, real-world applications, and the companies already running on Paycor.

Data fields
  • Full HTML job descriptions
  • Job titles
  • Office & remote locations
  • Department & team grouping
  • Native hex job IDs
  • Openings count
Use cases
  1. 01Career board aggregation
  2. 02SMB hiring signals
  3. 03Local job market tracking
  4. 04Recruiting data pipelines
DIY GUIDE

How to scrape Paycor.

Step-by-step guide to extracting jobs from Paycor-powered career pages—endpoints, authentication, and working code.

HTMLintermediateNo official limit; keep ~250ms between requests and cap concurrent detail fetches at ~3No auth

Validate the URL and extract the clientId

Every Paycor board lives on recruitingbypaycor.com and is keyed by a hexadecimal clientId (16+ hex chars) in the query string. Confirm the host and clientId before fetching, then build the canonical CareerHome.action listings URL.

Step 1: Validate the URL and extract the clientId
from urllib.parse import urlparse, parse_qs
import re

url = "https://recruitingbypaycor.com/career/CareerHome.action?clientId=8a7883c67239c84401727af009b53231"

parsed = urlparse(url)
assert parsed.netloc == "recruitingbypaycor.com", "Not a Paycor recruiting host"

client_id = (parse_qs(parsed.query).get("clientId") or [""])[0]
assert re.fullmatch(r"[0-9a-fA-F]{16,}", client_id), "Missing or malformed clientId"

listings_url = f"https://recruitingbypaycor.com/career/CareerHome.action?clientId={client_id}"
print(listings_url)

Fetch the career board HTML

Paycor serves the board as plain server-rendered HTML — there is no JSON endpoint (format=json still returns text/html and /career/api/jobs returns 404). Request the page with an HTML Accept header and a short timeout.

Step 2: Fetch the career board HTML
import requests

resp = requests.get(
    listings_url,
    headers={"Accept": "text/html,application/xhtml+xml"},
    timeout=15,
)
resp.raise_for_status()
page_html = resp.text

Parse Template A (server-rendered anchors)

Most tenants render a sort-by-location board with anchor tags pointing at JobIntroduction.action. Extract each job's clientId and id, skip cross-client links and duplicates, and build the canonical detail URL. Location group headers appear in div.gnewtonCareerGroupHeaderClass elements that precede each run of rows.

Step 3: Parse Template A (server-rendered anchors)
from bs4 import BeautifulSoup
from urllib.parse import urlparse, parse_qs
import html

soup = BeautifulSoup(page_html, "html.parser")

listings = []
seen = set()
for a in soup.select('a[href*="JobIntroduction.action"]'):
    href = html.unescape(a.get("href", ""))
    qs = parse_qs(urlparse(href).query)
    href_client = (qs.get("clientId") or [""])[0]
    job_id = (qs.get("id") or [""])[0]

    # Skip cross-client links and repeated job IDs.
    if href_client.lower() != client_id.lower() or not job_id or job_id in seen:
        continue
    seen.add(job_id)

    listings.append({
        "external_id": f"{client_id}:{job_id}",
        "job_id": job_id,
        "title": a.get_text(strip=True),
        "detail_url": (
            "https://recruitingbypaycor.com/career/JobIntroduction.action"
            f"?clientId={client_id}&id={job_id}&source=&lang=en"
        ),
    })

print(f"Template A found {len(listings)} jobs")

Fall back to Template B (JavaScript arrays)

Tenants using the sort-by-department view emit listings as JavaScript arrays (jobName[dep][i] and jobId[dep][i]) instead of anchors. If Template A yields nothing, pair the two arrays by department and index to recover titles and job IDs.

Step 4: Fall back to Template B (JavaScript arrays)
import re

if not listings:
    names = {}
    for dep, idx, title in re.findall(
        r'jobName\["([0-9a-fA-F]{16,})"\]\[(\d+)\s*-\s*1\]\s*=\s*"([^"]*)"',
        page_html,
    ):
        names[(dep, idx)] = title

    for dep, idx, job_id in re.findall(
        r'jobId\["([0-9a-fA-F]{16,})"\]\[(\d+)\s*-\s*1\]\s*=\s*"([0-9a-fA-F]{16,})"',
        page_html,
    ):
        if any(l["job_id"] == job_id for l in listings):
            continue
        listings.append({
            "external_id": f"{client_id}:{job_id}",
            "job_id": job_id,
            "title": names.get((dep, idx), ""),
            "detail_url": (
                "https://recruitingbypaycor.com/career/JobIntroduction.action"
                f"?clientId={client_id}&id={job_id}&source=&lang=en"
            ),
        })

    print(f"Template B found {len(listings)} jobs")

Fetch full job details

The board only carries titles and locations, so request each JobIntroduction.action detail page for the full description. Paycor renders labeled cells with stable gnewton ids: #gnewtonJobPosition (title), #gnewtonJobDescriptionText (description HTML), #gnewtonJobLocationInfo (one or more locations), plus #gnewtonJobID and #gnewtonJobOpening.

Step 5: Fetch full job details
import re
import requests
from bs4 import BeautifulSoup

def fetch_detail(detail_url: str) -> dict | None:
    resp = requests.get(
        detail_url,
        headers={"Accept": "text/html,application/xhtml+xml"},
        timeout=15,
    )
    resp.raise_for_status()

    # Skip stale pages that no longer describe a live job.
    if re.search(r"this\s+job\s+is\s+no\s+longer\s+active", resp.text, re.I):
        return None

    soup = BeautifulSoup(resp.text, "html.parser")

    def cell(selector: str, strip_label: bool = False) -> str | None:
        el = soup.select_one(selector)
        if el is None:
            return None
        text = el.get_text(" ", strip=True)
        if strip_label and ":" in text:
            text = text.split(":", 1)[1].strip()
        return text or None

    desc_el = soup.select_one("#gnewtonJobDescriptionText")
    return {
        "title": cell("#gnewtonJobPosition", strip_label=True),
        "description_html": desc_el.decode_contents().strip() if desc_el else None,
        "locations": [el.get_text(" ", strip=True) for el in soup.select("#gnewtonJobLocationInfo")],
        "client_job_id": cell("#gnewtonJobID", strip_label=True),
        "openings": cell("#gnewtonJobOpening", strip_label=True),
    }

for job in listings[:3]:
    print(fetch_detail(job["detail_url"]))

Distinguish an empty board from a parse failure

Never record zero jobs just because no links matched — that can mean template drift, a truncated body, or an interstitial. Only treat a board as genuinely empty when Paycor renders its explicit gnewtonNoActiveJobs sentinel; otherwise retry rather than persisting an empty snapshot.

Step 6: Distinguish an empty board from a parse failure
import re

if not listings:
    if re.search(r'id\s*=\s*["\']gnewtonNoActiveJobs["\']', page_html, re.I):
        print("Board explicitly reports no active jobs — safe to record as empty")
    else:
        raise RuntimeError(
            "No jobs parsed and no empty-state sentinel found — "
            "treat as incomplete and retry, do not record zero jobs"
        )
Common issues
highNo job links match, but the board is not actually empty

Template drift, a truncated body, or an interstitial can all produce zero matches. Only treat the board as empty when the explicit gnewtonNoActiveJobs sentinel is present; otherwise retry rather than persisting a zero-job snapshot.

mediumBoard renders as JavaScript arrays instead of anchors

Tenants on the sort-by-department view emit jobName[dep][i] and jobId[dep][i] arrays rather than <a> tags. Try anchor parsing (Template A) first, then fall back to pairing the JS arrays by department and index (Template B).

mediumQuery strings are sometimes HTML-escaped (&amp; vs &)

Some tenants escape the href query string and some don't. HTML-unescape each href before parsing and match both &amp; and & separators so clientId and id extraction is reliable.

mediumDetail page marked 'this job is no longer active'

The description table is missing on inactive landings. Detect the inactive sentinel and skip the record instead of storing empty or stale HTML as a valid job.

lowDuplicate or cross-client anchors on the page

Dedupe listings by their native hex job id and skip any anchor whose clientId does not match the tenant you are scraping, so shared or template rows don't create phantom jobs.

mediumRequests blocked or rate limited (403 / 429)

Paycor throttles aggressive scraping. Space requests ~250ms apart, cap concurrent detail fetches at ~3, and retry transient network/5xx failures a couple of times with a short backoff.

Best practices
  1. 1Validate the host is recruitingbypaycor.com and the clientId is 16+ hex chars before fetching
  2. 2Try Template A anchors first, then fall back to the Template B JavaScript arrays
  3. 3HTML-unescape hrefs and match both & and &amp; separators when extracting IDs
  4. 4Only record an empty board when the gnewtonNoActiveJobs sentinel is present
  5. 5Dedupe listings by native hex job id and drop cross-client anchors
  6. 6Fetch detail pages for full descriptions, locations, and openings count; keep ~250ms between requests
Or skip the complexity

One endpoint. All Paycor jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=paycor" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

Access Paycor
job data today.

One API call. Structured data. No scraping infrastructure to build or maintain — start with the free tier and scale as you grow.

99.9%API uptime
<200msAvg response
50M+Jobs processed