Personio Jobs Scraper: German Mittelstand Openings avatar

Personio Jobs Scraper: German Mittelstand Openings

Pricing

from $0.74 / 1,000 job scrapeds

Go to Apify Store
Personio Jobs Scraper: German Mittelstand Openings

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

Arman Hossain

Maintained by Community

Actor stats

0

Bookmarked

1

Total users

0

Monthly active users

2 days ago

Last modified

Share

Personio Jobs: German-market postings with salary range, seniority and years of experience

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 fieldMeaning
company, subcompanySubdomain used, and the legal entity the role sits under
jobId, titlePersonio position ID and the job title
office, additionalOfficesPrimary location and every other office the role is open in
department, recruitingCategoryThe company's own department and recruiting-category labels
employmentType, schedule, seniority, yearsOfExperiencepermanent/intern/trainee, full-time/full-or-part-time, entry-level/experienced, lt-1/2-5
occupation, occupationCategory, keywordsPersonio's normalised job taxonomy and the company's search keywords
salaryMin, salaryMax, salaryCurrency, salaryPeriodStructured salary band, populated only where the company published one
descriptionSectionsThe description as {name, text} blocks, keeping Personio's own headings
descriptionPlainThe same content flattened to one plain-text field
createdAt, applyUrlPosting timestamp and the direct apply link
scrapedAtRun 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, schedule and occupationCategory are 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

FieldTypeDefaultNotes
companiesarray-Required. Subdomains (circus) or full board URLs. Mixed input is fine, URLs are normalised to subdomains automatically, .de and .com alike.
searchTermsarray[]Keep only titles containing one of these terms (case-insensitive). Empty = all.
locationFilterarray[]Keep only jobs whose primary or additional offices contain one of these terms. Empty = all.
departmentFilterarray[]Keep only jobs whose department or recruiting category matches one of these terms. Empty = all.
maxJobsPerCompanyinteger0Cap saved jobs per company after filtering. 0 = no limit.
includeDescriptionbooleantrueInclude descriptionSections and descriptionPlain. Turn off for a much smaller, faster dataset.
languagestring""Two-letter code appended as ?language=. en gives English text where the company published a translation. Empty = the board's default.
domainstringdejobs.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 seeSubdomain
circus.jobs.personio.de/…circus
circus.jobs.personio.com/…circus
1komma5grad.jobs.personio.de/job/27311501komma5grad

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/xml with 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 in RUN_SUMMARY.boardsWithNoOpenRoles and 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=en returns 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.