SilkRoad OpenHire Jobs API.

Extract the complete vacancy inventory from a legacy SilkRoad OpenHire board in a single unpaginated ColdFusion request, then hydrate each posting from its stable element IDs.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on SilkRoad OpenHire.

Data fields

  • Full Job Descriptions
  • Required Skills & Experience
  • Tracking Codes
  • Position Type
  • Location Details
  • Multi-Portal Versions

Use cases

  1. 01Enterprise Job Aggregation
  2. 02Legacy ATS Migration Audits
  3. 03Multi-Language Board Coverage
  4. 04Careers Page Monitoring

Trusted by

  • Southland
  • WilmerHale
  • Hallmark Aviation Services
  • Kubota Engine
  • CPKC
DIY GUIDE

How to scrape SilkRoad OpenHire.

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

API type
HTML
Difficulty
advanced
Rate limit
No published limit; ~600ms between requests, max 2 concurrent detail fetches
Authentication
No auth

Read the tenant and portal version from the URL

Legacy OpenHire boards live at https://{tenant}.silkroad.com/epostings/. The tenant is the first host label and nothing else. The version query parameter selects which posting portal you get, defaulting to 1 — it is not a schema revision, and different versions carry entirely different jobs.

Step 1: Read the tenant and portal version from the URL
from urllib.parse import urlparse, parse_qs

DOMAIN = "silkroad.com"
# The modern SilkRoad product lives on these hosts and is a different contract.
EXCLUDED = {"jobs.silkroad.com", "jobs-ca.silkroad.com"}

def parse_openhire(url: str) -> dict:
    parsed = urlparse(url)
    host = parsed.netloc.lower()
    if host in EXCLUDED or not host.endswith("." + DOMAIN):
        raise ValueError("not a legacy OpenHire host")
    if "/epostings" not in parsed.path.lower():
        raise ValueError("not an /epostings route")

    # ColdFusion query keys are case-insensitive and the wild data mixes cases.
    query = {k.lower(): v[0] for k, v in parse_qs(parsed.query).items()}
    version = (query.get("version") or "1").lstrip("0") or "0"
    if not version.isdigit():
        raise ValueError("version must be numeric")

    return {
        "tenant": host.split(".")[0],
        "version": version,
        "job_id": query.get("jobid"),
        "fuseaction": (query.get("fuseaction") or "").lower(),
    }

print(parse_openhire("https://kcsouthern.silkroad.com/epostings/index.cfm"
                     "?fuseaction=app.jobinfo&jobid=1234&version=2"))

Fetch the whole inventory in one request

fuseaction=app.jobsearch renders the tenant's entire inventory as one unpaginated table — one audited board returned all 444 rows in a single 305 KB response. The startrow, startRow, and maxrows parameters have no effect, and no next-page or record-count control exists in the markup.

Step 2: Fetch the whole inventory in one request
import requests
from bs4 import BeautifulSoup

def board_url(tenant: str, version: str) -> str:
    return (f"https://{tenant}.silkroad.com/epostings/index.cfm"
            f"?fuseaction=app.jobsearch&version={version}")

def job_url(tenant: str, version: str, job_id: str) -> str:
    return (f"https://{tenant}.silkroad.com/epostings/index.cfm"
            f"?fuseaction=app.jobinfo&jobid={job_id}&version={version}")

session = requests.Session()
session.headers["Accept"] = "text/html,application/xhtml+xml"

resp = session.get(board_url("southland", "1"), timeout=60)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
print(f"{len(resp.text)} bytes in one response")

Locate rows and columns by their anchor IDs

Each row's title is an anchor with the id jobTitle_{jobId}, so the numeric job id comes from the id rather than the href — hrefs carry roughly fifteen volatile ColdFusion search-state parameters. Column positions come from the header anchor IDs, which stay English even on translated boards.

Step 3: Locate rows and columns by their anchor IDs
def column_index(soup) -> dict:
    """Header anchor ids stay English (header_location) even when the
    visible header text is translated, e.g. on the Spanish CPKC board."""
    index = {}
    for position, header in enumerate(soup.select("a[id^='header_']")):
        index[header.get("id").lower()] = position
    return index

