SuccessFactors Jobs API: Get SAP Career Site Listings as JSON

CleanJobData Engineering

TL;DR

SAP SuccessFactors Career Site Builder sites on a company subdomain of jobs.hr.cloud.sap often answer a keyless POST to /services/recruiting/v1/jobs with paged JSON, 10 jobs per page. Results are split by locale, so you query every locale the site offers and merge by job id. The list has no description or salary, so the full text comes from each job page's HTML, and dates arrive formatted for the locale you asked for; sites without the endpoint can be read from sitemap.xml or the /search?startrow=N pages.

On this page

To get a company's jobs from SAP SuccessFactors, send a POST to https://{company}.jobs.hr.cloud.sap/services/recruiting/v1/jobs with a JSON body containing locale and pageNumber. No API key is needed, and the response lists job IDs, titles, locations and start dates 10 at a time; the full description comes from each job's HTML page.

That's the DIY route, and it works for a handful of companies you already know. If you'd rather not build and maintain a SuccessFactors scraper, CleanJobData already ingests ATS platforms and company career sites and serves the jobs normalized through one REST API — see the Jobs API docs.

At a Glance

EndpointPOST https://{company}.jobs.hr.cloud.sap/services/recruiting/v1/jobs
API keyNone
PaginationpageNumber (0-based), 10 jobs per page, once per locale
Full descriptionJob page HTML (itemprop="description")
Salary fieldNo
RemoteNo flag; some sites show a free-text "Workplace Type" on the job page

Finding the Company Subdomain

SuccessFactors Career Site Builder sites live at https://{company}.jobs.hr.cloud.sap, so a careers site at acme.jobs.hr.cloud.sap has the identifier acme. Job pages look like https://acme.jobs.hr.cloud.sap/job/Equipment-Operator/12734-en_US/, where 12734 is the job ID and en_US the locale. Some sites put a brand segment in front, as in /acme-field/job/....

Not every Career Site Builder site exposes the JSON endpoint below. When it isn't there, use the sitemap or search-page fallback.

Locales Split the Results

locale isn't a display setting. Each locale can hold a different set of jobs, not translations of the same ones: on one Danish site, da_DK returned 23 jobs and en_US returned 4 different ones. Leaving locale out returns no jobs at all.

To find the locales a site uses, load its search page and collect the locale=xx_XX values from the language switcher:

curl -s "https://acme.jobs.hr.cloud.sap/search/?searchResultView=LIST" \
  | grep -o 'locale=[a-zA-Z]\{2\}_[a-zA-Z]\{2\}' | sort -u

If you find none, en_US is a reasonable single guess. Query every locale you find and merge the results by job id.

The List Request

curl -X POST "https://acme.jobs.hr.cloud.sap/services/recruiting/v1/jobs" \
  -H "content-type: application/json" \
  -d '{"locale":"en_US","pageNumber":0}'

The career site's own request also sends empty keywords, location, facetFilters and similar fields. The minimal body above worked on the site we tested; if a site rejects it, send those fields as empty values too. A trimmed response:

{
  "totalJobs": 1110,
  "jobSearchResult": [
    {
      "response": {
        "id": "12734",
        "unifiedStandardTitle": "Equipment Operator",
        "urlTitle": "Equipment-Operator",
        "unifiedUrlTitle": "Equipment-Operator",
        "unifiedStandardStart": "7/23/26",
        "jobLocationShort": ["Ashland, WI "]
      }
    }
  ]
}
FieldWhat it is
idJob ID, also the number in the job page URL
unifiedStandardTitleJob title
urlTitle / unifiedUrlTitleURL slug for the job page
unifiedStandardStartPosting start date, formatted for the requested locale
jobLocationShortArray of free-text locations, often with trailing spaces
totalJobsTotal reported for the query

Build the job page URL as https://{host}/job/{urlTitle}/{id}-{locale}/.

Pagination

pageNumber starts at 0 and each page holds 10 jobs. Keep requesting pages until one comes back empty, and dedupe by id as you go.

