- highWhy does an /o share link not give me the job ID?
- The /o token is an opaque, case-sensitive share key that can carry an underscore and a location suffix. It is not the native job ID. Follow the redirect to the canonical /jobs/{slug} document and read the numeric jobId from window.tlApp instead.
- highWhy does the same job appear several times in one board?
- TalentLyft publishes one row per location for a multi-location job. Merge rows only when the numeric job ID and the title both agree, union the locations onto the surviving row, and count the extras as duplicates rather than dropping them silently.
- highDoes a redirect back to the board mean the job was removed?
- No. A retired posting commonly 302s to the company board, which is a parse failure, not removal evidence. Only a canonical 404 or 410, or a rendered closed-application state, should expire a job; treating the redirect as removal deletes live postings.
- mediumHow do I scrape a TalentLyft board on a custom domain?
- Custom domains work exactly the same way, but their identity has to come from the page. Read the native subdomain and numeric websiteId from window.tlApp and window.initialData, and key the company on that pair so a custom host and its native subdomain never split into two employers.
- mediumWhy does JobList return nothing for a tenant with a site prefix?
- Multi-site tenants such as an /emploi or /kota prefix still serve JobList from the tenant origin, but the websiteUrl parameter must carry the exact scoped board including the prefix. Sending the bare origin returns the default site's jobs or nothing at all.
TalentLyft Jobs API.
Extract postings from any TalentLyft career site — native subdomains, custom domains, and multi-site tenants alike — through the paginated JobList fragment its own loader script calls.
What's in every response.
Data fields, real-world applications, and the companies already running on TalentLyft.
Data fields
- Full Job Descriptions
- Numeric Job & Website IDs
- Custom Domain Boards
- Multi-Site Tenants
- Modern & Legacy Templates
- Per-Location Job Rows
Use cases
- 01European Job Aggregation
- 02SMB Careers Page Monitoring
- 03Recruitment Marketing Research
- 04ATS Data Pipelines
Trusted by
- HTEC Group
- M Plus
- Barrage
- DEKRA Croatia
- PXP Financial
- Sched
How to scrape TalentLyft.
Step-by-step guide to extracting jobs from TalentLyft-powered career pages—endpoints, authentication, and working code.
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",
})
def canonical_job_url(share_url: str) -> str:
resp = session.get(share_url, timeout=30, allow_redirects=True)
resp.raise_for_status()
# /o/{token} -> https://{tenant}.talentlyft.com[/{site}]/jobs/{slug}
if "/jobs/" not in resp.url:
raise RuntimeError("share link did not resolve to a canonical job route")
return resp.url
print(canonical_job_url("https://m-plus.talentlyft.com/o/cjPXagN"))import json
import re
def read_window_object(html: str, name: str) -> dict:
match = re.search(rf"window\.{name}\s*=\s*(\{{.*?\}});", html, re.S)
if not match:
raise RuntimeError(f"page did not publish window.{name}")
# The objects use JS literal syntax; normalise the quoted keys you need.
return match.group(1)
LAYOUT = re.compile(r"\blayoutId\s*:\s*'(?P<value>Jobs-[1-3])'")
def board_proof(board_url: str) -> dict:
resp = session.get(board_url, timeout=30)
resp.raise_for_status()
app = read_window_object(resp.text, "tlApp")
initial = read_window_object(resp.text, "initialData")
def quoted(source: str, key: str) -> str | None:
found = re.search(rf"['\"]?{key}['\"]?\s*:\s*['\"](?P<value>[^'\"]+)", source)
return found.group("value") if found else None
layout = LAYOUT.search(resp.text)
return {
"board_url": resp.url.rstrip("/"),
"tenant": quoted(app, "subdomain"),
"website_id": quoted(app, "websiteId"),
"theme_id": quoted(initial, "themeId"),
"language": quoted(initial, "language") or "en",
"layout_id": layout.group("value") if layout else None,
}
board = board_proof("https://m-plus.talentlyft.com")
print(board)from urllib.parse import urlencode, urlparse
PAGE_SIZE = 100
def joblist_url(board: dict, page: int) -> str:
origin = "{0}://{1}".format(*urlparse(board["board_url"])[:2])
params = {
"layoutId": board["layout_id"],
"websiteUrl": board["board_url"], # carries the multi-site prefix
"themeId": board["theme_id"],
"language": board["language"],
"subdomain": board["tenant"],
"page": page,
"pageSize": PAGE_SIZE,
"contains": "",
}
return f"{origin}/JobList?" + urlencode(params)
fragment = session.get(joblist_url(board, 1), timeout=30)
fragment.raise_for_status()
print(len(fragment.text), "bytes of fragment")from bs4 import BeautifulSoup
from urllib.parse import urljoin
def parse_fragment(board: dict, html: str) -> tuple[list[dict], set[int]]:
soup = BeautifulSoup(html, "html.parser")
rows = []
for anchor in soup.select("a.jobs__box[href], a.job[href]"):
href = anchor.get("href")
url = urljoin(board["board_url"] + "/", href)
if "/jobs/" not in url:
continue
rows.append({
"listing_url": url,
"title": (anchor.select_one(".jobs__box__heading, .name") or anchor).get_text(strip=True),
"location": (anchor.select_one(".jobs__box__text").get_text(strip=True)
if anchor.select_one(".jobs__box__text") else None),
})
pages = {int(el["data-page"]) for el in soup.select("[data-page]")
if el.get("data-page", "").isdigit()}
return rows, pages
def crawl(board: dict) -> list[dict]:
collected, page = [], 1
while True:
resp = session.get(joblist_url(board, page), timeout=30)
resp.raise_for_status()
if "No open positions available" in resp.text:
return [] # authoritative empty board, not a failure
rows, pages = parse_fragment(board, resp.text)
collected.extend(rows)
if page + 1 not in pages:
return collected
page += 1
listings = crawl(board)
print(f"{len(listings)} rows")import time
from collections import OrderedDict
def merge_by_job(rows: list[dict]) -> list[dict]:
merged: OrderedDict[tuple, dict] = OrderedDict()
for row in rows:
native_id = row["listing_url"].rsplit("/", 1)[-1]
key = (native_id, row["title"])
if key in merged:
merged[key]["locations"].append(row["location"])
else:
merged[key] = {**row, "job_id": native_id, "locations": [row["location"]]}
return list(merged.values())
def fetch_detail(job: dict) -> dict | None:
resp = session.get(job["listing_url"], timeout=30, allow_redirects=False)
if resp.status_code in (404, 410):
return None # canonical removal
if resp.status_code in (301, 302, 303, 307, 308):
# A retired job redirects back to the board. That is a parse failure,
# NOT removal evidence — do not expire the job on it.
raise RuntimeError("retired-job redirect to the board")
resp.raise_for_status()
page = BeautifulSoup(resp.text, "html.parser")
body = page.select_one(".job-description, .jobs__single__description, article")
return {**job, "description_html": body.decode_contents() if body else None}
for job in merge_by_job(listings)[:3]:
detail = fetch_detail(job)
print(job["title"], "->", job["locations"])
time.sleep(0.2)- 1Read layoutId, themeId, language, subdomain and websiteId from the board page itself
- 2Pass the exact scoped board URL as websiteUrl so multi-site prefixes resolve
- 3Request pageSize=100 and drive pagination from the fragment's data-page values
- 4Support both a.jobs__box (modern) and a.job (legacy) row markup
- 5Merge duplicate rows on job ID plus title and union their locations
- 6Treat 'No open positions available' as an authoritative empty board, not an error
One endpoint. All TalentLyft jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=talentlyft" \
-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 TalentLyft
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.