All platforms

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.

Get API access
Networx / IRIS Recruitment
Live
<3haverage discovery time
1hrefresh interval
Companies using Networx / IRIS Recruitment
Hymans RobertsonJames Hutton InstituteKeystone GroupAldermore
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 Networx / IRIS Recruitment.

Data fields
  • Structured Salary Bands
  • Full JSON-LD Descriptions
  • Location & Postcode Data
  • Contract Type (Dynamic Fields)
  • Reference Codes & Dates
  • Hiring Organisation Details
Use cases
  1. 01UK Public-Sector Job Tracking
  2. 02Care & Charity Recruitment Monitoring
  3. 03Salary Benchmarking
  4. 04Aggregator Feed Building
Trusted by
Hymans RobertsonJames Hutton InstituteKeystone GroupAldermore
DIY GUIDE

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.

HybridadvancedNo official limit; self-throttle to ~300ms between requests and 3 concurrent detail fetchesNo auth

Bootstrap the session and anti-forgery token

Networx microsites live at {tenant}.current-vacancies.com/Careers/{page}-{cid}, where the trailing number is the tenant's ClientID. Hit the microsite root to establish the ASP.NET session and gateway-affinity cookies, then load the scoped careers page to read the hidden __RequestVerificationToken and the OnboardingPageID baked into InitialiseVacancySearch(...).

Step 1: Bootstrap the session and anti-forgery token
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,
    }

Read the authoritative vacancy count

POST to /Careers/SearchVacanciesCount with the form token echoed in both the body and the __RequestVerificationToken header. A rejected token 302-redirects to /Website/DisplayError, so detect failure by the final URL rather than the status code.

Step 2: Read the authoritative vacancy count
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")

Page through SearchVacancies in 25s

Page /Careers/SearchVacancies until the authoritative total is exhausted (25 rows per page, 1,000-job / 40-page safety cap). Each page must return a full 25 rows — or the remainder on the last page — so a short page signals an incomplete snapshot, not an empty board. VacancyPublishInfoID is the /Jobs/Advert id, and tenant-configured columns arrive under dynamic keys like '36461626_ContractType'.

Step 3: Page through SearchVacancies in 25s
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 listings

Fetch full details from the advert JSON-LD

Listing rows carry a truncated description, so fetch the canonical /Jobs/Advert/{id}?cid={cid} page for the full record. Read the JSON-LD JobPosting block for title, dates, employment type, hiring organisation and salary; fall back to the advert body under #top .col-lg-7 when JSON-LD omits the description.

Step 4: Fetch full details from the advert JSON-LD
import 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),
    }
Common issues
criticalSearchVacancies POST bounces to /Website/DisplayError

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.

highScraping a bare microsite root or www.current-vacancies.com returns unrelated employers

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).

mediumA page returns fewer rows than SearchVacanciesCount promised

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.

lowContract type (and other tenant columns) missing from the expected field

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.

mediumDetail page 404s or the apply link points at a different host

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.

Best practices
  1. 1Only mint scrape targets from scoped /Careers/{page}-{cid} URLs — never the www aggregator or a bare microsite root.
  2. 2Re-bootstrap each run (root GET then careers GET) to get a fresh anti-forgery token and gateway-affinity cookies.
  3. 3Reuse one requests.Session so the ASP.NET_SessionId and ApplicationGatewayAffinity cookies persist across the POSTs.
  4. 4Call SearchVacanciesCount first, then page in 25s until the total is met; stop at the 1,000-job (40-page) safety cap.
  5. 5Pull full descriptions and salary bands from the /Jobs/Advert JSON-LD, not the truncated listing payload.
  6. 6Throttle to ~300ms between requests and at most 3 concurrent detail fetches to stay polite.
Or skip the complexity

One endpoint. All Networx / IRIS Recruitment jobs. No scraping, no sessions, no maintenance.

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

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.

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