Don't stop as soon as your count reaches totalJobs. The endpoint has no stable sort order, so page contents can shift slightly between requests, and stopping early dropped jobs in our tests. Two more end-of-list signals to handle:

  • A page past the end returns {"totalJobs": 1110} with no jobSearchResult key at all, not an empty array. A locale with no jobs looks the same.
  • On some sites, a page past the end returns a 500 instead. If you already have results for that locale, treat it as the end of the list.

Here is the list phase in Python:

import re
import requests
 
def get_locales(host: str) -> list[str]:
    html = requests.get(f"https://{host}/search/?searchResultView=LIST", timeout=20).text
    return sorted(set(re.findall(r"locale=([a-zA-Z]{2}_[a-zA-Z]{2})", html))) or ["en_US"]
 
def list_successfactors_jobs(company: str) -> list[dict]:
    host = f"{company}.jobs.hr.cloud.sap"
    jobs = {}
    for locale in get_locales(host):
        page = 0
        while True:
            resp = requests.post(
                f"https://{host}/services/recruiting/v1/jobs",
                json={"locale": locale, "pageNumber": page},
                timeout=20,
            )
            if resp.status_code >= 400:
                if page == 0:
                    raise RuntimeError("No JSON endpoint here; use sitemap.xml instead")
                break  # an error after the first page means you're past the end
            results = resp.json().get("jobSearchResult") or []
            if not results:
                break
            for item in results:
                r = item["response"]
                slug = r.get("urlTitle") or r.get("unifiedUrlTitle")
                jobs[r["id"]] = {
                    "id": r["id"],
                    "title": r.get("unifiedStandardTitle"),
                    "location": (r.get("jobLocationShort") or [""])[0].strip(),
                    "start_raw": r.get("unifiedStandardStart"),
                    "locale": locale,
                    "url": f"https://{host}/job/{slug}/{r['id']}-{locale}/",
                }
            page += 1
    return list(jobs.values())

The Job Page

The list has no description, so fetch each job's URL and parse the HTML. Career Site Builder templates are configurable per company, so the markup differs between sites:

  • Description. Many templates mark it with itemprop="description". On some templates that attribute appears several times, with the first one empty and the text split across the rest, so join every non-empty match instead of taking the first.
  • Title. itemprop="title" when present, otherwise the og:title meta tag.
  • Location. Templates with schema.org microdata have one or more itemprop="jobLocation" blocks, with addressLocality, addressRegion and addressCountry in content attributes. A job can list several.
  • Date. itemprop="datePosted" when the template includes microdata.
  • Labeled fields. Many templates show fields such as "Job Location (Short)", "Req Id", "Employment Type" or "Posting Start Date" as .joblayouttoken-label elements followed by a value. The labels are set per company and translated per locale (Danish sites say "Arbejdssted" for location), so match them loosely.
  • Canonical URL. Read link rel="canonical". On some sites the job ID in the canonical URL differs from the one in the sitemap URL for the same posting, and the canonical one is the one to store.
from bs4 import BeautifulSoup
 
def parse_job_page(html: str) -> dict | None:
    if "Sorry, this position has been filled." in html:
        return None  # the posting is closed even though the page returned 200
    soup = BeautifulSoup(html, "html.parser")
    parts = [el.decode_contents() for el in soup.select('[itemprop="description"]')]
    title_el = soup.select_one('[itemprop="title"]')
    og_title = soup.find("meta", property="og:title")
    canonical = soup.find("link", rel="canonical")
    return {
        "title": title_el.get_text(strip=True) if title_el else (og_title or {}).get("content"),
        "description_html": "".join(p for p in parts if p.strip()),
        "locations": [
            {
                "city": (a.select_one('[itemprop="addressLocality"]') or {}).get("content"),
                "region": (a.select_one('[itemprop="addressRegion"]') or {}).get("content"),
                "country": (a.select_one('[itemprop="addressCountry"]') or {}).get("content"),
            }
            for a in soup.select('[itemprop="jobLocation"] [itemprop="address"]')
        ],
        "date_posted": (soup.select_one('[itemprop="datePosted"]') or {}).get("content"),
        "canonical_url": canonical["href"] if canonical else None,
    }

Some templates have no microdata at all. On those you have to find the description by page structure, and that needs checking against each template you meet.

When the JSON Endpoint Isn't There

