All platforms

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.

Get API access
Oleeo / WCN
Live
<3haverage discovery time
1hrefresh interval
Companies using Oleeo / WCN
City & GuildsVictim SupportThe Growth Company
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 Oleeo / WCN.

Data fields
  • Full Job Descriptions
  • Structured Salary Bands
  • Department & Business Area
  • Contract & Employment Type
  • City / County / Country Locations
  • Publish & Closing Dates
Use cases
  1. 01Public Sector Job Aggregation
  2. 02Recruitment Market Intelligence
  3. 03Salary Benchmarking
  4. 04Application Deadline Tracking
Trusted by
City & GuildsVictim SupportThe Growth Company
DIY GUIDE

How to scrape Oleeo / WCN.

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

HybridintermediateNo published limit; ~3 concurrent detail fetches, 200ms apart (recommended)No auth

Resolve the site's numeric p_web_site_id

Every Oleeo feed is scoped to one numeric site ID, not the domain. It lives in the vacancies.html query or fragment, sometimes URL-encoded and wrapped in a filter= prefix, so decode twice before matching.

Step 1: Resolve the site's numeric p_web_site_id
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)  # 5695

Fetch the complete job feed in one request

The ic_job_feeds.feed_engine endpoint returns the entire published vacancy set as JSON — there is no pagination, so a single GET is enough. Keep the parameter set exactly as below; the summary feed omits full descriptions.

Step 2: Fetch the complete job feed in one request
import 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)")

Map and validate each feed row

Extract the fields you need and reject rows that don't belong to this site or whose canonical /vacancy/...-{id}.html URL doesn't carry the same numeric ID. Classification blocks (department, salary, contract, location) are optional and vary by client.

Step 3: Map and validate each feed row
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))]

Scrape the vacancy page for the full description

The feed is summary-only, so fetch each canonical vacancy URL and parse the Oleeo detail block. Pull the h1 title and the apply link, then strip the heading, classifications and nav links to leave the full description HTML.

Step 4: Scrape the vacancy page for the full description
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)
Common issues
highFeed returns nothing usable without a site ID

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.

mediumDescriptions are truncated or missing

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.

mediumFeed rows point at mismatched or cross-site vacancy URLs

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.

mediumNo central board — Oleeo runs on white-label domains

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.

lowDepartment, salary and location fields are inconsistent

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.

Best practices
  1. 1Pin each client's numeric p_web_site_id — the feed is scoped to one site per request.
  2. 2Pull the whole feed in a single call; it returns the complete public vacancy set with no pagination.
  3. 3Fetch the /vacancy/...-{id}.html page for full descriptions and the verified apply route — the feed is summary-only.
  4. 4Treat department, salary, contract type and location as optional; classification coverage varies by client.
  5. 5Throttle detail requests (~3 concurrent, ~200ms apart) to stay polite to the legacy PL/SQL portal.
  6. 6Parse publish and closing dates as Europe/London local time.
Or skip the complexity

One endpoint. All Oleeo / WCN jobs. No scraping, no sessions, no maintenance.

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

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.

99.9%API uptime
<200msAvg response
50M+Jobs processed