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.
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.
- Full HTML Job Descriptions
- Department & Location Tags
- Application Closing Dates
- Responsible Recruiter Names
- Native Account, Site & Position IDs
- Canonical Apply URLs
- 01Nordic Job Market Tracking
- 02Employer Career Page Monitoring
- 03Recruitment Data Aggregation
- 04Closing-Date Alerting
How to scrape oneCruiter / Workbuster.
Step-by-step guide to extracting jobs from oneCruiter / Workbuster-powered career pages—endpoints, authentication, and working code.
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")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)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")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")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.
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.
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.
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.
- 1Scrape only verified tenant hosts — *.onecruiter.com minus www/api/wb-analytics, plus known white-label domains.
- 2Fetch the board root once; the whole collection is server-rendered, so there is no pagination to walk.
- 3Handle both card templates: read a.position-title and fall back to a.position-link + .head-title.
- 4Use the numeric /jobs/{id} route as the stable job key and cross-check positionId on the detail page.
- 5Pace requests ~150ms apart and cap concurrent detail fetches at ~3 to stay gentle on the board.
- 6Strip tracking query params down to a canonical /jobs/{id}-{slug} URL before storing.
One endpoint. All oneCruiter / Workbuster jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=onecruiter / workbuster" \
-H "X-Api-Key: YOUR_KEY" 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.