Oracle PeopleSoft Candidate Gateway Jobs API.

Extract postings from customer-hosted PeopleSoft Candidate Gateway boards, where every employer runs the same HRS_CG_SEARCH_FL component on its own domain behind a stateful search form.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on Oracle PeopleSoft Candidate Gateway.

Data fields

  • Full Posting Descriptions
  • Job Opening IDs
  • Department & Location
  • Employment Schedule and Duration
  • Opened Dates
  • Labelled Description Sections

Use cases

  1. 01Enterprise Job Aggregation
  2. 02Government & Utility Careers Feeds
  3. 03Self-Hosted ATS Extraction
  4. 04ATS Data Pipelines

Trusted by

  • Consolidated Communications
  • State of Tennessee (Edison)
  • Federated Hermes
DIY GUIDE

How to scrape Oracle PeopleSoft Candidate Gateway.

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

API type
HTML
Difficulty
advanced
Rate limit
No published limit; ~350ms between requests and at most 3 concurrent detail fetches
Authentication
No auth

Decompose the Candidate Gateway URL

Every PeopleSoft board runs on the customer's own domain, so the tenant is the host itself. The path encodes the environment, access mode and site — /psc/{environment}/{accessMode}/{site}/c/HRS_HRAM_FL.HRS_CG_SEARCH_FL.GBL — and the query adds a numeric SiteId. All five values together identify one board; the same host can serve more than one.

Step 1: Decompose the Candidate Gateway URL
import re
from urllib.parse import urlparse, parse_qs, quote

COMPONENT = "HRS_HRAM_FL.HRS_CG_SEARCH_FL.GBL"
SEARCH_PAGE = "HRS_APP_SCHJOB_FL"
DETAIL_PAGE = "HRS_APP_JBPST_FL"

def parse_board(url: str) -> dict | None:
    parsed = urlparse(url)
    parts = parsed.path.strip("/").split("/")
    # /psc/{environment}/{accessMode}/{site}/c/{component}
    if len(parts) != 6 or parts[0] != "psc" or parts[4] != "c" or parts[5] != COMPONENT:
        return None
    query = parse_qs(parsed.query)
    site_id = (query.get("SiteId") or [""])[0]
    if (query.get("Page") or [""])[0] != SEARCH_PAGE or not site_id.isdigit():
        return None
    return {
        "authority": parsed.netloc.lower(),   # the customer's own host is the tenant
        "environment": parts[1],              # case matters when rebuilding the path
        "access_mode": parts[2],
        "site": parts[3],
        "site_id": site_id,
    }

def board_url(board: dict) -> str:
    return (
        f"https://{board['authority']}/psc/{board['environment']}/{board['access_mode']}"
        f"/{board['site']}/c/{COMPONENT}"
        f"?Page={SEARCH_PAGE}&Action=U&FOCUS=Applicant&SiteId={board['site_id']}"
    )

board = parse_board(
    "https://careers.edison.tn.gov/psc/hrprdrs/EMPLOYEE/HRMS/c/"
    "HRS_HRAM_FL.HRS_CG_SEARCH_FL.GBL?Page=HRS_APP_SCHJOB_FL&Action=U&FOCUS=Applicant&SiteId=1"
)
print(board_url(board))

Load the search page and read the provider row count

The first request establishes a PeopleSoft session, so use a cookie-keeping client that follows the native redirects. The rendered page carries the vacancy rows as li elements whose ids start with HRS_AGNT_RSLT_I$0_row_, and a separate row-count element states how many results the board believes it has.

Step 2: Load the search page and read the provider row count
import requests
from bs4 import BeautifulSoup

ROW_PREFIX = "HRS_AGNT_RSLT_I$0_row_"
ROW_COUNT_ID = "win0divHRS_AGNT_RSLT_Irowcnt$0"

def load_board(session: requests.Session, board: dict) -> BeautifulSoup:
    response = session.get(
        board_url(board),
        headers={"Accept": "text/html,application/xhtml+xml"},
        timeout=60,
        allow_redirects=True,   # PeopleSoft sets its session cookie via redirects
    )
    response.raise_for_status()
    soup = BeautifulSoup(response.text, "html.parser")
    if soup.select_one("form[name='win0']") is None:
        raise RuntimeError("Response was not a PeopleSoft Candidate Gateway page")
    return soup

