All platforms

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.

Get API access
PeopleBank
Live
<3haverage discovery time
1hrefresh interval
Companies using PeopleBank
Krispy KremeDe VereCare Concern
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 PeopleBank.

Data fields
  • Full Job Descriptions
  • Location & Postcode
  • Closing Dates
  • Department & Site Name
  • Salary & Contract Text
  • Direct Apply URLs
Use cases
  1. 01Hospitality & Care Job Aggregation
  2. 02Careers Board Monitoring
  3. 03Recruitment Market Research
  4. 04ATS Feed Ingestion
Trusted by
Krispy KremeDe VereCare Concern
DIY GUIDE

How to scrape PeopleBank.

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

HTMLadvancedNo published limit, but a WAF fronts the board — keep to a single in-flight request with ~2s between requests and at most 3 concurrent detail fetchesNo auth

Fetch the tenant advert-search board

PeopleBank boards live at /pb3/corporate/{tenant}/advertsearch.php, and do_search=Show Vacancies renders the complete public advert set on a single page — there is no continuation cursor. The board is fronted by a WAF, so an anonymous request can return 403; treat that as a block to back off from, never as an empty board.

Step 1: Fetch the tenant advert-search board
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,
)

Extract opaque posting keys from advert links

Every advert on the board is an anchor to /pbank/owa/pbk24w12.main?p={key}. The p value is PeopleBank's stable public posting key — use it verbatim as the job id (it must match /^[A-Za-z0-9@_-]+$/). Pull title, location, postcode and the en-GB closing date (dd/mm/yyyy) from the surrounding row, and de-duplicate on the key.

Step 2: Extract opaque posting keys from advert links
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 listings

Fetch each advert detail across both templates

PeopleBank currently serves two provider-owned detail templates: a newer .job-* layout and the legacy .ad-display > .ad-content wrapper. Read the title from h1.job-title/h1 (or the branded h2), the description from .job-content (or the branded div sections), the apply link from the candidate.apply anchor, and keep the numeric P_nAdId as internal metadata. A 404/410 means the advert was removed; a 403 is another WAF block.

Step 3: Fetch each advert detail across both templates
INTERNAL_ID = re.compile(r"(?:[?&]|&amp;)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,
    }

Pace requests and assemble the record set

PeopleBank is WAF-guarded, so keep a single request in flight and space calls ~2s apart (its published runtime config allows at most 3 concurrent detail fetches). Reuse one requests.Session so any WAF cookies persist, walk the board once, then hydrate each advert into a normalized record.

Step 4: Pace requests and assemble the record set
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"])
Common issues
highAnonymous requests to the board or an advert return HTTP 403

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.

mediumDescriptions come back empty on some tenants

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.

highYou can't tell which company owns a bare peoplebank.com detail URL

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.

mediumThe posting key disagrees between the list, detail and apply URLs

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.

lowClosing dates land in the wrong month

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.

Best practices
  1. 1Always enter through the /pb3/corporate/{tenant}/advertsearch.php board with do_search=Show Vacancies; never guess a tenant from peoplebank.com.
  2. 2Send realistic browser headers, keep a single request in flight, and space calls ~2s apart to avoid the 403 WAF challenge.
  3. 3Use the opaque p value as the stable job id and keep it identical across the list, detail and apply URLs.
  4. 4Support both the .job-content and legacy .ad-display detail templates so descriptions never come back blank.
  5. 5Parse closing dates as en-GB (dd/mm/yyyy) in Europe/London before normalizing.
  6. 6Treat 403 as blocked (retry with backoff) and 404/410 as a removed advert — skip it rather than deleting the whole company.
Or skip the complexity

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

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

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.

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