eRecruit Jobs API.

eRecruit hosts candidate portals at {tenant}.erecruit.co, used by South African mining, engineering and government employers. Its board has no all-jobs page — the complete inventory is the union of its categories.

Get API access

What's in every response.

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

Data fields

  • Full Job Descriptions
  • JobPosting JSON-LD
  • Advertised Category Counts
  • Native Requisition Codes
  • Labelled Requisition Fields
  • Direct Apply URLs

Use cases

  1. 01African Job Market Aggregation
  2. 02Public Sector Hiring Trackers
  3. 03Mining & Engineering Talent Feeds
  4. 04Careers Page Monitoring

Trusted by

  • Exxaro
  • Pragma
  • Western Cape Government
DIY GUIDE

How to scrape eRecruit.

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

API type
HTML
Difficulty
advanced
Rate limit
No published limit; ~150ms between requests, max 2 concurrent detail fetches
Authentication
No auth

Resolve the tenant portal

Each employer gets a third-level subdomain of erecruit.co and the board root is /candidateapp/Jobs/Browse. Job pages are /candidateapp/Jobs/View/{requisitionCode}, where the code is a human-readable string like EXX260709-1 rather than a number. www.erecruit.co is the vendor's own site.

Step 1: Resolve the tenant portal
import re
from urllib.parse import urlparse

RESERVED = {"api", "app", "cdn", "mail", "support", "www"}
DETAIL_ROUTE = re.compile("^/candidateapp/Jobs/View/([A-Z0-9][A-Z0-9-]{2,63})$",
                          re.IGNORECASE)

def parse_erecruit(url: str) -> dict | None:
    parsed = urlparse(url)
    if parsed.scheme != "https":
        return None

    labels = parsed.netloc.lower().rstrip(".").split(".")
    if len(labels) != 3 or labels[1] != "erecruit" or labels[2] != "co":
        return None
    tenant = labels[0]
    if tenant in RESERVED:
        return None

    path = parsed.path.rstrip("/")
    detail = DETAIL_ROUTE.match(path)
    return {
        "tenant": tenant,
        "board_url": f"https://{tenant}.erecruit.co/candidateapp/Jobs/Browse",
        "job_id": detail.group(1).upper() if detail else None,
    }

print(parse_erecruit("https://westerncapegov.erecruit.co/candidateapp/Jobs/View/WCG260611-3"))

Read the category index and its counts

The Browse page lists every job category with the number of open vacancies in brackets after the name. Those counts are the portal's own claim about its inventory, and they are what makes a complete crawl verifiable — capture them before fetching anything.

Step 2: Read the category index and its counts
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin

CATEGORY_COUNT = re.compile("[(]([0-9]+)[)]\\s*$")

def fetch_categories(session, tenant: str) -> list[dict]:
    board_url = f"https://{tenant}.erecruit.co/candidateapp/Jobs/Browse"
    resp = session.get(board_url, headers={"Accept": "text/html"}, timeout=30)
    resp.raise_for_status()
    if "/candidateapp/Jobs/Browse" not in resp.text:
        raise RuntimeError("eRecruit board omitted its CandidateApp markers")

    soup = BeautifulSoup(resp.text, "html.parser")
    categories, seen = [], set()
    for anchor in soup.select("a[href*='/candidateapp/Jobs/Categories/']"):
        url = urljoin(board_url, anchor["href"])
        text = " ".join(anchor.get_text().split())
        match = CATEGORY_COUNT.search(text)
        identity = parse_erecruit(url)
        if not match or not identity or identity["tenant"] != tenant or url in seen:
            continue
        seen.add(url)
        categories.append({
            "name": text[: match.start()].strip(),
            "expected": int(match.group(1)),
            "url": url,
        })
    return categories

session = requests.Session()
categories = fetch_categories(session, "exxaro")
print(sum(c["expected"] for c in categories), "advertised vacancies")

Walk every category and verify the counts

Each category page is a table whose rows carry the detail route in an onclick handler. Collect the rows per category and compare the number you parsed against the advertised count — a shortfall means the page rendered partially and the snapshot is incomplete, not that jobs closed.

Step 3: Walk every category and verify the counts
import time
from html import unescape

DETAIL_PATH = re.compile("/candidateapp/Jobs/View/[A-Z0-9][A-Z0-9-]{2,63}",
                         re.IGNORECASE)

