- criticalPlain HTTP clients get a misleading HTTP 500 ('PageSize cannot be less than 1')
- Ciphr's anti-bot layer rejects non-browser TLS fingerprints. Use curl_cffi with impersonate="chrome131" (or another browser fingerprint) on every request to receive the real HTTP 200 page.
- highColumn order in the vacancy table differs between tenants
- Never read cells by position. Read the <thead> labels and map each field (Location, Salary, Type/Contract Type/Vacancy Type, Job Category, Closing Date) by matching the header text case-insensitively.
- mediumDetail pages use two different templates
- Branded tenants render the job at body scope while classic tenants wrap it in #VacancyDetails. Select '#VacancyDetails or body' as the root, then read the hidden #txtJobId, <h1>, and labeled <dl> which are identical across both.
- mediumThe 'N vacancies found' total is missing or disagrees with the parsed rows
- Treat the advertised count as authoritative. If it is absent or does not equal the number of rows, mark the snapshot incomplete and retry rather than persisting a partial result.
- lowNo pagination cursor is exposed on the listing page
- The board renders every vacancy on a single /Applicants/vacancy document with no pagination cursor. Parse the one page you receive rather than synthesizing page or PageSize query parameters.
Ciphr iRecruit Jobs API.
Pull structured vacancies — salary bands, contract types, and closing dates — from the UK council, charity, and hospice careers boards served by the Ciphr iRecruit HR suite.
What's in every response.
Data fields, real-world applications, and the companies already running on Ciphr iRecruit.
Data fields
- Structured Salary Text
- Contract & Vacancy Type
- Application Closing Dates
- Full Job Descriptions
- Job Category & Weekly Hours
- Native Vacancy Reference
Use cases
- 01UK Public-Sector Job Tracking
- 02Charity & Council Vacancy Feeds
- 03Salary Benchmarking
- 04Careers Board Aggregation
Trusted by
- Phyllis Tuckwell Hospice
- South Norfolk and Broadland Councils
- Refugee Council
How to scrape Ciphr iRecruit.
Step-by-step guide to extracting jobs from Ciphr iRecruit-powered career pages—endpoints, authentication, and working code.
# The first host label is the tenant id; the listings path is fixed.
tenant = "refugeecouncil"
board_url = f"https://{tenant}.ciphr-irecruit.com/Applicants/vacancy"from curl_cffi import requests # pip install curl_cffi
headers = {
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-GB,en;q=0.9",
}
resp = requests.get(board_url, headers=headers, impersonate="chrome131", timeout=20)
resp.raise_for_status() # a plain HTTP client would 500 here instead
html = resp.textimport re
from bs4 import BeautifulSoup
from urllib.parse import urljoin
soup = BeautifulSoup(html, "html.parser")
# Authoritative total — a missing or disagreeing count means an incomplete snapshot.
match = re.search(r"([\d,]+)\s+vacanc(?:y|ies)\s+found", soup.get_text(" "), re.I)
advertised = int(match.group(1).replace(",", "")) if match else None
table = soup.select_one("table.table")
rows = table.select("tbody > tr") if table else []
headers_row = [th.get_text(" ", strip=True) for th in table.select("thead th")] if table else []
def cell(cells, *labels):
wanted = {label.lower() for label in labels}
for i, header in enumerate(headers_row):
if i < len(cells) and header.lower() in wanted:
return cells[i].get_text(" ", strip=True)
return ""
jobs = []
for row in rows:
cells = row.select("td")
anchor = cells[0].select_one("a[href]") if cells else None
if not anchor:
continue
listing_url = urljoin(board_url, anchor["href"])
id_match = re.search(r"/Applicants/vacancy/(\d+)", listing_url, re.I)
if not id_match:
continue
jobs.append({
"external_id": id_match.group(1), # numeric segment only — never the slug
"title": anchor.get_text(" ", strip=True),
"listing_url": listing_url,
"location": cell(cells, "Location"),
"salary_text": cell(cells, "Salary"),
"contract_type": cell(cells, "Type", "Contract Type", "Vacancy Type"),
"job_category": cell(cells, "Job Category", "Category"),
"closing_text": cell(cells, "Application Deadline", "Closing Date"),
})
print(f"Parsed {len(jobs)} of {advertised} advertised vacancies")for job in jobs:
resp = requests.get(job["listing_url"], headers=headers, impersonate="chrome131", timeout=20)
if resp.status_code in (404, 410):
continue # vacancy was removed
resp.raise_for_status()
detail = BeautifulSoup(resp.text, "html.parser")
root = detail.select_one("#VacancyDetails") or detail.body
hidden = root.select_one("#txtJobId")
if not hidden or hidden.get("value") != job["external_id"]:
continue # route id and native id must agree
heading = root.select_one("h1")
reference = heading.select_one("small") if heading else None
fields = {}
for term in root.select("dl > dt"):
value = term.find_next_sibling("dd")
if value is not None:
fields[term.get_text(" ", strip=True)] = value
summary = fields.get("Job Summary")
apply_anchor = root.select_one("a[href*='/Applicants/vacancy/apply/']")
job.update({
"reference": reference.get_text(strip=True) if reference else "",
"description": summary.get_text(" ", strip=True) if summary else "",
"hours_text": fields["Hours per week"].get_text(" ", strip=True) if "Hours per week" in fields else "",
"apply_url": urljoin(job["listing_url"], apply_anchor["href"]) if apply_anchor else None,
})EMPTY_MARKERS = re.compile(r"no vacancies|there are no current vacancies|0 vacancies", re.I)
def snapshot_is_complete(soup, rows, advertised):
if not rows:
# A genuinely empty board says so; silence usually means a blocked fetch.
return advertised == 0 or EMPTY_MARKERS.search(soup.get_text(" ")) is not None
return advertised is not None and advertised == len(rows)
if not snapshot_is_complete(soup, rows, advertised):
raise RuntimeError("Ciphr snapshot incomplete — re-fetch before trusting the results")- 1Impersonate a Chrome TLS fingerprint (curl_cffi chrome131) on every request — plain clients get a 500.
- 2Map table columns by their header text, never by fixed position.
- 3Trust the 'N vacancies found' total; mark the snapshot incomplete when it is missing or disagrees.
- 4Key jobs on the numeric /Applicants/vacancy/{id} segment — never the slug, title, or reference.
- 5Throttle to ~200ms between requests and no more than 3 concurrent detail fetches.
- 6Parse closing dates as en-GB (day-first) in the Europe/London timezone.
One endpoint. All Ciphr iRecruit jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=ciphr irecruit" \
-H "X-Api-Key: YOUR_KEY"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.
Access Ciphr iRecruit
job data today.
One API call. Structured data. No scraping infrastructure to build or maintain — start with the $5 free starting balance and scale as you grow.