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.

Get API access

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

  1. 01Small Business Job Aggregation
  2. 02Local Hiring Trackers
  3. 03Hospitality & Trades Feeds
  4. 04Careers Page Monitoring
DIY GUIDE

How to scrape Betterteam.

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

API type
HTML
Difficulty
intermediate
Rate limit
Edge protection blocks aggressive clients; keep to one request at a time, ~1 second apart
Authentication
No auth

Resolve the tenant subdomain

Every Betterteam board is {tenant}.betterteam.com and the board root is the whole index — there is no /jobs path. www.betterteam.com and support.betterteam.com are the vendor's own marketing and help sites, so exclude them along with app.

Step 1: Resolve the tenant subdomain
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')

Send browser-shaped requests

Betterteam's edge inspects client fingerprints, and a default HTTP client is throttled or blocked outright. Send a realistic User-Agent and Accept header, keep a session so cookies persist, and set the board as the Referer on every detail request.

Step 2: Send browser-shaped requests
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)

Collect job links from the board root

The board renders every open vacancy in one page with no pagination. Job URLs are exactly one path segment on the same host, so filter to single-segment same-host anchors and drop the standing navigation pages: about, contact, privacy, terms and favicon.ico.

Step 3: Collect job links from the board root
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")

Read JobPosting JSON-LD from each job page

The job page is the canonical record: one application/ld+json JobPosting block holds the title, full HTML description, employment type, posted date and address. Rate-limit hard here — one request at a time, roughly a second apart.

Step 4: Read JobPosting JSON-LD from each job page
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])
Common issues
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.
Best practices
  1. 1Keep concurrency at one and space requests about a second apart
  2. 2Send a current browser User-Agent, Accept-Language and a board Referer
  3. 3Accept only single-segment, same-host anchors as job links
  4. 4Skip about, contact, privacy, terms and favicon.ico before requesting a page
  5. 5Require a JobPosting JSON-LD block before emitting a job record
  6. 6Exclude www, support and app when deriving the tenant from the subdomain
Or skip the complexity

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

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=betterteam" \
  -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 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.

99.9%API uptime
<200msAvg response
50M+Jobs processed