Your cart is currently empty!
How to Scrape Personio Career Sites (2026)
Personio career sites are a goldmine for recruiting intelligence and job market analysis — and because Personio powers HR for thousands of European SMBs, scraping their public job boards gives you structured hiring data that isn’t reliably indexed on LinkedIn or Indeed. This guide covers how to scrape Personio career sites in 2026, including the two main URL patterns, anti-bot posture, and a working Python approach.
How Personio Career Pages Are Structured
Personio hosts career pages on two URL patterns:
https://{company}.jobs.personio.de/(German-hosted, common for EU companies)https://{company}.jobs.personio.com/(global variant)
Some companies embed the widget on their own domain via an iframe or JavaScript snippet, but the underlying data still comes from Personio’s API. Each job listing URL follows the pattern /job/{id}, and the index page loads a full JSON payload on the client side — which is your primary extraction target.
The job listing data is injected into the page as a window.__NUXT__ server-side rendered object, or in newer deployments, fetched from a JSON API endpoint at https://{company}.jobs.personio.com/api/v1/jobs (no auth required). That API endpoint is the clean path — you bypass HTML parsing entirely.
Extracting the JSON API Directly
The undocumented but stable endpoint returns a JSON array of all open roles:
import httpx
import json
COMPANY = "yourcompany"
BASE = f"https://{COMPANY}.jobs.personio.com"
def fetch_jobs():
r = httpx.get(
f"{BASE}/api/v1/jobs",
headers={"Accept": "application/json", "Accept-Language": "en"},
timeout=15,
)
r.raise_for_status()
return r.json() # list of job dicts
jobs = fetch_jobs()
for job in jobs:
print(job["id"], job["name"], job.get("department", {}).get("name"))
Each job object contains id, name, department, office, employment_type, created_at, and a job_descriptions array with HTML content blocks. For full JD text, you’ll need a second call to /job/{id} or parse the job_descriptions key already in the response.
If the /api/v1/jobs endpoint returns 404, fall back to scraping the rendered HTML and extracting the JSON blob, then parsing the nested state tree.
Anti-Bot Posture and Rate Limits
Personio's job pages are relatively permissive compared to enterprise ATS platforms. There's no Cloudflare challenge on most subdomains, and Akamai is not present. That said, a few behaviors to watch for:
- Rate limiting: Aggressive crawling (more than ~30 req/min) triggers 429s from their CDN. Space requests with a 2-4 second jitter.
- User-Agent checks: The bare
python-httpxUA gets blocked on some subdomains. Use a realistic browser UA string. - Geo-blocking: Some Personio customers restrict their career page to specific regions. If you're scraping a German Mittelstand company from a US IP, you may get a redirect or empty results.
For the geo issue, residential proxies from a German or European IP pool resolve it cleanly. This is the same pattern used when scraping ATS platforms like SmartRecruiters hiring pages -- the target's CDN sees a local visitor rather than a datacenter range.
| Issue | Symptom | Fix |
|---|---|---|
| 429 Too Many Requests | Burst of requests blocked | Add jitter (2-4s), reduce concurrency |
| Empty JSON array | [] response but page shows jobs |
Switch Accept-Language header to de |
404 on /api/v1/jobs |
Older Personio tenant | Parse __NUXT_DATA__ from HTML |
| Geo-block redirect | 302 to /not-available |
Use EU residential proxy |
| iframe embed | Jobs on company domain, not Personio | Trace network tab for Personio API origin |
Parsing Job Detail Pages
If you need structured job descriptions (not just titles), here's the hierarchy to expect inside job_descriptions:
- Each object has a
namefield (section heading like "Your Role", "Requirements") - The
valuefield contains raw HTML - Nested
job_descriptionsarrays appear for grouped sections
A clean parse using BeautifulSoup:
from bs4 import BeautifulSoup
def extract_text_blocks(job: dict) -> dict:
sections = {}
for block in job.get("job_descriptions", []):
heading = block.get("name", "body")
html = block.get("value", "")
text = BeautifulSoup(html, "html.parser").get_text(separator="\n").strip()
sections[heading] = text
return sections
This is cleaner than scraping the rendered page and avoids the flaky CSS selectors that break when Personio updates their frontend. Similar structured-extraction approaches work well for Recruitee job pages, which also expose a JSON-first data layer.
Scaling Across Multiple Companies
If you're building a broader job market dataset -- tracking hiring velocity, team growth, or competitive intelligence -- you'll need to crawl many Personio tenants, not just one.
The main challenge is discovery. There's no public Personio company directory. Practical sourcing approaches:
- Search
site:jobs.personio.deorsite:jobs.personio.comon Google to surface active tenants - Pull from LinkedIn company pages (many link directly to their Personio career site)
- Use Crunchbase or Apollo to filter EU SMBs, then probe the Personio subdomain pattern
Once you have a list of subdomains, the /api/v1/jobs call is identical across all tenants. A simple async crawler with httpx.AsyncClient and a semaphore of 5-10 concurrent workers handles hundreds of companies per hour without triggering rate limits.
For pipelines that need to stay current, schedule daily or weekly crawls and diff against your previous snapshot. New created_at timestamps flag fresh postings; disappearing job IDs signal closed roles. Platforms like Ashby career sites use a similar versioned-listing model, making diff-based freshness detection a reusable pattern across ATS targets.
If your use case extends beyond job data into broader HR intelligence -- org structure signals, headcount trends, or location expansion -- consider pairing Personio data with iCIMS career site scrapes to cover the mid-market US segment that Personio doesn't reach.
One note on scope: Personio job data is B2B recruiting intelligence. If your pipeline is pivoting toward real estate or property listings across emerging markets, the extraction patterns here generalize, though the tooling differs -- DRT has separate coverage on scraping Latin American real estate sites like Imovelweb and Mercado Libre for those use cases.
Handling Edge Cases
A few Personio-specific quirks that'll bite you in production:
- Multilingual listings: A job may have separate entries for
deandenlanguage variants. They share the same base ID but differ by locale parameter. Filter by?language=enquery param on the API call. - Department normalization: Department names are free-text set by the employer. "Engineering", "Tech", "R&D", and "Product & Engineering" all need to be mapped in your downstream schema.
- Employment type inconsistency: Some tenants use "Full-time" vs "Vollzeit" vs custom strings. Normalize to a canonical enum before storing.
- Deleted vs. filled roles: Personio removes filled roles from the index immediately. If you need historical data, snapshot on every crawl.
Bottom Line
Personio's /api/v1/jobs endpoint is the right entry point -- skip the HTML, go straight to the JSON, add a realistic UA and EU residential proxy for geo-sensitive tenants, and run async crawls with conservative concurrency. The data is clean, consistently structured, and requires no authentication. For broader ATS coverage or freshness-detection patterns across European hiring markets, dataresearchtools.com covers the full stack of platforms engineers actually encounter in production pipelines.
Leave a Reply