JobAps Jobs API.

Collect open recruitments from US counties, cities, states and transit districts on JobAps, where every tenant publishes an authoritative RSS inventory and a server-rendered bulletin per job.

Get API access

What's in every response.

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

Data fields

  • Authoritative RSS Inventory
  • Full Bulletin Text
  • Approximate Salary
  • Department & Work Location
  • Opening and Closing Dates
  • Composite Recruitment Numbers

Use cases

  1. 01Public-Sector Job Aggregation
  2. 02County & State Agency Feeds
  3. 03Civic Data Research
  4. 04Government Hiring Trends

Trusted by

  • County of Alameda
  • State of Connecticut
  • State of Maryland
  • City of New Haven
DIY GUIDE

How to scrape JobAps.

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

API type
Hybrid
Difficulty
intermediate
Rate limit
No published limit; ~100ms between requests and at most 3 concurrent bulletin fetches
Authentication
No auth

Resolve the tenant from the first path segment

Every JobAps employer lives on www.jobapscloud.com under a short tenant segment. Two bulletin shapes exist — the legacy /{tenant}/sup/bulpreview.asp with R1, R2 and R3 parameters, and the modern /oec/{tenant}/Jobs/Bulletin with the same three — and both normalise onto one board so a single employer is not split in two.

Step 1: Resolve the tenant from the first path segment
from urllib.parse import urlparse, parse_qs

HOST = "www.jobapscloud.com"

def parse_url(url: str) -> tuple[str, str | None] | None:
    parsed = urlparse(url)
    if parsed.scheme != "https" or parsed.netloc.lower() != HOST:
        return None
    parts = parsed.path.strip("/").split("/")
    query = parse_qs(parsed.query)

    if len(parts) == 2 and parts[1].lower() == "rss.asp":
        return parts[0].lower(), None                       # the tenant inventory
    if len(parts) == 3 and parts[1].lower() == "sup" and parts[2].lower() == "bulpreview.asp":
        tenant = parts[0]
    elif len(parts) == 4 and parts[0].lower() == "oec" and parts[2:] == ["Jobs", "Bulletin"]:
        tenant = parts[1]
    else:
        return None

    tuple_parts = [(query.get(k) or [""])[0].upper() for k in ("R1", "R2", "R3")]
    if not all(p.isalnum() for p in tuple_parts):
        return None
    # The recruitment number is composite; keep the vendor's own three parts.
    return tenant.lower(), "-".join(tuple_parts)

def feed_url(tenant: str) -> str:
    return f"https://{HOST}/{tenant}/rss.asp"

print(parse_url("https://www.jobapscloud.com/oec/newhaven/Jobs/Bulletin?R1=2606&R2=0974&R3=01"))

Read the tenant RSS inventory

Each tenant publishes its complete list of open recruitments at /{tenant}/rss.asp — that feed is the authority for what is currently open, and there is no pagination. Check the channel's atom self link still names the same tenant before trusting the items, so a redirect cannot swap one employer's inventory for another's.

Step 2: Read the tenant RSS inventory
import requests
import xml.etree.ElementTree as ET

ATOM = "{http://www.w3.org/2005/Atom}link"

def fetch_inventory(session: requests.Session, tenant: str) -> tuple[str, list[ET.Element]]:
    response = session.get(
        feed_url(tenant),
        headers={"Accept": "application/rss+xml, application/xml;q=0.9"},
        timeout=30,
    )
    response.raise_for_status()
    channel = ET.fromstring(response.text).find("channel")
    if channel is None:
        raise RuntimeError("JobAps RSS omitted its channel")

    self_link = next(
        (l.get("href") for l in channel.findall(ATOM) if l.get("rel") == "self"), None
    )
    proved = parse_url(self_link or "")
    if not proved or proved[0] != tenant:
        raise RuntimeError("JobAps RSS contradicted its tenant channel proof")

    title = (channel.findtext("title") or "").strip()
    for suffix in (" - Current Job Openings", " - Employment Opportunities", " Job Openings"):
        if title.endswith(suffix):
            title = title[: -len(suffix)].strip()
    return title, channel.findall("item")

session = requests.Session()
company, items = fetch_inventory(session, "alameda")
print(company, len(items))

Map items to canonical bulletin URLs

Each item's link is a bulletin URL carrying the composite recruitment number. Parse it, require the guid to agree when present, and rebuild the canonical BulPreview address so the same job keeps one identity whether it arrived through the legacy or the modern route.

Step 3: Map items to canonical bulletin URLs
def canonical_job_url(tenant: str, recruitment: str) -> str:
    r1, r2, r3 = recruitment.split("-")
    return f"https://{HOST}/{tenant}/sup/BulPreview.asp?R1={r1}&R2={r2}&R3={r3}"

def map_item(item: ET.Element, tenant: str, company: str) -> dict | None:
    title = (item.findtext("title") or "").strip()
    link = (item.findtext("link") or "").strip()
    guid = (item.findtext("guid") or "").strip()
    parsed = parse_url(link)
    if not title or not parsed or parsed[0] != tenant or parsed[1] is None:
        return None
    # When a guid is present it must name the same tenant and recruitment.
    if guid and parse_url(guid) != parsed:
        return None

    return {
        "id": parsed[1],
        "title": title,
        "company": company,
        "summary": (item.findtext("description") or "").strip() or None,
        "posted_at": item.findtext("pubDate"),
        "listing_url": canonical_job_url(tenant, parsed[1]),
    }

