PeopleBank Jobs API.
Pull structured vacancies from PeopleBank's hosted, server-rendered career boards — job descriptions, closing dates, salary text and direct apply links — without wrangling its WAF-guarded HTML or opaque posting keys yourself.
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 PeopleBank.
- Full Job Descriptions
- Location & Postcode
- Closing Dates
- Department & Site Name
- Salary & Contract Text
- Direct Apply URLs
- 01Hospitality & Care Job Aggregation
- 02Careers Board Monitoring
- 03Recruitment Market Research
- 04ATS Feed Ingestion
How to scrape PeopleBank.
Step-by-step guide to extracting jobs from PeopleBank-powered career pages—endpoints, authentication, and working code.
import requests
def fetch_board(board_url: str, session: requests.Session) -> str:
# do_search renders the full public advert set on one page (no pagination).
resp = session.get(board_url, params={"do_search": "Show Vacancies"}, timeout=90)
if resp.status_code == 403:
# 403 is the WAF challenge, not an empty board -> retry with backoff.
raise RuntimeError("PeopleBank WAF challenge on board — back off and retry")
resp.raise_for_status()
return resp.text
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"Accept": "text/html,application/xhtml+xml",
})
html = fetch_board(
"https://careers.krispykreme.co.uk/pb3/corporate/krispykreme/advertsearch.php",
session,
)import re
from urllib.parse import urljoin, urlparse, parse_qs
from bs4 import BeautifulSoup
PUBLIC_KEY = re.compile(r"^[A-Za-z0-9@_-]+$")
def parse_listings(html: str, board_url: str) -> list[dict]:
soup = BeautifulSoup(html, "html.parser")
anchors = soup.select("a[href*='pbk24w12.main'][href*='p=']")
if not anchors:
# Distinguish an explicitly empty board from a blocked/changed page.
text = soup.get_text(" ", strip=True).lower()
if re.search(r"no vacancies|no positions|0 (?:jobs|positions)", text):
return []
raise RuntimeError("No advert links found — page was blocked or changed")
listings, seen = [], set()
for a in anchors:
detail_url = urljoin(board_url, a.get("href", ""))
key = (parse_qs(urlparse(detail_url).query).get("p") or [""])[0]
if not PUBLIC_KEY.match(key) or key in seen:
continue
seen.add(key)
row = a.find_parent(class_="result") or a.find_parent(["tr", "li"]) or a.parent
title_node = row.select_one(".result-headline, .job-title, h2, h3") if row else None
loc_node = row.select_one(".location, .job-location, .res-location") if row else None
close_text = row.get_text(" ", strip=True) if row else ""
close = re.search(r"(\d{1,2}/\d{1,2}/\d{4})", close_text)
listings.append({
"external_id": key, # opaque public posting key, stable across routes
"title": (title_node or a).get_text(strip=True),
"detail_url": detail_url,
"location": (row.get("data-location") if row else None)
or (loc_node.get_text(strip=True) if loc_node else None),
"postcode": loc_node.get("data-postcode") if loc_node else None,
"closes_at": close.group(1) if close else None, # dd/mm/yyyy (en-GB)
})
return listingsINTERNAL_ID = re.compile(r"(?:[?&]|&)P_nAdId=(\d+)", re.IGNORECASE)
def fetch_detail(session: requests.Session, listing: dict) -> dict:
resp = session.get(listing["detail_url"], timeout=90)
if resp.status_code in (404, 410):
return {**listing, "removed": True} # advert closed, not a block
if resp.status_code == 403:
raise RuntimeError("PeopleBank WAF challenge on detail — back off and retry")
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
# Newer .job-* template vs legacy De Vere .ad-display wrapper.
branded = soup.select_one(".ad-display > .ad-content")
title_node = soup.select_one("h1.job-title, h1")
if title_node:
title = title_node.get_text(strip=True)
elif branded and branded.select_one("h2"):
title = branded.select_one("h2").get_text(strip=True)
else:
title = listing.get("title", "")
body = soup.select_one(".job-content")
if body:
description = body.get_text("\n", strip=True)
elif branded:
parts = [d.get_text(" ", strip=True) for d in branded.find_all("div", recursive=False)]
description = "\n\n".join(p for p in parts if p)
else:
description = ""
if not title or not description:
raise RuntimeError("PeopleBank detail omitted title or job-content")
apply_a = soup.select_one("a[href*='candidate.apply'][href*='p=']")
apply_url = (urljoin(listing["detail_url"], apply_a["href"])
if apply_a and apply_a.has_attr("href") else None)
internal = INTERNAL_ID.search(resp.text)
salary_node = soup.select_one(".job-sup")
return {
**listing,
"title": title,
"description": description,
"apply_url": apply_url,
"native_advert_id": internal.group(1) if internal else None,
"contract_salary_text": salary_node.get_text(" ", strip=True) if salary_node else None,
}import time
def scrape(board_url: str) -> list[dict]:
session = requests.Session()
session.headers.update({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"})
listings = parse_listings(fetch_board(board_url, session), board_url)
jobs = []
for listing in listings:
detail = fetch_detail(session, listing) # single-threaded on purpose
if detail.get("removed"):
continue # skip closed adverts
jobs.append(detail)
time.sleep(2) # ~2s between requests
return jobs
for job in scrape("https://www.peoplebank.com/pb3/corporate/careconcern/advertsearch.php"):
print(job["external_id"], job["title"])A WAF fronts PeopleBank, so unwarmed anonymous requests are challenged with a 403. Treat a 403 as blocked/rate-limited (back off and retry with realistic browser headers, a single in-flight request, and ~2s spacing) — never as an authoritative empty board, or you will wipe a live company's jobs.
PeopleBank serves two live detail templates. Krispy Kreme and Care Concern use the newer .job-content layout; De Vere uses the legacy .ad-display > .ad-content wrapper. Parse both — read .job-content first and fall back to joining the branded content's direct child <div> sections.
Shared-host detail URLs (peoplebank.com/pbank/owa/pbk24w12.main?p=...) do not reveal the corporate tenant, so never guess identity from the host. Always start from the tenant-bearing /pb3/corporate/{tenant}/advertsearch.php board and carry the tenant down to each advert.
The opaque p value is one stable public posting key reused unchanged by pbk24w12.main and candidate.apply. If the key on the detail page or apply anchor differs from the listing's key, reject the record rather than emitting a mismatched or stale advert.
PeopleBank dates are en-GB dd/mm/yyyy in Europe/London, so 03/07/2026 is 3 July, not 7 March. Parse day-first and anchor to UK time before normalizing to avoid corrupting close dates.
- 1Always enter through the /pb3/corporate/{tenant}/advertsearch.php board with do_search=Show Vacancies; never guess a tenant from peoplebank.com.
- 2Send realistic browser headers, keep a single request in flight, and space calls ~2s apart to avoid the 403 WAF challenge.
- 3Use the opaque p value as the stable job id and keep it identical across the list, detail and apply URLs.
- 4Support both the .job-content and legacy .ad-display detail templates so descriptions never come back blank.
- 5Parse closing dates as en-GB (dd/mm/yyyy) in Europe/London before normalizing.
- 6Treat 403 as blocked (retry with backoff) and 404/410 as a removed advert — skip it rather than deleting the whole company.
One endpoint. All PeopleBank jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=peoplebank" \
-H "X-Api-Key: YOUR_KEY" Access PeopleBank
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.