DEV Community

DataDaemon
DataDaemon

Posted on

Oracle Recruiting Cloud jobs API: the public JSON endpoints behind every Oracle HCM career site (Python guide)

If you build a job board, a hiring-signals feed or a talent-intelligence dashboard, you will meet career pages that look like this:

https://efds.fa.em5.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX_1/requisitions
https://ebwh.fa.us2.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX_1001/requisitions
Enter fullscreen mode Exit fullscreen mode

That is Oracle Recruiting Cloud (part of Oracle Fusion Cloud HCM). The first one is Ford's global career site, the second Macy's. Banks, hospitals, universities, retailers and governments use the same product. The page is a single-page app, and everything it shows comes from a REST API on the same host. No login, no API key, no browser required.

This post documents the three endpoints you need, the parameters that matter, and four gotchas that cost me time. All examples were run on 29 October 2026.

Anatomy of a career-site URL

https://efds.fa.em5.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX_1/requisitions
        └──── tenant host ────────┘                              └ site ┘
Enter fullscreen mode Exit fullscreen mode
  • The tenant host (efds.fa.em5) identifies the customer's Fusion pod. The four letters are opaque; there is no way to derive them from the company name.
  • The site number (CX_1, CX_1001, sometimes just CX) identifies one career site on that tenant. Large employers run several: Ford has CX_1 (global), CX_1001 (interns), CX_5001 (Model e), CX_6001 and CX_7001.
import re

def parse_site(url):
    m = re.match(r"(https://[^/]+)/hcmUI/CandidateExperience/[^/]+/sites/([^/?#]+)", url)
    if not m:
        raise ValueError(f"not an Oracle Candidate Experience URL: {url}")
    return m.group(1), m.group(2)   # origin, site number
Enter fullscreen mode Exit fullscreen mode

Endpoint 1: list requisitions

GET {origin}/hcmRestApi/resources/latest/recruitingCEJobRequisitions
    ?onlyData=true
    &expand=requisitionList.secondaryLocations
    &finder=findReqs;siteNumber=CX_1,limit=25,offset=0,sortBy=POSTING_DATES_DESC
Enter fullscreen mode Exit fullscreen mode

The interesting part is the finder parameter. It is one string: the finder name (findReqs), a semicolon, then comma-separated key=value pairs. Text values are wrapped in double quotes.

finder key example notes
siteNumber CX_1 required
keyword "data engineer" full-text, quoted
location "Germany" as typed in the site's location box, quoted
selectedPostingDatesFacet "7" only 7 and 30 exist
selectedWorkplaceTypesFacet "ORA_REMOTE" also ORA_HYBRID, ORA_ON_SITE; separate several with ;
limit, offset 100, 200 paging
sortBy POSTING_DATES_DESC newest first
facetsList POSTING_DATES;WORKPLACE_TYPES;CATEGORIES;LOCATIONS ask for facet counts

The response is a single item (items[0]) holding TotalJobsCount, the facets, and the jobs in requisitionList. Each job has Id, Title, PostedDate, PrimaryLocation, PrimaryLocationCountry, ShortDescriptionStr, WorkplaceType, HotJobFlag and a few more.

The facets are free analytics

Ask for facetsList with limit=1 and you get a hiring snapshot of an employer in one request. Ford's global site on 29 October:

TotalJobsCount: 915
postingDatesFacet:   less than 7 days 207 · less than 30 days 558 · greater than 30 days 357
workplaceTypesFacet: Hybrid 596 · On-site 290 · Remote 29
Enter fullscreen mode Exit fullscreen mode

(The 30-day bucket includes the 7-day one.) Macy's (CX_1001 on ebwh.fa.us2): 5,852 open requisitions, 803 of them posted in the last 7 days. Oracle's own careers site (eeho.fa.us2, CX_45001): 2,710 open, of which 1,066 are in Product and Research and 863 are in Nashville. If you track hiring as a sales or investment signal, polling the facets daily is about as cheap as data gets.

Endpoint 2: the full job record

The list gives you a short description. Everything else (long description, qualifications, responsibilities, corporate description, hiring manager, number of openings, flex fields, secondary locations, skills) comes from:

GET {origin}/hcmRestApi/resources/latest/recruitingCEJobRequisitionDetails
    ?expand=all
    &onlyData=true
    &finder=ById;Id="349089",siteNumber=CX_45001
Enter fullscreen mode Exit fullscreen mode