def parse_category(session, category: dict, tenant: str) -> list[dict]:
    resp = session.get(category["url"], headers={"Accept": "text/html"}, timeout=30)
    resp.raise_for_status()

    soup = BeautifulSoup(resp.text, "html.parser")
    rows = []
    for row in soup.select("tr.item[onclick]"):
        match = DETAIL_PATH.search(unescape(row.get("onclick") or ""))
        if not match:
            continue
        url = urljoin(category["url"], match.group(0))
        identity = parse_erecruit(url)
        if not identity or not identity["job_id"]:
            continue
        cells = [" ".join(cell.get_text().split()) for cell in row.select("td")]
        rows.append({
            "id": identity["job_id"],
            "title": cells[0] if cells else None,
            "category": category["name"],
            "url": url,
        })

    if len(rows) != category["expected"]:
        raise RuntimeError(
            f"category {category['name']} returned {len(rows)} "
            f"of {category['expected']} advertised jobs")
    return rows

def scrape_board(session, tenant: str) -> list[dict]:
    jobs, seen = [], set()
    for category in fetch_categories(session, tenant):
        if category["expected"] == 0:
            continue
        for row in parse_category(session, category, tenant):
            if row["id"] not in seen:  # a job can sit in several categories
                seen.add(row["id"])
                jobs.append(row)
        time.sleep(0.15)
    return jobs

Read the job page

The canonical job page publishes JobPosting JSON-LD alongside labelled requisition rows — closing date, reference, region, employment type. Take the narrative from the structured block and the extra fields from the label/value pairs.

Step 4: Read the job page
import json

def find_job_posting(html: str) -> dict | None:
    soup = BeautifulSoup(html, "html.parser")
    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_detail(session, listing: dict) -> dict | None:
    resp = session.get(listing["url"], headers={"Accept": "text/html"}, timeout=30)
    if resp.status_code in (404, 410):
        return None
    resp.raise_for_status()

    # eRecruit renders a first-party "Not found" page for withdrawn requisitions.
    soup = BeautifulSoup(resp.text, "html.parser")
    if (soup.title and "not found" in soup.title.get_text(strip=True).lower()):
        return None

    posting = find_job_posting(resp.text)
    if not posting:
        return None

    fields = {}
    for row in soup.select("tr.item"):
        label = row.select_one("td.label")
        value = row.select_one("td.value")
        if label and value:
            key = " ".join(label.get_text().split()).rstrip(":").lower()
            fields[key.replace(" ", "_")] = " ".join(value.get_text().split())

    address = ((posting.get("jobLocation") or {}).get("address")) or {}
    return {
        "id": listing["id"],
        "title": posting.get("title") or listing["title"],
        "description_html": posting.get("description"),
        "employment_type": posting.get("employmentType"),
        "posted_at": posting.get("datePosted"),
        "closes_at": posting.get("validThrough"),
        "city": address.get("addressLocality"),
        "region": address.get("addressRegion"),
        "fields": fields,
        "url": listing["url"],
    }
Common issues
criticalThe board has no page that lists every job
Browse renders only the category index; there is no all-jobs route and no pagination on top of it. The complete inventory is the union of every category page, so a crawler that scrapes Browse alone comes back with zero jobs and no error.
highA category returns fewer rows than it advertises
Each category anchor states its own vacancy count in brackets. Compare that number against the rows you parsed and fail the run on a shortfall — silently accepting the smaller set makes downstream reconciliation close vacancies that are still open.
mediumThe same job appears several times
A requisition can be filed under more than one category, so unioning the category pages produces duplicates. Deduplicate on the requisition code from /candidateapp/Jobs/View/{code} rather than on the title, which repeats across regions.
mediumWithdrawn jobs return HTTP 200
eRecruit serves its own Not found page instead of a 404 for a requisition that has closed. Detect that page explicitly and treat it as a removal; otherwise the job is retried forever as a JSON-LD parse failure.
Best practices
  1. 1Build the inventory from the union of category pages, never from Browse alone
  2. 2Capture each category's advertised count and verify your row count against it
  3. 3Deduplicate on the requisition code, since jobs appear in multiple categories
  4. 4Read detail routes from the row onclick handler after HTML-unescaping it
  5. 5Treat the first-party Not found page as a removal, not a parse error
  6. 6Exclude www and other reserved labels when deriving the tenant subdomain
Or skip the complexity

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

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=erecruit" \
  -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 eRecruit
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