def parse_rows(soup) -> list[dict]:
    columns = column_index(soup)
    anchors = soup.select("a[id^='jobTitle_']")
    if not anchors and soup.select_one("a#header_jobTitle") is None:
        raise RuntimeError("this is not an OpenHire search page")

    rows = []
    for anchor in anchors:
        job_id = anchor.get("id")[len("jobTitle_"):]
        if not job_id.isdigit():
            continue
        cells = anchor.find_parent("tr").select("td") if anchor.find_parent("tr") else []

        def cell(name):
            position = columns.get(name)
            if position is None or position >= len(cells):
                return None
            return " ".join(cells[position].get_text().split())

        rows.append({
            "job_id": job_id,
            "title": " ".join(anchor.get_text().split()),
            "tracking_code": cell("header_trackingcode"),
            "location": cell("header_location"),
        })
    return rows

listings = parse_rows(soup)
print(f"{len(listings)} postings")

Parse the detail page from its stable element IDs

Detail pages expose their fields as a definition list of stable element IDs. There is no JSON-LD block anywhere, and no posting date is published on the board at all. Note the vendor's own long-standing typo in the description element id.

Step 4: Parse the detail page from its stable element IDs
import time

DETAIL_IDS = {
    "title": "#jobTitleDiv",
    "description": "#jobDesciptionDiv",   # vendor's own typo — do not "fix" it
    "required_skills": "#jobRequiredSkillsDiv",
    "required_experience": "#jobExperienceRqdDiv",
    "location": "#jobPositionLocationDiv",
    "tracking_code": "#jobCodeDiv",
    "position_type": "#translatedJobPostingTypeDiv",
}

def fetch_detail(tenant: str, version: str, job_id: str) -> dict | None:
    url = job_url(tenant, version, job_id)
    resp = session.get(url, timeout=45)
    if resp.status_code in (404, 410):
        return None
    resp.raise_for_status()
    page = BeautifulSoup(resp.text, "html.parser")

    # A pulled posting answers HTTP 200, so status code alone proves nothing.
    if page.select_one("#jobStatusChangeDiv") and page.select_one("#jobTitleDiv") is None:
        return None

    if page.select_one("#jobTitleDiv") is None:
        raise RuntimeError("neither a posting nor the status-changed marker — parse error")

    detail = {"job_id": job_id, "listing_url": url}
    for field, selector in DETAIL_IDS.items():
        node = page.select_one(selector)
        detail[field] = node.decode_contents() if field == "description" and node else (
            " ".join(node.get_text().split()) if node else None
        )
    return detail

for row in listings[:3]:
    print(fetch_detail("southland", "1", row["job_id"]))
    time.sleep(0.6)
Common issues
criticalWhy does a removed job still return HTTP 200?
OpenHire answers a pulled posting with 200 and a status-changed page, so status code alone keeps dead jobs alive forever. Accept removal only when the element #jobStatusChangeDiv is present and #jobTitleDiv is absent; a generic empty page is a parse error, not removal.
highWhy does the same tenant show completely different jobs?
The version query parameter selects a posting portal, not a schema revision. One audited tenant serves an English board on version 1 and a Spanish-language board with three entirely different jobs on version 2. Scope every snapshot by version, or one portal will expire the other's postings.
highWhy do location and tracking-code columns come back empty?
Column order varies per tenant and the visible header text is translated on non-English boards. Build a column index from the header anchor IDs (header_trackingCode, header_jobTitle, header_location), which stay English, instead of assuming fixed table positions.
mediumIs there a JSON or RSS feed I can use instead?
No. Probes of app.jobRSS, app.rss, app.jobSearchRSS, app.jobs, and app.feed all return the application's generic error page, and the shipped JavaScript bundles declare only an authenticated recruiter-side grid-preferences call. Adding format=json to a job URL returns the same HTML.
lowWhy can I not find a posting date on any job?
OpenHire publishes no posting date anywhere on the public board — not in the row, not on the detail page, and not in any meta tag. Derive freshness from when your own crawler first observed the job id rather than expecting a provider-supplied date.
Best practices
  1. 1Treat the first host label as the tenant and ignore any upstream board token
  2. 2Scope every snapshot by the version parameter, defaulting to 1 when absent
  3. 3Read job IDs from the jobTitle_{id} anchor id, never from the volatile href
  4. 4Build column positions from the header_* anchor IDs so translated boards still parse
  5. 5Require #jobStatusChangeDiv plus a missing #jobTitleDiv before recording a removal
  6. 6Throttle to roughly two requests per second with at most two concurrent detail fetches
Or skip the complexity

One endpoint. All SilkRoad OpenHire jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=silkroad openhire" \
  -H "X-Api-Key: YOUR_KEY"
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.

Ready to integrate

Access SilkRoad OpenHire
job data today.

One API call. Structured data. No scraping infrastructure to build or maintain — start with the $5 free starting balance and scale as you grow.

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