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:

  • id is a number, and the position's own page is /job/{id} on the board's host. name is the title.
  • subcompany names the legal entity advertising the job.
  • office is 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 under additionalOffices.
  • jobDescriptions holds one <jobDescription> per section of the text: <name> is the heading and <value> its HTML, in a CDATA block.
  • employmentType is a code (permanent, intern, freelance, working_student and temporary on the boards we have read) and schedule is full-time, part-time or full-or-part-time.
  • createdAt is ISO 8601 with an offset.
  • salaryInformation appears 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.