Oleeo / WCN Jobs API.
Tap the enterprise ATS (formerly WCN) behind UK government, policing and financial-services hiring — a single JSON feed request returns the complete public vacancy set with salary bands, reference numbers and closing dates.
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 Oleeo / WCN.
- Full Job Descriptions
- Structured Salary Bands
- Department & Business Area
- Contract & Employment Type
- City / County / Country Locations
- Publish & Closing Dates
- 01Public Sector Job Aggregation
- 02Recruitment Market Intelligence
- 03Salary Benchmarking
- 04Application Deadline Tracking
How to scrape Oleeo / WCN.
Step-by-step guide to extracting jobs from Oleeo / WCN-powered career pages—endpoints, authentication, and working code.
import re
from urllib.parse import urlsplit, unquote
def extract_site_id(vacancies_url: str) -> str | None:
parts = urlsplit(vacancies_url)
for chunk in (parts.query, parts.fragment):
decoded = unquote(unquote(chunk))
match = re.search(r"p_web_site_id=(\d+)", decoded)
if match:
return match.group(1)
return None
# e.g. https://recruitment.victimsupport.org.uk/vacancies.html#filter=p_web_site_id%3D5695
site_id = extract_site_id(
"https://recruitment.victimsupport.org.uk/vacancies.html#filter=p_web_site_id%3D5695"
)
print(site_id) # 5695import requests
def fetch_feed(origin: str, site_id: str) -> list[dict]:
url = f"{origin}/utf8/ic_job_feeds.feed_engine"
params = {
"p_web_site_id": site_id,
"p_published_to": "WWW",
"p_language": "DEFAULT",
"p_direct": "Y",
"p_format": "MOBILE",
"p_include_exclude_from_list": "N",
"p_search": "",
"p_summary": "Y",
}
resp = requests.get(url, params=params, headers={"Accept": "application/json"}, timeout=30)
resp.raise_for_status()
return resp.json().get("jobs", [])
origin = "https://recruitment.victimsupport.org.uk"
jobs = fetch_feed(origin, site_id)
print(f"{len(jobs)} vacancies (complete public set — no pagination)")import re
VACANCY_RE = re.compile(r"/vacancy/(?:[^/?#]*-)?(?P<job_id>\d+)\.html(?:[?#]|$)", re.I)
def first_class(classifications: dict, name: str) -> str | None:
for block in (classifications or {}).values():
if block.get("name") == name:
vals = [v.get("class_val", "").strip()
for v in block.get("values", []) if v.get("class_val")]
if vals:
return "; ".join(vals)
return None
def map_listing(row: dict, site_id: str, origin: str) -> dict | None:
job_id = str(row.get("id") or "")
weblink = row.get("weblink") or ""
if str(row.get("web_site_id")) != site_id or not weblink.startswith(origin):
return None
match = VACANCY_RE.search(weblink)
if not match or match.group("job_id") != job_id:
return None # drop rows whose pretty URL ID doesn't match the feed ID
cls = row.get("classifications", {})
internet = (row.get("publication") or {}).get("internet", {})
locations = row.get("locations") or [{}]
return {
"external_id": job_id,
"title": (row.get("title") or "").strip(),
"listing_url": weblink,
"reference": row.get("refno"),
"status": row.get("status"),
"department": first_class(cls, "Area of Business"),
"employment_type": first_class(cls, "Full time/Part time"),
"contract_type": first_class(cls, "Contract type"),
"salary": first_class(cls, "Basic Salary"),
"location": first_class(cls, "Location or Function") or locations[0].get("city"),
"published": internet.get("publish_date"),
"closes": internet.get("closing_date"),
}
listings = [m for row in jobs if (m := map_listing(row, site_id, origin))]import requests
from bs4 import BeautifulSoup
def fetch_description(listing: dict) -> dict:
resp = requests.get(listing["listing_url"], headers={"Accept": "text/html"}, timeout=30)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
root = soup.select_one(".job_detail .job_description")
if root is None:
raise ValueError("vacancy HTML is missing the Oleeo job_description block")
heading = root.select_one("h1")
apply_link = root.select_one(".apply_now a[href]")
for selector in ("h1", ".job_classifications", ".links"):
for element in root.select(selector):
element.decompose()
listing["title"] = heading.get_text(strip=True) if heading else listing["title"]
listing["apply_url"] = apply_link["href"] if apply_link else listing["listing_url"]
listing["description_html"] = root.decode_contents().strip()
return listing
# Cap detail concurrency and pace requests — the portal is a legacy PL/SQL runtime
for item in listings[:3]:
fetch_description(item)The feed is scoped by the numeric p_web_site_id, not the domain. Extract it from the vacancies.html query or fragment (unescape twice — it may be wrapped in a filter= prefix) before calling ic_job_feeds.feed_engine.
The JSON feed carries summaries only, not the full description or verified apply route. Fetch the canonical /vacancy/...-{id}.html page and parse the .job_detail .job_description block for the real body.
Reject any row whose web_site_id differs from your scope, or whose weblink doesn't match /vacancy/...-{id}.html with the same numeric ID. Re-check the detail page's apply link (p_web_page_id) against the listing ID before trusting it.
Each client is served on its own host (e.g. recruitment.victimsupport.org.uk), not a shared oleeo.com board. Allow-list every client domain and confirm it is Oleeo via the ic_job_feeds.feed_engine, p_web_site_id or /lisatemplate/ fingerprint.
These come from optional classification blocks that some sites don't publish (e.g. City & Guilds and Growth Company omit them). Treat every classification-derived field as optional and fall back to the locations array for geography.
- 1Pin each client's numeric p_web_site_id — the feed is scoped to one site per request.
- 2Pull the whole feed in a single call; it returns the complete public vacancy set with no pagination.
- 3Fetch the /vacancy/...-{id}.html page for full descriptions and the verified apply route — the feed is summary-only.
- 4Treat department, salary, contract type and location as optional; classification coverage varies by client.
- 5Throttle detail requests (~3 concurrent, ~200ms apart) to stay polite to the legacy PL/SQL portal.
- 6Parse publish and closing dates as Europe/London local time.
One endpoint. All Oleeo / WCN jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=oleeo / wcn" \
-H "X-Api-Key: YOUR_KEY" Access Oleeo / WCN
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.