OurCareerPages (Arcoro/BirdDogHR) Jobs API.

Read hiring from the contractors, manufacturers and trades employers on Arcoro's OurCareerPages, where one anonymous account endpoint returns the whole board and each job page carries the full advert.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on OurCareerPages (Arcoro/BirdDogHR).

Data fields

  • Complete Account Snapshot
  • Full Job Descriptions
  • Category Headings
  • City, State and Postal Code
  • Multi-Location Postings
  • Last Update Dates

Use cases

  1. 01Construction & Trades Job Aggregation
  2. 02Manufacturing Hiring Research
  3. 03Careers Page Extraction
  4. 04ATS Data Pipelines

Trusted by

  • 128 Plumbing
  • Crowder Constructors
  • Waupaca Foundry
DIY GUIDE

How to scrape OurCareerPages (Arcoro/BirdDogHR).

Step-by-step guide to extracting jobs from OurCareerPages (Arcoro/BirdDogHR)-powered career pages—endpoints, authentication, and working code.

API type
Hybrid
Difficulty
intermediate
Rate limit
No published limit; ~250ms between requests and at most 3 concurrent detail fetches
Authentication
No auth

Identify the account, not the job link

Every posting lives at jobs.ourcareerpages.com/job/{numericId}, and that URL names no employer at all. The tenant is Arcoro's numeric InAccountID, which appears in the board endpoint's query and inside each job page — so a detail link on its own can never be attributed offline.

Step 1: Identify the account, not the job link
from urllib.parse import urlparse

HOST = "jobs.ourcareerpages.com"
LISTING_PATH = "/WebServices/ccp_jobs.aspx"

def parse_detail(url: str) -> str | None:
    parsed = urlparse(url)
    if parsed.scheme != "https" or parsed.netloc.lower() != HOST:
        return None
    parts = parsed.path.strip("/").split("/")
    # /job/{id} is deliberately tenantless — the account must be proved from the page.
    if len(parts) != 2 or parts[0].lower() != "job" or not parts[1].isdigit():
        return None
    return parts[1]

def board_url(account_id: str) -> str:
    return (
        f"https://{HOST}{LISTING_PATH}?AutoGenerate=yes&GroupBy=&CCPCode="
        f"&InAccountID={account_id}&ElementID=jobs&JobOrderBy="
    )

print(parse_detail("https://jobs.ourcareerpages.com/job/996702?source=128Plumbing"))

Unwrap the JavaScript-wrapped JSON feed

The listing endpoint returns JavaScript, not JSON: the payload sits after a single ccpInfo: marker. Find that marker, then brace-match from the first opening brace while respecting strings and escapes — a naive split on the last brace breaks on any description containing one.

Step 2: Unwrap the JavaScript-wrapped JSON feed
import json
import requests

def extract_ccp_info(script: str) -> dict:
    marker = "ccpInfo:"
    start = script.find(marker)
    # Exactly one marker must be present; a second means the shape has changed.
    if start < 0 or script.find(marker, start + len(marker)) >= 0:
        raise RuntimeError("OurCareerPages payload did not carry a single ccpInfo block")

    start = script.index("{", start + len(marker))
    depth, in_string, escaped = 0, False, False
    for index in range(start, len(script)):
        char = script[index]
        if in_string:
            if escaped:
                escaped = False
            elif char == "\\":
                escaped = True
            elif char == '"':
                in_string = False
            continue
        if char == '"':
            in_string = True
        elif char == "{":
            depth += 1
        elif char == "}":
            depth -= 1
            if depth == 0:
                return json.loads(script[start:index + 1])
    raise RuntimeError("OurCareerPages ccpInfo block was not terminated")

def fetch_feed(session: requests.Session, account_id: str) -> dict:
    response = session.get(board_url(account_id), timeout=60)
    response.raise_for_status()
    feed = extract_ccp_info(response.text)
    if str(feed.get("InAccountID")) != str(account_id):
        raise RuntimeError("OurCareerPages feed contradicted the requested account")
    return feed

session = requests.Session()
feed = fetch_feed(session, "3862")
print(feed.get("CCPCode"), len(feed.get("CategoryList") or []))

Flatten the categories and merge repeated ids

Jobs are nested under CategoryList entries, each with a Heading and a JobList. The same numeric ID appears more than once when a posting runs at several sites, so merge those rows into one job with several locations rather than emitting duplicates. Dates arrive in the Microsoft /Date(milliseconds)/ format.

Step 3: Flatten the categories and merge repeated ids
import re
from datetime import datetime, timezone

MS_DATE = re.compile(r"^/Date\((-?[0-9]+)(?:[+-][0-9]{4})?\)/$")

def parse_ms_date(value: str | None) -> str | None:
    match = MS_DATE.match(value or "")
    if not match:
        return None
    return datetime.fromtimestamp(int(match.group(1)) / 1000, tz=timezone.utc).isoformat()

