# Personio Jobs Scraper: German Mittelstand Openings (`arman-bd/personio-jobs-scraper`) Actor

Parse Personio job XML feeds into structured records: title, department, office, employment type, seniority, schedule and full description. Strong DACH coverage, no login.

- **URL**: https://apify.com/arman-bd/personio-jobs-scraper.md
- **Developed by:** [Arman Hossain](https://apify.com/arman-bd) (community)
- **Categories:** Jobs, Lead generation, MCP servers
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.74 / 1,000 job scrapeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Personio Jobs Scraper: German Mittelstand Openings

![Personio Jobs: German-market postings with salary range, seniority and years of experience](https://api.apify.com/v2/key-value-stores/ZQOcNAOHrIgTacAmy/records/personio-jobs-scraper.jpg)

**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)**

```
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`, `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:

```json
{
 "companies": ["circus", "wandelbots"]
}
```

Remote and Hamburg tech roles, in English, capped:

```json
{
 "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:

```json
{
 "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):

```json
{
 "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:

```json
{
 "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:

```json
{
 "salaryMin": 45000,
 "salaryMax": 55000,
 "salaryCurrency": "EUR",
 "salaryPeriod": "yearly"
}
```

#### `RUN_SUMMARY`

```json
{
 "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

```bash
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

```js
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.

# Actor input Schema

## `companies` (type: `array`):

Company subdomains or board URLs. The subdomain is the company slug in the board URL. for https://circus.jobs.personio.de the value is 'circus'. Full URLs, including .jobs.personio.com ones, are accepted and normalized automatically. An unknown subdomain lands on Personio's wildcard bot challenge and is reported as a per-company failure.

## `searchTerms` (type: `array`):

Keep only jobs whose title contains at least one of these terms (case-insensitive). Titles on DACH boards are often German, so 'entwickler' catches roles that 'developer' misses. Leave empty to keep every job.

## `locationFilter` (type: `array`):

Keep only jobs whose office matches at least one of these terms (case-insensitive). Both the primary office and every entry in the job's additional-offices list are searched, so a role listed as Remote with a Berlin office matches either term. Leave empty for all locations.

## `departmentFilter` (type: `array`):

Keep only jobs whose department or recruiting category matches at least one of these terms (case-insensitive). Personio departments are whatever the company named them. 'Tech', 'Sales', 'Operations'. Leave empty for all departments.

## `maxJobsPerCompany` (type: `integer`):

Cap the number of jobs saved per company after filtering. Set 0 for no limit. the whole feed arrives in one request either way, so this is a cost control rather than a speed one.

## `includeDescription` (type: `boolean`):

Include the full job description as plain text, plus the section-by-section breakdown Personio's feed carries ('Deine Position', 'Dein Profil', 'Benefits'). Disable for a smaller, faster dataset.

## `language` (type: `string`):

Two-letter language code appended as ?language= on the feed. Personio serves whatever translations the company published. 'en' returns English titles and descriptions where they exist and the original text where they do not. Leave empty for the board's default language, which on DACH boards is usually German.

## `domain` (type: `string`):

Which Personio host to read. Both serve the same feed, so this only matters if a company's board is published under one and not the other.

## Actor input object example

```json
{
  "companies": [
    "circus",
    "https://1komma5grad.jobs.personio.de"
  ],
  "searchTerms": [
    "engineer",
    "entwickler"
  ],
  "locationFilter": [
    "berlin",
    "remote"
  ],
  "departmentFilter": [
    "tech",
    "sales"
  ],
  "maxJobsPerCompany": 0,
  "includeDescription": true,
  "language": "en",
  "domain": "de"
}
```

# Actor output Schema

## `items` (type: `string`):

Every record the run produced.

## `runsummary` (type: `string`):

The RUN\_SUMMARY record from the run's key-value store.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "companies": [
        "circus",
        "wandelbots"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("arman-bd/personio-jobs-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "companies": [
        "circus",
        "wandelbots",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("arman-bd/personio-jobs-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "companies": [
    "circus",
    "wandelbots"
  ]
}' |
apify call arman-bd/personio-jobs-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,arman-bd/personio-jobs-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/kbqMxqTISxIq21CkY/builds/p6bwfwhqVRLo2BfCn/openapi.json
