Teamtailor Jobs API.
Pull complete job listings—full HTML descriptions, structured locations, departments, and remote status—straight from each company's public Teamtailor RSS feed, no API key required.
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 Teamtailor.
- Full HTML Job Descriptions
- Structured Locations (City, Country, Zip)
- Department & Role Tags
- Remote Status (On-site / Hybrid / Remote)
- Publication Dates
- Stable Job IDs & GUIDs
- 01European Job Market Tracking
- 02Remote & Hybrid Role Aggregation
- 03Multi-Location Job Feeds
- 04Startup Hiring Signals
How to scrape Teamtailor.
Step-by-step guide to extracting jobs from Teamtailor-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse
def build_rss_url(board_url: str) -> str:
"""Build the Teamtailor RSS feed URL from any board or job URL.
Keeps the scheme + host and appends /jobs.rss, so regional hosts like
cardioone.na.teamtailor.com and numeric-suffix subdomains like
inlight-1733094855.teamtailor.com all resolve correctly.
"""
parsed = urlparse(board_url)
return f"{parsed.scheme}://{parsed.netloc}/jobs.rss"
print(build_rss_url("https://polestar.teamtailor.com/jobs"))
# https://polestar.teamtailor.com/jobs.rss
print(build_rss_url("https://cardioone.na.teamtailor.com"))
# https://cardioone.na.teamtailor.com/jobs.rssimport requests
board_url = "https://polestar.teamtailor.com"
rss_url = build_rss_url(board_url)
headers = {
"Accept": "application/rss+xml, application/xml, text/xml",
"User-Agent": "JobScraper/1.0",
}
response = requests.get(rss_url, headers=headers, timeout=30)
response.raise_for_status()
rss_content = response.text
if "<rss" not in rss_content:
print("RSS not available for this board; fall back to the /jobs HTML page")
else:
print(f"Fetched {len(rss_content)} bytes from {rss_url}")import xml.etree.ElementTree as ET
from html import unescape
import re
def decode_description(html: str) -> str:
"""Decode HTML entities; Teamtailor occasionally double-encodes descriptions."""
decoded = unescape(html or "")
if any(entity in decoded for entity in ("<", ">", "&")):
decoded = unescape(decoded)
return decoded
def parse_rss_feed(rss_content: str) -> list[dict]:
"""Parse a Teamtailor RSS feed into job dicts."""
root = ET.fromstring(rss_content)
channel = root.find("channel")
if channel is None:
return []
jobs = []
for item in channel.findall("item"):
link = item.findtext("link", "")
# Job ID lives in the URL: /jobs/{id}-{slug}
id_match = re.search(r"/jobs/(\d+)", link)
jobs.append({
"id": id_match.group(1) if id_match else None,
"title": (item.findtext("title") or "").strip(),
"url": link,
"description_html": decode_description(item.findtext("description", "")),
"published_at": item.findtext("pubDate"),
"remote_status": item.findtext("remoteStatus", "none"),
"company": item.findtext("company_name", ""),
})
return jobs
jobs = parse_rss_feed(rss_content)
print(f"Found {len(jobs)} jobs")TT_NS = {"tt": "https://teamtailor.com/locations"}
def extract_namespaced_data(item: ET.Element) -> dict:
"""Pull department and structured locations from the tt: namespace."""
department = item.findtext("tt:department", default=None, namespaces=TT_NS)
locations = []
for loc in item.findall("tt:locations/tt:location", TT_NS):
name = loc.findtext("tt:name", namespaces=TT_NS)
if name and name.strip():
locations.append(name.strip())
continue
city = (loc.findtext("tt:city", namespaces=TT_NS) or "").strip()
country = (loc.findtext("tt:country", namespaces=TT_NS) or "").strip()
combined = ", ".join(part for part in (city, country) if part)
if combined:
locations.append(combined)
return {
"department": department.strip() if department else None,
"locations": locations,
}
# Merge namespaced data back onto the parsed jobs (same item order).
root = ET.fromstring(rss_content)
channel = root.find("channel")
for job, item in zip(jobs, channel.findall("item")):
extra = extract_namespaced_data(item)
job["department"] = extra["department"]
job["locations"] = extra["locations"]import time
from typing import Optional
def fetch_teamtailor_jobs(board_url: str, max_retries: int = 3) -> Optional[list[dict]]:
"""Fetch and parse all jobs for a Teamtailor board with error handling."""
rss_url = build_rss_url(board_url)
for attempt in range(max_retries):
try:
response = requests.get(
rss_url,
headers={
"Accept": "application/rss+xml, application/xml, text/xml",
"User-Agent": "JobScraper/1.0",
},
timeout=30,
)
response.raise_for_status()
if "<rss" not in response.text:
print(f"No RSS feed for {board_url}; try the /jobs HTML page")
return None
return parse_rss_feed(response.text)
except requests.HTTPError as e:
status = e.response.status_code
if status == 404:
print(f"Board not found: {board_url}")
return None
if status in (403, 429): # scraper treats both as blocked / rate limited
wait = 2 ** attempt
print(f"Blocked or rate limited ({status}); waiting {wait}s")
time.sleep(wait)
continue
print(f"HTTP error {status}: {e}")
return None
except requests.RequestException as e:
print(f"Request failed: {e}")
if attempt < max_retries - 1:
time.sleep(1)
return None
# Pace requests (~200ms) when scanning multiple boards.
boards = [
"https://polestar.teamtailor.com",
"https://cardioone.na.teamtailor.com",
]
for board in boards:
jobs = fetch_teamtailor_jobs(board)
if jobs:
print(f"{board}: {len(jobs)} jobs")
time.sleep(0.2)The scraper only accepts a response whose root is <rss> with a <channel>; if it isn't, the board likely has RSS disabled. Fall back to parsing the job cards on the /jobs HTML page.
Teamtailor's per-tenant JSON API is not publicly accessible. Use the public /jobs.rss feed instead—it returns every job with full descriptions in a single request.
The subdomain is wrong or the tenant no longer exists. Verify the live careers URL (some tenants use numeric-suffix subdomains like inlight-1733094855.teamtailor.com) and build the feed URL from the resolved host.
Locations and departments live in the tt: namespace (https://teamtailor.com/locations). Resolve tt:location/tt:name and tt:department with the full namespace URI, and fall back to tt:city + tt:country when tt:name is absent.
Teamtailor sometimes double-encodes description HTML. Run html.unescape() once, then again if <, >, or & still remain—matching the scraper's two-pass decode.
Don't rebuild the URL from a bare subdomain—regional tenants use hosts like cardioone.na.teamtailor.com. Keep the original scheme + host and append /jobs.rss.
The scraper classifies both as rate limiting or blocking. Slow down (the platform paces requests ~200ms apart), lower concurrency, and retry 429s with exponential backoff.
- 1Source jobs from /jobs.rss—it returns every listing with full descriptions in one request.
- 2Build the feed URL from the board's scheme + host so regional (.na) and numeric-suffix subdomains keep working.
- 3Decode descriptions twice when entities remain—Teamtailor occasionally double-encodes HTML.
- 4Resolve the tt: namespace (https://teamtailor.com/locations) for structured location and department data.
- 5Extract the numeric job ID from /jobs/{id}-{slug} for stable deduplication.
- 6Pace requests (~200ms apart) and back off on 403/429; RSS responses appear cached for a few minutes.
One endpoint. All Teamtailor jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=teamtailor" \
-H "X-Api-Key: YOUR_KEY" Access Teamtailor
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.