Networx / IRIS Recruitment Jobs API.
Pull structured UK vacancies from IRIS Recruitment's current-vacancies.com employer microsites, where a stateful SearchVacancies JSON API sits behind an ASP.NET anti-forgery handshake and full descriptions live in per-advert JSON-LD.
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 Networx / IRIS Recruitment.
- Structured Salary Bands
- Full JSON-LD Descriptions
- Location & Postcode Data
- Contract Type (Dynamic Fields)
- Reference Codes & Dates
- Hiring Organisation Details
- 01UK Public-Sector Job Tracking
- 02Care & Charity Recruitment Monitoring
- 03Salary Benchmarking
- 04Aggregator Feed Building
How to scrape Networx / IRIS Recruitment.
Step-by-step guide to extracting jobs from Networx / IRIS Recruitment-powered career pages—endpoints, authentication, and working code.
import re
import requests
def bootstrap(careers_url: str) -> dict:
# The cid is the trailing -{digits} of the /Careers/{page}-{cid} path.
client_id = int(re.search(r"-(\d+)$", careers_url.split("?")[0]).group(1))
origin = "https://" + careers_url.split("/", 3)[2]
session = requests.Session()
session.headers["User-Agent"] = "Mozilla/5.0"
# 1. Root GET seeds ASP.NET_SessionId + ApplicationGatewayAffinity cookies.
session.get(origin + "/", timeout=15).raise_for_status()
# 2. The scoped careers page carries the anti-forgery form token + onboarding id.
html = session.get(careers_url, timeout=15).text
form_token = re.search(
r'name="__RequestVerificationToken"[^>]*value="([^"]+)"', html).group(1)
onboarding = re.search(r"InitialiseVacancySearch\([^)]*?(-?\d+)\s*\)", html)
return {
"session": session,
"origin": origin,
"careers_url": careers_url,
"client_id": client_id,
"form_token": form_token,
"onboarding_page_id": int(onboarding.group(1)) if onboarding else None,
}import json
def build_data(ctx: dict, page: int) -> str:
data = {
"ClientID": ctx["client_id"],
"DynamicFields": [],
"SearchResultFields": [],
"CurrentPage": page,
"PageSearchResults": True,
"SearchResultPageSize": 25,
"keywords": "",
"Locations": ["0"],
"Salary_All+Salaries": ["0"],
}
if ctx["onboarding_page_id"] is not None:
data["OnboardingPageID"] = ctx["onboarding_page_id"]
return json.dumps(data)
def post_search(ctx: dict, path: str, page: int) -> dict:
body = {
"__RequestVerificationToken": ctx["form_token"],
"data": build_data(ctx, page),
"hdnNewWorld": "True",
}
headers = {
"Origin": ctx["origin"],
"Referer": ctx["careers_url"],
"X-Requested-With": "XMLHttpRequest",
"__RequestVerificationToken": ctx["form_token"],
}
resp = ctx["session"].post(ctx["origin"] + path, data=body, headers=headers, timeout=15)
if "DisplayError" in resp.url:
raise RuntimeError("Anti-forgery token rejected — re-bootstrap the session")
return resp.json()
ctx = bootstrap("https://hymans.current-vacancies.com/Careers/Hymans%20external%20VSP-2054")
total = post_search(ctx, "/Careers/SearchVacanciesCount", 1)["Count"]
print(f"{total} live vacancies")import math
def scrape_listings(ctx: dict) -> list[dict]:
total = post_search(ctx, "/Careers/SearchVacanciesCount", 1)["Count"]
pages = math.ceil(total / 25)
listings = []
for page in range(1, pages + 1):
payload = post_search(ctx, "/Careers/SearchVacancies", page)
if not payload.get("OK"):
raise RuntimeError(f"SearchVacancies returned OK=false on page {page}")
rows = payload.get("Data") or []
for item in rows:
job_id = item.get("VacancyPublishInfoID")
if not job_id:
continue # no publish id -> can't build a detail URL, skip
postcode = item.get("Postcode")
listings.append({
"id": str(job_id),
"title": (item.get("VacancyTitle") or "").strip(),
"reference": item.get("Reference"),
"location": item.get("Location"),
"postcode": None if postcode in (None, "NO CODE") else postcode,
"salary": item.get("Salary"),
# Contract type lands under a per-tenant dynamic key; match by suffix.
"contract_type": next(
(v for k, v in item.items() if k.endswith("_ContractType")), None),
# ApplyLink may point at a sibling brand host sharing the same cid.
"detail_url": item.get("ApplyLink")
or f"{ctx['origin']}/Jobs/Advert/{job_id}?cid={ctx['client_id']}",
"posted": item.get("CreatedDate"),
"closes": item.get("ExpiryDate"),
})
expected = 25 if page < pages else total - (page - 1) * 25
if len(rows) != expected:
raise RuntimeError(f"page {page}: got {len(rows)} rows, expected {expected}")
return listingsimport json
from bs4 import BeautifulSoup
def fetch_detail(session: requests.Session, detail_url: str) -> dict:
html = session.get(detail_url, timeout=15).text
soup = BeautifulSoup(html, "html.parser")
posting = None
for tag in soup.find_all("script", type="application/ld+json"):
try:
blob = json.loads(tag.string or "{}")
except json.JSONDecodeError:
continue
for node in (blob if isinstance(blob, list) else [blob]):
if isinstance(node, dict) and node.get("@type") == "JobPosting":
posting = node
break
posting = posting or {}
org = posting.get("hiringOrganization") or {}
# The advert body backs the description when JSON-LD leaves it blank.
body = soup.select_one("#top .col-lg-7") or soup.select_one("#top")
return {
"title": posting.get("title"),
"employment_type": posting.get("employmentType"),
"posted_at": posting.get("datePosted"),
"closes_at": posting.get("validThrough"),
"company": org.get("name"),
"description_html": posting.get("description")
or (body.decode_contents() if body else None),
}The ASP.NET anti-forgery token was missing, stale or mismatched. Re-run the bootstrap (root GET then careers GET) to capture a fresh __RequestVerificationToken plus the ASP.NET_SessionId and ApplicationGatewayAffinity cookies, and echo the token in both the form body and the header. Detect the bounce by the final URL, not the HTTP status.
The bare {tenant}.current-vacancies.com root and the www host are generic aggregators that mix unrelated clients' jobs. Only scrape the scoped /Careers/{page}-{cid} board; the trailing numeric cid is the authoritative tenant (ClientID).
Read SearchVacanciesCount first and require each page to return a full 25 rows (or the exact remainder on the last page). Treat any mismatch as an incomplete snapshot rather than accepting a short page as the whole board.
Tenant-configured columns arrive under per-tenant dynamic keys such as '36461626_ContractType', not a fixed name. Resolve them by matching the key suffix (e.g. endswith('_ContractType')) instead of a hardcoded key.
ApplyLink can resolve to a sibling brand subdomain that shares the same cid, so always preserve the cid query parameter when building /Jobs/Advert/{id}?cid={cid}. A 404/410 means the advert closed; skip it rather than deleting the whole company.
- 1Only mint scrape targets from scoped /Careers/{page}-{cid} URLs — never the www aggregator or a bare microsite root.
- 2Re-bootstrap each run (root GET then careers GET) to get a fresh anti-forgery token and gateway-affinity cookies.
- 3Reuse one requests.Session so the ASP.NET_SessionId and ApplicationGatewayAffinity cookies persist across the POSTs.
- 4Call SearchVacanciesCount first, then page in 25s until the total is met; stop at the 1,000-job (40-page) safety cap.
- 5Pull full descriptions and salary bands from the /Jobs/Advert JSON-LD, not the truncated listing payload.
- 6Throttle to ~300ms between requests and at most 3 concurrent detail fetches to stay polite.
One endpoint. All Networx / IRIS Recruitment jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=networx / iris recruitment" \
-H "X-Api-Key: YOUR_KEY" Access Networx / IRIS Recruitment
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.