TeamWork Online Jobs API.

Extract sports and live-entertainment jobs from TeamWork Online organization boards, where the network and the hiring organization are separate path segments that must stay separate identities.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on TeamWork Online.

Data fields

  • Full Job Descriptions
  • JobPosting JSON-LD
  • Network & Organization Scopes
  • Career Level Fields
  • Authoritative Board Totals
  • validThrough Expiry Dates

Use cases

  1. 01Sports Industry Job Boards
  2. 02Live Entertainment Hiring Feeds
  3. 03League & Franchise Tracking
  4. 04Niche Vertical Aggregation

Trusted by

  • Atlanta Braves
  • Carolina Hurricanes
  • All-Star Sports Academy
  • Gary SouthShore RailCats
DIY GUIDE

How to scrape TeamWork Online.

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

API type
HTML
Difficulty
advanced
Rate limit
No published limit; ~250ms between requests, max 3 concurrent detail fetches; 403/429 means throttling, not absence
Authentication
No auth

Split the four-part route into category, network and organization

Job URLs are /{category}/{network}/{organization}/{job-slug}-{numericId}, and the board is the first three segments. Network and organization are separate identities — a league or agency network can host completely unrelated employers, so collapsing them merges those employers.

Step 1: Split the four-part route into category, network and organization
from urllib.parse import urlparse

HOST = "www.teamworkonline.com"

def parse_teamwork(url: str) -> dict:
    parsed = urlparse(url)
    if parsed.netloc.lower() != HOST:
        raise ValueError("not a TeamWork Online URL")

    parts = [p for p in parsed.path.strip("/").split("/") if p]
    if len(parts) not in (3, 4):
        raise ValueError("expected a board or job route")

    category, network, organization = parts[0], parts[1], parts[2]
    job_id = None
    if len(parts) == 4:
        tail = parts[3].rsplit("-", 1)
        if len(tail) != 2 or not tail[1].isdigit():
            raise ValueError("job slug must end in -{numericId}")
        job_id = tail[1]

    return {
        "category": category,
        "network": network,             # stable tenant
        "organization": organization,   # stable scope within the tenant
        "job_id": job_id,
        # Case is significant in the public path — never lowercase it.
        "board_url": f"https://{HOST}/{category}/{network}/{organization}",
    }

print(parse_teamwork("https://www.teamworkonline.com/baseball-jobs/aapb/"
                     "gary-southshore-railcats-jobs/multimedia-intern-photo-video-2141644"))

Fetch the organization board as HTML

There is no public listings API — requesting the same Rails route as JSON returns HTTP 406. Fetch the board as HTML with a realistic browser client, and map HTTP 403 or 429 to throttling rather than to an empty board.

Step 2: Fetch the organization board as HTML
import requests