def provider_row_count(soup: BeautifulSoup) -> int | None:
    node = soup.find(id=ROW_COUNT_ID)
    match = re.search(r"([0-9][0-9,]*)", node.get_text() if node else "")
    return int(match.group(1).replace(",", "")) if match else None

session = requests.Session()
soup = load_board(session, board)
print("board reports", provider_row_count(soup), "results")

Map the search rows

Each row's index is the suffix on its child element ids. Title, job opening id, location, department and the opened date all live in per-index elements, so read them by id rather than by position. The job opening id is the durable identifier and the search rows deliberately omit the posting sequence.

Step 3: Map the search rows
def cell(row, element_id: str) -> str | None:
    node = row.find(id=element_id)
    return " ".join(node.get_text().split()) if node else None

def detail_url(board: dict, opening_id: str, sequence: str) -> str:
    return (
        f"https://{board['authority']}/psc/{board['environment']}/{board['access_mode']}"
        f"/{board['site']}/c/{COMPONENT}"
        f"?Page={DETAIL_PAGE}&Action=U&FOCUS=Applicant"
        f"&JobOpeningId={opening_id}&PostingSeq={sequence}&SiteId={board['site_id']}"
    )

def parse_rows(soup: BeautifulSoup, board: dict) -> list[dict]:
    listings = []
    for row in soup.select(f"li[id^='{ROW_PREFIX}']"):
        index = row["id"][len(ROW_PREFIX):]
        opening_id = cell(row, f"HRS_APP_JBSCH_I_HRS_JOB_OPENING_ID${index}")
        title = cell(row, f"SCH_JOB_TITLE${index}")
        if not opening_id or not opening_id.isdigit() or not title:
            continue
        listings.append({
            "id": opening_id,
            "title": title,
            "location": cell(row, f"LOCATION${index}"),
            "department": cell(row, f"HRS_APP_JBSCH_I_HRS_DEPT_DESCR${index}"),
            "posted_at": cell(row, f"SCH_OPENED${index}"),
            # Search rows omit PostingSeq; 1 is canonical, 2 is the known fallback.
            "listing_url": detail_url(board, opening_id, "1"),
        })
    return listings

listings = parse_rows(soup, board)
print(f"{len(listings)} rows visible")

Post the native form state to widen the result window

Candidate Gateway exposes a stateful continuation rather than page URLs. Re-post the win0 form's hidden fields with ICAction set to HRS_AGNT_RSLT_I$hdown$0 to ask for the provider's full result window. Many deployments still cap the anonymous window at 100 rows even when the displayed count is larger — compare what you mapped with the reported count and mark the snapshot incomplete when they differ.

Step 4: Post the native form state to widen the result window
from urllib.parse import urljoin

def request_more(session: requests.Session, soup: BeautifulSoup, board: dict) -> BeautifulSoup:
    form = soup.select_one("form[name='win0']")
    fields = {
        node["name"]: node.get("value", "")
        for node in form.select("input[type='hidden'][name]")
    }
    fields["ICAction"] = "HRS_AGNT_RSLT_I$hdown$0"   # the native "show more" action

    response = session.post(
        urljoin(board_url(board), form.get("action") or ""),
        data=fields,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=60,
    )
    response.raise_for_status()
    return BeautifulSoup(response.text, "html.parser")

widened = request_more(session, soup, board)
listings = parse_rows(widened, board)
reported = provider_row_count(widened)

complete = reported is not None and len(listings) == reported
if not complete:
    # A capped window cannot authorise concluding that missing jobs were withdrawn.
    print(f"incomplete snapshot: {len(listings)} of {reported}")

Fetch the posting and rebuild its description sections

The detail page is the same component with Page=HRS_APP_JBPST_FL plus JobOpeningId, PostingSeq and SiteId. Verify the rendered job opening id matches what you requested, then concatenate the labelled description blocks. If sequence 1 redirects back to the search component, retry sequence 2 before concluding anything.

