All platforms

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.

Get API access
Ciphr iRecruit
Live
<3haverage discovery time
1hrefresh interval
Companies using Ciphr iRecruit
Phyllis Tuckwell HospiceSouth Norfolk and Broadland CouncilsRefugee Council
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 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
  1. 01UK Public-Sector Job Tracking
  2. 02Charity & Council Vacancy Feeds
  3. 03Salary Benchmarking
  4. 04Careers Board Aggregation
Trusted by
Phyllis Tuckwell HospiceSouth Norfolk and Broadland CouncilsRefugee Council
DIY GUIDE

How to scrape Ciphr iRecruit.

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

HTMLadvancedNo published limit; self-throttle to ~200ms between requests and 3 concurrent detail fetchesNo auth

Resolve the tenant board URL

Each Ciphr iRecruit customer gets a subdomain on ciphr-irecruit.com (older tenants live on irecruit-software.com). The canonical listing page is always /Applicants/vacancy for that host.

Step 1: Resolve the tenant board URL
# 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"

Fetch the listings with a browser TLS fingerprint

Ciphr fronts iRecruit with anti-bot protection: ordinary requests or curl receive a misleading ASP.NET HTTP 500 ('PageSize cannot be less than 1'). Impersonating Chrome's TLS fingerprint via curl_cffi returns HTTP 200 and the full server-rendered table.

Step 2: Fetch the listings with a browser TLS fingerprint
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.text

Parse the vacancy table by header label

The listing is a single <table class="table"> with one vacancy per row. Column order varies by tenant, so map fields by their header text rather than by index. The 'N vacancies found' phrase is the authoritative total.

Step 3: Parse the vacancy table by header label
import 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")

Fetch each vacancy detail page

Detail pages carry the full description in a labeled <dl>. Classic tenants wrap it in #VacancyDetails; branded tenants render the same field contract at body scope. Verify the hidden #txtJobId matches the route id before trusting the page.

Step 4: Fetch each vacancy detail page
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,
    })

Distinguish empty boards from broken fetches

An empty table is only trustworthy when the page explicitly reports zero vacancies. Otherwise treat zero rows as a parse failure, and only accept a full snapshot when the advertised total matches the rows you parsed.

Step 5: Distinguish empty boards from broken fetches
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")
Common issues
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.

Best practices
  1. 1Impersonate a Chrome TLS fingerprint (curl_cffi chrome131) on every request — plain clients get a 500.
  2. 2Map table columns by their header text, never by fixed position.
  3. 3Trust the 'N vacancies found' total; mark the snapshot incomplete when it is missing or disagrees.
  4. 4Key jobs on the numeric /Applicants/vacancy/{id} segment — never the slug, title, or reference.
  5. 5Throttle to ~200ms between requests and no more than 3 concurrent detail fetches.
  6. 6Parse closing dates as en-GB (day-first) in the Europe/London timezone.
Or skip the complexity

One endpoint. All Ciphr iRecruit jobs. No scraping, no sessions, no maintenance.

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

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.

99.9%API uptime
<200msAvg response
50M+Jobs processed