Fields worth knowing: ExternalDescriptionStr, ExternalQualificationsStr, ExternalResponsibilitiesStr (all HTML), CorporateDescriptionStr, Category, JobFunction, NumberOfOpenings, HiringManager, LegalEmployer, WorkplaceType, ExternalPostedStartDate, requisitionFlexFields (employer-defined fields such as job type or visa sponsorship), secondaryLocations, skills. Which of them are filled depends on how the employer configured Oracle.

Quote the Id and do not quote the site number; that is the form the career site itself sends. A closed or unknown Id also returns HTTP 200, with an empty items list, so check for it.

Endpoint 3: which sites exist on a tenant

GET {origin}/hcmRestApi/resources/latest/recruitingCESites?onlyData=true&limit=50
Enter fullscreen mode Exit fullscreen mode

Returns SiteNumber and SiteName for each site:

efds.fa.em5 → CX_1 Ford Global Career Site · CX_1001 Ford Intern · CX_5001 Ford Model-e U.S. · CX_6001 Ford BlueOvalCity/TEVC · CX_7001 Ford Platform Architecture Careers
ebwh.fa.us2 → CX Candidate Experience site · CX_1001 Macy's · CX_1002 Bloomingdale's · CX_4001 bluemercury · CX_2001 Macy's Seasonal · ...
eeug.fa.us6 → CX Diebold Nixdorf
Enter fullscreen mode Exit fullscreen mode

Useful for discovery, but see gotcha 2.

A complete script: one site to CSV

import csv
import re
import sys
import time

import requests

UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36"


def parse_site(url):
    m = re.match(r"(https://[^/]+)/hcmUI/CandidateExperience/[^/]+/sites/([^/?#]+)", url)
    if not m:
        raise ValueError(f"not an Oracle Candidate Experience URL: {url}")
    return m.group(1), m.group(2)


def q(value):
    return '"' + value.replace('"', "") + '"'


def list_jobs(origin, site, keyword=None, location=None, posted_within=None, page_size=100):
    s = requests.Session()
    s.headers.update({"User-Agent": UA, "Accept": "application/json"})
    offset, seen = 0, set()
    while True:
        finder = [f"siteNumber={site}"]
        if keyword:
            finder.append(f"keyword={q(keyword)}")
        if location:
            finder.append(f"location={q(location)}")
        if posted_within:  # Oracle only knows the 7 and 30 day buckets
            finder.append(f"selectedPostingDatesFacet={q(str(posted_within))}")
        finder += [f"limit={page_size}", f"offset={offset}", "sortBy=POSTING_DATES_DESC"]
        r = s.get(
            f"{origin}/hcmRestApi/resources/latest/recruitingCEJobRequisitions",
            params={
                "onlyData": "true",
                "expand": "requisitionList.secondaryLocations",
                "finder": "findReqs;" + ",".join(finder),
            },
            timeout=60,
        )
        r.raise_for_status()
        block = r.json()["items"][0]
        jobs = block.get("requisitionList", [])
        for job in jobs:
            if job["Id"] not in seen:          # dedupe: new postings shift pages
                seen.add(job["Id"])
                yield job
        offset += page_size
        if not jobs or offset >= block["TotalJobsCount"]:
            break
        time.sleep(0.5)                        # be polite


def job_detail(origin, site, job_id):
    r = requests.get(
        f"{origin}/hcmRestApi/resources/latest/recruitingCEJobRequisitionDetails",
        params={"expand": "all", "onlyData": "true",
                "finder": f"ById;Id={q(job_id)},siteNumber={site}"},
        headers={"User-Agent": UA, "Accept": "application/json"},
        timeout=60,
    )
    r.raise_for_status()
    items = r.json().get("items", [])
    return items[0] if items else None


