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.

Get API access

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

  1. 01European Job Aggregation
  2. 02SMB Careers Page Monitoring
  3. 03Recruitment Marketing Research
  4. 04ATS Data Pipelines

Trusted by

  • HTEC Group
  • M Plus
  • Barrage
  • DEKRA Croatia
  • PXP Financial
  • Sched
DIY GUIDE

How to scrape TalentLyft.

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

API type
HTML
Difficulty
advanced
Rate limit
No published limit; ~200ms between requests, max 3 concurrent detail fetches
Authentication
No auth

Resolve an /o share link to its canonical job route

TalentLyft shares jobs as opaque /o/{token} routes. The token is case-sensitive, may carry an underscore plus a location suffix, and is not the native job ID. Follow the redirect to reach the canonical /jobs/{slug} document that actually carries identity.

Step 1: Resolve an /o share link to its canonical job route
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"))

Read the board configuration from window.tlApp

Every TalentLyft page publishes its own configuration in two globals. window.tlApp carries the numeric jobId, numeric websiteId, jobSectionId and native subdomain; window.initialData carries themeId and language. The layoutId is always one of Jobs-1, Jobs-2 or Jobs-3.

Step 2: Read the board configuration from window.tlApp
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)

Page the JobList fragment

TalentLyft's public dynamicLoader.js defines a provider-owned /JobList request that returns an HTML fragment. It lives on the tenant origin even when the board carries a multi-site prefix, so pass the exact scoped board as websiteUrl. A page size of 100 works in production.

Step 3: Page the JobList fragment
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")

Parse modern and legacy rows, then follow data-page

Modern boards render a.jobs__box rows and legacy boards render a.job rows, so support both. Pagination authority is the set of data-page values in the fragment: keep requesting while the next page number is advertised, and stop when it is not.

Step 4: Parse modern and legacy rows, then follow data-page
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")

Merge duplicate locations and read details from the job document

The same numeric job appears once per location, so merge rows only when the ID and title both agree and union the locations. Then fetch the canonical job document, which carries the complete description and the embedded application action in both the modern and legacy templates.

Step 5: Merge duplicate locations and read details from the job document
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)
Common issues
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.
Best practices
  1. 1Read layoutId, themeId, language, subdomain and websiteId from the board page itself
  2. 2Pass the exact scoped board URL as websiteUrl so multi-site prefixes resolve
  3. 3Request pageSize=100 and drive pagination from the fragment's data-page values
  4. 4Support both a.jobs__box (modern) and a.job (legacy) row markup
  5. 5Merge duplicate rows on job ID plus title and union their locations
  6. 6Treat 'No open positions available' as an authoritative empty board, not an error
Or skip the complexity

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

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

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