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.
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).
- Structured Job Titles
- Full Job Descriptions
- Salary Ranges (Min/Max)
- Department & Category
- City/State Locations
- Posted & Closing Dates
- 01Public-Sector Job Aggregation
- 02Government Hiring Trends
- 03Civic Data Research
- 04Careers Page Monitoring
- 05ATS Data Pipelines
How to scrape GovernmentJobs (NeoGov).
Step-by-step guide to extracting jobs from GovernmentJobs (NeoGov)-powered career pages—endpoints, authentication, and working code.
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"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 jobsdef 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,
}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 (<p>...), 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"),
}/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.
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.
The JSON-LD description arrives as escaped markup (<p>...</p>) rather than real HTML. Run html.unescape on it before storing or rendering, otherwise downstream consumers see literal entity text instead of formatted content.
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.
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.
- 1Always send X-Requested-With: XMLHttpRequest and a Referer of the agency board when fetching the listing fragment
- 2Paginate via li.PagedList-skipToNext and stop when it is absent or disabled; also break when a page adds no new job IDs
- 3Prefer the JobPosting JSON-LD block and fall back to #details-info only when it is missing
- 4Run html.unescape on the JSON-LD description before persisting it
- 5Throttle to ~3 concurrent detail requests, ~250ms apart, to avoid 403/429 blocks
- 6Treat a 302 to the homepage or an empty listing as a relocated/dead agency, not an error
One endpoint. All GovernmentJobs (NeoGov) jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=governmentjobs (neogov)" \
-H "X-Api-Key: YOUR_KEY" 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.