All platforms

Personio Jobs API.

Pull an entire company's open roles from a single XML recruiting feed — full HTML descriptions, salary details, and every office location included, with no per-job requests and no authentication.

Get API access
Personio
Live
70K+jobs indexed monthly
<3haverage discovery time
1hrefresh interval
Companies using Personio
MercanisOtonomeeAssecor
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.

What's in every response.

Data fields, real-world applications, and the companies already running on Personio.

Data fields
  • Full HTML Job Descriptions
  • Structured Salary Information
  • Seniority & Years of Experience
  • Department & Recruiting Category
  • Multiple Office Locations
  • Posting Timestamps
Use cases
  1. 01European Job Market Tracking
  2. 02SMB & Scaleup Hiring Monitoring
  3. 03Salary Benchmarking
  4. 04Multi-Language Job Aggregation
Trusted by
MercanisOtonomeeAssecor
DIY GUIDE

How to scrape Personio.

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

RESTbeginnerNo official limit; pace ~200ms between requestsNo auth

Extract the company and TLD from the board URL

Personio boards live at {company}.jobs.personio.{com|de}. The TLD is part of the canonical URL, so derive both the subdomain and the extension from any board or job link before building the feed URL.

Step 1: Extract the company and TLD from the board URL
import re

HOST_RE = re.compile(
    r"(?:https?://)?([^./]+)\.jobs\.personio\.(com|de)",
    re.IGNORECASE,
)

def extract_company(url: str) -> tuple[str, str] | None:
    """Return (company_id, tld) from a Personio board or job URL."""
    match = HOST_RE.search(url)
    if not match:
        return None
    return match.group(1).lower(), match.group(2).lower()

print(extract_company("https://mercanis.jobs.personio.de"))  # ('mercanis', 'de')

Fetch the /xml recruiting feed

The /xml feed returns every open position in one public GET — no auth, no pagination. Prefer it over /search.json: the JSON endpoint returns the same jobs but ships empty description fields for most tenants.

Step 2: Fetch the /xml recruiting feed
import requests

def fetch_feed(company: str, tld: str = "de") -> str:
    """Download the Personio /xml recruiting feed (all open roles, one request)."""
    url = f"https://{company}.jobs.personio.{tld}/xml"
    response = requests.get(url, timeout=30)
    response.raise_for_status()
    return response.text

xml = fetch_feed("mercanis", "de")

Parse the workzag-jobs positions

A valid feed has a <workzag-jobs> root with one <position> element per role. Validate the root element first, then read the flat metadata fields off each position.

Step 3: Parse the workzag-jobs positions
import xml.etree.ElementTree as ET

def parse_positions(xml_text: str) -> list[dict]:
    """Parse the <workzag-jobs> feed into a list of position dicts."""
    root = ET.fromstring(xml_text)
    if root.tag != "workzag-jobs":
        raise ValueError("Unexpected root; expected <workzag-jobs>")

    positions = []
    for pos in root.findall("position"):
        positions.append({
            "id": pos.findtext("id"),
            "title": pos.findtext("name"),
            "office": pos.findtext("office"),
            "department": pos.findtext("department"),
            "recruiting_category": pos.findtext("recruitingCategory"),
            "employment_type": pos.findtext("employmentType"),
            "seniority": pos.findtext("seniority"),
            "schedule": pos.findtext("schedule"),
            "years_of_experience": pos.findtext("yearsOfExperience"),
            "salary_information": pos.findtext("salaryInformation"),
            "keywords": pos.findtext("keywords"),
            "subcompany": pos.findtext("subcompany"),
            "created_at": pos.findtext("createdAt"),
        })
    return positions

jobs = parse_positions(xml)
print(f"{len(jobs)} open roles")

Assemble descriptions, offices, and URLs

Descriptions arrive as named HTML sections (CDATA) under <jobDescriptions>; concatenate them with their headings to keep structure. Combine the primary office with additionalOffices, and build the canonical job and apply URLs.

