All platforms

Salesforce Sites fRecruit Jobs API.

Pull structured vacancies from fRecruit careers portals hosted on Salesforce Sites, where jobs render as server-side Visualforce tables behind JSF postback pagination — Jobo normalizes them into one clean jobs feed.

Get API access
Salesforce Sites fRecruit
Live
<3haverage discovery time
1hrefresh interval
Companies using Salesforce Sites fRecruit
Brit InsuranceImmunocoreAcoraCompugen
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 Salesforce Sites fRecruit.

Data fields
  • Full Job Descriptions
  • Employment Type
  • Work Locations
  • Department & Team
  • Posting & Closing Dates
  • Vacancy Reference Numbers
Use cases
  1. 01Job board aggregation
  2. 02Careers page monitoring
  3. 03Talent market tracking
  4. 04Recruiting data pipelines
Trusted by
Brit InsuranceImmunocoreAcoraCompugen
DIY GUIDE

How to scrape Salesforce Sites fRecruit.

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

HTMLadvancedNo published limit; ~300ms between requests, max 2 concurrent detail fetches recommendedNo auth

Resolve the fRecruit job board URL

fRecruit boards live at {tenant}.my.salesforce-sites.com/recruit/fRecruit__ApplyJobList, where the tenant is the site subdomain. Multi-brand tenants split their vacancies with an optional portal query parameter, so preserve it when present.

Step 1: Resolve the fRecruit job board URL
import requests

tenant = "peoplecore"        # the {tenant}.my.salesforce-sites.com subdomain
portal = None                # optional ?portal= value, e.g. "Brit Careers"

origin = f"https://{tenant}.my.salesforce-sites.com"
board_url = f"{origin}/recruit/fRecruit__ApplyJobList"
if portal:
    board_url += "?portal=" + requests.utils.quote(portal)

session = requests.Session()
session.headers["User-Agent"] = "Mozilla/5.0 (compatible; jobs-bot/1.0)"
html = session.get(board_url, timeout=20).text

Parse the Visualforce listings table

Each vacancy is a tr.dataRow whose link carries the vacancyNo id. Tenants rename and reorder columns, so map cells by their th header text (or the responsive data-label) and only fall back to fixed fRecruit positions when the table has no headers.

Step 2: Parse the Visualforce listings table
import re
from urllib.parse import urljoin, urlparse, parse_qs
from bs4 import BeautifulSoup

def _norm(text):
    return re.sub(r"[^a-z0-9]", "", (text or "").lower())

def build_header_index(table):
    headers = {}
    header_row = next((tr for tr in table.select("tr") if tr.find("th")), None) if table else None
    if header_row:
        for i, th in enumerate(header_row.select("th")):
            key = _norm(th.get_text())
            if key:
                headers.setdefault(key, i)
    return headers

def read_cell(cells, headers, labels, fallback):
    # Responsive fRecruit tables carry the column name on data-label.
    for cell in cells:
        if _norm(cell.get("data-label")) in labels:
            return cell.get_text(strip=True) or None
    # Otherwise map by the <th> header index.
    for key, idx in headers.items():
        if any(key == l or key.startswith(l) for l in labels) and idx < len(cells):
            return cells[idx].get_text(strip=True) or None
    # Fixed column order is only safe when the table exposes no headers.
    if not headers and 0 <= fallback < len(cells):
        return cells[fallback].get_text(strip=True) or None
    return None

def parse_listings(html, base_url):
    soup = BeautifulSoup(html, "html.parser")
    jobs = []
    for row in soup.select("tr.dataRow"):
        anchor = row.select_one("a[href*='fRecruit__ApplyJob'][href*='vacancyNo=']")
        if not anchor:
            continue
        detail_url = urljoin(base_url, anchor.get("href", ""))
        vacancy_no = parse_qs(urlparse(detail_url).query).get("vacancyNo", [None])[0]
        title = anchor.get_text(strip=True)
        if not vacancy_no or not title:
            continue
        cells = row.select("td.dataCell")
        headers = build_header_index(row.find_parent("table"))
        jobs.append({
            "vacancy_no": vacancy_no,
            "title": title,
            "detail_url": detail_url,
            "employment_type": read_cell(cells, headers,
                {"employmenttype", "contracttype", "jobtype", "type"}, 1),
            "location": read_cell(cells, headers,
                {"worklocation", "joblocation", "locationofrole", "location"}, 2),
            "department": read_cell(cells, headers,
                {"department", "team", "function"}, 3),
            "posting_date": read_cell(cells, headers,
                {"postingdate", "dateposted", "posteddate", "posted"}, -1),
        })
    return jobs

jobs = parse_listings(html, board_url)
print(f"Found {len(jobs)} vacancies")

Follow JSF postback pagination

There is no page number query — the Next link fires a jsfcljs() JSF postback bound to the form's hidden ViewState. Replay it by POSTing every form input plus the field/value from the onclick, url-encoded, with Referer and Origin headers, over one session so cookies persist. Stop when a page yields no new rows.

Step 3: Follow JSF postback pagination
POSTBACK = re.compile(
    r"jsfcljs\(document\.getElementById\('(?P<form>[^']+)'\),'(?P<field>[^,']+),(?P<value>[^']*)'",
    re.IGNORECASE)

def build_next_post(soup, base_url):
    nxt = next((a for a in soup.select("a.link-pagination")
                if a.get_text(strip=True).lower() == "next"), None)
    if not nxt or not nxt.get("onclick"):
        return None
    m = POSTBACK.search(nxt["onclick"])
    if not m:
        return None
    form = soup.find("form", id=m.group("form"))
    if form is None:
        return None
    fields = {}
    for inp in form.select("input[name]"):
        itype = (inp.get("type") or "").lower()
        if itype in ("checkbox", "radio") and not inp.has_attr("checked"):
            continue
        fields[inp["name"]] = inp.get("value", "")
    fields[m.group("field")] = m.group("value")
    action = urljoin(base_url, form.get("action") or base_url)
    return action, fields

