All platforms

GovernmentJobs (NeoGov) Jobs API.

Reach thousands of US city, county, and state agency job boards that all live under governmentjobs.com, where every posting ships with structured JSON-LD covering pay ranges, departments, and closing dates.

Get API access
GovernmentJobs (NeoGov)
Live
<3haverage discovery time
1hrefresh interval
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.

What's in every response.

Data fields, real-world applications, and the companies already running on GovernmentJobs (NeoGov).

Data fields
  • Structured Job Titles
  • Full Job Descriptions
  • Salary Ranges (Min/Max)
  • Department & Category
  • City/State Locations
  • Posted & Closing Dates
Use cases
  1. 01Public-Sector Job Aggregation
  2. 02Government Hiring Trends
  3. 03Civic Data Research
  4. 04Careers Page Monitoring
  5. 05ATS Data Pipelines
DIY GUIDE

How to scrape GovernmentJobs (NeoGov).

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

HTMLadvancedNo documented server limit; self-throttle to ~3 concurrent detail requests, ~250ms apart to avoid 403/429 blocksNo auth

Resolve the agency slug from the URL

Every board lives at governmentjobs.com/careers/{slug}, where the slug is the agency identifier used to build every call. Confirm the host and reject reserved first segments like 'home' before treating a segment as a slug.

Step 1: Resolve the agency slug from the URL
from urllib.parse import urlparse

HOST = "www.governmentjobs.com"
RESERVED = {"home", "agencycustomstylescheme"}

def extract_agency_slug(url: str) -> str | None:
    parsed = urlparse(url)
    if parsed.netloc.lower() != HOST:
        return None
    parts = [p for p in parsed.path.strip("/").split("/") if p]
    # Boards live at /careers/{slug}[/...]; the first segment must be "careers".
    if len(parts) < 2 or parts[0].lower() != "careers":
        return None
    slug = parts[1]
    return None if slug.lower() in RESERVED else slug

# "https://www.governmentjobs.com/careers/seattle/jobs/5338985" -> "seattle"

Fetch the paginated listing fragment

The complete job list comes from /careers/home/index, but the HTML fragment is only served when the X-Requested-With: XMLHttpRequest header is present. Send that header plus a Referer of the agency board and walk pages until the 'next' control is gone.

Step 2: Fetch the paginated listing fragment
import requests
from bs4 import BeautifulSoup

BASE = f"https://{HOST}"

def listings_url(slug: str, page: int) -> str:
    return (
        f"{BASE}/careers/home/index"
        f"?agency={slug}&page={page}&sort=PositionTitle&isDescendingSort=false"
    )

def has_next_page(soup: BeautifulSoup) -> bool:
    nxt = soup.select_one("li.PagedList-skipToNext")
    return nxt is not None and "disabled" not in nxt.get("class", [])

def fetch_listings(slug: str, session: requests.Session, max_pages: int = 100) -> list[dict]:
    jobs, seen = [], set()
    page = 1
    while page <= max_pages:
        resp = session.get(
            listings_url(slug, page),
            headers={
                "X-Requested-With": "XMLHttpRequest",  # fragment only served for XHR
                "Referer": f"{BASE}/careers/{slug}",
            },
            timeout=30,
        )
        resp.raise_for_status()
        soup = BeautifulSoup(resp.text, "html.parser")

        added = 0
        for card in soup.select("li.list-item[data-job-id]"):
            job_id = (card.get("data-job-id") or "").strip()
            if not job_id or job_id in seen:
                continue
            seen.add(job_id)
            added += 1
            jobs.append(parse_card(card, slug, job_id))

        # Defensive stop: no new IDs on this page, or no enabled "next" control.
        if added == 0 or not has_next_page(soup):
            break
        page += 1
    return jobs

Decode each listing card

Each posting is an <li.list-item> carrying a data-job-id. Read the detail link and the ul.list-meta rows, which are label-prefixed ('Category:', 'Department:'), a salary/employment summary, or the location.

