- mediumDo I need a USAJOBS API key to pull job listings?
- Not for this route. The public search endpoint behind usajobs.gov and the canonical /job/{controlNumber} pages are anonymous. USAJOBS also publishes a separate documented Data API that does require an issued authorization key — that is a different surface with different terms.
- highWhy does the same agency appear under two identities?
- Upstream feeds prefix the agency code with a department code, so one agency can surface as both AR-ARXT and DD-ARXT. The four-character agency code read from the announcement page collapses those to one tenant; department data is also absent for independent agencies.
- mediumWhy does a closed announcement still return HTTP 200?
- USAJOBS keeps closed announcements addressable so applicants can see what they missed. The page carries a structured JobClosed state — treat that as explicit unavailability, separate from a 404 or 410, and expect the search endpoint not to return those announcements at all.
- mediumHow do I know an empty result is real?
- Accept an empty first page only when the Total, the pager's NumberOfItems and the Jobs array all agree on zero. If the pager still reports results while the array is empty, the response is inconsistent and must be retried rather than recorded as an empty agency.
USAJOBS Jobs API.
Pull US federal vacancy announcements agency by agency from the public search endpoint behind usajobs.gov, then hydrate each announcement from its canonical page and JobPosting JSON-LD.
What's in every response.
Data fields, real-world applications, and the companies already running on USAJOBS.
Data fields
- Full Announcement Text
- Pay Grades & Salary Display
- Agency & Department Codes
- Work Schedule & Type
- Open & Close Dates
- Explicit Total Metadata
Use cases
- 01Federal Job Aggregation
- 02Public-Sector Hiring Trends
- 03Veteran & Security-Clearance Feeds
- 04Civic Data Research
Trusted by
- Air National Guard
- Air Mobility Command
- Veterans Health Administration
DIY GUIDE
How to scrape USAJOBS.
Step-by-step guide to extracting jobs from USAJOBS-powered career pages—endpoints, authentication, and working code.
Step 1: Choose an agency code, not a department name
HOST = "www.usajobs.gov"
def board_url(agency_code: str) -> str:
return f"https://{HOST}/Search/Results?a={agency_code.upper()}"
def job_url(control_number: str) -> str:
return f"https://{HOST}/job/{control_number}"
# Four-character agency codes, not department names or display titles.
AGENCIES = {
"AF34": "Air National Guard",
"AF1L": "Air Mobility Command",
"VATA": "Veterans Health Administration",
}
for code, name in AGENCIES.items():
print(code, name, board_url(code))Step 2: Post to the public search endpoint
import requests
SEARCH = f"https://{HOST}/Search/ExecuteSearch"
PAGE_SIZE = 100
session = requests.Session()
session.headers.update({
"Accept": "application/json",
"Content-Type": "application/json",
})
def search_page(agency_code: str, page: int) -> dict:
resp = session.post(
SEARCH,
json={"Agency": [agency_code], "ResultsPerPage": PAGE_SIZE, "Page": page},
headers={"Referer": board_url(agency_code)},
timeout=30,
)
resp.raise_for_status()
return resp.json()
payload = search_page("AF34", 1)
pager = payload["Pager"]
print(payload["Total"], "results across", pager["NumberOfPages"], "pages")Step 3: Page with the Pager metadata and validate the accounting
def fetch_agency(agency_code: str) -> list[dict]:
jobs, page = [], 1
while True:
payload = search_page(agency_code, page)
pager = payload.get("Pager") or {}
batch = payload.get("Jobs") or []
if pager.get("ItemsPerPage") != PAGE_SIZE:
raise RuntimeError("search returned an unexpected page size")
if pager.get("HasNextPage") != (page < pager.get("NumberOfPages", 0)):
raise RuntimeError("Pager contradicted its own page count")
if not batch:
# An empty first page is authoritative only when everything agrees on zero.
if page == 1 and str(payload.get("Total")) in ("0", "") and pager.get("NumberOfItems") == 0:
return []
raise RuntimeError("empty page while the pager still reports results")
jobs.extend(batch)
if not pager.get("HasNextPage"):
return jobs
page = pager["NextPageIndex"]
listings = fetch_agency("AF34")
print(f"{len(listings)} announcements")Step 4: Map the search rows
def to_listing(row: dict, agency_code: str) -> dict:
control_number = row.get("DocumentID")
return {
"id": control_number,
"agency_code": agency_code,
"agency": (row.get("Agency") or "").strip(),
"department": row.get("Department"),
"title": row.get("Title"),
"position_id": row.get("PositionID"),
"location": row.get("LocationDisplay") or row.get("LocationName"),
"salary": row.get("SalaryDisplay"),
"grade": row.get("JobGrade"),
"grade_low": row.get("LowGrade"),
"grade_high": row.get("HighGrade"),
"schedule": row.get("WorkSchedule"),
"work_type": row.get("WorkType"),
"opens_at": row.get("PositionStartDate"),
"closes_at": row.get("PositionEndDate"),
"categories": [c.get("Name") for c in (row.get("JobCategoryCode") or [])],
"listing_url": job_url(control_number),
}
for row in listings[:3]:
listing = to_listing(row, "AF34")
print(listing["title"], "|", listing["grade"], "|", listing["location"])Step 5: Hydrate the announcement and read its closed state
import json
import time
from bs4 import BeautifulSoup
def agency_from_page(html: str) -> str | None:
"""The page's filter-query element carries the provider-owned agency code."""
node = BeautifulSoup(html, "html.parser").select_one("#joaFilterQuery")
if node is None or not node.get("value"):
return None
identity = json.loads(node["value"])
agencies = identity.get("Agency") or []
return agencies[0].strip() if len(agencies) == 1 else None
def hydrate(control_number: str, agency_code: str) -> dict | None:
resp = session.get(job_url(control_number),
headers={"Accept": "text/html,application/xhtml+xml"}, timeout=30)
if resp.status_code in (404, 410):
return None # canonical removal
resp.raise_for_status()
if agency_from_page(resp.text) != agency_code.upper():
raise RuntimeError("announcement did not prove the requested agency")
if '"ClockDisplay": "JobClosed"' in resp.text:
return None # structured unavailability
soup = BeautifulSoup(resp.text, "html.parser")
posting = 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":
posting = node
if posting is None:
raise RuntimeError("announcement carried no JobPosting JSON-LD")
return {
"id": control_number,
"title": posting.get("title"),
"description_html": posting.get("description"),
"posted_at": posting.get("datePosted"),
"closes_at": posting.get("validThrough"),
"listing_url": job_url(control_number),
}
for row in listings[:3]:
print(bool(hydrate(row["DocumentID"], "AF34")))
time.sleep(0.1) Common issues
Best practices
- 1Scope every crawl by the four-character agency code, not by department name
- 2Request ResultsPerPage=100 and follow HasNextPage plus NextPageIndex
- 3Validate ItemsPerPage and the pager's own page count on every response
- 4Prove the agency from the announcement page before storing a hydrated job
- 5Treat the structured JobClosed state as unavailability, distinct from 404/410
- 6Publish listings straight from the search rows; hydrate details only where you need the full text
Or skip the complexity
One endpoint. All USAJOBS jobs. No scraping, no sessions, no maintenance.
Get API accesscURL
curl "https://connect.jobo.world/api/jobs?sources=usajobs" \
-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 USAJOBS
Access USAJOBS
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