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.
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 Ciphr iRecruit.
- Structured Salary Text
- Contract & Vacancy Type
- Application Closing Dates
- Full Job Descriptions
- Job Category & Weekly Hours
- Native Vacancy Reference
- 01UK Public-Sector Job Tracking
- 02Charity & Council Vacancy Feeds
- 03Salary Benchmarking
- 04Careers Board Aggregation
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")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.
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.
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.
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.
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.
- 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" Access Ciphr iRecruit
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.