All platforms

Gusto Jobs API.

Pull open roles, pay ranges, and locations from small-business hiring boards hosted on jobs.gusto.com, parsed straight from server-rendered HTML.

Get API access
Gusto
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 Gusto.

Data fields
  • Job Titles
  • Salary & Pay Ranges
  • Location & Work Type
  • Full Job Descriptions
  • Employment Type
  • Direct Apply URLs
Use cases
  1. 01Local & SMB Job Tracking
  2. 02Small-Business Hiring Monitoring
  3. 03Salary Benchmarking
  4. 04Job Board Aggregation
DIY GUIDE

How to scrape Gusto.

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

HTMLintermediateNo published limit; scrape ~1 request / 2s to avoid Cloudflare 403sNo auth

Fetch a Gusto board page

Every Gusto careers board lives at jobs.gusto.com/boards/{tenant-slug-uuid}. The slug ends in an immutable 36-character UUID that identifies the tenant; keep it intact or the board 404s. Send a browser-like User-Agent to reduce Cloudflare challenges.

Step 1: Fetch a Gusto board page
import requests

# The board token is the tenant slug plus its trailing UUID.
board = "birdies-lattes-4ba055ed-4bda-4873-bc68-b16b6bccaae9"
url = f"https://jobs.gusto.com/boards/{board}"

resp = requests.get(
    url,
    headers={"User-Agent": "Mozilla/5.0 (compatible; JoboBot/1.0)"},
    timeout=15,
)
resp.raise_for_status()
html = resp.text

Parse the posting cards

The board renders one anchor per job matching a[href^="/postings/"]. Inside each anchor, the <h3> holds the title and two <p> rows carry the location and a 'salary · employment type' line. Dedupe on the posting slug, which contains the per-job UUID.

Step 2: Parse the posting cards
import re
from bs4 import BeautifulSoup

UUID_RE = re.compile(
    r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I
)

soup = BeautifulSoup(html, "html.parser")
jobs, seen = [], set()

for a in soup.select('a[href^="/postings/"]'):
    slug = a["href"].split("/postings/")[-1].split("/")[0]
    match = UUID_RE.search(slug)
    if not match or slug in seen:
        continue
    seen.add(slug)

    paras = a.select("p")
    title = a.select_one("h3")
    location = paras[0].get_text(" ", strip=True) if paras else None
    # Second row is "{salary} · {employment_type}" joined by a middle dot.
    combined = paras[1].get_text(" ", strip=True) if len(paras) > 1 else ""
    parts = [p.strip() for p in combined.split("\u00b7") if p.strip()]
    salary = next((p for p in parts if "$" in p or any(c.isdigit() for c in p)), None)
    employment = next((p for p in parts if p != salary), None)

    jobs.append({
        "job_uuid": match.group(0).lower(),
        "posting_slug": slug,
        "url": f"https://jobs.gusto.com/postings/{slug}",
        "title": title.get_text(strip=True) if title else None,
        "location": location,
        "salary": salary,
        "employment_type": employment,
    })

print(f"Found {len(jobs)} postings")

Scrape the posting detail page

Detail pages are pure HTML with no JSON blob. The <h1> stacks three <span>s (company, title, then 'location · type'), the body lives in one or more .rich-text-container sections, and salary is the <p> that follows the <h4>Salary</h4> label.

Step 3: Scrape the posting detail page
from urllib.parse import urljoin

def fetch_detail(posting_url: str) -> dict:
    resp = requests.get(
        posting_url,
        headers={"User-Agent": "Mozilla/5.0 (compatible; JoboBot/1.0)"},
        timeout=15,
    )
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")

    spans = soup.select("h1 span")
    company = spans[0].get_text(strip=True) if len(spans) >= 1 else None
    title = spans[1].get_text(strip=True) if len(spans) >= 2 else None
    location_line = spans[2].get_text(" ", strip=True) if len(spans) >= 3 else None

    # Concatenate every rich-text section for the full description.
    description = "\n".join(
        c.decode_contents().strip() for c in soup.select(".rich-text-container")
    )

    salary = None
    for h4 in soup.select("h4"):
        if h4.get_text(strip=True).lower() == "salary":
            sibling = h4.find_next_sibling()
            salary = sibling.get_text(" ", strip=True) if sibling else None
            break

    apply_a = soup.select_one('a[href*="/applicants/new"]')
    apply_url = urljoin(posting_url, apply_a["href"]) if apply_a else posting_url

    return {
        "company": company,
        "title": title,
        "location_line": location_line,
        "description_html": description,
        "salary": salary,
        "apply_url": apply_url,
    }

