PeopleScout Springboard Jobs API.

Collect vacancies from PeopleScout Springboard candidate portals — vendor-hosted and employer-branded alike — through the jobtools search endpoint that every one of them serves.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on PeopleScout Springboard.

Data fields

  • Authoritative Job Totals
  • Full Advertisement Bodies
  • schema.org JobPosting Data
  • Vendor Job References
  • Multi-Employer Portals
  • Structured Locations

Use cases

  1. 01RPO & Staffing Job Aggregation
  2. 02Australian Public-Sector Feeds
  3. 03Enterprise Careers Monitoring
  4. 04ATS Data Pipelines

Trusted by

  • Amplifon
  • Urbis
  • PeopleReady
  • Goodyear
DIY GUIDE

How to scrape PeopleScout Springboard.

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

API type
Hybrid
Difficulty
advanced
Rate limit
No published limit; ~250ms between requests and at most 3 concurrent detail fetches
Authentication
No auth

Pair the portal host with the numeric organ id

Springboard identifies a tenant by a numeric in_organid, and the portal hostname is a separate axis: one employer can publish through several branded hosts, and one shared portal can publish several employers. Take both from the servlet URL, and remember that the pretty /jobs/{reference} route deliberately hides the organ id.

Step 1: Pair the portal host with the numeric organ id
import re
from urllib.parse import urlparse, parse_qs

JOBTOOLS = "jobtools"
SEARCH_SERVLET = "jncustomsearch"
ORGAN_ID = re.compile(r"^[1-9][0-9]{2,8}$")

def parse_servlet(url: str) -> tuple[str, str, str | None] | None:
    parsed = urlparse(url)
    parts = parsed.path.strip("/").split("/")
    if len(parts) != 2 or parts[0].lower() != JOBTOOLS:
        return None
    if not parts[1].lower().startswith(f"{SEARCH_SERVLET}."):
        return None   # JnCustomLogin and cmcustomaa are not job surfaces
    query = parse_qs(parsed.query)
    organ_id = (query.get("in_organid") or [""])[0]
    if not ORGAN_ID.match(organ_id):
        return None
    counter = (query.get("in_jnCounter") or [None])[0]
    return parsed.netloc.lower(), organ_id, counter

def board_url(host: str, organ_id: str) -> str:
    return (
        f"https://{host}/{JOBTOOLS}/{SEARCH_SERVLET}.searchResults"
        f"?in_organid={organ_id}&in_jobDate=All"
    )

host, organ_id, _ = parse_servlet(
    "https://jobs.careers.vic.gov.au/jobtools/jncustomsearch.viewFullSingle"
    "?in_organid=17833&in_jnCounter=226664082"
)
print(board_url(host, organ_id))

Read the total and the rows from the JSON search endpoint

The portal's own scripts call jn_search_xml.pr_get_jobad_search_filters, which returns the authoritative count and the complete result set in one request. Several tenants emit unescaped characters inside the filter block, so the whole document is not valid JSON — read the total with a targeted expression and slice the jobs array out by bracket scanning.

Step 2: Read the total and the rows from the JSON search endpoint
import json
import requests

MAX_ROWS = 2000
TOTAL = re.compile(r'"total"\s*:\s*\[\s*\{[^{}]*?"count"\s*:\s*"?([0-9]+)"?')

def slice_array(text: str, key: str) -> str | None:
    start = text.find(f'"{key}"')
    if start < 0:
        return None
    start = text.find("[", start)
    depth = 0
    for index in range(start, len(text)):
        if text[index] == "[":
            depth += 1
        elif text[index] == "]":
            depth -= 1
            if depth == 0:
                return text[start:index + 1]
    return None

def fetch_search(session: requests.Session, host: str, organ_id: str) -> tuple[int, list[dict]]:
    response = session.get(
        f"https://{host}/{JOBTOOLS}/jn_search_xml.pr_get_jobad_search_filters",
        params={"in_organid": organ_id, "in_jobDate": "All", "in_maxrows": MAX_ROWS},
        headers={"Accept": "application/json, text/plain"},
        timeout=60,
    )
    response.raise_for_status()
    body = response.text

    # Cross-posting an organ id onto the wrong portal returns 200 with an EMPTY body.
    # No total means "this portal does not publish this tenant", not "no jobs".
    match = TOTAL.search(body)
    if not match:
        raise RuntimeError("PeopleScout portal did not publish a total for this organ id")

    rows = slice_array(body, "jobs")
    try:
        parsed = json.loads(rows) if rows else []
    except json.JSONDecodeError:
        parsed = []
    return int(match.group(1)), [row for row in parsed if isinstance(row, dict) and row]