Step 5: Fetch the posting and rebuild its description sections
def fetch_posting(session: requests.Session, board: dict, listing: dict) -> dict | None:
    for sequence in ("1", "2"):
        url = detail_url(board, listing["id"], sequence)
        response = session.get(url, timeout=60)
        if response.status_code in (404, 410):
            return None  # canonical removal
        response.raise_for_status()
        soup = BeautifulSoup(response.text, "html.parser")

        proved = soup.find(id="HRS_SCH_WRK2_HRS_JOB_OPENING_ID")
        title = soup.find(id="HRS_SCH_WRK2_POSTING_TITLE")
        if not proved or " ".join(proved.get_text().split()) != listing["id"]:
            continue  # this sequence bounced back to the search page — try the next

        sections = []
        for block in soup.select("[id^='win0divHRS_SCH_PSTDSC_row$']"):
            index = block["id"].rsplit("$", 1)[-1]
            label = soup.find(id=f"HRS_SCH_WRK_DESCR100${index}lbl")
            body = soup.find(id=f"HRS_SCH_PSTDSC_DESCRLONG${index}")
            if body is None or not body.get_text(strip=True):
                continue
            if label and label.get_text(strip=True):
                sections.append(f"<h2>{label.get_text(strip=True)}</h2>")
            sections.append(body.decode_contents().strip())

        location = soup.find(id="HRS_SCH_WRK_HRS_DESCRLONG")
        schedule = soup.find(id="HRS_SCH_WRK_HRS_FULL_PART_TIME")
        return {
            **listing,
            "listing_url": url,
            "posting_sequence": sequence,
            "title": " ".join(title.get_text().split()) if title else listing["title"],
            "description_html": "\n".join(sections) or None,
            "location": " ".join(location.get_text().split()) if location else listing["location"],
            "employment_schedule": " ".join(schedule.get_text().split()) if schedule else None,
        }
    return None  # both sequences bounced: structured unavailable, not an error

for listing in listings[:3]:
    job = fetch_posting(session, board, listing)
    print(job["title"] if job else f"{listing['id']} is no longer posted")
Common issues
criticalThe result window silently caps at 100 rows
Many deployments stop returning rows at 100 even when the board's own count is larger. Compare the number of mapped rows with the row-count element and mark the snapshot incomplete when they disagree — a capped snapshot must never be used to conclude that the missing postings were withdrawn.
highThe path case is normalised and the board stops resolving
PeopleSoft paths are case-sensitive: /psc/HRMS/ and /psc/hrms/ are not the same route, and environments such as hrprdrs are lowercase while sites such as HRMS are not. Preserve the exact casing from the first-party URL when rebuilding board and detail addresses.
highDetail links built with sequence 1 bounce to the search page
Search rows do not expose PostingSeq. Sequence 1 is canonical, but real boards also publish sequence 2. Try 1, then 2, and only treat a same-board redirect back to the search component after both as evidence the posting has gone.
mediumThe first request lands on a login page
Candidate Gateway establishes its session through native redirects that set cookies. Use a cookie-retaining client that follows them; a page with no form[name='win0'] is a login or error surface and must not be treated as an empty board.
Best practices
  1. 1Treat the customer's own host plus environment, access mode, site and SiteId as one board identity
  2. 2Preserve exact path casing — PeopleSoft routes are case-sensitive
  3. 3Use a cookie-keeping session that follows the gateway's native redirects
  4. 4Re-post the win0 hidden form state with ICAction to widen the result window
  5. 5Reconcile mapped rows against the provider's row count before trusting a snapshot
  6. 6Retry posting sequence 2 before treating a search-page redirect as removal
Or skip the complexity

One endpoint. All Oracle PeopleSoft Candidate Gateway jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=oracle peoplesoft candidate gateway" \
  -H "X-Api-Key: YOUR_KEY"
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.

Ready to integrate

Access Oracle PeopleSoft Candidate Gateway
job data today.

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

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