All platforms

HealthBoxHR Jobs API.

Aggregate live vacancies from UK health and social care employers running hosted HealthBoxHR careers feeds. Every company board and application page is server-rendered HTML at a predictable app.hbhr.io URL, so a plain GET-and-parse loop captures titles, salaries, employment types and closing dates with no login or headless browser.

Get API access
HealthBoxHR
Live
<3haverage discovery time
1hrefresh interval
Companies using HealthBoxHR
Barnardo'sNorth Yorkshire Hospice CareCWC Group
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 HealthBoxHR.

Data fields
  • Full Job Descriptions
  • Employment Type & Work Style
  • Salary & Experience Level
  • Location, County & Postcode
  • Application Open & Close Dates
  • Native Application URLs
Use cases
  1. 01Health & Social Care Hiring Feeds
  2. 02Care Provider Vacancy Tracking
  3. 03Charity & Nonprofit Recruitment Data
  4. 04Sector Talent Market Monitoring
Trusted by
Barnardo'sNorth Yorkshire Hospice CareCWC Group
DIY GUIDE

How to scrape HealthBoxHR.

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

HTMLintermediateNo published limit; the native scraper waits ~150ms between requests (~6 req/sec), caps concurrent detail fetches at 3 and up to 10,000 listings per runNo auth

Validate the host and resolve the company feed

HealthBoxHR feeds live on app.hbhr.io or app.healthboxhr.com at /jobs-feed/web/company/{company_id}, where the company ID is a 13-character hex string and the durable tenant key. Validate the host and path shape before trusting a board — an external-application link on its own carries no company ID and cannot start a scrape.

Step 1: Validate the host and resolve the company feed
import re
import requests
from urllib.parse import urlparse

HOSTS = ("app.hbhr.io", "app.healthboxhr.com")
COMPANY_ID = re.compile(r"^/jobs-feed/web/company/([a-f0-9]{13})/?$", re.I)

def resolve_feed(board_url):
    parts = urlparse(board_url)
    host = parts.netloc.lower()
    assert host in HOSTS, "not a HealthBoxHR host"
    m = COMPANY_ID.match(parts.path)
    assert m, "not a company-feed URL"
    company_id = m.group(1).lower()   # 13-hex tenant key, kept verbatim
    return f"https://{host}/jobs-feed/web/company/{company_id}", company_id

Fetch the feed and confirm the provider fingerprint

Request the feed with an HTML Accept header and confirm it is really a HealthBoxHR document before parsing: either the meta author is 'HealthBox Software' or an external-application form hash input is present. This rejects shared-host error and login pages that would otherwise parse as empty boards.

Step 2: Fetch the feed and confirm the provider fingerprint
from bs4 import BeautifulSoup

HEADERS = {"Accept": "text/html"}
FINGERPRINT_FORM = "form[action*='/job-application/external/store/'] input[name='hash']"

def is_provider_document(soup):
    author = soup.select_one("meta[name='author']")
    if author and (author.get("content") or "").strip().lower() == "healthbox software":
        return True
    return soup.select_one(FINGERPRINT_FORM) is not None

def fetch_feed(feed_url):
    html = requests.get(feed_url, headers=HEADERS, timeout=30).text
    soup = BeautifulSoup(html, "html.parser")
    assert is_provider_document(soup), "missing HealthBoxHR document fingerprint"
    return soup

Extract vacancy cards and handle the empty state

Each vacancy is a .card that contains an a[href*='/job-application/external/'] link. Read the title from the card-header anchor (falling back to any external-application anchor), the location from .card-body.vacancy-item > h5 and the teaser from .card-body.vacancy-item .mt-3. If no cards render, only treat the feed as genuinely empty when it shows the literal 'There are no job vacancies found.' message — otherwise it is a layout change to re-inspect.

Step 3: Extract vacancy cards and handle the empty state
from urllib.parse import urljoin

JOB_ID = re.compile(r"/job-application/external/([a-f0-9]{8}(?:-[a-f0-9]{8}){3})", re.I)

def parse_listings(soup, feed_url, company_id):
    jobs = []
    for card in soup.select(".card"):
        anchor = (card.select_one(".card-header h3 a[href*='/job-application/external/']")
                  or card.select_one("a[href*='/job-application/external/']"))
        if not anchor:
            continue
        url = urljoin(feed_url, anchor.get("href", ""))
        m = JOB_ID.search(url)
        title = anchor.get_text(strip=True)
        if not m or not title:
            continue
        location = card.select_one(".card-body.vacancy-item > h5")
        summary = card.select_one(".card-body.vacancy-item .mt-3")
        jobs.append({
            "external_id": m.group(1).lower(),      # segmented-hex job key
            "company_id": company_id,               # carry tenant into detail
            "detail_url": url.split("?")[0],
            "title": title,
            "location": location.get_text(strip=True) if location else None,
            "summary": summary.get_text(strip=True) if summary else None,
        })
    if not jobs and "There are no job vacancies found." in soup.get_text():
        return []          # genuinely empty feed, not a parse failure
    return jobs

