Personio Jobs Scraper: German Mittelstand Openings
Pricing
from $0.74 / 1,000 job scrapeds
Personio Jobs Scraper: German Mittelstand Openings
Parse Personio job XML feeds into structured records: title, department, office, employment type, seniority, schedule and full description. Strong DACH coverage, no login.
Pricing
from $0.74 / 1,000 job scrapeds
Rating
0.0
(0)
Developer
Arman Hossain
Maintained by CommunityActor stats
0
Bookmarked
1
Total users
0
Monthly active users
2 days ago
Last modified
Categories
Share

Personio Jobs Scraper pulls every open role from any Personio-hosted job board, title, office, department, employment type, seniority, schedule, salary band where published, the section-by-section description and the direct apply URL.
Personio is the ATS behind a large slice of the German Mittelstand, and every tenant exposes a public XML feed. This Actor reads that feed directly: no browser, no proxies, no login, no rate-limit games. A board with 317 roles returns in one request.
Agent skill: SKILL.md
https://api.apify.com/v2/key-value-stores/t7YoTxpZEJOWvw4Ug/records/personio-jobs-scraper.md
What you get
| Output field | Meaning |
|---|---|
company, subcompany | Subdomain used, and the legal entity the role sits under |
jobId, title | Personio position ID and the job title |
office, additionalOffices | Primary location and every other office the role is open in |
department, recruitingCategory | The company's own department and recruiting-category labels |
employmentType, schedule, seniority, yearsOfExperience | permanent/intern/trainee, full-time/full-or-part-time, entry-level/experienced, lt-1/2-5… |
occupation, occupationCategory, keywords | Personio's normalised job taxonomy and the company's search keywords |
salaryMin, salaryMax, salaryCurrency, salaryPeriod | Structured salary band, populated only where the company published one |
descriptionSections | The description as {name, text} blocks, keeping Personio's own headings |
descriptionPlain | The same content flattened to one plain-text field |
createdAt, applyUrl | Posting timestamp and the direct apply link |
scrapedAt | Run timestamp |
A RUN_SUMMARY record in the key-value store holds per-run counts, the filters used, and any board that failed.
Common use cases
- DACH hiring intelligence. Personio boards are where German SMEs post first, long before the aggregators pick them up.
- Job aggregators and boards. Ingest hundreds of company feeds on a schedule.
- Lead generation. A Mittelstand company hiring for a role is a company buying for it.
- Labour-market analysis.
seniority,scheduleandoccupationCategoryare normalised across every tenant, so they aggregate cleanly. - Personal job hunting. Watch a shortlist of companies and filter to what you actually want.
Quick start
Two boards, everything they have:
{"companies": ["circus", "wandelbots"]}
Remote and Hamburg tech roles, in English, capped:
{"companies": ["1komma5grad", "https://circus.jobs.personio.de"],"searchTerms": ["engineer", "entwickler"],"locationFilter": ["hamburg", "remote"],"departmentFilter": ["tech"],"maxJobsPerCompany": 50,"language": "en"}
Fast, lightweight sweep for change detection:
{"companies": ["1komma5grad"],"includeDescription": false}
Input
| Field | Type | Default | Notes |
|---|---|---|---|
companies | array | - | Required. Subdomains (circus) or full board URLs. Mixed input is fine, URLs are normalised to subdomains automatically, .de and .com alike. |
searchTerms | array | [] | Keep only titles containing one of these terms (case-insensitive). Empty = all. |
locationFilter | array | [] | Keep only jobs whose primary or additional offices contain one of these terms. Empty = all. |
departmentFilter | array | [] | Keep only jobs whose department or recruiting category matches one of these terms. Empty = all. |
maxJobsPerCompany | integer | 0 | Cap saved jobs per company after filtering. 0 = no limit. |
includeDescription | boolean | true | Include descriptionSections and descriptionPlain. Turn off for a much smaller, faster dataset. |
language | string | "" | Two-letter code appended as ?language=. en gives English text where the company published a translation. Empty = the board's default. |
domain | string | de | jobs.personio.de or jobs.personio.com. Both serve the same feed. |
All three filters are independent and combine with AND: a job must match at least one term in each non-empty filter.
Output example
A real record from a live run against circus (descriptionSections and descriptionPlain abridged):
{"company": "circus","subcompany": "Circus SE","jobId": 2726425,"title": "(Senior) Financial Accountant / Bilanzbuchhalter:in (f/m/d)","office": "Munich","additionalOffices": [],"department": "Finance & Legal","recruitingCategory": "HQ","employmentType": "permanent","seniority": "experienced","schedule": "full-or-part-time","yearsOfExperience": "2-5","occupation": "bookkeeping","occupationCategory": "accounting_and_finance","keywords": ["accounting", "bilanzbuchhalter", "buchhaltung", "financial accountant"],"salaryMin": null,"salaryMax": null,"salaryCurrency": null,"salaryPeriod": null,"createdAt": "2026-07-23T16:58:07+00:00","descriptionSections": [{ "name": "Our Mission", "text": "At Circus (Xetra: CA1), headquartered in Munich. We are pioneering…" },{ "name": "About the Role", "text": "We are looking for a motivated, professional Financial Accountant…" },{ "name": "Your Daily Business", "text": "• Independent management of financial accounting…" },{ "name": "Our Offer", "text": "• Competitive Compensation: Competitive compensation package with stock options…" }],"descriptionPlain": "Our Mission\n\nAt Circus (Xetra: CA1), headquartered in Munich. We are pioneering…","applyUrl": "https://circus.jobs.personio.de/job/2726425","scrapedAt": "2026-08-06T12:03:13.185Z"}
A role at a multi-site company carries every location it is open in:
{"title": "(Junior) Data Engineer - Data Platform (m/f/d)","office": "Remote","additionalOffices": ["Hamburg", "Berlin", "München"],"department": "Tech","applyUrl": "https://1komma5grad.jobs.personio.de/job/2731150?language=en"}
Where a company fills in Personio's salary fields, they come through structured rather than buried in prose:
{"salaryMin": 45000,"salaryMax": 55000,"salaryCurrency": "EUR","salaryPeriod": "yearly"}
RUN_SUMMARY
{"companiesRequested": 5,"companiesFailed": 1,"failures": [{ "company": "zzzqqqnotarealcompany", "error": "no Personio board at \"zzzqqqnotarealcompany\", the request hit the wildcard bot challenge, which is what an unknown subdomain gets" }],"boardsWithNoOpenRoles": ["moss"],"jobsListed": 20,"jobsSaved": 9,"filters": { "searchTerms": [], "locationFilter": [], "departmentFilter": [], "maxJobsPerCompany": 3, "includeDescription": true, "language": null, "domain": "jobs.personio.de" },"finishedAt": "2026-08-06T12:03:13.190Z"}
boardsWithNoOpenRoles is deliberately separate from failures: a company with an empty feed is hiring nobody today, which is data, not an error.
Finding a company subdomain
Open a company's careers page and look at the URL:
| URL you see | Subdomain |
|---|---|
circus.jobs.personio.de/… | circus |
circus.jobs.personio.com/… | circus |
1komma5grad.jobs.personio.de/job/2731150 | 1komma5grad |
You can paste the whole URL, the Actor extracts the subdomain itself. If a company's careers page is on its own domain, view source and search for personio; the subdomain is almost always in an iframe or fetch URL. The feed itself is always at https://{subdomain}.jobs.personio.de/xml, so you can confirm a guess in a browser before running anything.
API example
curl -X POST "https://api.apify.com/v2/acts/arman-bd~personio-jobs-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN" \-H "Content-Type: application/json" \-d '{"companies": ["1komma5grad"],"departmentFilter": ["tech"],"language": "en","maxJobsPerCompany": 25}'
JavaScript example
import { ApifyClient } from 'apify-client';const client = new ApifyClient({ token: 'YOUR_TOKEN' });const run = await client.actor('arman-bd/personio-jobs-scraper').call({companies: ['circus', 'wandelbots'],locationFilter: ['berlin', 'remote'],});const { items } = await client.dataset(run.defaultDatasetId).listItems();for (const job of items) console.log(`${job.company}, ${job.title} (${job.office})`);
Limits and behaviour
- One request per company. The feed is not paginated, every open role arrives in a single response. A 317-role board is about 2.3 MB.
- XML, parsed without a dependency. The feed is
text/xmlwith CDATA-wrapped HTML descriptions. It is read with scoped regex readers scoped to one<position>at a time, not a general parser. - Two element names repeat at different depths.
<name>is both the job title and every description section's heading;<office>is both the primary location and each entry under<additionalOffices>. The parser peels the nested blocks off before reading any scalar, so neither can be confused for the other. - An unknown subdomain answers 429, not 404. Personio's wildcard host serves a bot challenge to subdomains that do not exist. The Actor tells that apart from real throttling by content type and reports it as "no Personio board at …" rather than retrying pointlessly.
- Empty feeds are not failures.
<workzag-jobs></workzag-jobs>means the company has nothing open. It appears inRUN_SUMMARY.boardsWithNoOpenRolesand the run continues. - Bad boards don't kill the run. The Actor only errors out if every company fails.
- Transient errors are retried. 429 and 5xx get three attempts with linear backoff.
- Language is best-effort.
language=enreturns English where the company translated the posting and the original German where it did not. The Actor does not translate anything itself. - Public data only. No authentication, no personal data, no access-control bypass.
FAQ
Do I need a Personio API key? No. You supply no credentials.
Does it include salary? Yes, when the company fills in Personio's salary fields, salaryMin, salaryMax, salaryCurrency and salaryPeriod come through structured. Most boards leave them empty, and this Actor does not guess.
How many companies can I pass at once? There is no hard cap. Boards are fetched sequentially and each is one request, so hundreds per run is normal.
Why did a board return nothing? Either the company has no open roles, check boardsWithNoOpenRoles, or your filters excluded everything. RUN_SUMMARY reports listed-vs-matched-vs-saved counts per board, which distinguishes the two immediately.
Why is department empty on some jobs? Because the company did not set one. recruitingCategory is often populated where department is not, and departmentFilter searches both.
What do seniority and yearsOfExperience contain? Personio's own enumerations: entry-level, experienced, senior, lead, student, intern; and lt-1, 1-2, 2-5, 5-7, 7-10, gt-10. They are consistent across every tenant, which makes them the most reliable fields for cross-company analysis.
Can I get only new or changed jobs? Run on a schedule and diff on jobId plus createdAt. Setting includeDescription: false makes those sweeps much cheaper.
Can I integrate it with something else? Yes, Apify API, client libraries, webhooks, scheduled runs, dataset exports (JSON/CSV/Excel) or MCP. Output is structured JSON.