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.
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.
- Full Job Descriptions
- Salary Ranges & Free Text
- Contract Type & Basis
- Department & Section
- Open & Closing Dates
- Canonical Apply URLs
- 01UK Public Sector Job Aggregation
- 02Education & Academy Trust Hiring Feeds
- 03Salary Benchmarking
- 04Vacancy Monitoring & Alerts
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.
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")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 jobsimport 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")],
}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 jobsdef 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"),
}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.
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.
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.
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.
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.
- 1Branch on the host: path-segment tenant for accesspeople.accessacloud.com, host-label tenant for legacy *.accessacloud.com boards.
- 2Reuse the bootstrap token and cookie for every VacancyPanel POST in a session; re-bootstrap when a 401/403 appears.
- 3Page the VacancyPanel with rp=100 and stop once the accumulated offset covers Parameters.total.
- 4Validate each detail's structured ID (GoogleJobsData Id or legacy VacNo) against the listing ID before trusting the page.
- 5Throttle to ~150ms between requests and cap concurrent detail fetches at 3.
- 6Exclude api.my.xd.accessacloud.com document links and Access marketing hosts from your target list.
One endpoint. All Access PeopleHR / rEcruit jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=access peoplehr / recruit" \
-H "X-Api-Key: YOUR_KEY" 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.