How to Scrape Personio Job Listings

CleanJobData Engineering

TL;DR

Personio career sites expose an XML feed at https://<slug>.jobs.personio.de/xml (or .com) listing every open position in one request. Locations are plain office names, salary is rarely present, and on many accounts the job descriptions in the feed are empty and have to be read from each job page instead.

Personio is widely used by small and mid-sized European companies, especially in the DACH region. Its hosted career pages come with a public XML feed of open positions, which makes listing a company's jobs simple. The parts that need care are the two Personio domains, the mostly empty descriptions on some accounts, and the lack of structured locations.

Finding the Company Slug and Domain

A Personio career site is a subdomain: https://<slug>.jobs.personio.de/ or https://<slug>.jobs.personio.com/. The <slug> is the company's identifier. Many companies embed the board on their own website, so open the network tab and look for requests to *.jobs.personio.de or *.jobs.personio.com.

Which of the two domains an account uses isn't something you can assume. For one account we tested, both domains returned byte-for-byte the same feed; for a slug that doesn't exist, the server answers with a 307 redirect to personio.com instead of an error page. So try .de first, then .com, and treat a redirect away from jobs.personio.* (or a response that isn't XML) as "no account here". If you store slugs, store the domain that actually worked next to each one.

The XML Feed

curl -H "accept: application/xml" "https://acme.jobs.personio.de/xml?language=en"

The response is XML with a <workzag-jobs> root and one <position> element per job. A board with over 300 openings came back in a single response of about 650 KB, so there's no pagination. Each position looks like this (trimmed):

<position>
  <id>2749999</id>
  <subcompany>Heartbeat AI GmbH</subcompany>
  <office>Hamburg</office>
  <additionalOffices>
    <office>Remote</office>
    <office>Berlin</office>
  </additionalOffices>
  <department>Customer Service</department>
  <recruitingCategory>Customer Service</recruitingCategory>
  <name>(Junior) Conversational AI Specialist (m/w/d)</name>
  <jobDescriptions>
    <jobDescription><name>Your role</name><value><![CDATA[<p>…</p>]]></value></jobDescription>
  </jobDescriptions>
  <employmentType>permanent</employmentType>
  <seniority>entry-level</seniority>
  <schedule>full-time</schedule>
  <yearsOfExperience>0-2</yearsOfExperience>
  <keywords>…</keywords>
  <createdAt>2026-08-11T12:53:30+00:00</createdAt>
</position>

Older accounts may use <posting> instead of <position>, so handle both. The public job page is https://<slug>.jobs.personio.<domain>/job/<id>.

Descriptions Are Often Empty

This is the surprise. On the 311-job board above, 282 positions had an empty <jobDescriptions> block, and only 29 carried any description text. When the feed's descriptions are empty, the job page itself has the full text. Each description section on the page is a .jb-description-item block containing a title (.detail-block-title) and a body (.detail-block-description).

<div class="jb-description-item">
  <h2 class="detail-block-title">Your role</h2>
  <div class="detail-block-description"><p>…</p></div>
</div>

The job page we tested was server-rendered, so a plain HTTP request is enough, and it had no JSON-LD block, so read the HTML sections instead.

Fetching Jobs (Node/TypeScript)

This version reads the feed, then fills in empty descriptions from the job pages. It uses cheerio (npm install cheerio):

import { load } from "cheerio";
 
const DOMAINS = ["de", "com"] as const;
 
async function fetchXml(slug: string) {
  for (const domain of DOMAINS) {
    const host = `${slug}.jobs.personio.${domain}`;
    const res = await fetch(`https://${host}/xml?language=en`, {
      headers: { accept: "application/xml, text/xml, */*" },
    });
    const body = res.ok ? await res.text() : "";
    // A missing account redirects to personio.com, so check that we really got XML.
    if (body.trimStart().startsWith("<?xml")) return { host, xml: body };
  }
  return null;
}
 
async function pageDescription(host: string, id: string) {
  const res = await fetch(`https://${host}/job/${id}?language=en`);
  if (!res.ok) return "";
  const $ = load(await res.text());
  return $(".jb-description-item")
    .map((_, el) => {
      const title = $(el).find(".detail-block-title").first().text().trim();
      const body = $(el).find(".detail-block-description").first().html() ?? "";
      return body ? `${title ? `<h3>${title}</h3>\n` : ""}${body}` : "";
    })
    .get()
    .filter(Boolean)
    .join("\n");
}
 
export async function fetchPersonioJobs(slug: string) {
  const found = await fetchXml(slug);
  if (!found) return [];
 
  const $ = load(found.xml, { xmlMode: true });
  const positions = $("position").length ? $("position") : $("posting");
 
  const jobs = positions
    .map((_, el) => {
      const $el = $(el);
      const text = (tag: string) => $el.children(tag).text().trim();
      const id = text("id");
      const description = $el
        .find("jobDescriptions > jobDescription")
        .map((_, d) => {
          const name = $(d).children("name").text().trim();
          const value = $(d).children("value").text().trim();
          return value ? `${name ? `<h3>${name}</h3>\n` : ""}${value}` : "";
        })
        .get()
        .filter(Boolean)
        .join("\n");
 
      return {
        id,
        title: text("name"),
        company: text("subcompany"),
        offices: [text("office"), ...$el.find("additionalOffices > office").map((_, o) => $(o).text().trim()).get()],
        employmentType: text("employmentType"),
        schedule: text("schedule"),
        seniority: text("seniority"),
        published: text("createdAt"),
        url: `https://${found.host}/job/${id}`,
        description,
      };
    })
    .get();
 
  // Fill in empty descriptions from the job pages, a few at a time.
  const BATCH = 5;
  for (let i = 0; i < jobs.length; i += BATCH) {
    await Promise.all(
      jobs.slice(i, i + BATCH).filter((j) => !j.description).map(async (j) => {
        j.description = await pageDescription(found.host, j.id);
      }),
    );
  }
  return jobs;
}

Filling in descriptions costs one request per job with an empty description, so on a large board it's the slow part. If you only need titles, locations and dates, skip it.

A JSON Alternative

Personio also serves https://<slug>.jobs.personio.<domain>/search.json?language=en, a flat JSON array with id, name, office, offices, schedule, employment_type, seniority, department, category and subcompany for every job. In our tests it listed the same jobs as the XML but its description field was empty, and a job's posting date isn't included. It's handy for a quick index, or as a fallback if an account doesn't serve the XML export.

Gotchas

Locations are office names. Both the XML and the JSON give you names like "Hamburg" or "Lüneburg,Seevetal" rather than a city, region and country. You have to resolve them yourself; see the job location normalization guide. Also note that additionalOffices can contain "Remote" as if it were an office.

Employment type and schedule are two fields. employmentType says permanent for most staff, which tells you nothing about hours, so read schedule (full-time or part-time) as well. Values like intern, trainee, freelance and temporary do carry meaning.

Seniority uses Personio's labels, such as entry-level, experienced, executive and student, and they map loosely onto the levels you use. See the experience level mapping guide. There's also a yearsOfExperience field on most positions.

Salary is rare. Only one of the 311 positions carried a <salaryInformation> block (min, max, currencyCode, and a type such as yearly). When it appears, check type before comparing ranges.

Titles can be in another language. With language=en, you can still get German text such as the (m/w/d) suffix if the employer only wrote the posting in one language.

Remote isn't a field. Look for "remote" or "hybrid" in the title and the office names.

Old postings can stay open. Compare createdAt with today's date before treating a job as fresh. There's no webhook; poll and compare job IDs between runs to detect closures.

When This Is Enough

For one company, the XML feed is close to ideal: a single request, all the metadata, and descriptions available from the job pages.

When It Isn't

Across many accounts you inherit the domain guesswork, the empty descriptions, the office-name locations and the inconsistent labels. Other platforms have their own quirks; see Greenhouse and Lever.

CleanJobData reads Personio accounts together with other platforms and returns one schema, with locations resolved to cities, states and countries and employment type and experience level normalized. It can't recover salary or seniority a company never published. Try it in the Playground or see the API Documentation.