- highThe board returns 403 to a default HTTP client
- Betterteam's edge scores client fingerprints, and a stock requests or curl signature is blocked before the page renders. Send a current browser User-Agent and Accept-Language, reuse one session so cookies persist, and keep concurrency at one — a single parallel burst is enough to earn a block.
- highNavigation pages are scraped as if they were jobs
- About, contact, privacy and terms sit at the same single-segment depth as job pages, so a naive link walk emits them as vacancies. Filter them by slug and confirm the resulting page actually contains a JobPosting block before writing a record.
- mediumwww.betterteam.com is scraped as a customer board
- The apex host is Betterteam's own marketing site and support.betterteam.com is its help centre; neither is a tenant. Exclude both, plus app, when deriving a tenant from a subdomain, otherwise the vendor's own article pages enter the pipeline as jobs.
- lowJob IDs are slugs, not numbers
- The external identifier is the URL slug, for example general-manager-64. It is stable while the posting lives but is derived from the title, so a retitled job appears as a new posting. Key records on the slug and reconcile removals from the board index rather than assuming ID stability.
Betterteam Jobs API.
Betterteam hosts small-business careers pages on {company}.betterteam.com. Each board is one server-rendered page of job links, and every job page carries canonical JobPosting JSON-LD.
What's in every response.
Data fields, real-world applications, and the companies already running on Betterteam.
Data fields
- Full Job Descriptions
- JobPosting JSON-LD
- Employment Type
- City & Region
- Posted Dates
- Direct Apply URLs
Use cases
- 01Small Business Job Aggregation
- 02Local Hiring Trackers
- 03Hospitality & Trades Feeds
- 04Careers Page Monitoring
How to scrape Betterteam.
Step-by-step guide to extracting jobs from Betterteam-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse
SUFFIX = ".betterteam.com"
RESERVED = {"www", "support", "app"}
def parse_board(url: str):
"""Return (tenant, job_slug) for a Betterteam board or job URL."""
host = urlparse(url).netloc.lower()
if not host.endswith(SUFFIX):
return None
tenant = host[: -len(SUFFIX)]
if not tenant or "." in tenant or tenant in RESERVED or len(tenant) > 63:
return None
segments = [s for s in urlparse(url).path.split("/") if s]
job_slug = segments[0] if len(segments) == 1 else None
return tenant, job_slug
print(parse_board("https://110grill.betterteam.com/general-manager-64"))
# ('110grill', 'general-manager-64')import requests
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
def make_session(board_url: str) -> requests.Session:
session = requests.Session()
session.headers.update({
"User-Agent": UA,
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
"Referer": board_url,
})
return session
board_url = "https://110grill.betterteam.com"
session = make_session(board_url)from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
NAV_PAGES = {"about", "contact", "privacy", "terms", "favicon.ico"}
def fetch_board(session, board_url: str) -> list[dict]:
resp = session.get(board_url, timeout=30)
resp.raise_for_status()
host = urlparse(board_url).netloc.lower()
soup = BeautifulSoup(resp.text, "html.parser")
listings, seen = [], set()
for anchor in soup.select("a[href]"):
absolute = urljoin(board_url, anchor["href"])
parsed = urlparse(absolute)
if parsed.netloc.lower() != host:
continue
segments = [s for s in parsed.path.split("/") if s]
if len(segments) != 1:
continue
slug = segments[0]
if slug.lower() in NAV_PAGES or slug in seen:
continue
seen.add(slug)
listings.append({
"id": slug,
"title": " ".join(anchor.get_text().split()),
"url": f"{parsed.scheme}://{parsed.netloc}{parsed.path}",
})
return listings
listings = fetch_board(session, board_url)
print(f"{len(listings)} open jobs")import json
import time
def find_job_posting(html: str) -> dict | None:
soup = BeautifulSoup(html, "html.parser")
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_details(session, listings: list[dict]) -> list[dict]:
out = []
for listing in listings:
resp = session.get(listing["url"], timeout=30)
if resp.status_code in (404, 410):
continue # posting removed
resp.raise_for_status()
posting = find_job_posting(resp.text)
if not posting:
continue
address = ((posting.get("jobLocation") or {}).get("address")) or {}
out.append({
"id": listing["id"],
"title": posting.get("title") or listing["title"],
"description_html": posting.get("description"),
"employment_type": posting.get("employmentType"),
"posted_at": posting.get("datePosted"),
"closes_at": posting.get("validThrough"),
"company": (posting.get("hiringOrganization") or {}).get("name"),
"city": address.get("addressLocality"),
"state": address.get("addressRegion"),
"url": listing["url"],
})
time.sleep(1.0) # one request at a time, ~1s apart
return out
jobs = fetch_details(session, listings[:3])- 1Keep concurrency at one and space requests about a second apart
- 2Send a current browser User-Agent, Accept-Language and a board Referer
- 3Accept only single-segment, same-host anchors as job links
- 4Skip about, contact, privacy, terms and favicon.ico before requesting a page
- 5Require a JobPosting JSON-LD block before emitting a job record
- 6Exclude www, support and app when deriving the tenant from the subdomain
One endpoint. All Betterteam jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=betterteam" \
-H "X-Api-Key: YOUR_KEY"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.
Access Betterteam
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.