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.

Get API access

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

  1. 01Local Government Job Feeds
  2. 02Nonprofit Hiring Trackers
  3. 03Regional Job Aggregation
  4. 04ATS Data Pipelines

Trusted by

  • Cameron County
  • American USA
  • Goodwill Southeast Michigan
DIY GUIDE

How to scrape ApplicantPool.

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

API type
Hybrid
Difficulty
intermediate
Rate limit
No published limit; 403/429 under load — ~250ms between requests, max 3 concurrent detail fetches
Authentication
No auth

Resolve the tenant and the board scope

Every ApplicantPool board is a third-level subdomain of applicantpool.com. The first path segment picks the scope: /jobs is the public board and /internaljobs is a separate, independently complete inventory of internal vacancies. Treat them as two boards, never as one.

Step 1: Resolve the tenant and the board scope
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')

Read the domain ID off the board page

The listings API is keyed by ApplicantPool's internal domain ID, not by the subdomain. The server-rendered board embeds that ID in the JobListings component data together with the organization ID, the domain name and the subdomain name. Read it once per tenant and cache it.

Step 2: Read the domain ID off the board page
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': '...'}

Fetch the complete vacancy collection

The core endpoint returns every open job for the scope in one response — the public board has no pagination control. The envelope carries a jobCount that must equal the length of the jobs array; treat a mismatch as a truncated snapshot rather than a valid result.

Step 3: Fetch the complete vacancy collection
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")

Map each listing row

Rows carry the native job ID, title, department, employment and workplace type, classification and a jobLocation string alongside split city / state / ISO-3 country fields. Confirm each row's siteId matches the domain ID you proved, and rebuild the canonical URL from the tenant and surface.

Step 4: Map each listing row
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]

Hydrate the description from the detail API

Listing rows carry no body text. The canonical job page publishes the domain ID and jobListingId in its own component data; read those, then call the job-details endpoint for the description, benefits and pay details. A 404 or 410 on the canonical page means the posting was removed.

Step 5: Hydrate the description from the detail API
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,
    }
Common issues
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.
Best practices
  1. 1Cache the domainId per tenant — it never changes and every API call needs it
  2. 2Verify data.jobCount equals the number of rows before persisting a snapshot
  3. 3Scrape /jobs and /internaljobs as two separate boards with separate scopes
  4. 4Confirm each row's siteId and subdomain match the board you proved before emitting it
  5. 5Treat the /notset.php?disabled=1 document as an empty board, not an error
  6. 6Throttle to ~250ms between requests and cap concurrent detail fetches at three
Or skip the complexity

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

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

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