All platforms

OCCY Jobs API.

Pull structured UK job listings — salary bands, locations and full multi-section descriptions — from any OCCY-hosted career page through one public REST API, no login required.

Get API access
OCCY
Live
<3haverage discovery time
1hrefresh interval
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 OCCY.

Data fields
  • Structured Salary Bands
  • Location & Postcode Data
  • Multi-Section Descriptions
  • Employment & Remote Status
  • Job Category & Type
  • Post & Close Dates
Use cases
  1. 01UK Job Market Aggregation
  2. 02Salary Benchmarking
  3. 03Careers Page Monitoring
  4. 04Recruitment Data Pipelines
DIY GUIDE

How to scrape OCCY.

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

RESTintermediateNo published limit; back off on HTTP 429No auth

Identify the board type and company key

OCCY serves two board generations. Legacy company hosts live at {tenant}.cp.occy.com, where the subdomain is the tenant key. Shared hosts live at app.occy.com/p/jobs/{ULID}, where the 26-character key is case-sensitive. Detect which one you have before calling the API.

Step 1: Identify the board type and company key
from urllib.parse import urlparse


def resolve_board(url: str):
    """Return (board_kind, company_key) for an OCCY board URL."""
    parsed = urlparse(url)
    host = (parsed.hostname or "").lower()

    if host.endswith(".cp.occy.com"):
        # Legacy company host: the subdomain is the tenant key (lowercase).
        return "legacy", host[: -len(".cp.occy.com")]

    if host == "app.occy.com":
        # Shared host: /p/jobs/{ULID}; the 26-char key is case-sensitive.
        segments = [s for s in parsed.path.split("/") if s]
        if len(segments) == 3 and segments[:2] == ["p", "jobs"]:
            return "shared", segments[2].upper()

    raise ValueError(f"Unrecognised OCCY board URL: {url}")

Page through the public listing endpoint

Legacy boards read from careersite/company/jobs with a domain query; shared hosts read from jobs with a companyId query. Both cap perPage at 50 (perPage=100 returns HTTP 400), echo total/page/perPage, and are paged newest-first. Continue while the consumed count is below the reported total.

Step 2: Page through the public listing endpoint
import requests

BASE = "https://api.occy.com/api/public"


def list_jobs(kind: str, company_key: str):
    # OCCY enforces perPage <= 50 on both listing endpoints.
    if kind == "legacy":
        path, key_param = "careersite/company/jobs", "domain"
    else:
        path, key_param = "jobs", "companyId"

    page, rows = 1, []
    while True:
        resp = requests.get(
            f"{BASE}/{path}",
            params={
                key_param: company_key,
                "perPage": 50,
                "page": page,
                "sort": "createdAt",
                "sortDir": "DESC",
            },
            timeout=30,
        )
        resp.raise_for_status()
        data = resp.json()

        batch = data.get("jobs", [])
        rows.extend(batch)

        total = data.get("total", 0)
        per_page = data.get("perPage") or len(batch)
        consumed = (page - 1) * per_page + len(batch)
        if not batch or consumed >= total:
            break
        page += 1

    return rows

Extract the immutable posting IDs

The stable public key is posting_id / jobPosting.id — never the separate id, job_id, or jobId values. The two board shapes differ: legacy rows nest one or more postings under postingDetails[], while shared-host rows expose posting_id inline.

Step 3: Extract the immutable posting IDs
def posting_ids(kind: str, rows: list[dict]) -> list[str]:
    ids: list[str] = []

    if kind == "legacy":
        # A legacy job can carry several postings.
        for job in rows:
            for posting in job.get("postingDetails", []):
                pid = (posting.get("id") or "").strip()
                if pid:
                    ids.append(pid)
    else:
        # Shared-host rows carry the posting key directly.
        for job in rows:
            pid = (job.get("posting_id") or "").strip()
            if pid:
                ids.append(pid)

    return ids

Fetch full details for each posting

Both generations resolve a posting through jobs/posting/{postingId}/details. Legacy boards must supply their public domain query; shared hosts omit it. Descriptions arrive as separate HTML sections (elevator pitch, description, requirements, benefits, about, notes, DEI). Removed postings return 404 or 410.

Step 4: Fetch full details for each posting
def get_details(posting_id: str, kind: str, company_key: str):
    # Legacy detail calls include ?domain=; shared-host calls omit it.
    params = {"domain": company_key} if kind == "legacy" else {}
    resp = requests.get(
        f"{BASE}/jobs/posting/{posting_id}/details",
        params=params,
        timeout=30,
    )
    if resp.status_code in (404, 410):
        return None  # posting was removed
    resp.raise_for_status()
    return resp.json()


# End-to-end
kind, key = resolve_board("https://wearewithyou.cp.occy.com/p/jobs")
rows = list_jobs(kind, key)
for pid in posting_ids(kind, rows):
    details = get_details(pid, kind, key)
    if details:
        print(details.get("title"))
Common issues
highRequesting more than 50 results per page returns HTTP 400. OCCY's public API enforces a perPage maximum of 50 on both listing endpoints.

Cap perPage at 50 and page with the page parameter, using the echoed total and perPage to decide when to stop.

highShared-host boards (app.occy.com/p/jobs/{ULID}) use case-sensitive 26-character company keys; the lowercased form of a key returns HTTP 400.

Preserve the exact upstream case of the ULID (keep it uppercase) and only lowercase legacy *.cp.occy.com subdomain tenants.

mediumOCCY runs two board generations with different endpoints and JSON shapes — legacy careersite/company/jobs (camelCase, postings nested under postingDetails[]) and shared-host jobs (snake_case, posting_id inline).

Detect the board type from the URL host and branch: *.cp.occy.com uses the domain query, app.occy.com/p/jobs/{ULID} uses the companyId query.

mediumEach job exposes several identifiers (id, job_id, jobPosting.jobId) alongside the posting key; keying on the wrong one breaks detail lookups and deduplication.

Always use posting_id / jobPosting.id as the stable public posting key and keep the other identifiers as metadata only.

lowA posting can be pulled after it appears in a listing, returning HTTP 404 or 410 from the detail endpoint.

Treat 404/410 from jobs/posting/{id}/details as a removed posting and skip it rather than failing the whole run.

Best practices
  1. 1Cap perPage at 50 and keep paging while (page - 1) * perPage + returned < total.
  2. 2Route by URL host: subdomain tenants use the domain query, app.occy.com ULIDs use companyId.
  3. 3Preserve shared-host ULID case exactly; lowercasing it triggers HTTP 400.
  4. 4Key every job on posting_id (jobPosting.id); treat id, job_id and jobId as metadata only.
  5. 5Send sort=createdAt&sortDir=DESC so new postings surface first and can be detected without a full re-crawl.
  6. 6Space out detail requests and back off on HTTP 429; no auth or cookies are required.
Or skip the complexity

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

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

Access OCCY
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