listings = [m for m in (map_item(i, "alameda", company) for i in items) if m]
print(f"{len(listings)} open recruitments")

Parse the bulletin and pick up the apply link

Bulletins render the recruitment number in #JobBulletinNum, the title in #JobBulletinTitle and the body in #JobBulletinBody, with salary, department and dates in a table.DetailTable. Some tenants also publish JobPosting JSON-LD; prefer it when its identifier matches, and fall back to the native elements on the templates that omit it.

Step 4: Parse the bulletin and pick up the apply link
import json
from urllib.parse import urljoin
from bs4 import BeautifulSoup

def read_detail_table(soup) -> dict:
    fields = {}
    for row in soup.select("table.DetailTable tr"):
        cells = row.select("th, td")
        if len(cells) < 2:
            continue
        key = " ".join(cells[0].get_text().split()).rstrip(":")
        value = " ".join(cells[1].get_text().split())
        if key and value:
            fields.setdefault(key, value)
    return fields

def find_job_posting(soup, recruitment: str) -> 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 not isinstance(node, dict) or node.get("@type") != "JobPosting":
                continue
            identifier = node.get("identifier")
            value = identifier.get("value") if isinstance(identifier, dict) else identifier
            if (value or "").upper() == recruitment.upper():
                return node
    return None

def fetch_bulletin(session: requests.Session, listing: dict) -> dict | None:
    response = session.get(listing["listing_url"], headers={"Accept": "text/html"}, timeout=30)
    if response.status_code in (404, 410):
        return None  # canonical removal
    response.raise_for_status()
    soup = BeautifulSoup(response.text, "html.parser")

    heading = soup.select_one("h1")
    if heading and "Recruitment Not Found" in heading.get_text():
        return None  # the vendor's own explicit "no such recruitment" page

    apply_anchor = soup.select_one(".ApplyPanelDiv a[href], a[href*='TermsOfUse'], a[href*='termsofuse']")
    apply_url = urljoin(response.url, apply_anchor["href"]) if apply_anchor else None

    posting = find_job_posting(soup, listing["id"])
    if posting:
        return {**listing, "description_html": posting.get("description"),
                "posted_at": posting.get("datePosted") or listing["posted_at"],
                "apply_url": apply_url}

    number = " ".join((soup.select_one("#JobBulletinNum") or soup.new_tag("i")).get_text().split())
    body = soup.select_one("#JobBulletinBody")
    if number.upper() != listing["id"].upper() or body is None:
        raise RuntimeError("JobAps bulletin contradicted its recruitment tuple")

    fields = read_detail_table(soup)
    return {
        **listing,
        "title": " ".join(soup.select_one("#JobBulletinTitle").get_text().split()),
        "description_html": body.decode_contents().strip(),
        "salary_text": fields.get("Approximate Salary") or fields.get("Salary"),
        "department": fields.get("Department"),
        "location": fields.get("Work Location") or fields.get("Location"),
        "closes_at": fields.get("Closing Date") or fields.get("Filing Deadline"),
        "apply_url": apply_url,
    }

for listing in listings[:3]:
    job = fetch_bulletin(session, listing)
    if job:
        print(job["title"], "-", job.get("salary_text"))
Common issues
highOne employer is split across two boards
The same agency is reachable through the legacy /{tenant}/sup/bulpreview.asp route and the modern /oec/{tenant}/Jobs/Bulletin route. Normalise both onto the single /{tenant}/rss.asp board and rebuild one canonical bulletin URL, otherwise a county appears twice with half its jobs each.
highAn empty bulletin is mistaken for a closed job
A page that loads but carries no bulletin body is not proof the recruitment closed. Only an HTTP 404 or 410, or the vendor's explicit 'Recruitment Not Found' page, should be treated as removal; anything else — timeouts, 5xx, a WAF challenge — should fail the run rather than expire jobs.
mediumJSON-LD is missing on some tenants
Structured data is present on part of the estate and absent on the rest, so requiring it silently drops whole agencies. Use JSON-LD when its identifier matches the recruitment number and otherwise parse #JobBulletinNum, #JobBulletinTitle, #JobBulletinBody and table.DetailTable.
mediumThe recruitment number is rewritten as a single id
JobAps identifies a job with a composite of R1, R2 and R3, and each part is needed to rebuild the bulletin and the apply URL. Keep the vendor's own three components rather than synthesising a hash, so a job keeps its identity across snapshots.
Best practices
  1. 1Take the tenant from the URL path, never from an aggregator's board token
  2. 2Treat /{tenant}/rss.asp as the authoritative inventory of what is currently open
  3. 3Verify the channel's atom self link names the same tenant before mapping items
  4. 4Normalise legacy and OEC routes onto one canonical bulletin URL per recruitment
  5. 5Prefer JSON-LD when its identifier matches, and fall back to the native bulletin elements
  6. 6Follow the terms-of-use anchor to record the real apply URL alongside the bulletin
Or skip the complexity

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

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