- 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.
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.
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
- 01SMB & Manufacturing Job Feeds
- 02Regional Job Aggregation
- 03Careers Page Monitoring
- 04ATS Data Pipelines
Trusted by
- Bollinger Shipyards
- F.A. Wilhelm Construction
- JVIS
How to scrape ExactHire.
Step-by-step guide to extracting jobs from ExactHire-powered career pages—endpoints, authentication, and working code.
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"))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)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")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)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"- 1Resolve the career-site ID once per tenant and cache it — every API call needs it
- 2Unwrap the "0" key on each element of the positions array
- 3Map an envelope code of 406 with 'Error Access Blocked' to a rate limit, never to an empty board
- 4Exclude is_template rows and anything carrying archived_at or deleted_at
- 5Treat an empty-string title on a detail record as ExactHire's removal tombstone
- 6Throttle to ~300ms between requests and cap concurrency at three
One endpoint. All ExactHire jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=exacthire" \
-H "X-Api-Key: YOUR_KEY"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.
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.