Fetch the external-application document and verify the hash

Each vacancy's canonical detail lives at /job-application/external/{job_id}. Fetch it, then cross-check the application form's hidden hash input equals the native job ID before trusting the page — this guards against a shared-host page hydrating the wrong vacancy. A 404 or 410 means the posting was withdrawn.

Step 4: Fetch the external-application document and verify the hash
def fetch_detail(job):
    resp = requests.get(job["detail_url"], headers=HEADERS, timeout=30)
    if resp.status_code in (404, 410):
        return None                       # canonical application was removed
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")

    form_hash = soup.select_one(FINGERPRINT_FORM)
    value = form_hash.get("value", "").strip().lower() if form_hash else None
    if value != job["external_id"]:
        raise ValueError("application form hash did not match the native job ID")
    return soup

Parse the structured field rows

The .job-vacancy-details root holds one .row > div per field, each pairing an h5.card-title label with a p.card-text value (or a div.card-text for rich HTML like the Description). Zip labels to values, take the description HTML from the 'Description' row, and derive the company name from the document title after the last '|'. Reject duplicate labels as a layout change rather than silently overwriting.

Step 5: Parse the structured field rows
def parse_detail(soup, job):
    root = soup.select_one(".job-vacancy-details")
    assert root is not None, "missing vacancy-details root"

    fields = {}
    for div in root.select(":scope > .row > div"):
        label = div.select_one("h5.card-title")
        if not label:
            continue
        key = label.get_text(strip=True)
        if key in fields:                 # duplicate labels signal a layout change
            raise ValueError(f"duplicate field label: {key}")
        text = div.select_one("p.card-text")
        html = div.select_one("div.card-text")
        fields[key] = {
            "text": text.get_text(strip=True) if text else None,
            "html": html.decode_contents().strip() if html else None,
        }

    title = soup.select_one("h3")
    doc_title = soup.select_one("title")
    company = None
    if doc_title and "|" in doc_title.get_text():
        company = doc_title.get_text().rsplit("|", 1)[-1].strip()

    def value(name):
        return fields.get(name, {}).get("text")

    return {
        "external_id": job["external_id"],
        "title": title.get_text(strip=True) if title else None,
        "description": fields.get("Description", {}).get("html"),
        "employment_type": value("Employment Type"),
        "work_style": value("Work Style"),
        "salary": value("Salary"),
        "experience": value("Experience"),
        "location": value("Location"),
        "posted_at": value("Application Start Date"),
        "closes_at": value("Application End Date"),
        "company": company or job.get("company"),
    }
Common issues
highTrying to scrape from an /job-application/external/ link directly

The external-application URL carries only the segmented-hex job hash and never the 13-hex company ID, so it cannot seed a scrape or mint a tenant. Always start from the /jobs-feed/web/company/{id} feed and carry the company ID into each detail fetch.

highA shared-host error or login page is parsed as a vacancy

Confirm the HealthBoxHR document fingerprint first (meta author 'HealthBox Software' or an external-application form hash input), and on detail pages assert the form hash equals the native job ID before mapping any fields.

mediumAn empty feed is misread as a parse failure

A company with no published jobs still renders a valid document with the literal 'There are no job vacancies found.' message. Treat that as a genuinely empty feed and only flag a parse error when neither cards nor that message are present.

lowDuplicate field labels overwrite each other

A themed detail page that repeats an h5.card-title label indicates a layout change, not clean data. Detect duplicate labels and fail the parse rather than silently keeping the last value.

lowDetail requests return 404 or 410

HealthBoxHR serves 404/410 once an external application is withdrawn. Treat those status codes as a removal signal for that job and drop it from the active set instead of retrying as a hard failure.

Best practices
  1. 1Start every scrape from the 13-hex company feed and carry the company ID into each detail fetch, since the application page never exposes it.
  2. 2Confirm the provider document fingerprint (meta author 'HealthBox Software' or the application form hash) before parsing either page.
  3. 3Cross-check the application form hash equals the native job ID so you never map a wrong or shared-host document.
  4. 4Key jobs on the segmented-hex external ID and companies on the 13-hex feed ID; keep both verbatim, never hashed or remapped.
  5. 5Distinguish a genuinely empty feed ('There are no job vacancies found.') from a missing card layout that needs re-inspection.
  6. 6Self-throttle to ~150ms between requests and cap concurrent detail fetches at ~3, matching the native scraper's pacing.
Or skip the complexity

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

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

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