All platforms

Access PeopleHR / rEcruit Jobs API.

Pull live UK vacancies from every Access recruitment tenant on accessacloud.com — the legacy rEcruit WebForms boards and the current AccessPeople JSON grid — normalized into one clean schema.

Get API access
Access PeopleHR / rEcruit
Live
<3haverage discovery time
1hrefresh interval
Companies using Access PeopleHR / rEcruit
ApleonaSouth Essex CollegeKite Academy Trust
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 Access PeopleHR / rEcruit.

Data fields
  • Full Job Descriptions
  • Salary Ranges & Free Text
  • Contract Type & Basis
  • Department & Section
  • Open & Closing Dates
  • Canonical Apply URLs
Use cases
  1. 01UK Public Sector Job Aggregation
  2. 02Education & Academy Trust Hiring Feeds
  3. 03Salary Benchmarking
  4. 04Vacancy Monitoring & Alerts
Trusted by
ApleonaSouth Essex CollegeKite Academy Trust
DIY GUIDE

How to scrape Access PeopleHR / rEcruit.

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

HybridadvancedNo public limit documented; self-throttle to ~150ms between requests, max 3 concurrent detail fetchesNo auth

Resolve the tenant and product variant

Access recruitment ships two generations under one brand. The current AccessPeople variant lives on the shared host accesspeople.accessacloud.com with the tenant in the first path segment; the legacy rEcruit / AccessSelect.rEcruit variant puts the tenant in the host label of {tenant}.accessacloud.com. Branch on the host before scraping.

Step 1: Resolve the tenant and product variant
from urllib.parse import urlparse

EXCLUDED_HOSTS = {"www.accessacloud.com", "api.my.xd.accessacloud.com"}

def resolve_target(url: str) -> dict:
    parsed = urlparse(url)
    host = (parsed.hostname or "").lower()
    segments = [s for s in parsed.path.split("/") if s]

    # Current AccessPeople: shared host, tenant is the first path segment.
    if host == "accesspeople.accessacloud.com":
        return {
            "variant": "accesspeople",
            "root": f"https://{host}",
            "tenant": segments[0],
        }

    # Legacy rEcruit / AccessSelect.rEcruit: tenant is the host label,
    # scope is the first path segment ("rEcruit" or "AccessSelect.recruit").
    if host.endswith(".accessacloud.com") and host not in EXCLUDED_HOSTS:
        return {
            "variant": "recruit",
            "root": f"https://{host}",
            "tenant": host[: -len(".accessacloud.com")],
            "scope": segments[0],
        }

    raise ValueError("URL is not a recognised Access recruitment tenant")

Scrape the legacy rEcruit WebForms board

Legacy tenants publish the complete vacancy list in one server-rendered ASP.NET WebForms page at /{scope}/Recruitment/Vacancies.aspx. Parse the ul.vacancyList container and read each numeric VacNo detail link plus the bold-label metadata rows.

Step 2: Scrape the legacy rEcruit WebForms board
import requests
from urllib.parse import urljoin, urlparse, parse_qs
from bs4 import BeautifulSoup

def label_value(card, label: str):
    # Legacy rows render as <p><b>Label:</b> value</p>.
    for p in card.select("p"):
        bold = p.select_one("b, strong")
        if bold and bold.get_text(strip=True).rstrip(":").strip().lower() == label.lower():
            text = p.get_text(strip=True)
            return text.split(":", 1)[1].strip() if ":" in text else text
    return None

def scrape_recruit_listings(target: dict) -> list[dict]:
    listing_url = f"{target['root']}/{target['scope']}/Recruitment/Vacancies.aspx"
    html = requests.get(listing_url, headers={"Accept": "text/html"}, timeout=30).text
    soup = BeautifulSoup(html, "html.parser")

    container = soup.select_one("ul.vacancyList")
    if container is None:
        raise ValueError("rEcruit board omitted its vacancyList structure")

    jobs = []
    for card in container.select("li[id^='ContentPlaceHolder1_RepeaterVacancies_liVacancy_']"):
        anchor = card.select_one("h4 a[href*='VacancyDetails.aspx']")
        if not anchor or not anchor.get_text(strip=True):
            continue
        detail_url = urljoin(listing_url, anchor["href"])
        vac_no = parse_qs(urlparse(detail_url).query).get("VacNo", [None])[0]
        if not vac_no:
            continue
        jobs.append({
            "external_id": vac_no,
            "title": anchor.get_text(strip=True),
            "listing_url": detail_url,
            "apply_url": detail_url,
            "reference": label_value(card, "Reference"),
            "location": label_value(card, "Location"),
            "salary": label_value(card, "Salary"),
            "category": label_value(card, "Category"),
        })
    return jobs