all_jobs = list(jobs)
soup = BeautifulSoup(html, "html.parser")
while True:
    nxt = build_next_post(soup, board_url)
    if not nxt:
        break
    action_url, fields = nxt
    parts = urlparse(board_url)
    resp = session.post(action_url, data=fields, timeout=20, headers={
        "Referer": board_url,
        "Origin": f"{parts.scheme}://{parts.netloc}",
        "Content-Type": "application/x-www-form-urlencoded",
    })
    resp.raise_for_status()
    page_jobs = parse_listings(resp.text, board_url)
    if not page_jobs:            # defensive: no new vacancyNo rows -> stop
        break
    all_jobs.extend(page_jobs)
    soup = BeautifulSoup(resp.text, "html.parser")

Fetch and parse vacancy details

GET each fRecruit__ApplyJob?vacancyNo= page. Some tenants embed a schema.org JobPosting as JSON-LD; older ones only expose labelled Visualforce field rows (.labelCol paired with .data2Col/.dataCol/.dataCell). Prefer the JSON-LD, fall back to the labelled fields, and require at least 80 characters of role text.

Step 4: Fetch and parse vacancy details
import json

def _plain_len(value):
    return len(re.sub(r"<[^>]+>", "", value or ""))

def parse_details(html, listing):
    soup = BeautifulSoup(html, "html.parser")

    # Preferred: schema.org JobPosting JSON-LD, embedded by some tenants.
    for tag in soup.find_all("script", type="application/ld+json"):
        try:
            data = json.loads(tag.string or "")
        except (TypeError, ValueError):
            continue
        for node in (data if isinstance(data, list) else [data]):
            if isinstance(node, dict) and node.get("@type") == "JobPosting":
                desc = node.get("description", "")
                if _plain_len(desc) >= 80:
                    org = node.get("hiringOrganization") or {}
                    return {
                        "vacancy_no": listing["vacancy_no"],
                        "title": node.get("title") or listing["title"],
                        "description": desc,
                        "employment_type": node.get("employmentType"),
                        "posted_at": node.get("datePosted"),
                        "closes_at": node.get("validThrough"),
                        "company_name": org.get("name") if isinstance(org, dict) else None,
                    }

    # Fallback: labelled Visualforce field rows.
    fields = {}
    for row in soup.select("tr"):
        label = row.select_one(".labelCol")
        value = row.select_one(".data2Col, .dataCol, .dataCell")
        if label and value:
            fields[label.get_text(strip=True).rstrip(":")] = value

    def first_text(*names):
        for n in names:
            v = fields.get(n)
            if v and v.get_text(strip=True):
                return v.get_text(strip=True)
        return None

    description = None
    for name in ("Role Details", "Description", "Job Description", "Key Responsibilities"):
        v = fields.get(name)
        if v and len(v.get_text(strip=True)) >= 80:
            description = v.decode_contents()
            break
    if not description:
        raise ValueError("fRecruit detail omitted substantive role details")

    return {
        "vacancy_no": listing["vacancy_no"],
        "title": first_text("Vacancy Name") or listing["title"],
        "description": description,
        "employment_type": first_text("Employment Type"),
        "location": first_text("Location", "Location of role", "Work Location"),
    }

for job in all_jobs[:3]:
    detail_html = session.get(job["detail_url"], timeout=20).text
    print(parse_details(detail_html, job))
Common issues
highPagination never advances past page one

The Next link is a jsfcljs() JSF postback tied to the form's hidden ViewState, not a page-number URL. Collect every form input plus the field/value in the onclick and POST them url-encoded to the form action with Referer and Origin headers, reusing one session so cookies persist.

highColumns like location or department come back wrong

fRecruit lets each tenant rename and reorder its table columns, so fixed positions can mislabel a posting date as a location. Map cells by the th header text (or the responsive data-label) and only fall back to fixed positions when the table has no header row.

mediumDetail page exposes no JSON-LD JobPosting

Only some tenants embed schema.org JSON-LD; older deployments render labelled Visualforce rows instead. Try the JSON-LD first, then fall back to the .labelCol -> .data2Col/.dataCol field map keyed on labels like Vacancy Name, Role Details, Employment Type, and Location.

mediumA salesforce-sites.com host is not a job board

The same *.my.salesforce-sites.com domain also serves non-recruiting community pages. Only treat hosts whose path is under /recruit/ and contains fRecruit__ApplyJob (or the bare /recruit/) as fRecruit boards before scraping.

lowVacancies drop out with thin or empty descriptions

fRecruit records need at least 80 characters of substantive role text; title-only rows are skipped. Enforce the same 80-character minimum, and treat a 404 or 410 on a detail fetch as a removed vacancy rather than a hard error.

Best practices
  1. 1Persist one requests.Session so Salesforce Sites cookies carry across the JSF pagination POSTs
  2. 2Map listing columns by their th header or data-label, never by fixed position — fRecruit tenants reorder columns
  3. 3Prefer JSON-LD JobPosting on the detail page and fall back to labelled Visualforce fields
  4. 4Space requests ~300ms apart and keep concurrent detail fetches to 2 to stay polite
  5. 5Terminate pagination when a page returns no new vacancyNo rows
  6. 6Treat 404/410 on a vacancy detail as a removal signal, not a failure
Or skip the complexity

One endpoint. All Salesforce Sites fRecruit jobs. No scraping, no sessions, no maintenance.

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

Access Salesforce Sites fRecruit
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