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.
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).
- Salary Range & Period
- Contract & Employment Type
- Workplace / Remote Flag
- Sector & Subsector Taxonomy
- Structured Location Fields
- Publish & Close Dates
- 01UK & Ireland Jobs Aggregation
- 02SMB Hiring Market Analysis
- 03Salary Benchmarking Datasets
- 04Employer Careers-Page Monitoring
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.
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")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)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")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")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.
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.
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.
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.
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.
- 1Bootstrap the jobs-frame URL from the careers page each run; the frame identifier rotates.
- 2Key every job on the GraphQL uuid, never the mutable application slug or route.
- 3Send one request at a time with a ~2s delay; these boards are small and exhaustive.
- 4Skip roles where closedForApplicants is true or isActive is false instead of deleting them later.
- 5Reproduce the reference Origin/Referer headers so the anonymous GraphQL call keeps working.
- 6Treat a missing or unparseable board document as a failed run, not zero vacancies.
One endpoint. All Cezanne Recruitment (formerly Occupop) jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=cezanne recruitment (formerly occupop)" \
-H "X-Api-Key: YOUR_KEY" 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.