ExactHire Jobs API.

ExactHire gives every employer a board at {tenant}.exacthire.com backed by a public JSON API. One call resolves the career site, a second returns every open position with its full description.

Get API access

What's in every response.

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

Data fields

  • Full Job Descriptions
  • Organization & Career Site IDs
  • Employment Type
  • Location Fields
  • Created & Updated Timestamps
  • Application Template Metadata

Use cases

  1. 01SMB & Manufacturing Job Feeds
  2. 02Regional Job Aggregation
  3. 03Careers Page Monitoring
  4. 04ATS Data Pipelines

Trusted by

  • Bollinger Shipyards
  • F.A. Wilhelm Construction
  • JVIS
DIY GUIDE

How to scrape ExactHire.

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

API type
REST
Difficulty
intermediate
Rate limit
Bursts return HTTP 200 with an envelope code of 406 and 'Error Access Blocked' — ~300ms between requests, max 3 concurrent
Authentication
No auth

Resolve the tenant subdomain

ExactHire boards are {tenant}.exacthire.com with the board at the host root and jobs at /job/{numericId}. Nothing else is a valid route, and api, app, help, status, support and www are vendor hosts rather than employers.

Step 1: Resolve the tenant subdomain
import re
from urllib.parse import urlparse

SUFFIX = ".exacthire.com"
RESERVED = {"api", "app", "help", "status", "support", "www"}
TENANT = re.compile("^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$", re.IGNORECASE)

def parse_exacthire(url: str) -> dict | None:
    parsed = urlparse(url)
    host = parsed.netloc.lower()
    if parsed.query or parsed.fragment or not host.endswith(SUFFIX):
        return None

    tenant = host[: -len(SUFFIX)]
    if tenant in RESERVED or not TENANT.match(tenant):
        return None

    segments = [s for s in parsed.path.split("/") if s]
    if not segments:
        return {"tenant": tenant, "job_id": None}
    if len(segments) == 2 and segments[0] == "job" and segments[1].isdigit():
        return {"tenant": tenant, "job_id": segments[1]}
    return None

print(parse_exacthire("https://bollingershipyards.exacthire.com/job/201817"))

Resolve the career site

The positions API is keyed by a numeric career-site ID, not by the subdomain. The public career_sites endpoint returns that ID together with the organization ID, and echoes the protocol, vendor url and subdomain — check all three before trusting the response.

Step 2: Resolve the career site
import requests

API = "https://api.exacthire.com"

def read_envelope(payload: dict) -> dict:
    """Unwrap the ExactHire response envelope, mapping code 406 to a rate limit."""
    code = payload.get("code")
    message = payload.get("message") or ""
    if code == 406 and "blocked" in message.lower():
        raise RuntimeError("ExactHire edge blocked the request — back off and retry")
    content = payload.get("content")
    if not isinstance(content, dict):
        raise RuntimeError("ExactHire response omitted its content object")
    return content

def resolve_site(session, tenant: str) -> dict:
    resp = session.get(f"{API}/api/public/career_sites/{tenant}",
                       headers={"Accept": "application/json"}, timeout=30)
    resp.raise_for_status()
    content = read_envelope(resp.json())

    site = content.get("career_site") or {}
    if content.get("protocol") != "https" or content.get("url") != "exacthire.com":
        raise RuntimeError("career site did not echo the ExactHire vendor host")
    if site.get("archived_at") or site.get("deleted_at"):
        raise LookupError(f"ExactHire career site {tenant} is retired")

    return {
        "tenant": tenant,
        "career_site_id": str(site.get("id")),
        "organization_id": str((content.get("organization") or {}).get("id")),
    }

session = requests.Session()
site = resolve_site(session, "bollingershipyards")
print(site)

Fetch every open position

The positions endpoint returns the whole inventory in one response — there is no cursor or page parameter. Each element of the positions array is a wrapper whose real record sits under a key named "0", which is easy to miss and yields an empty result set if you skip it.

Step 3: Fetch every open position
def unwrap(wrapper: dict) -> dict | None:
    """ExactHire nests each position under a literal '0' key."""
    position = wrapper.get("0") if isinstance(wrapper, dict) else None
    return position if isinstance(position, dict) else None