session = requests.Session()
total, rows = fetch_search(session, host, organ_id)
print(f"vendor total {total}, usable rows {len(rows)}")

Fall back to the XML feed, and mark short snapshots incomplete

A few tenants render an empty JSON row template even though their total is correct. For those, xml_feeds.current_jobs carries the same jobs, but it is tenant-capped on large boards — so only treat the result as complete when the rows you collected account for the JSON total, and mark it incomplete otherwise so nothing gets expired.

Step 3: Fall back to the XML feed, and mark short snapshots incomplete
import xml.etree.ElementTree as ET

def fetch_feed(session: requests.Session, host: str, organ_id: str) -> list[dict]:
    response = session.get(
        f"https://{host}/{JOBTOOLS}/xml_feeds.current_jobs",
        params={"in_orgi": organ_id},
        timeout=60,
    )
    response.raise_for_status()
    root = ET.fromstring(response.text)
    return [
        {
            "jn_counter": job.findtext("JNCOUNTER"),
            "reference": job.findtext("REFERENCE"),
            "title": job.findtext("TITLE"),
            "short_url": job.findtext("SHORT_URL"),   # the vendor's own alias URL
        }
        for job in root.iter("JOB")
    ]

def collect(session: requests.Session, host: str, organ_id: str) -> dict:
    total, rows = fetch_search(session, host, organ_id)
    source = "json"
    if not rows:
        rows = fetch_feed(session, host, organ_id)
        source = "xml"
    return {
        "total": total,
        "rows": rows,
        "source": source,
        # A snapshot that does not account for the vendor total must never authorise
        # expiring the jobs it could not see.
        "complete": len(rows) == total,
    }

snapshot = collect(session, host, organ_id)
print(snapshot["source"], len(snapshot["rows"]), "of", snapshot["total"],
      "complete" if snapshot["complete"] else "INCOMPLETE")

Hydrate the advert from viewFullSingle

The only surface carrying the complete advertisement is jncustomsearch.viewFullSingle. Prefer the .jobDesc container, which keeps the advert's original markup, and fall back to the JSON-LD description, which is the same text with tags stripped. Take structured fields such as dates and locations from the schema.org JobPosting block.

Step 4: Hydrate the advert from viewFullSingle
from bs4 import BeautifulSoup

def job_url(host: str, organ_id: str, counter: str) -> str:
    return (
        f"https://{host}/{JOBTOOLS}/{SEARCH_SERVLET}.viewFullSingle"
        f"?in_organid={organ_id}&in_jnCounter={counter}"
    )

def find_job_posting(soup) -> dict | None:
    for tag in soup.find_all("script", type="application/ld+json"):
        try:
            data = json.loads(tag.string or "")
        except (json.JSONDecodeError, TypeError):
            continue
        for node in (data if isinstance(data, list) else [data]):
            if isinstance(node, dict) and node.get("@type") == "JobPosting":
                return node
    return None

def fetch_job(session: requests.Session, host: str, organ_id: str, counter: str) -> dict | None:
    url = job_url(host, organ_id, counter)
    response = session.get(url, timeout=30)
    if response.status_code in (404, 410):
        return None  # canonical removal
    response.raise_for_status()
    soup = BeautifulSoup(response.text, "html.parser")

    posting = find_job_posting(soup)
    if posting is None:
        # A closed advert keeps answering 200 but drops its JobPosting block.
        return None

    body = soup.select_one(".jobDesc")
    return {
        "id": counter,
        "title": posting.get("title"),
        "company": (posting.get("hiringOrganization") or {}).get("name"),
        "reference": posting.get("identifier", {}).get("value")
                     if isinstance(posting.get("identifier"), dict) else posting.get("identifier"),
        "description_html": body.decode_contents().strip() if body else posting.get("description"),
        "posted_at": posting.get("datePosted"),
        "valid_through": posting.get("validThrough"),
        "employment_type": posting.get("employmentType"),
        "listing_url": url,
    }