Step 3: Decode each listing card
def parse_card(card, slug: str, job_id: str) -> dict:
    link = card.select_one("a.item-details-link")
    href = (link.get("href") if link else None) or f"/careers/{slug}/jobs/{job_id}"
    detail_url = requests.compat.urljoin(f"{BASE}/", href)

    department = category = salary_summary = location = None
    for li in card.select("ul.list-meta > li"):
        text = " ".join(li.get_text(" ", strip=True).split())
        if not text:
            continue
        classes = li.get("class", [])
        if "categories-list" in classes or text.startswith("Category:"):
            category = text.removeprefix("Category:").strip()
        elif text.startswith("Department:"):
            department = text.removeprefix("Department:").strip()
        elif "Full-Time" in text or "Part-Time" in text or "$" in text:
            salary_summary = text
        elif location is None:
            location = text

    return {
        "id": job_id,
        "title": link.get_text(strip=True) if link else None,
        "listing_url": detail_url,
        "department": department,
        "category": category,
        "salary_summary": salary_summary,
        "location": location,
    }

Extract the detail page JobPosting JSON-LD

Each detail page is server-rendered HTML with one application/ld+json JobPosting block holding the canonical fields. The description is double HTML-encoded, so unescape it; fall back to the #details-info block only when the JSON-LD is missing.

Step 4: Extract the detail page JobPosting JSON-LD
import html as html_lib
import json

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 fetch_detail(detail_url: str, session: requests.Session) -> dict:
    resp = session.get(detail_url, timeout=30)
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")

    posting = find_job_posting(soup)
    if not posting:
        # Fallback: the description is also rendered under #details-info.
        node = soup.select_one("#details-info")
        return {"description_html": node.decode_contents() if node else None}

    salary = (posting.get("baseSalary") or {}).get("value") or {}
    raw_desc = posting.get("description")
    return {
        "title": posting.get("title"),
        # GovernmentJobs double-encodes the description (&lt;p&gt;...), so unescape it.
        "description_html": html_lib.unescape(raw_desc) if raw_desc else None,
        "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"),
        "salary_unit": salary.get("unitText"),
        "organization": (posting.get("hiringOrganization") or {}).get("name"),
    }
Common issues
highThe listing endpoint returns the SPA shell instead of job cards

/careers/home/index only serves the HTML fragment containing li.list-item cards when the X-Requested-With: XMLHttpRequest header is set. Without it you get the full application shell and zero listings, so always send that header plus a Referer of the agency board.

highThe map/JSON endpoints are incomplete or 404

The /careers/home/loadJobsOnMaps?agency={slug} endpoint is JSON but can return success with zero records while the board has active jobs, and /careers/{slug}/jobs.json responds with an HTTP 404 page. Treat the paginated HTML fragment as the only complete listing source.

mediumThe JobPosting description is double HTML-encoded

The JSON-LD description arrives as escaped markup (&lt;p&gt;...&lt;/p&gt;) rather than real HTML. Run html.unescape on it before storing or rendering, otherwise downstream consumers see literal entity text instead of formatted content.

mediumRetired agencies 302-redirect to the homepage

Some slugs have left the platform (for example an agency that closed its board) and now redirect to the governmentjobs.com homepage, yielding zero listings. Treat a redirect off /careers/{slug} or an empty result as a relocated/dead agency rather than a scrape failure.

mediumAggressive request rates trigger HTTP 403/429

Unthrottled hits against the shared host can return 403 blocks or 429 rate limits. Keep concurrency to roughly 3 detail requests at a time with about 250ms between calls, and back off on 429 before retrying.

Best practices
  1. 1Always send X-Requested-With: XMLHttpRequest and a Referer of the agency board when fetching the listing fragment
  2. 2Paginate via li.PagedList-skipToNext and stop when it is absent or disabled; also break when a page adds no new job IDs
  3. 3Prefer the JobPosting JSON-LD block and fall back to #details-info only when it is missing
  4. 4Run html.unescape on the JSON-LD description before persisting it
  5. 5Throttle to ~3 concurrent detail requests, ~250ms apart, to avoid 403/429 blocks
  6. 6Treat a 302 to the homepage or an empty listing as a relocated/dead agency, not an error
Or skip the complexity

One endpoint. All GovernmentJobs (NeoGov) jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=governmentjobs (neogov)" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

Access GovernmentJobs (NeoGov)
job data today.

One API call. Structured data. No scraping infrastructure to build or maintain — start with the free tier and scale as you grow.

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