- criticalTwo boards collapse into one company
- Factorial reuses company labels across its regional fleets, so 22dogs.factorial.it and a same-named board on factorialhr.de are unrelated employers. Key every record on the label plus the regional domain, or one country's board will repeatedly expire the other's jobs.
- highThe multi-part domains are matched incorrectly
- factorialhr.com.br, factorialhr.co.uk and factorialhr.com.de all end with a shorter valid domain, so a first-match suffix test slices the tenant in the wrong place. Always pick the longest matching suffix from the allow-list before splitting off the company label.
- highThere is no JSON-LD to read
- Unlike most European ATS boards, Factorial publishes no JobPosting structured data and no anonymous detail API. Parse the canonical page: the heading class for the title and the styledText block for the description. Anything expecting JSON-LD returns nothing on every job.
- mediumDetail URLs vary by tracking query
- The data-job-postings-url attribute can carry filter or tracking parameters that differ between renders, so storing it verbatim creates duplicate records for one vacancy. Strip the query and fragment, and key the job on the trailing numeric ID of the slug.
Factorial Jobs API.
Factorial is a European HR suite whose ATS publishes careers pages at {company}.factorial.es and twenty-odd sibling regional domains. Every board renders its complete vacancy list server-side.
What's in every response.
Data fields, real-world applications, and the companies already running on Factorial.
Data fields
- Full Job Descriptions
- Contract Type
- Remote Flag
- Team & Location IDs
- Complete Server-Rendered List
- Multi-Region Domains
Use cases
- 01European Job Aggregation
- 02SMB Hiring Trackers
- 03Multi-Country Talent Research
- 04Careers Page Monitoring
Trusted by
- Aldesa
- 24H Assistance
- Agrotools
How to scrape Factorial.
Step-by-step guide to extracting jobs from Factorial-powered career pages—endpoints, authentication, and working code.
import re
from urllib.parse import urlparse, unquote
# The regional first-party domains Factorial's own public boards are served from.
BOARD_DOMAINS = [
"factorial.ae", "factorial.be", "factorial.ch", "factorial.co",
"factorial.com", "factorial.es", "factorial.fr", "factorial.it",
"factorial.ke", "factorial.mx", "factorial.pl", "factorial.rs",
"factorialhr.ar", "factorialhr.cl", "factorialhr.co", "factorialhr.co.uk",
"factorialhr.com", "factorialhr.com.ar", "factorialhr.com.br",
"factorialhr.com.de", "factorialhr.de", "factorialhr.pt",
]
RESERVED = {"api", "app", "assets", "help", "support", "www"}
TENANT = re.compile("^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", re.IGNORECASE)
def parse_factorial(url: str) -> dict | None:
parsed = urlparse(url)
host = parsed.netloc.lower().rstrip(".")
# Longest suffix wins, so factorialhr.com.br beats factorialhr.com.
domain = max((d for d in BOARD_DOMAINS if host.endswith("." + d)),
key=len, default=None)
if not domain:
return None
tenant = host[: -(len(domain) + 1)]
if "." in tenant or tenant in RESERVED or not TENANT.match(tenant):
return None
segments = [s for s in parsed.path.split("/") if s]
job_id = None
if segments:
if len(segments) != 2 or segments[0] != "job_posting":
return None
tail = unquote(segments[1]).rsplit("-", 1)[-1]
if not tail.isdigit():
return None
job_id = tail
return {"tenant": tenant, "domain": domain,
"board_url": f"https://{tenant}.{domain}", "job_id": job_id}
print(parse_factorial("https://22dogs.factorial.it/job_posting/junior-vfx-recruiter-310433"))import requests
FINGERPRINT = "assets.factorialhr.com/ats/"
def fetch_board(session, board_url: str) -> str:
resp = session.get(board_url, headers={"Accept": "text/html"}, timeout=30)
resp.raise_for_status()
if FINGERPRINT not in resp.text:
raise RuntimeError("page did not carry the Factorial ATS fingerprint")
return resp.text
session = requests.Session()
html = fetch_board(session, "https://aldesa.factorial.es")from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlsplit, urlunsplit
def canonical(url: str) -> str:
parts = urlsplit(url)
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
def parse_rows(html: str, board_url: str) -> list[dict]:
soup = BeautifulSoup(html, "html.parser")
rows, seen = [], set()
for item in soup.select("li[data-job-postings-url]"):
raw = item.get("data-job-postings-url") or ""
detail_url = canonical(urljoin(board_url, raw))
identity = parse_factorial(detail_url)
if not identity or not identity["job_id"]:
continue
if identity["job_id"] in seen:
continue
heading = item.select_one(".factorial__headingFontFamily")
seen.add(identity["job_id"])
rows.append({
"id": identity["job_id"],
"title": (" ".join(heading.get_text().split()) if heading else None),
"url": detail_url,
"team_id": item.get("data-team-id"),
"location_id": item.get("data-location-id"),
"contract_type": item.get("data-contract-type"),
"remote": item.get("data-remote"),
})
return rows
listings = parse_rows(html, "https://aldesa.factorial.es")
print(f"{len(listings)} open vacancies")import time
def fetch_detail(session, listing: dict) -> dict | None:
resp = session.get(listing["url"], headers={"Accept": "text/html"}, timeout=30)
if resp.status_code in (404, 410):
return None # vacancy removed
resp.raise_for_status()
if FINGERPRINT not in resp.text:
return None
soup = BeautifulSoup(resp.text, "html.parser")
heading = soup.select_one("h1.factorial__headingFontFamily")
body = soup.select_one(".styledText")
if not heading or not body:
return None
logo = soup.select_one("a[href='/'] img[alt]")
return {
"id": listing["id"],
"title": " ".join(heading.get_text().split()),
"description_html": body.decode_contents().strip(),
"company": logo.get("alt") if logo else None,
"contract_type": listing.get("contract_type"),
"remote": listing.get("remote"),
"url": listing["url"],
"apply_url": listing["url"],
}
for listing in listings[:3]:
print(fetch_detail(session, listing))
time.sleep(0.15)- 1Key boards on the company label plus the regional domain, never the label alone
- 2Match the longest domain suffix so factorialhr.com.br is not read as factorialhr.com
- 3Require the assets.factorialhr.com/ats/ fingerprint before parsing a page
- 4Take the whole inventory from the board root — there is no pagination to follow
- 5Strip the query and fragment from data-job-postings-url before storing it
- 6Key the vacancy on the trailing numeric ID of the /job_posting/ slug
One endpoint. All Factorial jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=factorial" \
-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 Factorial
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.