Bootstrap an AccessPeople session

AccessPeople tenants gate the vacancy feed behind an antiforgery token. GET the tenant root /{tenant}/ (the /Home/Vacancy route can return HTTP 500) and lift the __RequestVerificationToken input, its Set-Cookie, and the inline GridParameterObject grid contract that supplies the panel key, sort fields, and column list.

Step 3: Bootstrap an AccessPeople session
import json

def extract_json_object(text: str, marker: str):
    # Faithfully brace-match the first {...} after a JS call marker.
    i = text.find(marker)
    if i < 0:
        return None
    i += len(marker)
    while i < len(text) and text[i].isspace():
        i += 1
    if i >= len(text) or text[i] != "{":
        return None
    depth, in_str, esc = 0, False, False
    for j in range(i, len(text)):
        ch = text[j]
        if in_str:
            if esc:
                esc = False
            elif ch == "\\":
                esc = True
            elif ch == '"':
                in_str = False
            continue
        if ch == '"':
            in_str = True
        elif ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                try:
                    return json.loads(text[i:j + 1])
                except json.JSONDecodeError:
                    return None
    return None

def bootstrap_accesspeople(target: dict) -> dict:
    session = requests.Session()  # carries the verification cookie automatically
    bootstrap_url = f"{target['root']}/{target['tenant']}/"
    resp = session.get(bootstrap_url, headers={"Accept": "text/html"}, timeout=30)
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")

    token_input = soup.select_one("input[name='__RequestVerificationToken']")
    token = token_input["value"].strip() if token_input and token_input.get("value") else None
    if not token:
        raise ValueError("AccessPeople bootstrap omitted its request verification token")

    grid = extract_json_object(resp.text, "GridParameterObject(")
    if grid is None or not grid.get("Key") or not grid.get("ActionUrl"):
        raise ValueError("AccessPeople bootstrap omitted the vacancy grid contract")

    return {
        "session": session,
        "bootstrap_url": bootstrap_url,
        "token": token,
        "key": grid["Key"],                       # GUID
        "panel": grid["UniqueName"],
        "action_url": urljoin(bootstrap_url, grid["ActionUrl"]),  # /{tenant}/Home/VacancyPanel?view=VacancyPanel...
        "sort_order": grid.get("DefaultSortDirection", "Desc"),
        "sort_name": grid.get("SortBy", "ClosingDate"),
        "columns": [c["FieldName"] for c in grid.get("FlexigridColumns", []) if c.get("FieldName")],
    }

Page the AccessPeople VacancyPanel feed

POST the resolved VacancyPanel action with the antiforgery token as both header and cookie. The JSON envelope wraps the payload under 'Json' with Parameters.total and Results; page with rp=100 and stop once the accumulated offset covers the reported total.

Step 4: Page the AccessPeople VacancyPanel feed
def scrape_accesspeople_listings(target: dict, session_info: dict, page_size: int = 100) -> list[dict]:
    s = session_info["session"]
    jobs, page = [], 1
    while True:
        body = {
            "PanelName": session_info["panel"],
            "key": session_info["key"],
            "page": page,
            "rp": page_size,
            "qtype": None, "query": None,
            "sortorder": session_info["sort_order"],
            "sortname": session_info["sort_name"],
            "total": None, "StartDate": None, "EndDate": None,
            "PageWidth": 1200,
            "gridColumns": [
                {"FieldName": f, "SearchText": None, "SelectedItem": None, "Options": []}
                for f in session_info["columns"]
            ],
            "treeParams": [],
        }
        resp = s.post(
            session_info["action_url"],
            json=body,
            headers={
                "Accept": "application/json",
                "Referer": session_info["bootstrap_url"],
                "X-Requested-With": "XMLHttpRequest",
                "__RequestVerificationToken": session_info["token"],
            },
            timeout=30,
        )
        resp.raise_for_status()  # 401/403 => re-bootstrap; token or cookie was rejected
        payload = resp.json().get("Json") or {}
        params = payload.get("Parameters") or {}
        results = payload.get("Results") or []
        total = params.get("total")
        if payload.get("success") is not True or total is None:
            raise ValueError("AccessPeople VacancyPanel omitted success or total")

        for row in results:
            if not row.get("Id") or not row.get("Description"):
                continue
            vid = str(row["Id"])
            tenant = target["tenant"]
            jobs.append({
                "external_id": vid,
                "title": row["Description"].strip(),
                "reference": row.get("Reference"),
                "salary": row.get("SalaryDescription") or row.get("SalaryFreeText"),
                "listing_url": f"{target['root']}/{tenant}/Home/Vacancy?id={vid}&returnurl=/{tenant}/",
                "apply_url": f"{target['root']}/{tenant}/Application/Apply/{vid}?ApplicationId=0",
            })

        # Defensive termination: stop when this page completes the reported total.
        if not results or (page - 1) * page_size + len(results) >= total:
            break
        page += 1
    return jobs

