All platforms

oneCruiter / Workbuster Jobs API.

Server-rendered Nordic career pages (Workbuster heritage) publish their entire job collection in a single HTML document — no API keys and no pagination, just one request per employer.

Get API access
oneCruiter / Workbuster
Live
<3haverage discovery time
1hrefresh interval
Companies using oneCruiter / Workbuster
TrelleborgStrawberry HotelsCoromatic
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 oneCruiter / Workbuster.

Data fields
  • Full HTML Job Descriptions
  • Department & Location Tags
  • Application Closing Dates
  • Responsible Recruiter Names
  • Native Account, Site & Position IDs
  • Canonical Apply URLs
Use cases
  1. 01Nordic Job Market Tracking
  2. 02Employer Career Page Monitoring
  3. 03Recruitment Data Aggregation
  4. 04Closing-Date Alerting
Trusted by
TrelleborgStrawberry HotelsCoromatic
DIY GUIDE

How to scrape oneCruiter / Workbuster.

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

HTMLintermediate~150ms between requests, ~3 concurrent detail fetches (unofficial)No auth

Fetch the career board document

A oneCruiter board lives on a single tenant subdomain (or a verified white-label host) and server-renders its whole job collection into one HTML document. Request the board root once and parse the position cards — there is no jobs API and no pagination to follow.

Step 1: Fetch the career board document
import requests
from bs4 import BeautifulSoup

# One tenant = one board. The entire collection is in this single document.
board_url = "https://trelleborg.onecruiter.com/"

resp = requests.get(board_url, headers={"Accept": "text/html"}, timeout=30)
resp.raise_for_status()
html = resp.text

soup = BeautifulSoup(html, "html.parser")
cards = soup.select(".position-container.filter-item")
print(f"Found {len(cards)} job cards")

Read the embedded account and site IDs

The tenant identity is injected into a window.customer (or window.MilouConfig) script rather than the markup. accountId and siteId together form the stable tenant key, so pull them with a regex before parsing cards.

Step 2: Read the embedded account and site IDs
import re

# accountId can live in either config object; siteId lives in window.customer.
account_re = re.compile(
    r"(?:window\.MilouConfig\s*=\s*\{|window\.customer\s*=\s*\{)"
    r"[\s\S]{0,500}?accountId\s*:\s*(\d+)"
)
site_re = re.compile(r"window\.customer\s*=\s*\{[\s\S]{0,500}?siteId\s*:\s*(\d+)")

m_account = account_re.search(html)
m_site = site_re.search(html)
account_id = m_account.group(1) if m_account else None
site_id = m_site.group(1) if m_site else None

# The tenant key is exactly accountId:siteId (never hashed or remapped).
tenant_id = f"{account_id}:{site_id}" if account_id and site_id else None
print("tenant:", tenant_id)

Parse each position card

Handle both server-rendered templates: most tenants put the title and route on a.position-title, while others use an empty overlay a.position-link and publish the title in .head-title. Every job exposes the same numeric /jobs/{id}-{slug} route, which is your stable external ID.

Step 3: Parse each position card
from urllib.parse import urljoin

JOB_ROUTE = re.compile(r"/jobs/(\d+)-[^/?#]+")

def parse_card(card, base_url):
    anchor = card.select_one(
        "a.position-title[href*='/jobs/'], a.position-link[href*='/jobs/']"
    )
    if anchor is None:
        return None
    href = urljoin(base_url, anchor.get("href", ""))
    match = JOB_ROUTE.search(href)
    if not match:
        return None

    # Overlay template: anchor text is empty, title sits in .head-title.
    title = anchor.get_text(strip=True)
    if not title:
        head = card.select_one(".head-title")
        title = head.get_text(strip=True) if head else None
    if not title:
        return None

    location = card.select_one(".meta-container .location.tag")
    department = card.select_one(".meta-container .department.tag")
    closes = card.select_one(".last-apply-date")
    canonical = href.split("?")[0].split("#")[0]  # drop tracking params

    return {
        "external_id": match.group(1),
        "url": canonical,
        "apply_url": canonical,
        "title": title,
        "location": location.get_text(strip=True) if location else None,
        "department": department.get_text(strip=True) if department else None,
        "closes_at": closes.get_text(strip=True) if closes else None,
    }

