- 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.
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.
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
- 01Sports Industry Job Boards
- 02Live Entertainment Hiring Feeds
- 03League & Franchise Tracking
- 04Niche Vertical Aggregation
Trusted by
- Atlanta Braves
- Carolina Hurricanes
- All-Star Sports Academy
- Gary SouthShore RailCats
How to scrape TeamWork Online.
Step-by-step guide to extracting jobs from TeamWork Online-powered career pages—endpoints, authentication, and working code.
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"))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")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")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")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)- 1Preserve the exact path casing and hyphenation when requesting a page
- 2Key the tenant on the network segment and scope it by the organization segment
- 3Treat .organization-portal__no-results as an authoritative empty board
- 4Follow only same-board rel=next links, verifying network and organization each hop
- 5Prefer JobPosting JSON-LD and fall back to the preview markup only after proving data-return_to
- 6Map HTTP 403 and 429 to throttling and back off rather than recording an empty board
One endpoint. All TeamWork Online jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=teamwork online" \
-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 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.