VIVAHR / AvaHR Jobs API.

Extract an employer's complete vacancy list from a VIVAHR (AvaHR) board in one request, keyed on the numeric company ID that sits in every first-party URL, with JobPosting JSON-LD per job.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on VIVAHR / AvaHR.

Data fields

  • Complete Board In One Page
  • JobPosting JSON-LD
  • Numeric Company IDs
  • Direct Apply URLs
  • Employer Branding Data
  • Explicit HTTP 410 Removals

Use cases

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

Trusted by

  • Adelante Healthcare
  • American Roofing & Waterproofing
  • Arizona Biltmore Dentistry
  • Zzeeks Pizza
DIY GUIDE

How to scrape VIVAHR / AvaHR.

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

API type
HTML
Difficulty
beginner
Rate limit
No published limit; ~100ms between requests, max 3 concurrent detail fetches
Authentication
No auth

Read the numeric company ID from the URL

Every AvaHR URL embeds its identifiers. A board is jobs.avahr.com/{companyId}-{company-slug}/jobs/ and a job is jobs.avahr.com/{companyId}-{company-slug}/{jobId}-{job-slug}. The numeric company ID is the stable tenant key; the slug is display text.

Step 1: Read the numeric company ID from the URL
from urllib.parse import urlparse

PUBLIC_HOST = "jobs.avahr.com"

def split_id_slug(segment: str) -> tuple[str, str]:
    head, _, tail = segment.partition("-")
    if not head.isdigit() or not tail:
        raise ValueError(f"expected {{id}}-{{slug}}, got {segment!r}")
    return head, tail

def parse_avahr(url: str) -> dict:
    parsed = urlparse(url)
    if parsed.netloc.lower() != PUBLIC_HOST:
        raise ValueError("not an AvaHR host")

    parts = [p for p in parsed.path.strip("/").split("/") if p]
    if not parts:
        raise ValueError("no company segment")

    company_id, company_slug = split_id_slug(parts[0])
    job_id = None
    if len(parts) == 2 and parts[1] != "jobs":
        job_id, _ = split_id_slug(parts[1])
    elif len(parts) > 2:
        # /apply/ and other subroutes are not identities.
        raise ValueError("unsupported AvaHR subroute")

    return {
        "company_id": company_id,
        "company_slug": company_slug,
        "job_id": job_id,
        "board_url": f"https://{PUBLIC_HOST}/{company_id}-{company_slug}/jobs/",
    }

print(parse_avahr("https://jobs.avahr.com/8147-zzeeks-pizza/78012-delivery-driver"))

Fetch the whole board in one request

AvaHR renders the employer's complete vacancy collection on one server-rendered page — there is no cursor and no page parameter. Confirm the page really is an AvaHR board before parsing, by checking that it carries the vendor's own domain reference.

Step 2: Fetch the whole board in one request
import requests
from bs4 import BeautifulSoup

session = requests.Session()
session.headers["Accept"] = "text/html,application/xhtml+xml"

def fetch_board(board_url: str) -> BeautifulSoup:
    resp = session.get(board_url, timeout=30)
    if resp.status_code in (404, 410):
        raise LookupError("this AvaHR board no longer exists")
    resp.raise_for_status()

    # First-party ownership proof: the page references the vendor's own site.
    if "https://avahr.com" not in resp.text.lower():
        raise RuntimeError("page carried no AvaHR ownership proof")
    return BeautifulSoup(resp.text, "html.parser")

board = parse_avahr("https://jobs.avahr.com/8677-adelante-healthcare/jobs/")
soup = fetch_board(board["board_url"])
print(soup.select_one("div.company-logo h3").get_text(strip=True))

Parse the listing rows

Each posting is a div.listing. The title anchor is the link inside div.text that contains an h2; the meta line sits in div.text h3, and the apply link is inside div.apply. Every row's URL must resolve back to the same numeric company ID.

Step 3: Parse the listing rows
from urllib.parse import urljoin

def parse_rows(board: dict, soup: BeautifulSoup) -> list[dict]:
    rows = []
    for listing in soup.select("div.listing"):
        anchor = next((a for a in listing.select("div.text a[href]") if a.select_one("h2")), None)
        if anchor is None:
            continue

        url = urljoin(f"https://{PUBLIC_HOST}/", anchor["href"])
        identity = parse_avahr(url)
        if identity["company_id"] != board["company_id"] or not identity["job_id"]:
            continue        # a row that belongs to another company is a rejection

        meta = listing.select_one("div.text h3")
        apply_anchor = listing.select_one("div.apply a[href]")
        rows.append({
            "job_id": identity["job_id"],
            "title": anchor.select_one("h2").get_text(strip=True),
            "meta": meta.get_text(" ", strip=True) if meta else None,
            "listing_url": url,
            "apply_url": urljoin(f"https://{PUBLIC_HOST}/", apply_anchor["href"]) if apply_anchor else url,
        })
    return rows

