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.
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.
- Job Titles
- Salary & Pay Ranges
- Location & Work Type
- Full Job Descriptions
- Employment Type
- Direct Apply URLs
- 01Local & SMB Job Tracking
- 02Small-Business Hiring Monitoring
- 03Salary Benchmarking
- 04Job Board Aggregation
How to scrape Gusto.
Step-by-step guide to extracting jobs from Gusto-powered career pages—endpoints, authentication, and working code.
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.textimport 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")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,
}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()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 respGusto 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.
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.
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.
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.
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.
- 1Scrape one request at a time with a ~2s delay between requests
- 2Send a browser-like User-Agent to reduce Cloudflare 403 challenges
- 3Keep the full board slug including its trailing UUID — it is the tenant id
- 4Resolve /postings/ URLs to their board via the breadcrumb before scraping
- 5Concatenate every .rich-text-container section to capture the full description
- 6Cache board HTML — small-business boards change infrequently
One endpoint. All Gusto jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=gusto" \
-H "X-Api-Key: YOUR_KEY" 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.