All platforms

Kallidus Recruit (Advorto) Jobs API.

Extract structured vacancies from Kallidus Recruit (formerly Advorto) careers boards, resolving each job's native VId, salary and closing date straight from the server-rendered ASP.NET vacancy pages.

Get API access
Kallidus Recruit (Advorto)
Live
<3haverage discovery time
1hrefresh interval
Companies using Kallidus Recruit (Advorto)
IcelandShaw TrustThames Water
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 Kallidus Recruit (Advorto).

Data fields
  • Native VId Vacancy Keys
  • Business Reference Codes
  • Salary & Contract Details
  • Category / Division
  • Posted & Closing Dates
  • Full HTML Job Descriptions
Use cases
  1. 01UK Public-Sector Job Aggregation
  2. 02Vacancy Monitoring & Alerts
  3. 03Salary Benchmarking
  4. 04Careers Board Sync
Trusted by
IcelandShaw TrustThames Water
DIY GUIDE

How to scrape Kallidus Recruit (Advorto).

Step-by-step guide to extracting jobs from Kallidus Recruit (Advorto)-powered career pages—endpoints, authentication, and working code.

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

Fetch the first Search.aspx page and locate the Advorto grid

A Kallidus Recruit board is a hosted ASP.NET WebForms site — jobs.icelandcareers.co.uk, jobs.shaw-trust.org.uk, or any *.kallidusrecruit.com host. GET /Search.aspx and confirm the Advorto DataGrid is present; a live board with no openings renders a 'no current vacancies' message and no grid, which is a valid empty result rather than a failure.

Step 1: Fetch the first Search.aspx page and locate the Advorto grid
import re
import requests
from bs4 import BeautifulSoup

# Any hosted Kallidus Recruit / Advorto board works the same way.
BASE_URL = "https://jobs.icelandcareers.co.uk/Search.aspx"
GRID_ID = "MainPlaceholder_VacancyList_SearchableDataTable_DataGrid_DataGrid"

session = requests.Session()
session.headers.update({"User-Agent": "Mozilla/5.0 (compatible; JoboBot/1.0)"})

def clean(text):
    return re.sub(r"\s+", " ", text or "").strip()

def load(html):
    return BeautifulSoup(html, "html.parser")

resp = session.get(BASE_URL, timeout=30)
resp.raise_for_status()
soup = load(resp.text)

grid = soup.find(id=GRID_ID)
if grid is None:
    if re.search(r"no (?:current )?vacancies|no jobs", soup.get_text(), re.I):
        raise SystemExit("Board has no current vacancies")
    raise SystemExit("Advorto vacancy grid not found - not a Kallidus Recruit board")

Parse vacancy rows, keying on the native VId

Each row links to VacancyInformation.aspx?VId={id}. The VId query param is the immutable vacancy key — never key on the displayed Ref column, which can be a distinct business requisition (Shaw Trust Ref 27210 maps to VId 33059). Read the remaining columns by matching header labels, since column order varies by board.

Step 2: Parse vacancy rows, keying on the native VId
from urllib.parse import urljoin, urlparse, parse_qs

def vid_of(url):
    return (parse_qs(urlparse(url).query).get("VId") or [""])[0].strip()

def parse_rows(grid, base_url):
    headers = [clean(th.get_text()) for th in grid.select("tr th")]

    def field(cells, *labels):
        for label in labels:
            for i, header in enumerate(headers):
                if header.lower() == label.lower() and i < len(cells):
                    return clean(cells[i].get_text())
        return ""

    jobs = []
    for row in grid.select("tr"):
        if "advorto-data-table-pager" in (row.get("class") or []):
            continue
        anchor = row.select_one("a[href*='VacancyInformation.aspx'][href*='VId=']")
        cells = row.select("td")
        if anchor is None or len(cells) < 4:
            continue
        detail_url = urljoin(base_url, anchor.get("href", ""))
        vid = vid_of(detail_url)          # native VId, NOT the displayed Ref
        title = clean(anchor.get_text())
        if not vid or not title:
            continue
        jobs.append({
            "external_id": vid,
            "title": title,
            "listing_url": detail_url,
            "business_reference": field(cells, "Ref", "Reference"),
            "category": field(cells, "Category", "Job Category", "Division"),
            "location": field(cells, "Location"),
            "salary_text": field(cells, "Salary"),
            "closing_date": field(cells, "Closing date"),
        })
    return jobs

listings = parse_rows(grid, BASE_URL)
print(f"Page 1: {len(listings)} vacancies")

Advance pages with the WebForms __VIEWSTATE postback

Paging is not a query param — it is an ASP.NET __doPostBack. Harvest every hidden input from form#form1, set __EVENTTARGET / __EVENTARGUMENT to the pager's Page$N event, and POST it back to the same Search.aspx URL. Reusing one Session preserves the board's session cookies. Stop when no sequential next-page event remains.

Step 3: Advance pages with the WebForms __VIEWSTATE postback
POSTBACK = re.compile(
    r"__doPostBack\('(?P<target>[^']+)','(?P<arg>Page\$(?:\d+|Last))'\)")

def next_postback(soup):
    pager = soup.select_one("tr.advorto-data-table-pager")
    if pager is None:
        return None
    current = int(clean(pager.select_one("span").get_text()) or "1")
    for a in pager.select("a[href]"):
        m = POSTBACK.search(a.get("href", ""))
        arg = m.group("arg")[5:] if m else ""
        if m and arg.isdigit() and int(arg) == current + 1:
            fields = {}
            for inp in soup.select("form#form1 input[type='hidden'][name]"):
                fields[inp["name"]] = inp.get("value", "")
            fields["__EVENTTARGET"] = m.group("target")
            fields["__EVENTARGUMENT"] = m.group("arg")
            return fields
    return None

