A company that recruits with Personio gets a job page at {company}.jobs.personio.de. Behind it sits one XML document at /xml that holds every published position, its text included. Personio documents the feed for companies that list their jobs on their own website ("Integration via code" on developer.personio.de), and its help centre says the feed needs no credentials and updates as soon as a job is edited in Personio. Both were read on 26 October 2026.
So there is no key, no paging and no second request per job. What there is: a language to send, two hosts, and a few answers worth knowing before you write the reader. Every figure below was measured against live feeds between 26 and 30 October 2026.
The company name
The job links carry it:
https://acme.jobs.personio.de/job/1234567
acme is the company name. acme.personio.de, without jobs, is the company's own Personio sign-in, not its job page. Personio's own job page is personio.
The request
curl "https://personio.jobs.personio.de/xml?language=en"
The answer is text/xml: a <workzag-jobs> root with one <position> per published job. The same board answers on {company}.jobs.personio.com; Personio's own board returned the same bytes on both hosts.
Send the language
language picks the text, and leaving it out is not neutral. Without it, both boards we read answered exactly as they did with language=de. A code Personio does not have, xx, answered 200 with the same positions and no text at all, not an error. And a position with no text in the language asked for keeps its other fields and comes back with an empty <jobDescriptions>. On one board of 26 positions on 30 October, 18 had English text; asked in German, 14 of the 26 had none.
So send the language you want, and treat an empty <jobDescriptions> as "not written in this language", not as "no description".
What a position holds
A position, trimmed (the company and the text are made up):
<position>
<id>1234567</id>
<subcompany>Acme Software GmbH</subcompany>
<office>Berlin</office>
<additionalOffices>
<office>Mobil</office>
</additionalOffices>
<department>Engineering</department>
<recruitingCategory>Engineering</recruitingCategory>
<name>Backend Engineer (m/f/d)</name>
<jobDescriptions>
<jobDescription>
<name>Your tasks</name>
<value><![CDATA[<ul><li>Build and run our order services</li></ul>]]></value>
</jobDescription>
</jobDescriptions>
<employmentType>permanent</employmentType>
<seniority>experienced</seniority>
<schedule>full-time</schedule>
<yearsOfExperience>2-5</yearsOfExperience>
<keywords>Go,PostgreSQL</keywords>
<occupation>software_and_web_development</occupation>
<occupationCategory>it_software</occupationCategory>
<createdAt>2026-09-14T08:30:00+00:00</createdAt>
</position>
The children come in that order, and a company can leave several of them out. What a jobs table wants from them:
-
idis a number, and the position's own page is/job/{id}on the board's host.nameis the title. -
subcompanynames the legal entity advertising the job. -
officeis the company's own label for the first place: a city, or something like "Head Office - Dublin" or "Mobil" (German for mobile working). Further places sit underadditionalOffices. -
jobDescriptionsholds one<jobDescription>per section of the text:<name>is the heading and<value>its HTML, in a CDATA block. -
employmentTypeis a code (permanent, intern, freelance, working_student and temporary on the boards we have read) andscheduleis full-time, part-time or full-or-part-time. -
createdAtis ISO 8601 with an offset. -
salaryInformationappears only where the company has set it, with a minimum, a currency code and a period. It is rare: 2 of the 104 positions we read across twelve boards on 25 September 2026 had it, each with a minimum only.
Three things the feed never says: whether a job is remote (only the office label hints at it), when a position was last edited, and when it closes. And one thing to leave alone: some companies add a section to the text that names the recruiter, with an email address and a phone number, under a heading such as "Contact".
A board that does not exist
On 26 October 2026, a name with no board behind it answered HTTP 404 with an HTML "Page Not Found" page, on both hosts. On 25 September the same request was answered with a redirect to personio.com instead. A reader that treats any 404, or any redirect off the jobs.personio host, as "no such board" handles both.
A script that reads a board
For Node 18 or later, with nothing to install. It writes one JSON line per position:
// personio-jobs.mjs: every open position on one Personio job page, one JSON line each (Node 18 or later)
// node personio-jobs.mjs acme en > jobs.jsonl
const [company, language = 'en'] = process.argv.slice(2);
if (!company) throw new Error('Give the company name: the part before .jobs.personio.de in its job links');
const url = `https://${company}.jobs.personio.de/xml?language=${language}`;
const response = await fetch(url);
if (response.status === 404) throw new Error(`No Personio job page named "${company}"`);
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
const xml = await response.text();
const entities = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
const text = (s) => s == null ? null : (s.match(/^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/)?.[1]
?? s.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(+d)).replace(/&(amp|lt|gt|quot|apos);/g, (_, e) => entities[e])).trim() || null;
const all = (s, tag) => [...s.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))].map((m) => m[1]);
const one = (s, tag) => all(s, tag)[0] ?? null;
const positions = all(xml, 'position');
for (const position of positions) {
// <jobDescriptions> and <additionalOffices> hold <name> and <office> elements of their own.
const own = position.replace(/<jobDescriptions>[\s\S]*?<\/jobDescriptions>/, '').replace(/<additionalOffices>[\s\S]*?<\/additionalOffices>/, '');
const id = text(one(own, 'id'));
console.log(JSON.stringify({
id,
title: text(one(own, 'name')),
office: text(one(own, 'office')),
furtherOffices: all(one(position, 'additionalOffices') ?? '', 'office').map(text),
department: text(one(own, 'department')),
employmentType: text(one(own, 'employmentType')),
schedule: text(one(own, 'schedule')),
createdAt: text(one(own, 'createdAt')),
sections: all(one(position, 'jobDescriptions') ?? '', 'jobDescription').map((d) => text(one(d, 'name'))),
url: `https://${company}.jobs.personio.de/job/${id}`,
}));
}
console.error(`${positions.length} positions`);
The one trap is in the comment: the position's own <name> and <office> share their tag names with elements inside the text and the further offices, so the script reads the position's fields with those two blocks taken out. Run exactly as printed on 30 October 2026, it read a public board of 26 positions in one request, and Personio's own board, which held none that morning.
What one board holds
Counted from that run's 26 lines and the feed itself:
| Fact | Positions with it | Notes |
|---|---|---|
| Text in English | 18 of 26 | The other 8 are written in German only |
| Legal entity | 25 of 26 | From subcompany
|
| Office | 26 of 26 | "Mobil" on 23, Berlin on 2 and one other city; 5 list further offices |
| Department | 18 of 26 | Five departments |
| Employment type | 26 of 26 | Permanent 24, intern 2; full-time 24, full or part time 2 |
| Pay | 0 of 26 | No salaryInformation on any position |
| Created date | 26 of 26 | 4 created in October 2026 and 8 in September; the oldest still open dates from April 2025 |
The whole feed was 109,365 bytes in English. That is the trade Personio offers: a board, text and all, in one request, with the work moved to reading it well.
Where a script stops
- Nothing says what changed. No element records an edit, so watching a board means keeping the last read, comparing, and deciding what counts as new, changed or gone. The text arrives in the same response, so comparing it costs nothing extra.
- Remote work is a label. "Mobil" on 23 of 26 positions is the company's word for it; another company writes "Remote" or nothing.
- Pay is almost never there, and when it is, it is a minimum.
We publish a Personio jobs scraper on the Apify Store that starts from the company name. It reads one feed per board in the language you choose, and in incremental mode it returns, and charges for, only the positions that are new, changed (text included) or gone since its last run. It never reads a section that names a contact person. The full reference page has the measured runs behind these numbers.
Disclosure: Adderley Data, who wrote this, builds and sells that Actor; neither link carries a referral or affiliate code. We are not affiliated with Personio, and "Personio" is used only to say which job pages this describes.
Top comments (0)