if __name__ == "__main__":
    origin, site = parse_site(sys.argv[1])
    keyword = sys.argv[2] if len(sys.argv) > 2 else None
    rows = [{
        "id": job["Id"],
        "title": job["Title"],
        "posted": job["PostedDate"],
        "location": job["PrimaryLocation"],
        "url": f"{origin}/hcmUI/CandidateExperience/en/sites/{site}/job/{job['Id']}",
    } for job in list_jobs(origin, site, keyword=keyword, posted_within=7)]

    with open("jobs.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=["id", "title", "posted", "location", "url"])
        w.writeheader()
        w.writerows(rows)
    print(f"{len(rows)} jobs -> jobs.csv")

    if rows:
        d = job_detail(origin, site, rows[0]["id"])
        print(d["Title"], "|", d.get("Category"), "|",
              len(d.get("ExternalDescriptionStr") or ""), "chars of description")
Enter fullscreen mode Exit fullscreen mode
$ python oracle_jobs.py "https://eeho.fa.us2.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX_45001/requisitions" python
100 jobs -> jobs.csv
Principal Software Engineer – Backend, Java/Python | Oracle Health | Product Development | 3386 chars of description
Enter fullscreen mode Exit fullscreen mode

The gotchas

1. Without expand, you get counts and no jobs. Drop expand=requisitionList... and the response still returns TotalJobsCount and facets, but requisitionList is missing. Easy to misread as "this site has no jobs".

2. A wrong site number fails silently. Ask Ford's tenant for siteNumber=CX_999, which does not exist, and you get HTTP 200 with the 915 jobs of its default site CX_1. Macy's tenant answers CX_999 with 7,323 jobs, more than any of its real sites (Macy's 5,852, Bloomingdale's 1,148, bluemercury 323). No error either way. Oracle's own tenant goes further: the retired CX_1, the live CX_45001, the non-existent CX_999 and even the word jobsearch all return the same 2,710 jobs. If you build site numbers from a list or a typo-prone config, you will happily ingest the wrong career site. Always take the site number from the public URL, and sanity-check the result (for example, compare TotalJobsCount with what the career page shows). Related: recruitingCESites also lists retired and internal sites (Oracle's tenant lists six, among them "Oracle Modern FOR REFERENCE ONLY", two backups and a "Baseline DO NOT UPDATE"), so do not crawl every site it returns.

3. Posted-date filtering has two buckets. selectedPostingDatesFacet accepts 7 and 30. Any other value is ignored without an error: "3" returns every open job. For "last 3 days", fetch the 7-day bucket sorted by POSTING_DATES_DESC and stop when PostedDate is older than your cutoff.

4. Pages shift while you read them. Sorting by posting date means a job published mid-crawl pushes everything down by one, and you see a duplicate on the next page. Dedupe by Id. For large sites, larger pages (100) reduce the window; the UI uses 25, and the tenants I tested accepted 100 and 200.

Also: the detail endpoint is one request per job, so a 6,000-job site is 6,000 requests. Run them with modest concurrency (I use 5) and back off on 429 and 5xx.

Finding employers that use Oracle Recruiting Cloud

There is no public directory and the tenant codes are opaque. What works:

  • Google: site:oraclecloud.com "CandidateExperience" "company name".
  • The career link on the employer's own website usually redirects to an *.oraclecloud.com/hcmUI/CandidateExperience/... URL.
  • GitHub code search for hcmUI/CandidateExperience returns about 13,000 files: job-board and aggregator projects that hard-code tenant URLs.

When not to maintain this yourself

The script above is fine for one employer. It gets tedious when you need dozens of tenants on a schedule, full details with retries, change monitoring, and someone to notice when Oracle ships a release that changes a field.

That is why I packaged it as the Oracle Cloud HCM Jobs Scraper on Apify (disclosure: I built it). Paste any career-site URL (even a single job URL), optionally a keyword, location, 7/30-day window or workplace type, and it walks every listed site and returns one record per requisition with the detail fields above. It costs $0.002 per job plus $0.002 per full detail record, so a 500-job site with details is $2; from 2 November the details are included in the $0.002 per job (plus $0.001 per run), so the same site costs about $1. It runs on a schedule, exposes a REST endpoint, and works as an MCP tool for AI agents. Ready-made examples:

Either way, the endpoints above are what the career site itself uses. Keep request rates low, cache what you can, and respect each employer's terms, especially for fields like hiring-manager names.

If you run into a tenant that behaves differently, post the host in the comments. I am collecting the edge cases.

FAQ

Does Oracle Recruiting Cloud have a public jobs API?
Yes, in practice. Oracle documents the authenticated Fusion HCM REST API for recruiters, which needs credentials. The candidate-facing career sites use the recruitingCEJobRequisitions, recruitingCEJobRequisitionDetails and recruitingCESites resources shown above, and those answer without a login or API key.

How do I find an employer's tenant host and site number?
Open the employer's careers page and look at the URL: https://<tenant>.fa.<pod>.oraclecloud.com/hcmUI/CandidateExperience/<lang>/sites/<site number>/.... Some employers put a vanity domain in front (careers.oracle.com shows sites/jobsearch, while the page itself loads from eeho.fa.us2.oraclecloud.com); the API calls go to the oraclecloud.com host.

Is this the same as Taleo?
No. Taleo is Oracle's older recruiting product with a different URL scheme (*.taleo.net) and none of these endpoints. This guide covers Oracle Recruiting Cloud only.

Top comments (0)