- 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.
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.
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
- 01RPO & Staffing Job Aggregation
- 02Australian Public-Sector Feeds
- 03Enterprise Careers Monitoring
- 04ATS Data Pipelines
Trusted by
- Amplifon
- Urbis
- PeopleReady
- Goodyear
How to scrape PeopleScout Springboard.
Step-by-step guide to extracting jobs from PeopleScout Springboard-powered career pages—endpoints, authentication, and working code.
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))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)}")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")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"))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"))- 1Treat the numeric in_organid as the tenant and the portal hostname as a separate scope
- 2Prefer the jn_search_xml JSON endpoint and use in_maxrows large enough to cover the total
- 3Read the total with a targeted expression rather than parsing the whole response
- 4Fall back to the XML feed only for tenants whose JSON rows come back empty
- 5Prefer the .jobDesc container over the JSON-LD description so the advert keeps its markup
- 6Compare job references through one normalisation — the vendor writes them two ways
One endpoint. All PeopleScout Springboard jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=peoplescout springboard" \
-H "X-Api-Key: YOUR_KEY"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.
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.