Hydrate canonical vacancy details

Both variants expose the full description as provider-owned HTML. AccessPeople detail pages render a .vacancy-container plus an inline GoogleJobsData JSON blob; legacy pages render an #ContentPlaceHolder1_ulForm with labeled control rows. Validate the structured ID against the listing ID before trusting the page.

Step 5: Hydrate canonical vacancy details
def labeled_control(form, label: str):
    if form is None:
        return None
    for li in form.select("li"):
        lbl = li.find(class_="label", recursive=False)
        ctrl = li.find(class_="control", recursive=False)
        if lbl and ctrl and lbl.get_text(strip=True).lower() == label.lower():
            return ctrl.get_text(strip=True) or None
    return None

def scrape_detail(listing_url: str) -> dict:
    html = requests.get(listing_url, headers={"Accept": "text/html"}, timeout=30).text
    soup = BeautifulSoup(html, "html.parser")

    # Current AccessPeople canonical detail.
    container = soup.select_one(".vacancy-container")
    if container is not None:
        structured = extract_json_object(html, "GoogleJobsData(") or {}
        heading = container.select_one("h3")
        body = container.select_one(".vacancy-description-html")
        return {
            "title": heading.get_text(strip=True) if heading else None,
            "description_html": body.decode_contents() if body else None,
            "department": structured.get("DepartmentString"),
            "contract_type": structured.get("ContractTypeDescription"),
            "salary": structured.get("SalaryFreeText"),
            "location": structured.get("Location") or structured.get("SectionAddressPostCode"),
            "closes_at": structured.get("ClosingDate"),
        }

    # Legacy rEcruit WebForms detail.
    form = soup.select_one("#ContentPlaceHolder1_ulForm")
    body = soup.select_one("#ContentPlaceHolder1_PlaceHolderAdvertText .control")
    return {
        "title": labeled_control(form, "Advert Title"),
        "description_html": body.decode_contents() if body else None,
        "location": labeled_control(form, "Location"),
        "reference": labeled_control(form, "Reference"),
    }
Common issues
highTwo product variants hide behind one brand and behave completely differently.

Branch on the host before scraping: accesspeople.accessacloud.com uses a path-segment tenant and a JSON grid, while legacy {tenant}.accessacloud.com boards put the tenant in the host label and serve WebForms HTML. Treating both the same way breaks one or the other.

highThe AccessPeople VacancyPanel POST returns 401/403 without a valid antiforgery pair.

Bootstrap the tenant root first, then send the __RequestVerificationToken both as a request header and via the matching verification cookie (a requests.Session carries the cookie for you). Re-bootstrap and retry when the token is rejected.

mediumThe /{tenant}/Home/Vacancy listing route can return HTTP 500 for some tenants.

Bootstrap from the tenant root /{tenant}/ instead — it serves the same GridParameterObject grid contract, and the canonical /Home/Vacancy?id={id} detail routes remain valid.

mediumEncrypted document links and marketing pages masquerade as scrapeable tenants.

Exclude api.my.xd.accessacloud.com document URLs (opaque key1/key2 params resolve no tenant), www.accessacloud.com marketing, and Volcanic career layers that merely link to Access documents. None of these enumerate a public vacancy list.

lowAccessPeople dates arrive as Microsoft /Date(ms)/ epoch strings.

Parse the milliseconds out of the /Date(...)/ wrapper for open dates, and convert closing dates to Europe/London end-of-day so UK deadlines don't roll a day early.

Best practices
  1. 1Branch on the host: path-segment tenant for accesspeople.accessacloud.com, host-label tenant for legacy *.accessacloud.com boards.
  2. 2Reuse the bootstrap token and cookie for every VacancyPanel POST in a session; re-bootstrap when a 401/403 appears.
  3. 3Page the VacancyPanel with rp=100 and stop once the accumulated offset covers Parameters.total.
  4. 4Validate each detail's structured ID (GoogleJobsData Id or legacy VacNo) against the listing ID before trusting the page.
  5. 5Throttle to ~150ms between requests and cap concurrent detail fetches at 3.
  6. 6Exclude api.my.xd.accessacloud.com document links and Access marketing hosts from your target list.
Or skip the complexity

One endpoint. All Access PeopleHR / rEcruit jobs. No scraping, no sessions, no maintenance.

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

Access Access PeopleHR / rEcruit
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