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.

Get API access

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

  1. 01European Job Aggregation
  2. 02SMB Hiring Trackers
  3. 03Multi-Country Talent Research
  4. 04Careers Page Monitoring

Trusted by

  • Aldesa
  • 24H Assistance
  • Agrotools
DIY GUIDE

How to scrape Factorial.

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

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

Resolve the tenant and its regional domain

Factorial runs one careers fleet per country, so the board key is the company label plus the regional domain it lives on. The same label on factorial.es and factorialhr.de is two different employers. Vacancy URLs are /job_posting/{slug}-{numericId}.

Step 1: Resolve the tenant and its regional domain
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"))

Fetch the board and confirm it is Factorial

The board root is the complete vacancy collection — Factorial's own JavaScript only filters and navigates markup that is already there, so there is nothing to wait for and nothing to paginate. Require the shared ATS asset fingerprint before parsing, so a parked domain cannot masquerade as a board.

Step 2: Fetch the board and confirm it is Factorial
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")

Parse the vacancy rows

Each vacancy is a list item carrying a data-job-postings-url attribute with its canonical detail URL. The heading class holds the title, and the row also exposes the contract type, remote flag and Factorial's internal team and location IDs. Strip any query string from the detail URL.

Step 3: Parse the vacancy rows
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")

Read the vacancy page

Factorial publishes no JobPosting JSON-LD, so the canonical page markup is the record. The title is the page heading in the same font-family class and the body lives in the styledText block, which holds the employer's rich-text description.

Step 4: Read the vacancy page
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)
Common issues
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.
Best practices
  1. 1Key boards on the company label plus the regional domain, never the label alone
  2. 2Match the longest domain suffix so factorialhr.com.br is not read as factorialhr.com
  3. 3Require the assets.factorialhr.com/ats/ fingerprint before parsing a page
  4. 4Take the whole inventory from the board root — there is no pagination to follow
  5. 5Strip the query and fragment from data-job-postings-url before storing it
  6. 6Key the vacancy on the trailing numeric ID of the /job_posting/ slug
Or skip the complexity

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

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

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