def flatten(feed: dict) -> list[dict]:
    merged: dict[str, dict] = {}
    for category in feed.get("CategoryList") or []:
        heading = category.get("Heading")
        for row in category.get("JobList") or []:
            job_id = str(row.get("ID") or "")
            if not job_id.isdigit():
                continue
            location = row.get("Location") or ", ".join(
                p for p in [row.get("City"), row.get("StateAbbrev") or row.get("StateFull")] if p
            )
            entry = merged.setdefault(job_id, {
                "id": job_id,
                "title": (row.get("JobTitle") or "").strip() or None,
                "company": (row.get("CompanyName") or "").strip() or None,
                "summary": (row.get("BriefDesc") or "").strip() or None,
                "category": heading,
                "postal_code": row.get("PostalCode"),
                "updated_at": parse_ms_date(row.get("LastUpdateDate")),
                "locations": [],
                "listing_url": f"https://{HOST}/job/{job_id}",
            })
            # Repeated ids are multi-location postings, not duplicates.
            if location and location not in entry["locations"]:
                entry["locations"].append(location)
    return list(merged.values())

listings = flatten(feed)
print(f"{len(listings)} distinct postings")

Prove the account on the job page and take the description

Each job page must prove itself before you trust it: the hidden #jobNumberStringHF must equal the id you asked for, and exactly one InAccountID must appear in the page script. The advert is the element wrapping the JobStat.aspx tracking pixel — strip that image out before storing the HTML.

Step 4: Prove the account on the job page and take the description
from bs4 import BeautifulSoup

ACCOUNT_ID = re.compile(r"\bInAccountID\s*:\s*[\"']?([1-9][0-9]{0,9})[\"']?")
TRACKER = re.compile(r"<img\b[^>]*\bsrc\s*=\s*[\"'][^\"']*/JobStat\.aspx[^\"']*[\"'][^>]*>", re.I)

def fetch_detail(session: requests.Session, listing: dict, account_id: str) -> dict | None:
    response = session.get(listing["listing_url"], timeout=30)
    if response.status_code in (404, 410):
        return None  # canonical removal
    response.raise_for_status()
    soup = BeautifulSoup(response.text, "html.parser")

    number = soup.select_one("#jobNumberStringHF")
    accounts = set(ACCOUNT_ID.findall(response.text))
    tracker = soup.select_one("img[src*='/JobStat.aspx']")
    proved = (
        number is not None and (number.get("value") or "").strip() == listing["id"]
        and len(accounts) == 1 and accounts.pop() == str(account_id)
        and soup.select_one("script[src*='ccp_widget_support.js']") is not None
        and tracker is not None
    )
    if not proved:
        # A 200 without an exact job/account proof is inconclusive, never a removal.
        raise RuntimeError("OurCareerPages detail page omitted its job or account proof")

    heading = soup.select_one("#pageheader h2")
    body = TRACKER.sub("", tracker.parent.decode_contents()).strip()
    location = soup.select_one(".job_location")
    return {
        **listing,
        "title": " ".join(heading.get_text().split()) if heading else listing["title"],
        "description_html": body or None,
        "location": " ".join(location.get_text().split()) if location else None,
        "account_id": str(account_id),
    }

for listing in listings[:3]:
    job = fetch_detail(session, listing, "3862")
    if job:
        print(job["title"], "-", job["location"])
Common issues
criticalThe listing endpoint does not parse as JSON
ccp_jobs.aspx returns JavaScript with the payload after a ccpInfo: marker, so a direct json.loads fails. Locate the single marker and brace-match the object while tracking strings and escapes; splitting on the last brace breaks on descriptions that contain one.
highA /job/{id} link cannot be attributed to an employer
Detail URLs are tenantless by design. Fetch the page and take the account from the single InAccountID that appears in its script; if zero or more than one is present, leave the job unattributed rather than assigning the first match.
highThe distributor code is mistaken for the tenant
CCPCode is a distributor or feed code, not an employer identity — many pages publish none at all and others carry a code belonging to a different distributor. Key the employer on the numeric InAccountID and keep CCPCode as metadata only.
mediumMulti-location postings are counted as duplicates
The same numeric ID appears once per site when a posting runs at several locations. Merge those rows into one job with a list of locations instead of emitting one record per row, which otherwise inflates the board count.
Best practices
  1. 1Key the employer on the numeric InAccountID and never on the CCPCode
  2. 2Brace-match the ccpInfo payload rather than trying to parse the response as JSON
  3. 3Confirm the feed's InAccountID equals the account you requested before mapping
  4. 4Merge repeated job IDs into a single posting with multiple locations
  5. 5Require #jobNumberStringHF and a single InAccountID on the detail page before trusting it
  6. 6Strip the JobStat.aspx tracking pixel out of the description HTML
Or skip the complexity

One endpoint. All OurCareerPages (Arcoro/BirdDogHR) jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=ourcareerpages (arcoro/birddoghr)" \
  -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 OurCareerPages (Arcoro/BirdDogHR)
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