Wizehire Jobs API.

Pull small-business openings from Wizehire through its public jobseeker API, which returns full descriptions and compensation but filters by company name fuzzily — so every row needs an exact re-check.

Get API access

What's in every response.

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

Data fields

  • Full Job Descriptions
  • Structured Compensation
  • Responsibilities & Qualifications
  • Company Profile Fields
  • Relay Cursor Pagination
  • Remote & Location Flags

Use cases

  1. 01Small Business Job Boards
  2. 02Local Hiring Feeds
  3. 03Franchise & Trades Recruitment
  4. 04Compensation Benchmarking

Trusted by

  • ARK Hospitality
  • C&L Autobody
  • CAMCO Property Management
DIY GUIDE

How to scrape Wizehire.

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

API type
REST
Difficulty
intermediate
Rate limit
No published limit; ~150ms between requests, max 3 concurrent detail fetches
Authentication
No auth

Extract the 16-character job ID from a job URL

Wizehire job URLs are jobs.wizehire.com/job/{slug}-{jobId}, where the ID is 16 lowercase hex characters. The URL carries no company at all, which is why identity has to come from the API rather than from the slug.

Step 1: Extract the 16-character job ID from a job URL
import re
from urllib.parse import urlparse, parse_qs

BOARD_HOST = "jobs.wizehire.com"
JOB_ID = re.compile(r"-(?P<job_id>[0-9a-f]{16})$")

def parse_wizehire(url: str) -> dict:
    parsed = urlparse(url)
    host = parsed.netloc.lower()
    # The legacy jobseeker host normalises to the canonical board host.
    if host not in (BOARD_HOST, "jobseeker.wizehire.com"):
        raise ValueError("not a Wizehire URL")

    parts = [p for p in parsed.path.strip("/").split("/") if p]
    if len(parts) == 2 and parts[0] == "job":
        match = JOB_ID.search(parts[1])
        if not match:
            raise ValueError("job slug did not end in a 16-hex job id")
        return {"kind": "job", "job_id": match.group("job_id")}

    company = (parse_qs(parsed.query).get("companyName") or [None])[0]
    if company:
        return {"kind": "board", "company_name": company}
    raise ValueError("a bare Wizehire search has no employer scope")

print(parse_wizehire("https://jobs.wizehire.com/job/director-of-sales-in-wichita-ks-us-e6e2576e1496fdfa"))
# {'kind': 'job', 'job_id': 'e6e2576e1496fdfa'}

Prove the employer from the job record

Ask the API for the job and take the company name it returns. Do not derive the employer from the URL slug — in one 771-row audit, a normalised slug matched the API's company name for only 83% of jobs, with the rest being numbered accounts, renamed businesses, or materially different names.

Step 2: Prove the employer from the job record
import requests

API = "https://api.jobseeker.wizehire.com/api/v1/jobs"

session = requests.Session()
session.headers["Accept"] = "application/json"

def prove_employer(job_id: str) -> dict | None:
    resp = session.get(f"{API}/{job_id}", timeout=30)
    if resp.status_code in (404, 410):
        return None
    resp.raise_for_status()

    job = (resp.json() or {}).get("job") or {}
    if job.get("id") != job_id or not job.get("companyName"):
        return None

    return {
        "job_id": job_id,
        "company_name": job["companyName"].strip(),
        "canonical_job_url": job.get("canonicalJobUrl") or job.get("url"),
    }

proof = prove_employer("e6e2576e1496fdfa")
print(proof)

Page the company search with Relay cursors

The listings endpoint takes first for the page size and an opaque after cursor. The response's pagination block carries the authoritative total plus hasNextPage and endCursor. Never construct a cursor yourself — always carry forward the endCursor you were given.

Step 3: Page the company search with Relay cursors
from urllib.parse import quote

PAGE_SIZE = 100

def fetch_pages(company_name: str):
    after = None
    while True:
        url = f"{API}?first={PAGE_SIZE}&companyName={quote(company_name)}"
        if after:
            url += f"&after={quote(after)}"

        resp = session.get(url, timeout=30)
        resp.raise_for_status()
        payload = resp.json()

        pagination = payload.get("pagination") or {}
        yield payload.get("jobs") or [], pagination

        if not pagination.get("hasNextPage") or not pagination.get("endCursor"):
            return
        after = pagination["endCursor"]