jobs = [j for j in (parse_card(c, board_url) for c in cards) if j]
print(f"Parsed {len(jobs)} jobs")

Fetch the canonical job page for full details

The board card carries only summary fields. Request the /jobs/{id}-{slug} page for the full HTML description, and cross-check the embedded positionId against the route ID so a mismatched or recycled page is skipped. Treat 404/410 as a removed position.

Step 4: Fetch the canonical job page for full details
def fetch_details(job):
    resp = requests.get(job["url"], headers={"Accept": "text/html"}, timeout=30)
    if resp.status_code in (404, 410):
        return None  # canonical position was removed
    resp.raise_for_status()

    page = resp.text
    soup = BeautifulSoup(page, "html.parser")

    # positionId is embedded in window.customer; it must equal the route ID.
    pos = re.search(r"window\.customer\s*=\s*\{[\s\S]{0,700}?positionId\s*:\s*(\d+)", page)
    if not pos or pos.group(1) != job["external_id"]:
        return None  # identity mismatch

    title_el = soup.select_one(
        "h1.position-title, .position-title h1.title, .position-title .title"
    )
    desc_el = soup.select_one(".position-description .job-text")
    location_el = soup.select_one(".tag-container.locations .tag")
    closes_el = soup.select_one(".tag-container.last_application_at .tag")
    recruiter_el = soup.select_one(".responsible-fullname")

    return {
        **job,
        "title": title_el.get_text(strip=True) if title_el else job["title"],
        "description_html": desc_el.decode_contents().strip() if desc_el else None,
        "location": location_el.get_text(strip=True) if location_el else job["location"],
        "closes_at": closes_el.get_text(strip=True) if closes_el else job["closes_at"],
        "recruiter": recruiter_el.get_text(strip=True) if recruiter_el else None,
    }

detailed = [d for d in (fetch_details(j) for j in jobs) if d]
print(f"Enriched {len(detailed)} jobs")
Common issues
highScraping the wrong host returns no jobs

Only tenant boards are real listing sources. Accept {tenant}.onecruiter.com (excluding the www, api, and wb-analytics subdomains) plus known white-label hosts such as jobs.strawberryhotels.com and career.coromatic.com. The api.onecruiter.com host only exposes the /api/v1/apply submission mutation, not a read API.

highTitles go missing on some boards

oneCruiter ships two server-rendered templates. Read a.position-title first, and when that anchor is an empty overlay (a.position-link) fall back to the .head-title element inside the same card. Selecting only one variant silently drops every job on the other.

mediumaccountId / siteId / positionId come back empty

These IDs live in a window.customer or window.MilouConfig script, not in the DOM. If the config is absent or restructured the regex returns None — keep the numeric /jobs/{id} route as your stable key and treat the embedded IDs as best-effort enrichment.

lowClosing dates land on the wrong day

Boards are Nordic and render dates in the tenant locale; the backend interprets them as Europe/Stockholm end-of-day. Parse the .last-apply-date and .tag-container.last_application_at values as end-of-day CET/CEST rather than naive local time.

Best practices
  1. 1Scrape only verified tenant hosts — *.onecruiter.com minus www/api/wb-analytics, plus known white-label domains.
  2. 2Fetch the board root once; the whole collection is server-rendered, so there is no pagination to walk.
  3. 3Handle both card templates: read a.position-title and fall back to a.position-link + .head-title.
  4. 4Use the numeric /jobs/{id} route as the stable job key and cross-check positionId on the detail page.
  5. 5Pace requests ~150ms apart and cap concurrent detail fetches at ~3 to stay gentle on the board.
  6. 6Strip tracking query params down to a canonical /jobs/{id}-{slug} URL before storing.
Or skip the complexity

One endpoint. All oneCruiter / Workbuster jobs. No scraping, no sessions, no maintenance.

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

Access oneCruiter / Workbuster
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