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.

Get API access

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

  1. 01Federal Job Aggregation
  2. 02Public-Sector Hiring Trends
  3. 03Veteran & Security-Clearance Feeds
  4. 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.

API type
Hybrid
Difficulty
intermediate
Rate limit
No published limit on the public search endpoint; ~100ms between requests, max 3 concurrent detail fetches
Authentication
No auth

Choose an agency code, not a department name

USAJOBS scopes searches by a four-character agency code. That code is the stable identity: one agency appears under two different department-prefixed hints in upstream feeds, and department data is missing entirely for independent agencies.

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))

Post to the public search endpoint

The public search UI drives /Search/ExecuteSearch, which takes an Agency list, a Page number and a ResultsPerPage size and answers with JSON. No API key is involved; the separate documented USAJOBS Data API is a different surface that does require issued credentials.

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")

Page with the Pager metadata and validate the accounting

The response's Pager block is explicit: NumberOfPages, NumberOfItems, ItemsPerPage, CurrentPageIndex, HasNextPage and NextPageIndex. Honour HasNextPage and NextPageIndex, and check that ItemsPerPage matches what you asked for before trusting the page.

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")

Map the search rows

Each row carries the control number, position ID, agency and department names, the location display string, the salary display, the work schedule and type, and the pay grade range. That is enough to publish a listing without touching the detail page.

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"])

Hydrate the announcement and read its closed state

The canonical page at /job/{controlNumber} carries a JobPosting JSON-LD block plus the agency proof. Two removal signals exist: HTTP 404 or 410, and the page's own structured JobClosed state, which marks an announcement that has closed but is still addressable.

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
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.
Best practices
  1. 1Scope every crawl by the four-character agency code, not by department name
  2. 2Request ResultsPerPage=100 and follow HasNextPage plus NextPageIndex
  3. 3Validate ItemsPerPage and the pager's own page count on every response
  4. 4Prove the agency from the announcement page before storing a hydrated job
  5. 5Treat the structured JobClosed state as unavailability, distinct from 404/410
  6. 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 access
cURL
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
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