Step 4: Assemble descriptions, offices, and URLs
def build_record(pos_el, company: str, tld: str) -> dict:
    """Assemble a full job record: description HTML, all offices, canonical URLs."""
    job_id = pos_el.findtext("id")

    # Named HTML sections (e.g. "Your mission"); keep the heading with the body.
    sections = []
    descriptions = pos_el.find("jobDescriptions")
    if descriptions is not None:
        for jd in descriptions.findall("jobDescription"):
            name = (jd.findtext("name") or "").strip()
            value = (jd.findtext("value") or "").strip()
            if not value:
                continue
            sections.append(f"<h3>{name}</h3>{value}" if name else value)

    # Primary office plus any additionalOffices, de-duplicated.
    offices = [(pos_el.findtext("office") or "").strip()]
    extra = pos_el.find("additionalOffices")
    if extra is not None:
        offices += [(o.text or "").strip() for o in extra.findall("office")]
    offices = [o for o in dict.fromkeys(offices) if o]

    job_url = f"https://{company}.jobs.personio.{tld}/job/{job_id}"
    return {
        "id": job_id,
        "title": pos_el.findtext("name"),
        "description_html": "".join(sections),
        "locations": offices,
        "salary": pos_el.findtext("salaryInformation"),
        "listing_url": job_url,
        "apply_url": f"{job_url}?apply",
    }

Map HTTP errors to clear outcomes

Because subdomains rarely match the brand name and boards split across .de and .com, a wrong pair returns 404. Blocking and throttling surface as 403 or 429. Translate status codes so bad targets fail loudly instead of looking empty.

Step 5: Map HTTP errors to clear outcomes
import requests
import xml.etree.ElementTree as ET

def scrape_personio(company: str, tld: str = "de") -> list[dict]:
    """Fetch and parse a Personio board, mapping HTTP errors to clear outcomes."""
    url = f"https://{company}.jobs.personio.{tld}/xml"
    try:
        response = requests.get(url, timeout=30)
        response.raise_for_status()
    except requests.HTTPError as exc:
        status = exc.response.status_code
        if status == 404:
            raise LookupError(f"Personio company '{company}' not found") from exc
        if status in (403, 429):
            raise RuntimeError(f"Rate limited / blocked for '{company}'") from exc
        raise

    root = ET.fromstring(response.text)
    return [build_record(p, company, tld) for p in root.findall("position")]
Common issues
highThe /search.json endpoint returns empty job descriptions.

Use the /xml recruiting feed instead. It carries the full HTML descriptions inline (CDATA), whereas /search.json ships empty description fields for most tenants.

mediumCompany not found (HTTP 404) on the wrong TLD or subdomain.

Boards live on either .de or .com and the subdomain rarely equals the brand name. Derive both from the actual board URL; a 404 means the company/TLD pair is wrong, not that the role list is empty.

mediumFeed parses but yields zero positions.

Assert the root element is <workzag-jobs> before iterating. Redirects and error pages parse into a different root, so a missing root should raise a parse error rather than being read as an empty board.

mediumRequests get blocked or throttled (HTTP 403/429).

The /xml path is disallowed in robots.txt though publicly reachable. Keep a delay between requests (~200ms), scrape one board at a time, and back off on 429.

lowSome positions have missing or blank description sections.

Skip <jobDescription> entries with an empty value and tolerate a missing section heading. Still emit the position from its remaining sections rather than dropping it.

Best practices
  1. 1Fetch the /xml feed, not /search.json — only /xml carries full descriptions
  2. 2Derive the .de or .com TLD from the board URL rather than assuming one
  3. 3Validate the <workzag-jobs> root element before parsing positions
  4. 4Keep ~200ms between requests and cache the feed to avoid redundant re-fetches
  5. 5Concatenate jobDescription sections with their headings to preserve structure
  6. 6Read salaryInformation and yearsOfExperience for richer, benchmarkable records
Or skip the complexity

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

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=personio" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

Access Personio
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.

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