- highThe board page exposes no JobPosting JSON-LD
- Many ApplicantPool tenants publish no structured data at all, so there is nothing to lift from the markup. Read the domainId out of the JobListings component data instead and call /core/jobs/{domainId} — that JSON collection is the only complete inventory the public board exposes.
- highThe returned jobs array is shorter than jobCount
- The envelope's data.jobCount is authoritative. If it disagrees with len(data.jobs) the response is truncated or the domain ID is wrong. Fail the run rather than persisting a partial snapshot, because downstream reconciliation will read the missing rows as closed jobs.
- mediumA tenant redirects to /notset.php?disabled=1
- ApplicantPool serves a provider-owned document reading 'This career site has been disabled.' when an employer switches off their board. Treat that exact redirect as an empty inventory for a live tenant, not as a network failure or a parse error.
- mediumInternal vacancies leak into the public snapshot
- /jobs and /internaljobs are separate scopes driven by the getParams isInternal flag. Scrape them under different keys so one snapshot cannot expire the other, and never merge the two collections into a single board.
ApplicantPool Jobs API.
ApplicantPool gives every employer its own careers subdomain. Each board publishes a numeric domain ID that unlocks a JSON endpoint returning the complete vacancy collection and an authoritative job count.
What's in every response.
Data fields, real-world applications, and the companies already running on ApplicantPool.
Data fields
- Full Job Descriptions
- Benefits & Pay Details
- Department & Classification
- Employment & Workplace Type
- City, State & Country
- Start & Close Dates
Use cases
- 01Local Government Job Feeds
- 02Nonprofit Hiring Trackers
- 03Regional Job Aggregation
- 04ATS Data Pipelines
Trusted by
- Cameron County
- American USA
- Goodwill Southeast Michigan
How to scrape ApplicantPool.
Step-by-step guide to extracting jobs from ApplicantPool-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse
ROOT_DOMAIN = "applicantpool.com"
RESERVED = {"api", "app", "mail", "secure", "support", "www"}
def parse_board(url: str):
"""Return (tenant, surface) for an ApplicantPool board or job URL."""
parsed = urlparse(url)
labels = parsed.netloc.lower().rstrip(".").split(".")
if len(labels) != 3 or ".".join(labels[1:]) != ROOT_DOMAIN:
return None
tenant = labels[0]
if tenant in RESERVED:
return None
segments = [s for s in parsed.path.split("/") if s]
if not segments or segments[0] not in ("jobs", "internaljobs"):
return None
return tenant, segments[0]
print(parse_board("https://cameroncountytx.applicantpool.com/jobs/1310915"))
# ('cameroncountytx', 'jobs')import re
import requests
BOARD_COMPONENT = re.compile(
r"componentData\s*:\s*[{]\s*organizationId\s*:\s*([0-9]+)\s*,"
r"\s*domainId\s*:\s*([0-9]+)\s*,"
r"\s*getParams\s*:\s*([{][^{}]*[}])\s*,"
r"\s*domainName\s*:\s*\"([^\"]+)\"\s*,"
r"\s*subdomainName\s*:\s*\"([^\"]+)\""
)
def read_board_proof(session, tenant: str, surface: str) -> dict:
board_url = f"https://{tenant}.{ROOT_DOMAIN}/{surface}/"
resp = session.get(board_url, headers={"Accept": "text/html"}, timeout=30)
resp.raise_for_status()
# A disabled career site redirects to /notset.php?disabled=1 — not an error.
if "notset.php" in resp.url and "disabled=1" in resp.url:
raise LookupError(f"ApplicantPool career site {tenant} is disabled")
matches = BOARD_COMPONENT.findall(resp.text)
proofs = {(m[0], m[1]) for m in matches if m[4].lower() == tenant}
if len(proofs) != 1:
raise LookupError("board omitted or contradicted its domain proof")
organization_id, domain_id = proofs.pop()
return {"organization_id": organization_id, "domain_id": domain_id}
session = requests.Session()
proof = read_board_proof(session, "cameroncountytx", "jobs")
print(proof) # {'organization_id': '...', 'domain_id': '...'}import json
from urllib.parse import quote
def fetch_jobs(session, tenant: str, surface: str, domain_id: str) -> list[dict]:
is_internal = 1 if surface == "internaljobs" else 0
get_params = quote(json.dumps({"isInternal": is_internal}, separators=(",", ":")))
url = f"https://{tenant}.{ROOT_DOMAIN}/core/jobs/{domain_id}?getParams={get_params}"
resp = session.get(url, headers={"Accept": "application/json"}, timeout=30)
resp.raise_for_status()
payload = resp.json()
if not payload.get("success"):
raise RuntimeError("ApplicantPool listings API reported failure")
data = payload.get("data") or {}
jobs = data.get("jobs") or []
count = data.get("jobCount")
if count is None or count != len(jobs):
raise RuntimeError("authoritative jobCount disagreed with the returned rows")
return jobs
jobs = fetch_jobs(session, "cameroncountytx", "jobs", proof["domain_id"])
print(f"{len(jobs)} open jobs")def map_listing(job: dict, tenant: str, surface: str, domain_id: str) -> dict | None:
if str(job.get("siteId")) != str(domain_id):
return None # cross-tenant row — reject it
if (job.get("subdomain") or "").lower() != tenant:
return None
job_id = job["id"]
country = {"USA": "US", "CAN": "CA"}.get(
(job.get("iso3") or "").upper(), job.get("iso3")
)
return {
"id": job_id,
"title": (job.get("title") or "").strip(),
"url": f"https://{tenant}.{ROOT_DOMAIN}/{surface}/{job_id}",
"department": job.get("orgTitle"),
"employment_type": job.get("employmentType"),
"workplace_type": job.get("workplaceType"),
"classification": job.get("classification"),
"location": job.get("jobLocation") or job.get("city"),
"city": job.get("city"),
"state": job.get("abbreviation"),
"country": country,
"starts_at": job.get("startDateRef"),
"ends_at": job.get("endDateRef"),
}
rows = [map_listing(j, "cameroncountytx", "jobs", proof["domain_id"]) for j in jobs]
rows = [r for r in rows if r]DETAIL_COMPONENT = re.compile(
r"componentData\s*:\s*[{]\s*token\s*:\s*\"[^\"]*\"\s*,"
r"\s*organizationId\s*:\s*([0-9]+)\s*,"
r"\s*domainId\s*:\s*([0-9]+)\s*,"
r"\s*domainTitle\s*:\s*\"((?:[^\"]|\\.)*)\"\s*,"
r"\s*jobListingId\s*:\s*([0-9]+)"
)
def fetch_detail(session, tenant: str, surface: str, job_id: str) -> dict | None:
page_url = f"https://{tenant}.{ROOT_DOMAIN}/{surface}/{job_id}"
page = session.get(page_url, headers={"Accept": "text/html"}, timeout=30)
if page.status_code in (404, 410):
return None # removal candidate
page.raise_for_status()
proofs = {m for m in DETAIL_COMPONENT.findall(page.text) if m[3] == str(job_id)}
if len(proofs) != 1:
raise RuntimeError("detail page omitted its domain and job proof")
organization_id, domain_id, company_name, _ = proofs.pop()
api = f"https://{tenant}.{ROOT_DOMAIN}/core/jobs/{domain_id}/{job_id}/job-details"
resp = session.get(api, headers={"Accept": "application/json"}, timeout=30)
if resp.status_code in (404, 410):
return None
resp.raise_for_status()
detail = (resp.json() or {}).get("data") or {}
return {
"id": detail.get("id"),
"title": detail.get("title"),
"description_html": detail.get("description"),
"benefits": detail.get("benefits"),
"pay_details": detail.get("payDetails"),
"company_name": company_name,
"organization_id": organization_id,
"url": page_url,
}- 1Cache the domainId per tenant — it never changes and every API call needs it
- 2Verify data.jobCount equals the number of rows before persisting a snapshot
- 3Scrape /jobs and /internaljobs as two separate boards with separate scopes
- 4Confirm each row's siteId and subdomain match the board you proved before emitting it
- 5Treat the /notset.php?disabled=1 document as an empty board, not an error
- 6Throttle to ~250ms between requests and cap concurrent detail fetches at three
One endpoint. All ApplicantPool jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=applicantpool" \
-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 ApplicantPool
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.