session = requests.Session()
session.headers.update({
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9",
    "User-Agent": "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 get_board(board_url: str) -> str:
    resp = session.get(board_url, timeout=30)
    if resp.status_code in (403, 429):
        raise RuntimeError("throttled — back off; this is not an empty board")
    if resp.status_code in (404, 410):
        raise LookupError("organization board has been retired")
    resp.raise_for_status()
    return resp.text

board = parse_teamwork("https://www.teamworkonline.com/baseball-jobs/atlanta-braves/atlanta-braves-jobs")
html = get_board(board["board_url"])
print(len(html), "bytes")

Parse job cards and read the explicit no-results marker

Postings are .organization-portal__grid-item cards. An empty board renders an explicit .organization-portal__no-results marker — that is an authoritative empty snapshot. Unknown HTML with neither cards nor the marker is a parse failure, never an empty board.

Step 3: Parse job cards and read the explicit no-results marker
from bs4 import BeautifulSoup
from urllib.parse import urljoin

def parse_cards(board: dict, html: str) -> list[dict]:
    soup = BeautifulSoup(html, "html.parser")
    cards = soup.select(".organization-portal__grid-item")

    if not cards:
        if soup.select_one(".organization-portal__no-results"):
            return []                       # authoritative empty board
        raise RuntimeError("neither job cards nor a no-results marker — parse failure")

    rows = []
    for card in cards:
        anchor = card.select_one(".organization-portal__job-title a[href]")
        if not anchor:
            continue
        url = urljoin(f"https://{HOST}/", anchor["href"])
        identity = parse_teamwork(url)
        # A card must stay in the same category, network and organization scope.
        if (identity["category"], identity["network"], identity["organization"]) != (
                board["category"], board["network"], board["organization"]):
            continue
        rows.append({
            "job_id": identity["job_id"],
            "title": anchor.get_text(strip=True),
            "listing_url": url,
            "location": (card.select_one(".organization-portal__job-location") or anchor).get_text(strip=True),
            "career_level": (card.select_one(".organization-portal__job__career-level").get_text(strip=True)
                             if card.select_one(".organization-portal__job__career-level") else None),
        })
    return rows

listings = parse_cards(board, html)
print(f"{len(listings)} jobs on page 1")

Follow rel=next within the same board

A same-board rel=next link is the only admitted pagination cursor. Verify each next URL still resolves to the same category, network and organization before following it, so a related-board link cannot pull foreign jobs into the snapshot.

Step 4: Follow rel=next within the same board
def crawl(board: dict, max_pages: int = 50) -> list[dict]:
    collected, seen, page_url = [], set(), board["board_url"]

    for _ in range(max_pages):
        page_html = get_board(page_url)
        for row in parse_cards(board, page_html):
            if row["job_id"] and row["job_id"] not in seen:
                seen.add(row["job_id"])
                collected.append(row)

        soup = BeautifulSoup(page_html, "html.parser")
        nxt = soup.select_one("a[rel~='next'][href]")
        if not nxt:
            return collected

        candidate = urljoin(f"https://{HOST}/", nxt["href"])
        probe = parse_teamwork(candidate.split("?")[0])
        if probe["organization"] != board["organization"] or probe["network"] != board["network"]:
            raise RuntimeError("rel=next escaped the organization board")
        page_url = candidate

    raise RuntimeError("pagination exceeded its page bound")

listings = crawl(board)
print(f"{len(listings)} jobs in total")

Hydrate details and treat a past validThrough as expiry

Indexed job pages carry complete JobPosting JSON-LD. Pages marked noindex may omit it and instead expose the exact native job route in the Google sign-in element's data-return_to attribute, plus the title and body in the preview markup. A validThrough date in the past is structured removal evidence.

Step 5: Hydrate details and treat a past validThrough as expiry
import json
import time
from datetime import datetime, timezone

def hydrate(row: dict) -> dict | None:
    resp = session.get(row["listing_url"], timeout=30)
    if resp.status_code in (404, 410):
        return None                                   # canonical removal
    resp.raise_for_status()
    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:
        valid_through = posting.get("validThrough")
        if valid_through:
            expires = datetime.fromisoformat(valid_through.replace("Z", "+00:00"))
            if expires < datetime.now(timezone.utc):
                return None                            # structured expiry
        return {**row, "description_html": posting.get("description"),
                "posted_at": posting.get("datePosted"), "expires_at": valid_through}

    # noindex fallback: prove identity from data-return_to before reading the body.
    return_to = (soup.select_one("#g_id_onload[data-return_to]") or {}).get("data-return_to", "")
    if row["job_id"] not in return_to:
        raise RuntimeError("noindex page did not prove the requested job identity")

    body = soup.select_one(".opportunity-preview__body")
    return {**row,
            "title": (soup.select_one("h1.opportunity-preview__title") or {}).get_text(strip=True),
            "description_html": body.decode_contents() if body else None}

for row in listings[:3]:
    print(bool(hydrate(row)))
    time.sleep(0.25)
Common issues
criticalWhy does lowercasing a TeamWork Online URL return HTTP 404?
Case is significant in the public path. Mixed-case network segments such as All-Star-Sports-Academy-jobs 404 when lowercased, and real organization slugs contain trailing and doubled hyphens. Normalise identity fields internally but keep the provider's exact path when you request a page.
highWhy do unrelated employers end up under one company?
A network segment can be a league, agency, or multi-property group hosting several independent employers. Key the tenant on the network and scope it by the organization segment; collapsing to the network alone merges those employers into a single fictional company.
highIs there a JSON endpoint for the organization board?
No. Requesting the same Rails route as JSON returns HTTP 406, and browser inspection exposes no public listings endpoint. The server-rendered board is the authoritative source, so parse the HTML and treat any unknown markup as a parse failure rather than an empty board.
mediumWhy do some job pages have no JSON-LD?
Evergreen and noindex pages omit the JobPosting block. They still expose the exact native job route in the #g_id_onload element's data-return_to attribute, plus the title and body in the preview markup. Prove identity from data-return_to before falling back to that HTML.
mediumWhy does most of a board's history look closed?
TeamWork Online keeps expired postings addressable — in one audit 117 of 136 historical jobs were closed while their pages still returned HTTP 200. Use the structured validThrough date as expiry evidence instead of relying on the status code.
Best practices
  1. 1Preserve the exact path casing and hyphenation when requesting a page
  2. 2Key the tenant on the network segment and scope it by the organization segment
  3. 3Treat .organization-portal__no-results as an authoritative empty board
  4. 4Follow only same-board rel=next links, verifying network and organization each hop
  5. 5Prefer JobPosting JSON-LD and fall back to the preview markup only after proving data-return_to
  6. 6Map HTTP 403 and 429 to throttling and back off rather than recording an empty board
Or skip the complexity

One endpoint. All TeamWork Online jobs. No scraping, no sessions, no maintenance.

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