listings = parse_rows(board, soup)
print(f"{len(listings)} open jobs")

Hydrate each job from its JobPosting JSON-LD

Detail pages carry rich JobPosting JSON-LD covering the description, dates, employment type and salary. Prove the page still belongs to the same numeric company before reading it, and treat only a canonical 404 or 410 as a removal.

Step 4: Hydrate each job from its JobPosting JSON-LD
import json
import time

def find_job_posting(soup: BeautifulSoup) -> dict | None:
    for tag in soup.find_all("script", type="application/ld+json"):
        try:
            data = json.loads(tag.string or "")
        except (json.JSONDecodeError, TypeError):
            continue
        for node in (data if isinstance(data, list) else [data]):
            if isinstance(node, dict) and node.get("@type") == "JobPosting":
                return node
    return None

def hydrate(board: dict, row: dict) -> dict | None:
    resp = session.get(row["listing_url"], timeout=30)
    if resp.status_code in (404, 410):
        return None            # AvaHR uses 410 heavily for retired jobs
    resp.raise_for_status()

    page = BeautifulSoup(resp.text, "html.parser")
    if "https://avahr.com" not in resp.text.lower():
        raise RuntimeError("detail page contradicted its first-party identity")

    posting = find_job_posting(page)
    if posting is None:
        raise RuntimeError("detail page carried no JobPosting JSON-LD")

    salary = (posting.get("baseSalary") or {}).get("value") or {}
    apply_anchor = page.select_one("div.apply-btn a[href]")
    return {
        "job_id": row["job_id"],
        "company_id": board["company_id"],
        "title": posting.get("title"),
        "description_html": posting.get("description"),
        "posted_at": posting.get("datePosted"),
        "closes_at": posting.get("validThrough"),
        "employment_type": posting.get("employmentType"),
        "salary_min": salary.get("minValue"),
        "salary_max": salary.get("maxValue"),
        "listing_url": row["listing_url"],
        "apply_url": apply_anchor["href"] if apply_anchor else row["apply_url"],
    }

for row in listings[:3]:
    print(hydrate(board, row)["title"])
    time.sleep(0.1)

Keep dead URLs resolvable instead of deleting them

AvaHR retires boards and jobs with HTTP 410, and a substantial share of any historical corpus is in that state — one audit found 78 of 206 job URLs and 33 of 206 boards already gone. Record those as stale identities rather than dropping the employer.

Step 5: Keep dead URLs resolvable instead of deleting them
def classify(url: str) -> str:
    try:
        resp = session.head(url, timeout=15, allow_redirects=True)
    except requests.Timeout:
        return "unreachable"       # retry later; not evidence of anything

    if resp.status_code == 410:
        return "retired"           # identity stays valid, vacancy is gone
    if resp.status_code == 404:
        return "not_found"
    if resp.status_code < 400:
        return "live"
    return "error"

for url in (board["board_url"],
            "https://jobs.avahr.com/8147-zzeeks-pizza/78012-delivery-driver"):
    print(classify(url), url)
Common issues
mediumWhy does an AvaHR URL return HTTP 410 rather than 404?
AvaHR retires both boards and individual jobs with an explicit 410 Gone. That is canonical removal evidence for the vacancy, but the numeric company ID remains a valid historical identity — keep the employer resolvable and mark the posting stale.
highShould I key the employer on the company slug?
No. The slug is display text and can be rewritten. The numeric company ID that prefixes it is the stable tenant key, and it appears in every first-party URL, so parse both and store the number.
mediumHow do I confirm a page is really an AvaHR board?
Require first-party ownership proof in the markup — the page references the vendor's own site — and require every parsed row to resolve back to the same numeric company ID. A page that matches the URL shape but carries neither is not a board.
lowHow does pagination work on a large AvaHR board?
It does not. The board returns the employer's complete vacancy collection in a single server-rendered page, so there is no cursor to follow. If you find yourself building a page loop, you are on the wrong page — check that the URL ends in /jobs/.
Best practices
  1. 1Key the employer on the numeric company ID, never on the mutable slug
  2. 2Fetch the board once — the collection is complete and unpaginated
  3. 3Require first-party ownership proof before parsing any page as a board
  4. 4Reject listing rows whose URL resolves to a different company ID
  5. 5Read descriptions and salary from JobPosting JSON-LD rather than the rendered markup
  6. 6Treat HTTP 410 as a retired vacancy while keeping the employer identity resolvable
Or skip the complexity

One endpoint. All VIVAHR / AvaHR jobs. No scraping, no sessions, no maintenance.

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