print(fetch_job(session, host, organ_id, "226664082"))

Attribute a pretty /jobs/{reference} link

The SEO alias route hides the organ id, and a job page can link a sibling board, so 'the only organ id on the page' is unsafe. Read the hidden in_organid input inside the portal's own results form — it renders exactly once and always names the serving board — and compare references through one normalisation, because the vendor writes AMP/1942433 in JSON-LD and AMP-1942433 in the path.

Step 5: Attribute a pretty /jobs/{reference} link
def normalize_reference(value: str) -> str:
    return value.strip().replace("/", "-").replace("_", "-").replace(" ", "-").upper()

def resolve_alias(session: requests.Session, alias_url: str) -> dict | None:
    response = session.get(alias_url, timeout=30, allow_redirects=True)
    if not response.ok:
        return None
    soup = BeautifulSoup(response.text, "html.parser")

    # Exactly one hidden in_organid must be present; a page that links a sibling board
    # exposes other organ ids elsewhere, so uniqueness of this field is the proof.
    organ_ids = {
        (field.get("value") or "").strip()
        for field in soup.select("input[name='in_organid']")
    }
    organ_ids = {value for value in organ_ids if ORGAN_ID.match(value)}
    if len(organ_ids) != 1:
        return None

    posting = find_job_posting(soup)
    identifier = (posting or {}).get("identifier")
    reference = identifier.get("value") if isinstance(identifier, dict) else identifier
    requested = urlparse(response.url).path.strip("/").split("/")
    requested = requested[1] if len(requested) == 2 else requested[2] if len(requested) > 2 else ""
    if not reference or normalize_reference(reference) != normalize_reference(requested):
        return None

    return {"host": urlparse(response.url).netloc.lower(), "organ_id": organ_ids.pop(),
            "reference": normalize_reference(reference)}

print(resolve_alias(session, "https://amplifon.springboard.com.au/jobs/Warrnambool/AMP-1924558"))
Common issues
criticalThe search response is not valid JSON
Several tenants emit unescaped characters inside the filter block, so parsing the whole document fails even though the data is intact. Read the total with a targeted expression and slice the jobs array out by bracket scanning, then parse only that fragment.
criticalA cross-posted organ id looks like an empty board
Requesting an organ id on a portal that does not publish it returns HTTP 200 with an empty body. Treat a missing job total as 'this portal does not serve this tenant' rather than as a board with no jobs, otherwise a mismatched pair expires a live employer.
highThe wrong organ id is taken from a job page
Some portals link a sibling board from every job page, so picking the only organ id on the document is unsafe. Read the hidden in_organid input inside the portal's own results form, require exactly one distinct value, and refuse the document otherwise.
highA closed advertisement still returns HTTP 200
When an advert closes the portal keeps serving the page but drops the JobPosting block and renders the empty search shell. That shell still carries the hidden organ id, so it is structured evidence the role has ended — a page with no organ id at all is a parse failure instead.
mediumThe XML feed silently truncates a large board
xml_feeds.current_jobs is tenant-capped, around 300 rows on big boards. Use it only when the JSON row template is empty, and compare what you collected with the JSON total before calling the snapshot complete.
Best practices
  1. 1Treat the numeric in_organid as the tenant and the portal hostname as a separate scope
  2. 2Prefer the jn_search_xml JSON endpoint and use in_maxrows large enough to cover the total
  3. 3Read the total with a targeted expression rather than parsing the whole response
  4. 4Fall back to the XML feed only for tenants whose JSON rows come back empty
  5. 5Prefer the .jobDesc container over the JSON-LD description so the advert keeps its markup
  6. 6Compare job references through one normalisation — the vendor writes them two ways
Or skip the complexity

One endpoint. All PeopleScout Springboard jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=peoplescout springboard" \
  -H "X-Api-Key: YOUR_KEY"
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.

Ready to integrate

Access PeopleScout Springboard
job data today.

One API call. Structured data. No scraping infrastructure to build or maintain — start with the $5 free starting balance and scale as you grow.

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