for batch, pagination in fetch_pages("ARK Hospitality"):
    print(len(batch), "rows of", pagination.get("total"))

Re-check every row against the exact company name

This is the step that decides whether the data is correct. The companyName filter is a fuzzy search, so the result set routinely contains other businesses. Recompute the employer identity for each row and keep only exact matches, accounting for the rest as deliberate exclusions.

Step 4: Re-check every row against the exact company name
import unicodedata

def normalise(name: str) -> str:
    folded = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
    return " ".join(folded.lower().replace("&", "and").split())

def collect(company_name: str) -> dict:
    target = normalise(company_name)
    kept, excluded, total = [], 0, None

    for batch, pagination in fetch_pages(company_name):
        total = pagination.get("total", total)
        for job in batch:
            if normalise(job.get("companyName") or "") != target:
                excluded += 1        # a fuzzy-search neighbour, not this employer
                continue
            kept.append(job)

    # The API total describes the fuzzy traversal, not this employer's board.
    return {"jobs": kept, "excluded": excluded, "fuzzy_total": total}

result = collect("ARK Hospitality")
print(f"{len(result['jobs'])} exact rows, {result['excluded']} excluded")

Map the record and filter closed jobs

Every listing row is already complete: description, responsibilities, qualifications, compensation, location, and the canonical job URL. Drop rows where isClosed is set, and use canonicalJobUrl rather than rebuilding a URL from the title.

Step 5: Map the record and filter closed jobs
def to_job(job: dict) -> dict | None:
    if job.get("isClosed"):
        return None

    compensation = job.get("compensation") or {}
    return {
        "id": job.get("id"),
        "title": job.get("title"),
        "company": job.get("companyName"),
        "description_html": job.get("description"),
        "responsibilities": job.get("responsibilities"),
        "qualifications": job.get("qualifications"),
        "salary_min": compensation.get("min"),
        "salary_max": compensation.get("max"),
        "salary_interval": compensation.get("interval"),
        "salary_raw": compensation.get("raw"),
        "city": job.get("city"),
        "state": job.get("state"),
        "country": job.get("country"),
        "fully_remote": job.get("fullyRemote", False),
        "posted_at": job.get("publishedAt"),
        "listing_url": job.get("canonicalJobUrl") or job.get("url"),
    }

open_jobs = [mapped for job in result["jobs"] if (mapped := to_job(job))]
for job in open_jobs[:3]:
    print(job["title"], "|", job["salary_min"], "-", job["salary_max"], job["salary_interval"])
Common issues
criticalWhy does a company search return other businesses' jobs?
The companyName parameter is a fuzzy search, not an exact filter. Normalise and compare the companyName on every returned row against the employer you asked for, keep only exact matches, and count the rest as deliberate exclusions rather than silently importing them.
highWhy does the URL slug not match the real company name?
Job URLs are tenantless — the slug is generated from the title and location. In one 771-row audit a normalised slug matched the API's company name for only 639 rows; the rest were numbered accounts, renamed businesses, and punctuation differences. Take the name from the API.
mediumIs the pagination total the employer's job count?
No. The total describes the complete fuzzy traversal, not the exact employer's board. Preserve it as the authoritative total for the traversal so you know when paging finished, but publish the count of exactly-matched rows as the employer's job count.
mediumCan I build the after cursor myself?
No. Pagination is Relay-style with an opaque endCursor, so any value you construct will be rejected or silently reset the walk. Carry the endCursor forward exactly as received, and stop as soon as hasNextPage is false or the cursor is absent.
Best practices
  1. 1Prove the employer from the API's companyName, never from the URL slug
  2. 2Re-check every returned row for an exact company match before emitting it
  3. 3Carry the opaque endCursor forward rather than constructing your own cursor
  4. 4Publish the exact-match count as the employer total, not the fuzzy traversal total
  5. 5Normalise jobseeker.wizehire.com URLs to the canonical jobs.wizehire.com host
  6. 6Skip per-job detail requests — listing rows already carry the full description
Or skip the complexity

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

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