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.
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.
- Full HTML Job Descriptions
- Structured Salary Information
- Seniority & Years of Experience
- Department & Recruiting Category
- Multiple Office Locations
- Posting Timestamps
- 01European Job Market Tracking
- 02SMB & Scaleup Hiring Monitoring
- 03Salary Benchmarking
- 04Multi-Language Job Aggregation
How to scrape Personio.
Step-by-step guide to extracting jobs from Personio-powered career pages—endpoints, authentication, and working code.
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')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")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")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",
}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")]Use the /xml recruiting feed instead. It carries the full HTML descriptions inline (CDATA), whereas /search.json ships empty description fields for most tenants.
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.
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.
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.
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.
- 1Fetch the /xml feed, not /search.json — only /xml carries full descriptions
- 2Derive the .de or .com TLD from the board URL rather than assuming one
- 3Validate the <workzag-jobs> root element before parsing positions
- 4Keep ~200ms between requests and cache the feed to avoid redundant re-fetches
- 5Concatenate jobDescription sections with their headings to preserve structure
- 6Read salaryInformation and yearsOfExperience for richer, benchmarkable records
One endpoint. All Personio jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=personio" \
-H "X-Api-Key: YOUR_KEY" 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.