Resolve a posting URL back to its board

A /postings/ URL cannot be scraped on its own — the board (tenant slug + UUID) is the only valid entry point. The posting page links back to its board in a breadcrumb; read that /boards/{tenant} link and fail closed if zero or more than one distinct board appears.

Step 4: Resolve a posting URL back to its board
def resolve_board(posting_url: str) -> str:
    resp = requests.get(
        posting_url,
        headers={"User-Agent": "Mozilla/5.0 (compatible; JoboBot/1.0)"},
        timeout=15,
    )
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")

    tenants = set()
    for a in soup.select('a[href*="/boards/"]'):
        tenant = a["href"].split("/boards/")[-1].split("/")[0]
        if UUID_RE.search(tenant):
            tenants.add(tenant)

    if len(tenants) != 1:
        raise ValueError(f"Expected one board breadcrumb, found {len(tenants)}")
    return tenants.pop()

Throttle and handle Cloudflare

Gusto boards sit behind Cloudflare and can answer with a 403 HTML challenge. Scrape one request at a time with a ~2s delay, treat 403 as a slow-down signal, and treat 404/410 as a removed board or posting.

Step 5: Throttle and handle Cloudflare
import time

def polite_get(url: str) -> requests.Response:
    resp = requests.get(
        url,
        headers={"User-Agent": "Mozilla/5.0 (compatible; JoboBot/1.0)"},
        timeout=15,
    )
    if resp.status_code == 403:
        raise RuntimeError("Cloudflare challenge (HTTP 403) — slow down or rotate IP")
    if resp.status_code in (404, 410):
        raise RuntimeError(f"Board or posting removed (HTTP {resp.status_code})")
    resp.raise_for_status()
    time.sleep(2)  # one request at a time, ~2s apart
    return resp
Common issues
highCloudflare returns a 403 HTML challenge instead of the board

Gusto boards sit behind Cloudflare. Scrape one request at a time with a ~2s delay, send a realistic browser User-Agent, and back off or rotate your IP when you see a 403.

highSelectors return nothing because there is no structured data

Gusto emits no JSON, JSON-LD, or embedded state — every field comes from CSS selectors (h1 span order, .rich-text-container, the h4 'Salary' label). Pin these selectors and monitor for layout drift.

mediumA /postings/ URL can't be scraped directly

Only a canonical /boards/{tenant-uuid} URL is a valid scrape entry point. Fetch the posting page, read its /boards/ breadcrumb link to recover the board tenant, then scrape that board.

mediumBoard URL 404s because the trailing UUID was dropped

The board token is the tenant slug plus an immutable 36-character UUID (e.g. birdies-lattes-4ba055ed-...). Always keep the full slug including the UUID; a slug without it is rejected.

lowSalary and employment type are mashed into one string

The card's second <p> is 'salary · employment type' joined by a middle dot (U+00B7). Split on the dot; treat a token containing a digit or '$' as the salary and the other as employment type.

Best practices
  1. 1Scrape one request at a time with a ~2s delay between requests
  2. 2Send a browser-like User-Agent to reduce Cloudflare 403 challenges
  3. 3Keep the full board slug including its trailing UUID — it is the tenant id
  4. 4Resolve /postings/ URLs to their board via the breadcrumb before scraping
  5. 5Concatenate every .rich-text-container section to capture the full description
  6. 6Cache board HTML — small-business boards change infrequently
Or skip the complexity

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

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

Access Gusto
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