def fetch_positions(session, site: dict) -> list[dict]:
    url = f"{API}/api/public/career_sites/{site['career_site_id']}/positions?include_all=0"
    resp = session.get(url, headers={"Accept": "application/json"}, timeout=30)
    resp.raise_for_status()
    content = read_envelope(resp.json())

    rows = []
    for wrapper in content.get("positions") or []:
        position = unwrap(wrapper)
        if not position:
            continue
        # Skip archived, deleted and template records — they are not open jobs.
        if position.get("archived_at") or position.get("deleted_at"):
            continue
        if position.get("is_template") is True:
            continue

        position_id = str(position.get("id") or "")
        title = (position.get("title") or "").strip()
        if not position_id.isdigit() or not title:
            continue

        rows.append({
            "id": position_id,
            "title": title,
            "url": f"https://{site['tenant']}.exacthire.com/job/{position_id}",
            "location": position.get("location"),
            "employment_type": position.get("employment_type"),
            "created_at": position.get("created_at"),
            "updated_at": position.get("updated_at"),
        })
    return rows

positions = fetch_positions(session, site)
print(f"{len(positions)} open positions")

Fetch the position detail

The career-site-scoped detail endpoint returns the full record: description, location, timestamps, employment type and the application template. Ask for it with the same career-site ID you resolved, so a job ID from another tenant cannot be read through your board.

Step 4: Fetch the position detail
import time

def fetch_detail(session, site: dict, position_id: str) -> dict | None:
    url = (f"{API}/api/public/career_sites/{site['career_site_id']}"
           f"/positions/{position_id}?include_all=0")
    resp = session.get(url, headers={"Accept": "application/json"}, timeout=30)
    if resp.status_code in (404, 410):
        return None
    resp.raise_for_status()

    content = read_envelope(resp.json())
    position = unwrap(content.get("position") or {})
    if not position:
        return None

    template = position.get("application_template") or {}
    return {
        "id": str(position.get("id")),
        "title": (position.get("title") or "").strip(),
        "description_html": position.get("description"),
        "location": position.get("location"),
        "employment_type": position.get("employment_type"),
        "created_at": position.get("created_at"),
        "updated_at": position.get("updated_at"),
        "application_template_id": template.get("id"),
        "organization_id": site["organization_id"],
        "url": f"https://{site['tenant']}.exacthire.com/job/{position['id']}",
    }

for row in positions[:3]:
    print(fetch_detail(session, site, row["id"]))
    time.sleep(0.3)

Recognise closed positions

ExactHire has two distinct closed states. Archived or deleted timestamps mean the record was withdrawn, and a detail record whose title is an empty string is the platform's tombstone for a job that no longer exists. Everything else — malformed JSON, a missing title, a transport error — is a failure, not a closure.

Step 5: Recognise closed positions
def classify(session, site: dict, position_id: str) -> str:
    url = (f"{API}/api/public/career_sites/{site['career_site_id']}"
           f"/positions/{position_id}?include_all=0")
    resp = session.get(url, headers={"Accept": "application/json"}, timeout=30)
    if resp.status_code in (404, 410):
        return "removed"
    if not resp.ok:
        return "inconclusive"

    try:
        content = read_envelope(resp.json())
    except RuntimeError:
        return "inconclusive"

    position = unwrap(content.get("position") or {})
    if position is None:
        return "inconclusive"
    if position.get("archived_at") or position.get("deleted_at"):
        return "removed"
    # The exact tombstone: a record whose title is the empty string.
    if position.get("title") == "":
        return "removed"
    return "active" if (position.get("title") or "").strip() else "inconclusive"
Common issues
criticalThe positions array parses as empty
Every element of positions is a wrapper object whose real record lives under a key literally named "0". Reading the wrapper's own fields yields nothing, so the board looks empty. Unwrap that key first, then read id, title and description from the inner object.
criticalA burst returns HTTP 200 but no jobs
Under load the edge answers with a normal 200 whose envelope carries code 406 and 'Error Access Blocked'. Map that exact shape to a rate limit and back off — treating it as an empty inventory closes every job on the board in one run.
highTemplates and archived records enter the snapshot
The positions collection also contains reusable templates and withdrawn records. Exclude anything with is_template true or a non-null archived_at or deleted_at before emitting a job, otherwise unpublished drafts appear as live vacancies.
mediumA retired tenant looks like an empty board
Decommissioned subdomains return a null career-site bootstrap and an 'Unknown Career Site' page rather than an error. Require the career_sites response to echo the subdomain with a positive site and organization ID before scraping, and mark anything else as retired.
Best practices
  1. 1Resolve the career-site ID once per tenant and cache it — every API call needs it
  2. 2Unwrap the "0" key on each element of the positions array
  3. 3Map an envelope code of 406 with 'Error Access Blocked' to a rate limit, never to an empty board
  4. 4Exclude is_template rows and anything carrying archived_at or deleted_at
  5. 5Treat an empty-string title on a detail record as ExactHire's removal tombstone
  6. 6Throttle to ~300ms between requests and cap concurrency at three
Or skip the complexity

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

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=exacthire" \
  -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 ExactHire
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