p = urlparse(BASE_URL)
origin = f"{p.scheme}://{p.netloc}"
while True:
    fields = next_postback(soup)
    if fields is None:
        break  # no sequential next page -> pagination complete
    resp = session.post(
        BASE_URL, data=fields, timeout=30,
        headers={"Origin": origin, "Referer": BASE_URL})
    resp.raise_for_status()
    soup = load(resp.text)
    grid = soup.find(id=GRID_ID)
    listings.extend(parse_rows(grid, BASE_URL))

print(f"Collected {len(listings)} vacancies across all pages")

Fetch each VacancyInformation.aspx detail page

GET the listing URL and confirm the detail form's action VId matches the listing's VId before trusting the page. Fields live in an Advorto definition list (dt label -> dd value); most boards label the body 'Description', but some (e.g. Thames Water) render an unlabelled wide-description block. Posted / closing dates come from the datePosted / validThrough markers embedded in the HTML.

Step 4: Fetch each VacancyInformation.aspx detail page
def scrape_detail(session, listing):
    resp = session.get(listing["listing_url"], timeout=30)
    if resp.status_code in (404, 410):
        return {**listing, "removed": True}   # vacancy withdrawn
    resp.raise_for_status()
    soup = load(resp.text)

    # Confirm the detail form's VId matches the listing before trusting the page
    form = soup.select_one("form#form1")
    action = urljoin(listing["listing_url"], form.get("action", "")) if form else ""
    if vid_of(action) != listing["external_id"]:
        raise ValueError("Detail form VId does not match the listing VId")

    # Fields live in an Advorto definition list (dt label -> dd value)
    fields = {}
    for dt in soup.select("dl.AdvortoDefinitionList > dt"):
        dd = dt.find_next_sibling("dd")
        if dd is not None:
            fields[clean(dt.get_text())] = dd

    title = clean(fields["Job title"].get_text()) if "Job title" in fields else listing["title"]
    if "Description" in fields:
        description = fields["Description"].decode_contents().strip()
    else:
        # Fall back to the widest definition-list cell with substantive text
        description = next(
            (dd.decode_contents().strip()
             for dd in soup.select(
                 ".vacancy-information-fields dd.AdvortoDefinitionListElementWide")
             if len(dd.get_text(strip=True)) >= 80),
            "")

    posted = re.search(r'"datePosted"\s*:\s*"([^"]+)"', resp.text)
    closes = re.search(r'"validThrough"\s*:\s*"([^"]+)"', resp.text)
    return {
        **listing,
        "title": title,
        "description_html": description,
        "contract_type": clean(fields["Contract type"].get_text()) if "Contract type" in fields else "",
        "hours": clean(fields["Hours"].get_text()) if "Hours" in fields else "",
        "posted_at": posted.group(1) if posted else None,
        "closes_at": closes.group(1) if closes else listing.get("closing_date"),
    }

details = [scrape_detail(session, job) for job in listings]
print(f"Scraped {len(details)} Kallidus Recruit job details")
Common issues
highThe displayed Ref column is not the vacancy's real key — Advorto's business reference can differ from the VId (Shaw Trust Ref 27210 links to VId 33059), so keying on Ref mis-links and double-counts jobs.

Always key each job on the VId query param in the VacancyInformation.aspx URL, and keep the displayed Ref only as secondary business_reference metadata.

highThere is no jobs JSON endpoint and pagination is not a query param — the next page is an ASP.NET __doPostBack that must replay the page's hidden __VIEWSTATE / __EVENTVALIDATION fields plus the DataGrid Page$N event.

Harvest all hidden inputs from form#form1 on each page, set __EVENTTARGET / __EVENTARGUMENT to the pager's Page$(current+1) event, POST them to Search.aspx, and stop when no sequential next-page event exists.

mediumDescriptions render in two layouts: most boards use a labelled 'Description' dt/dd, but some (e.g. Thames Water) emit an unlabelled dd.AdvortoDefinitionListElementWide, so a label-only parser returns empty descriptions.

Try the labelled Description field first, then fall back to the widest definition-list cell whose stripped text is at least ~80 characters.

lowA valid board with zero openings renders a 'no current vacancies' message and omits the Advorto grid entirely, which can look like a parse failure.

Detect the no-vacancies text before flagging a missing grid as an error, and treat it as a legitimate empty snapshot.

lowExpired or withdrawn vacancies return HTTP 404 or 410 from VacancyInformation.aspx.

Treat 404/410 detail responses as removal signals and skip the record rather than failing the run.

Best practices
  1. 1Key every job on the VacancyInformation.aspx VId query param, never the displayed Ref column.
  2. 2Replay form#form1's hidden __VIEWSTATE fields on each POST — paging is an ASP.NET postback, not a page query param.
  3. 3Reuse one requests.Session so the board's ASP.NET session cookies persist across pages and detail fetches.
  4. 4Validate that a detail page's form VId matches the listing before saving the record.
  5. 5Support both the labelled Description field and the unlabelled wide-description layout.
  6. 6Self-throttle (~100ms apart, ~2 concurrent detail fetches) to mirror polite board access.
Or skip the complexity

One endpoint. All Kallidus Recruit (Advorto) jobs. No scraping, no sessions, no maintenance.

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

Access Kallidus Recruit (Advorto)
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