All platforms

Jobtrain Jobs API.

Sweep UK public-sector, NHS and transport vacancies from a multi-tenant HTML board: paginated 12-card partials hand you an authoritative job count, and every detail page embeds a full schema.org JobPosting with salary, dates and locations.

Get API access
Jobtrain
Live
<3haverage discovery time
1hrefresh interval
Companies using Jobtrain
OdeonCitizens AdviceYorkshire Building SocietyVoyage CareHFT
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 Jobtrain.

Data fields
  • Full HTML Job Descriptions
  • Free-text Salary Bands
  • Employment Type
  • Structured Locations & Postcodes
  • Posted & Closing Dates
  • Hiring Organization & Logo
Use cases
  1. 01UK Public-Sector Hiring Monitoring
  2. 02NHS Job Tracking
  3. 03Salary Benchmarking
  4. 04Recruitment Aggregation
Trusted by
OdeonCitizens AdviceYorkshire Building SocietyVoyage CareHFTSIG Plc
DIY GUIDE

How to scrape Jobtrain.

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

HTMLintermediateNo published limit; self-throttle to ~2 req/s (500ms apart), ~3 concurrent detail fetchesNo auth

Resolve the card partial and detail URLs from a tenant board

Each employer lives at a canonical board URL in one of two layouts: path-hosted (www.jobtrain.co.uk/{tenant}/Home/Job, also jobtrain.net) or subdomain-hosted ({tenant}.jobtrain.co.uk/Home/Job). The listing cards load over XHR from a sibling partial at /Home/_JobCard on the same host, so derive both URLs from the board you already trust.

Step 1: Resolve the card partial and detail URLs from a tenant board
import requests
from urllib.parse import urlsplit

def board_urls(board_url: str) -> tuple[str, str]:
    # "https://www.jobtrain.co.uk/sigplc/Home/Job"  (path-hosted)
    # "https://hft.jobtrain.co.uk/Home/Job"          (subdomain-hosted)
    parts = urlsplit(board_url)
    origin = f"{parts.scheme}://{parts.netloc}"
    prefix = parts.path[: -len("/Job")]  # ".../Home"
    listing_url = f"{origin}{prefix}/Job"
    card_url = f"{origin}{prefix}/_JobCard"
    return listing_url, card_url

listing_url, card_url = board_urls("https://www.jobtrain.co.uk/sigplc/Home/Job")
print(card_url)
# https://www.jobtrain.co.uk/sigplc/Home/_JobCard

Paginate the _JobCard partial by Skip offset

The partial returns 12 job-card records per page, offset by a Skip query param. Each page carries two hidden inputs: totalMatchRecords (the authoritative total) and totalCurrentRecords (rows on this page). Keep requesting pages until the rows you have collected reach the advertised total.

Step 2: Paginate the _JobCard partial by Skip offset
from bs4 import BeautifulSoup

PAGE_SIZE = 12
MAX_PAGES = 200  # 12 * 200 = 2,400-record audited safety cap

def hidden_int(soup, element_id):
    el = soup.select_one(f"#{element_id}")
    value = el.get("value") if el is not None else None
    return int(value) if value and value.isdigit() else None

cards, total, received = [], None, 0
for page in range(MAX_PAGES):
    resp = requests.get(f"{card_url}?Skip={page * PAGE_SIZE}", timeout=30)
    if resp.status_code in (404, 410):
        raise SystemExit("Tenant board not found")
    if resp.status_code in (401, 403, 429):
        raise SystemExit(f"Blocked or auth-gated (HTTP {resp.status_code})")
    resp.raise_for_status()
    if not resp.text.strip():
        break  # empty page terminates pagination

    soup = BeautifulSoup(resp.text, "html.parser")
    page_total = hidden_int(soup, "totalMatchRecords")
    current = hidden_int(soup, "totalCurrentRecords")
    page_cards = soup.select("div.job-card")
    if page_total is None or current is None or current != len(page_cards):
        raise SystemExit("Incomplete page — missing/mismatched count markers")

    total = page_total if total is None else total
    received += len(page_cards)
    cards.extend(page_cards)
    if received >= total or len(page_cards) < PAGE_SIZE:
        break

print(f"Collected {received} of {total} advertised jobs")

Parse each job-card stub

Cards are stubs: pull the numeric JobId from the detail anchor's href, plus title, location, reference and employment type. The .jobdetailsitem paragraphs are '<strong>Label:</strong> value', so peel the bold label off the front.

Step 3: Parse each job-card stub
import re
from urllib.parse import urljoin

JOB_ID_RE = re.compile(r"[?&]JobId=(\d+)", re.IGNORECASE)

def detail_item(card, class_name):
    el = card.select_one(f".jobdetailsitem.{class_name}")
    if el is None:
        return None
    full = el.get_text(" ", strip=True)
    label_el = el.select_one("strong")
    label = label_el.get_text(" ", strip=True) if label_el is not None else ""
    value = full[len(label):].strip() if label and full.startswith(label) else full
    return value or None

