All platforms

Cezanne Recruitment (formerly Occupop) Jobs API.

Pull structured vacancies — pay ranges, contract type, and sector taxonomy — from the embedded careers boards that Cezanne Recruitment (formerly Occupop) powers for UK and Ireland employers, with no login or API keys.

Get API access
Cezanne Recruitment (formerly Occupop)
Live
<3haverage discovery time
1hrefresh interval
Companies using Cezanne Recruitment (formerly Occupop)
KingspanTateBabington
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 Cezanne Recruitment (formerly Occupop).

Data fields
  • Salary Range & Period
  • Contract & Employment Type
  • Workplace / Remote Flag
  • Sector & Subsector Taxonomy
  • Structured Location Fields
  • Publish & Close Dates
Use cases
  1. 01UK & Ireland Jobs Aggregation
  2. 02SMB Hiring Market Analysis
  3. 03Salary Benchmarking Datasets
  4. 04Employer Careers-Page Monitoring
Trusted by
KingspanTateBabington
DIY GUIDE

How to scrape Cezanne Recruitment (formerly Occupop).

Step-by-step guide to extracting jobs from Cezanne Recruitment (formerly Occupop)-powered career pages—endpoints, authentication, and working code.

HybridintermediateNo published limit; self-throttle to ~1 request / 2sNo auth

Parse application links from the company careers page

Cezanne boards live on the employer's own domain. Fetch the careers page HTML and collect anchors that point at the public application hosts — the last path segment of each link is the slug you hydrate later.

Step 1: Parse application links from the company careers page
import re
import requests
from urllib.parse import urlparse

HTML_HEADERS = {
    "Accept": "text/html",
    "Accept-Language": "en-GB,en;q=0.9",
}

# Only these public hosts/paths identify a real vacancy link.
APPLICATION_PATHS = {
    "api.occupop.com": ("/job/application/", "/shared/job/"),
    "recruitment.cezannehr.com": ("/job/application/",),
}
HREF = re.compile(r'href\s*=\s*["\']([^"\']+)["\']', re.I)
SLUG = re.compile(r"[a-z0-9_-]+", re.I)

def parse_application_slugs(page_source: str) -> list[str]:
    slugs, seen = [], set()
    for href in HREF.findall(page_source):
        parsed = urlparse(href)
        prefixes = APPLICATION_PATHS.get(parsed.netloc.lower())
        if not prefixes or not parsed.path.startswith(prefixes):
            continue
        slug = parsed.path.rstrip("/").rsplit("/", 1)[-1]
        if SLUG.fullmatch(slug) and slug not in seen:
            seen.add(slug)
            slugs.append(slug)
    return slugs

careers_url = "https://babington.co.uk/about-us/work-with-us/"
page = requests.get(careers_url, headers=HTML_HEADERS, timeout=30).text
slugs = parse_application_slugs(page)
print(len(slugs), "application links")

Fall back to the embedded jobs-frame

Some boards (e.g. Tate, Kingspan) don't list anchors directly; they embed a public api.occupop.com/api/jobs-frame/{id} document instead. Extract that URL from the page — or from the AEM /.model.json for CMS-backed sites — then parse links from the frame.

Step 2: Fall back to the embedded jobs-frame
from html import unescape

FRAME = re.compile(r"https://api\.occupop\.com/api/jobs-frame/[A-Za-z0-9_-]{30,}")

def extract_frame_url(page_source: str) -> str | None:
    # Company sites escape the URL in HTML or JSON; normalize before matching.
    decoded = unescape(page_source).replace("\\/", "/")
    match = FRAME.search(decoded)
    return match.group(0) if match else None

if not slugs:
    frame_url = extract_frame_url(page)
    if frame_url is None:
        # AEM-backed boards expose the frame URL via the page model JSON.
        model = requests.get(
            careers_url.rstrip("/") + "/.model.json",
            headers={**HTML_HEADERS, "Accept": "application/json"},
            timeout=30,
        )
        frame_url = extract_frame_url(model.text) if model.ok else None
    if frame_url:
        frame_html = requests.get(frame_url, headers=HTML_HEADERS, timeout=30).text
        slugs = parse_application_slugs(frame_html)

Hydrate each job via the anonymous GraphQL query

Every slug is resolved through the public careersPage.getJob operation on the shared GraphQL gateway. It is anonymous — no Authorization header — but reproduce the reference Origin/Referer headers to stay compatible.