Two fallbacks give you the job URLs, and then you parse each job page as above:

  • /sitemap.xml. A flat list of job URLs in loc, each with a lastmod date. One request covers the whole site. lastmod is a last-modified date with day precision, not a posting date.
  • /search?startrow=N. The server-rendered search page, 25 job links per page. Step startrow by 25 and stop when a page adds no new /job/ links. A 4xx here can just mean the site has no jobs or no search page.

Gotchas

Dates follow the locale. unifiedStandardStart is formatted the way that locale displays dates: 7/23/26 for en_US, 20.08.2026 for da_DK, 19.06.26 for de_DE, 29/07/2026 for fr_FR, 2026-05-29 for sv_SE and 2026/08/30 for ja_JP. Parse it with the locale's day, month and year order (Intl.DateTimeFormat or Babel can tell you the order) rather than one fixed format.

Labeled dates are ambiguous too. "Posting Start Date" on US sites is M/D/YY, while some UK sites use day-first DD/MM/YYYY. When both numbers are 12 or less, the "Posting End Date" on the same page can settle it, because a job can't end before it starts.

Don't mistake the end date for the start date. A loose label match on "date" also matches "Posting End Date", which often comes after the start date on the page and silently replaces it. Exclude it explicitly.

Closed jobs return 200. A filled position still answers with a normal page whose body says "Sorry, this position has been filled." Check for that text before you parse.

Location is free text. jobLocationShort has values like "Ashland, WI " with trailing whitespace and no country. Use the page's microdata when it exists, then resolve the rest with a gazetteer; see the location normalization guide.

No salary and no remote flag. Neither the list nor the job page has a pay field we read. "Workplace Type", where a site shows it, is free text such as "Office" or "Field", so check it along with the title and location for "remote" or "hybrid".

Career Site Endpoint vs SAP's Official API

SAP SuccessFactors has authenticated OData APIs for its customers. They need credentials from the employer's own SuccessFactors instance and cover recruiting data well beyond the public listings. The endpoint in this article is the one the public career site itself calls. It needs no account and only returns published jobs, but it's undocumented, it isn't enabled on every site, and its behavior can change.

When This Is Enough

For one company on a jobs.hr.cloud.sap site, the list endpoint plus a job-page parser for that company's template is a reasonable amount of work.

When It Isn't

Across many SuccessFactors employers you end up handling several page templates, many locales and six date formats, before you add other platforms like Workday and Taleo. See which ATS have a public job postings API for how the platforms compare.

CleanJobData reads SuccessFactors career sites alongside other platforms and returns one schema, with dates resolved, locations matched to cities, states and countries, and a remote type. Try it in the Playground or see the API Documentation.

Skip the scraper: get this data from the CleanJobData API

If you'd rather not maintain a SuccessFactors collector, you can query normalized jobs from ATS platforms and company career sites with a single request. The response has structured city/state/country locations, parsed salary when the employer publishes one, a remote type, and company metadata, and you page through results with the cursor in pagination.next_page.

curl "https://api.cleanjobdata.com/jobs?title=engineer&domain=acme.jobs.hr.cloud.sap&limit=20" \
  -H "Authorization: Bearer $CLEANJOBDATA_API_KEY"

The domain filter is an exact match on the host of each job's application_url. Every SuccessFactors company has its own jobs.hr.cloud.sap subdomain, so this scopes the query to one employer; company_website_url and employer_id work too. Coverage depends on which employers are in the dataset, so check that the companies you care about appear before you rely on it.

FAQ

Does SAP SuccessFactors have a public jobs API?

Not a documented one, but many Career Site Builder sites answer a keyless POST to https://{company}.jobs.hr.cloud.sap/services/recruiting/v1/jobs with paged JSON. Sites that don't can still be read from sitemap.xml or the /search?startrow=N pages.

Why does the SuccessFactors API return fewer jobs than the careers site shows?

Results are split by locale, and each locale can hold different jobs. Query every locale listed in the site's language switcher, and merge the results by job ID.

How do I get the job description from SuccessFactors?

Fetch each job's page and read the HTML. Many templates mark the text with itemprop="description", sometimes several times with the first one empty, so join every non-empty match.

Does SuccessFactors include salary in its job data?

Not in any field we read. If an employer publishes pay, it's inside the description text.