jobs = []
for card in cards:
    anchor = (card.select_one("a[data-testid^='a-job-detail-']")
              or card.select_one("a[href*='JobDetail']"))
    href = anchor.get("href") if anchor is not None else None
    if not href:
        continue
    detail_url = urljoin(listing_url, href)
    match = JOB_ID_RE.search(detail_url)
    title = anchor.get_text(" ", strip=True) if anchor is not None else None
    if not (match and title):
        continue  # skip cards with no numeric JobId or title
    loc = card.select_one(".job-card__location strong")
    jobs.append({
        "external_id": match.group(1),
        "title": title,
        "location": loc.get_text(" ", strip=True) if loc is not None else None,
        "reference": detail_item(card, "jobreference"),
        "employment_type": detail_item(card, "employmenttype"),
        "detail_url": detail_url,
    })

print(f"Parsed {len(jobs)} cards")

Enrich from the detail page's JSON-LD JobPosting

Fetch /Job/JobDetail?JobId={id} and read the single schema.org JobPosting embedded in the document for description, dates, salary, employment type, hiring organization and postal locations. The same page exposes the canonical apply route via a DecideInternalExternal anchor. A removed or expired posting responds HTTP 200 with an empty body.

Step 4: Enrich from the detail page's JSON-LD JobPosting
import json

def scrape_detail(job: dict) -> dict:
    resp = requests.get(job["detail_url"], timeout=30)
    if resp.status_code in (404, 410) or not resp.text.strip():
        return {**job, "removed": True}
    resp.raise_for_status()

    soup = BeautifulSoup(resp.text, "html.parser")
    posting = None
    for tag in soup.select('script[type="application/ld+json"]'):
        try:
            data = json.loads(tag.string or "")
        except (ValueError, TypeError):
            continue
        for node in (data if isinstance(data, list) else [data]):
            if isinstance(node, dict) and node.get("@type") == "JobPosting":
                posting = node
                break
        if posting:
            break
    if posting is None:
        raise ValueError("No JSON-LD JobPosting on detail page")

    org = posting.get("hiringOrganization") or {}
    raw_locs = posting.get("jobLocation")
    raw_locs = raw_locs if isinstance(raw_locs, list) else [raw_locs] if raw_locs else []

    apply_anchor = (
        soup.select_one("a[data-testid='a-btn-decide-internal-external'][href*='JobId=']")
        or soup.select_one("a[href*='/DecideInternalExternal/'][href*='JobId=']")
    )
    apply_url = urljoin(job["detail_url"], apply_anchor["href"]) if apply_anchor else None

    return {
        **job,
        "description_html": posting.get("description"),
        "posted_at": posting.get("datePosted"),
        "closes_at": posting.get("validThrough"),
        "employment_type": posting.get("employmentType") or job.get("employment_type"),
        "salary": posting.get("baseSalary"),   # free-text string, not a schema.org object
        "company_name": org.get("name"),
        "logo": org.get("logo"),
        "locations": [loc.get("address", {}) for loc in raw_locs if isinstance(loc, dict)],
        "apply_url": apply_url,
    }

detailed = [scrape_detail(j) for j in jobs]
print(f"Scraped {len(detailed)} Jobtrain job details")
Common issues
mediumThere is no structured jobs API — the board loads cards from a server-rendered HTML partial at /Home/_JobCard.

Request /Home/_JobCard?Skip={offset} in increments of 12 and parse the returned HTML. Treat totalMatchRecords as the authoritative total and stop once the rows you have collected reach it.

highA page can arrive blank, without the totalMatchRecords/totalCurrentRecords markers, or with a card count that disagrees with totalCurrentRecords.

Require both hidden count inputs on every page and verify totalCurrentRecords equals the number of div.job-card elements. Treat any blank, markerless, malformed or mismatched page as an incomplete snapshot rather than a valid empty result.

mediumTenants exist in two layouts — path-hosted (www.jobtrain.co.uk/{tenant}) and subdomain-hosted ({tenant}.jobtrain.co.uk) — and reserved segments like www, account, cms, session and talentpool are not tenants.

Derive the card partial and detail URLs from the exact board host, and reject reserved path segments so you never mint a scraper target from a marketing, account or asset URL.

lowA removed or expired posting responds HTTP 200 with a zero-length body (or 404/410) instead of a JobPosting.

Treat an empty response body and 404/410 as a removed posting and skip it — do not attempt to parse JSON-LD from an empty document.

lowbaseSalary is free text such as '32000' or 'c. £60,000' with HTML entities, not a schema.org MonetaryAmount object.

Read baseSalary as a string and HTML-decode it; do not assume a numeric value or a nested currency/value structure.

Best practices
  1. 1Derive the card partial and detail URLs from the exact board host — path-hosted and subdomain-hosted tenants both exist.
  2. 2Page /Home/_JobCard in Skip increments of 12 and stop when the rows you have collected reach totalMatchRecords.
  3. 3Trust totalMatchRecords as the authoritative total; treat missing markers or a totalCurrentRecords/card-count mismatch as an incomplete snapshot.
  4. 4Read rich fields — description, salary, dates and locations — from the detail page's schema.org JobPosting, not the listing cards.
  5. 5Self-throttle to about two requests per second (500ms apart) and keep concurrent detail fetches to roughly three.
  6. 6Skip reserved path segments (www, account, cms, session, talentpool, assets) — they are never tenant boards.
Or skip the complexity

One endpoint. All Jobtrain jobs. No scraping, no sessions, no maintenance.

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

Access Jobtrain
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