Step 3: Hydrate each job via the anonymous GraphQL query
GRAPHQL_URL = "https://gateway.server.occupop.com/graphql"
DETAIL_QUERY = (
    "query q($uuid: String!) { careersPage { getJob(uuid: $uuid) { "
    "title uniqueSlug uuid closedForApplicants description period contract "
    "publishedAt closeDate workplace isActive language companyLogo "
    "salary { type period start end details } "
    "jobListingsConfig { uuid settings { id uuid } } "
    "hiringCompany { name uuid } "
    "location { city cityFullName country countryShort state } "
    "subsectors { name translationKey sector { name translationKey } } "
    "} } }"
)
GRAPHQL_HEADERS = {
    "Accept": "application/json",
    "Content-Language": "en",
    "Content-Timezone-name": "UTC",
    "Content-Timezone-offset": "+00:00",
    "Origin": "https://recruitment.cezannehr.com",
    "Referer": "https://recruitment.cezannehr.com/",
}

def fetch_job(slug: str) -> dict | None:
    resp = requests.post(
        GRAPHQL_URL,
        headers=GRAPHQL_HEADERS,
        json={"query": DETAIL_QUERY, "variables": {"uuid": slug}},
        timeout=30,
    )
    resp.raise_for_status()
    payload = resp.json()
    if payload.get("errors"):
        return None  # 200 + errors[] is returned for unknown slugs
    return ((payload.get("data") or {}).get("careersPage") or {}).get("getJob")

Map open roles into records

Use the immutable getJob.uuid as the primary key and keep uniqueSlug as metadata only. Drop closed or inactive roles so filled vacancies never enter your dataset.

Step 4: Map open roles into records
def to_record(job: dict) -> dict | None:
    # Filled or hidden roles are still linked; exclude them from the snapshot.
    if job.get("closedForApplicants") or job.get("isActive") is False:
        return None
    location = job.get("location") or {}
    salary = job.get("salary") or {}
    company = job.get("hiringCompany") or {}
    return {
        "external_id": job["uuid"],            # immutable UUID = primary key
        "unique_slug": job.get("uniqueSlug"),  # mutable; metadata only
        "title": job.get("title"),
        "company": company.get("name"),
        "location": location.get("cityFullName") or location.get("city") or location.get("country"),
        "country_code": location.get("countryShort"),
        "contract": job.get("contract"),
        "workplace": job.get("workplace"),
        "salary_min": salary.get("start"),
        "salary_max": salary.get("end"),
        "salary_period": salary.get("period"),
        "posted_at": job.get("publishedAt"),
        "closes_at": job.get("closeDate"),
        "description": job.get("description"),
    }

records = []
for slug in slugs:
    job = fetch_job(slug)
    if job and (record := to_record(job)):
        records.append(record)
print(len(records), "open jobs")
Common issues
mediumThe careers page exposes no direct application links

Tate- and Kingspan-style boards embed a public jobs-frame instead of anchors. Fall back to extracting the api.occupop.com/api/jobs-frame/{id} URL from the page HTML, and for AEM sites fetch {url}/.model.json to find it, then parse links from that frame document.

highTreating the application slug as the stable job ID

The /job/application/{slug} path carries a mutable uniqueSlug, not the native key. Pass the slug only as the GraphQL uuid variable, then persist the returned getJob.uuid (a real UUID) as the external ID so records survive slug changes.

mediumFilled or inactive roles are still linked from the board

Every published link is hydrated even when the role is closed. Skip records where closedForApplicants is true or isActive is false rather than emitting them, and count them as intentionally excluded.

mediumBad slugs return HTTP 200 with an errors array, not an error status

The anonymous endpoint answers 200 even for unknown UUIDs, putting the failure in errors[] or returning getJob: null. Check for both before mapping so you don't index into a missing object.

mediumHard-coded jobs-frame URLs stop resolving over time

The 30+ character jobs-frame identifiers rotate. Re-discover the frame URL from the company careers page on every run instead of caching it, so a rotation doesn't silently drop the whole board.

Best practices
  1. 1Bootstrap the jobs-frame URL from the careers page each run; the frame identifier rotates.
  2. 2Key every job on the GraphQL uuid, never the mutable application slug or route.
  3. 3Send one request at a time with a ~2s delay; these boards are small and exhaustive.
  4. 4Skip roles where closedForApplicants is true or isActive is false instead of deleting them later.
  5. 5Reproduce the reference Origin/Referer headers so the anonymous GraphQL call keeps working.
  6. 6Treat a missing or unparseable board document as a failed run, not zero vacancies.
Or skip the complexity

One endpoint. All Cezanne Recruitment (formerly Occupop) jobs. No scraping, no sessions, no maintenance.

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

Access Cezanne Recruitment (formerly Occupop)
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