# Personio Jobs by Company: Career Site Feed API (`yadroo/personio-jobs`) Actor

Open positions of any company career site on Personio, read live from its keyless job-board feed: title, department, offices, employment type, schedule, seniority, occupation category, skill keywords, disclosed minimum salary, creation date and job page. A summary mode counts open jobs per group.

- **URL**: https://apify.com/yadroo/personio-jobs.md
- **Developed by:** [Samat Makatov](https://apify.com/yadroo) (community)
- **Categories:** Jobs, Business, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.84 / 1,000 job row returneds

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/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Personio Jobs by Company: Career Site Feed API

Open positions of any company career site on Personio, read live from its keyless job-board feed: title, department, offices, employment type, schedule, seniority, occupation category, skill keywords, disclosed minimum salary, creation date and job page. A summary mode counts open jobs per group.

Give it the slugs of the employers you care about (`carbmee`, `stark`, `deskbird`) and every position they publish right now comes back as a row — read at run time from the company's own career site, not from an index built days ago. One request per company returns the whole board, so there is no paging and no per-job call. No API key, no proxy, no browser.

### Use cases

- **Hiring signals for GTM and lead generation**: watch a list of target accounts on a schedule with `postedWithinDays` and `onlyNew`, and get a row the day a company opens a role — a budget and a priority you can act on.
- **Niche and regional job boards**: pull the boards of the employers you cover into your own listing site, with the real apply link (`url`) for every posting instead of a re-indexed copy.
- **Student and internship boards**: `employmentTypes: ["working_student", "intern"]` is a code the source publishes itself, so the slice is exact across employers instead of guessed from the title.
- **Pay-transparency research**: `onlySalaryDisclosed` keeps the postings where the employer named a figure, with amount, currency and period, so you can benchmark who discloses and from how much.
- **Org and growth research**: `mode: "summary"` counts open jobs per department, office, seniority or skill keyword from the same single request — which team a company is scaling, and where.
- **Agents and RAG**: ask "who is hiring a data engineer in Munich" against a live board, trim rows with `fields`, and hand the agent `url` so the answer links to the real application form.

### Input

| Field | Type | Default | Allowed values / notes |
|---|---|---|---|
| `companies` | string\[] | **required** | Career-site slugs, e.g. `["carbmee", "stark", "deskbird"]`. A full career-site or job URL works too — the slug is read out of it. See [Finding the slug](#finding-the-slug) |
| `mode` | string | `jobs` | `jobs` = a row per open position · `summary` = a row per group with its open-job count |
| `groupBy` | string | `department` | Only in `summary` mode: `department`, `office`, `recruitingCategory`, `employmentType`, `schedule`, `seniority`, `occupationCategory`, `occupation`, `keyword` |
| `titleKeywords` | string\[] | all | Keep a position whose title contains any of these words (case-insensitive) |
| `excludeTitleKeywords` | string\[] | none | Drop a position whose title contains any of these words |
| `departments` | string\[] | all | Department contains any of these. Free text per employer — see [Vocabularies](#vocabularies) |
| `offices` | string\[] | all | Office contains any of these; matched against the main **and** the further offices |
| `recruitingCategories` | string\[] | all | Recruiting category contains any of these. Free text per employer |
| `employmentTypes` | string\[] | all | Platform codes, whole-value match: `permanent`, `fixed_term`, `intern`, `working_student`, … see [Employment type](#employment-type) |
| `seniorities` | string\[] | all | Platform codes: `student`, `entry-level`, `experienced`, `executive` — see [Seniority](#seniority) |
| `schedule` | string | `any` | `any`, `full-time`, `part-time`, `full-or-part-time` |
| `occupationCategories` | string\[] | all | Platform job family, e.g. `it_software` — see [Occupation category](#occupation-category) |
| `keywordsAny` | string\[] | all | Keep positions carrying one of these skill keywords, matched as whole words — see [Skill keywords](#skill-keywords) |
| `onlySalaryDisclosed` | boolean | `false` | Keep only positions with a published salary figure |
| `postedWithinDays` | integer | no limit | 1–3650. Keep positions created in the last N days (UTC) |
| `onlyNew` | boolean | `false` | Write only positions this actor has not returned before (`jobs` mode) — see [Watching for new postings](#watching-for-new-postings) |
| `language` | string | `source` | `source`, `en`, `de`, `fr`, `es`, `it`, `nl`, `pt` — see [Language](#language) |
| `includeDescription` | boolean | `false` | Add `descriptionSections`, `descriptionHtml`, `descriptionText` to every row |
| `sortBy` | string | `createdDesc` | `createdDesc`, `createdAsc`, `titleAsc`, `departmentAsc`, `officeAsc`, `feedOrder` |
| `maxItems` | integer | `50` | 1–5000 rows in total |
| `maxItemsPerCompany` | integer | no cap | 1–2000 rows per career site, applied before `maxItems` |
| `fields` | string\[] | all | Keep only these output fields, in this order |

Filters combine with AND, the values inside one filter with OR. They run over the complete board the feed returned, so nothing is lost to paging. In `summary` mode the filters narrow the positions that are counted.

### Reference

#### Finding the slug

A career site on this platform lives on its own subdomain: `https://<slug>.jobs.personio.com`. The slug is the part in front of `.jobs.personio.com`, and it is not always the company name — open the employer's job list and copy it out of the address bar. All three forms are accepted and end up as the same slug:

```
carbmee
carbmee.jobs.personio.com
https://carbmee.jobs.personio.com/job/2717004
```

A slug nobody publishes a career site under is answered by the platform with a redirect to its marketing site. The actor does not follow it: the run stays successful and writes one row with `found: false` and a `message` saying so.

#### Vocabularies

`department`, `recruitingCategory` and `office` are free text each employer types itself, often in German (`R&D / Software`, `Werkstudenten`, `Munich HQ (Karlsfeld)`, `Plant (Gendorf)`). Do not guess them — run the board once with `mode: "summary"` and `groupBy` set to that field and you get the exact list the company uses, with a count per value. A filter value that resembles a value on the boards of the run is corrected to it (`Enginering` → `Engineering`) and the correction is logged in the run log and in the `SUMMARY` record; a value that resembles nothing stays as typed and simply matches nothing, so a filter never quietly widens.

#### Employment type

Whole-value codes; `-`, `_` and spaces are interchangeable, so `fixed_term`, `fixed-term` and `Fixed Term` are the same value.

| Code | Meaning |
|---|---|
| `permanent` | Open-ended contract — the bulk of every board |
| `fixed_term` | Contract with an end date |
| `intern` | Internship |
| `working_student` | Working student next to university studies (common in DACH) |
| `trainee` | Trainee or apprenticeship programme |
| `freelance` | Freelance or contractor engagement |

#### Seniority

`student`, `entry-level`, `experienced`, `executive`, `senior-management`. Part of the postings leave the field empty; those are dropped by a seniority filter and appear as the `(none)` group of a seniority summary.

#### Working time

`full-time`, `part-time` and `full-or-part-time` — the third value is the source's own, for a position the employer opened for either. Ask for `part-time` and you get the genuinely part-time postings only.

#### Occupation category

The platform's own job family, so it is comparable across employers — unlike `department`, which every company invents. Values seen live: `it_software`, `engineering`, `sales_and_business_development`, `marketing_and_product`, `accounting_and_finance`, `human_resources`, `production_and_operations`, `logistics_and_transportation`, `project_and_program_management`, `r_and_d_and_science`, `creative_and_design`, `customer_support_and_client_care`, `administrative_and_clerical`, `editorial_and_writing`, `business_and_strategic_development`, `security_and_protective_services`, `other`. The narrower `occupation` code sits inside a category (`software_and_web_development`, `aeronautic_and_avionic_engineering`, `corporate_accounting`, …); group by it to see the list one employer really uses.

#### Language

`source` (the default) asks the feed for no language at all, which returns the complete board in the languages the employer published. A language code asks for one version: titles and description sections come back translated where the employer maintains that translation and in the original language where it does not. Asking for a language can also **shrink** a board — a posting the employer never published for that language is left out — which is why it is never the default.

#### Salary

The source carries a minimum amount, a currency and a period (`hourly`, `monthly`, `yearly`), and an upper bound on a small share of postings. `salaryIsMinimum` is `true` when the employer named a floor and no ceiling, `false` when `salaryMax` carries one. Most employers publish nothing at all, so `onlySalaryDisclosed` returns a short list per career site.

#### Skill keywords

A posting can carry a list of skills the employer attached (`Supply Chain`, `SQL`, `Python`, `ERP Systems`, …). `keywords` holds **all** of them in the order the source lists them — 1 to 14 per posting on the boards read while this actor was built, and empty on the postings where the employer filled nothing in. `keywordsAny` matches against that full list, **by whole words, not by fragments**: `["SQL"]` keeps a posting tagged `SQL` or `SQL Server` and leaves one tagged only `PostgreSQL` out, and `["java"]` does not drag in `JavaScript`. A multi-word tag stays reachable through its words, so `["software"]` still finds `Software Development`, and `+`/`#` belong to the word (`c++` and `c#` are their own skills). When none of the keywords you asked for exists on the boards of the run, the log and `SUMMARY.filtersWithoutMatch` say so instead of returning near-misses. `mode: "summary"` with `groupBy: "keyword"` turns the same list into a skill-demand count per employer.

#### Watching for new postings

With `onlyNew: true` a run writes only the positions it has not handed out before, which is what turns a schedule into a hiring-signal feed. What a run delivered is remembered as career site plus position id in a **named key-value store in your own account**:

- `personio-jobs-state` for runs you start by hand or over the API;
- `personio-jobs-state-<task id>` when the run comes from a task — so two schedules with different company lists or filters cannot blind each other.

The first run returns everything that matches, so start it with a `maxItems` you are happy to pay for. Only rows a run really wrote are remembered: positions cut off by `maxItems` come back on the next run. Runs that start at the same second read the same memory and therefore can return the same posting twice — schedule a watchlist as one run after another, not in parallel. An edited posting is not delivered again, because the feed publishes no "last updated" moment. Delete the store (or switch `onlyNew` off) to start over; the `SUMMARY` record names the store and how many positions it holds.

### Examples

**All open jobs of one career site**

```json
{ "companies": ["carbmee"], "mode": "jobs", "maxItems": 25 }
```

**Engineering roles across several employers**

```json
{ "companies": ["carbmee", "stark", "deskbird", "tozero"], "titleKeywords": ["engineer"], "maxItems": 30 }
```

**Working-student and internship postings**

```json
{ "companies": ["anton", "tozero", "stark"], "employmentTypes": ["working_student", "intern"], "maxItems": 30 }
```

**IT and software roles on the platform's own taxonomy**

```json
{ "companies": ["carbmee", "stark", "deskbird", "personio"], "occupationCategories": ["it_software"], "maxItems": 30 }
```

**Postings tagged with a specific skill**

```json
{ "companies": ["optiply", "carbmee", "stark"], "keywordsAny": ["SQL"], "maxItems": 15 }
```

**Hiring signal: what opened in the last 60 days**

```json
{ "companies": ["carbmee", "stark", "deskbird", "optiply", "anton", "fact-finder"], "postedWithinDays": 60, "onlyNew": false, "maxItems": 25 }
```

**Positions with a published salary**

```json
{ "companies": ["anton", "stark"], "onlySalaryDisclosed": true, "maxItems": 20 }
```

**Open jobs per department**

```json
{ "companies": ["stark", "carbmee", "fact-finder"], "mode": "summary", "groupBy": "department", "maxItems": 30 }
```

### Output

A real row, from a run with `{"companies": ["optiply", "carbmee", "stark"], "keywordsAny": ["SQL"], "maxItems": 15}` on 2026-09-29 — the only posting of the three career sites tagged with the skill `SQL`:

```json
{
  "companySlug": "optiply",
  "companyName": null,
  "positionId": "2818912",
  "title": "Supply Chain Engineer",
  "department": "Customer Success",
  "recruitingCategory": "Optiply",
  "office": "Amsterdam",
  "additionalOffices": [],
  "offices": ["Amsterdam"],
  "officeCount": 1,
  "employmentType": "fixed_term",
  "schedule": "full-time",
  "seniority": "experienced",
  "yearsOfExperience": "2-5",
  "occupation": "systems_and_process_engineering",
  "occupationCategory": "engineering",
  "keywords": ["Supply Chain", "Supply Chain Management", "Inventory Management", "SQL", "Python", "Data Analysis", "Demand Forecasting", "Workflow Automation", "ERP Systems", "Supply Chain Analyst", "replenishment", "safety stock", "Retool", "forecasting"],
  "salaryMin": null,
  "salaryMax": null,
  "salaryCurrency": null,
  "salaryCurrencySymbol": null,
  "salaryPeriod": null,
  "salaryIsMinimum": false,
  "salaryDisclosed": false,
  "createdAt": "2026-09-29T10:53:17.000Z",
  "daysSincePosted": 0,
  "language": null,
  "found": true,
  "message": null,
  "url": "https://optiply.jobs.personio.com/job/2818912",
  "boardUrl": "https://optiply.jobs.personio.com/",
  "fetchedAt": "2026-09-29T20:53:44.983Z"
}
```

| Field | Type | Always filled | Meaning |
|---|---|---|---|
| `companySlug` | string | yes | The career site the row came from |
| `companyName` | string|null | no | Legal entity the employer attached to the posting; empty when it publishes under one name only |
| `positionId` | string | yes (job rows) | Position id on the career site; the id in `url` |
| `title` | string | yes (job rows) | Job title as published |
| `department` | string|null | no | The employer's own team name |
| `recruitingCategory` | string|null | no | The employer's own posting category |
| `office` | string|null | yes (job rows) | Main office label as the employer typed it |
| `additionalOffices` | string\[] | yes | Further offices of the same posting, main one excluded |
| `offices` | string\[] | yes | Main office plus further offices — what the office filter matches |
| `officeCount` | integer | yes | Length of `offices` |
| `employmentType` | string|null | yes (job rows) | Platform code, see [Employment type](#employment-type) |
| `schedule` | string|null | yes (job rows) | `full-time`, `part-time`, `full-or-part-time` |
| `seniority` | string|null | no | Platform code, see [Seniority](#seniority) |
| `yearsOfExperience` | string|null | no | Band the employer picked, e.g. `2-5`, `lt-1` |
| `occupation` | string|null | yes (job rows) | Narrow platform job code |
| `occupationCategory` | string|null | yes (job rows) | Platform job family |
| `keywords` | string\[] | no | Every skill keyword the employer attached, in source order; empty on postings without any |
| `salaryMin` | number|null | no | Published amount, a lower bound |
| `salaryMax` | number|null | no | Upper bound where the employer published one |
| `salaryCurrency` | string|null | no | ISO currency code, e.g. `EUR` |
| `salaryCurrencySymbol` | string|null | no | Symbol as published, e.g. `€` |
| `salaryPeriod` | string|null | no | `hourly`, `monthly` or `yearly` |
| `salaryIsMinimum` | boolean | yes | `true` when the figure is a floor with no ceiling |
| `salaryDisclosed` | boolean | yes | `true` when the employer published pay at all |
| `createdAt` | string|null | yes (job rows) | Creation moment, ISO 8601 UTC |
| `daysSincePosted` | integer|null | yes (job rows) | Whole days since `createdAt`, never negative |
| `language` | string|null | no | The language version asked for; empty when the board was read as published |
| `found` | boolean | yes | `false` on the marker row of a slug without a career site |
| `message` | string|null | no | Why a row is a marker row |
| `url` | string | yes | Public job page with the application form (career site on summary and marker rows) |
| `boardUrl` | string | yes | The career site the row came from |
| `fetchedAt` | string | yes | Moment of the request, ISO 8601 UTC |

With `includeDescription: true` every job row also carries `descriptionSections` (`[{ "name": "Your mission", "text": "…" }]`), `descriptionHtml` and `descriptionText`. Some postings, and some language versions of a posting, carry no sections — those rows get empty values.

A **summary** row (same run family, `stark` grouped by department):

```json
{
  "companySlug": "stark",
  "companyName": null,
  "groupBy": "department",
  "value": "R&D / Hardware",
  "jobCount": 48,
  "boardJobCount": 138,
  "found": true,
  "message": null,
  "url": "https://stark.jobs.personio.com/",
  "boardUrl": "https://stark.jobs.personio.com/",
  "fetchedAt": "2026-09-29T20:17:00.228Z"
}
```

`jobCount` is the number of open positions in the group, `boardJobCount` the number of positions the groups were counted from. A position open in three offices counts once per office and a position with five keywords once per keyword, so those two groupings can add up to more than `boardJobCount`; positions with an empty field land in the group `(none)`.

A slug that serves no career site produces one row with `found: false`, the same field names with empty values, and a `message` naming the slug — the run still succeeds.

Every run also writes a `SUMMARY` record into the run's key-value store: the career sites requested and found, positions per board, how many rows the filters cut, the filter corrections that were applied, the request count and — with `onlyNew` — the state store it used, how many positions it knew before the run and how many it knows now.

Dataset views: **Open positions** (slug, title, department, office, employment type, working time, seniority, created, days online, salary from, job page) · **Level, pay and skills** · **Open jobs per group**.

### Use it from code / agents

```bash
curl -X POST "https://api.apify.com/v2/acts/yadroo~personio-jobs/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"companies":["carbmee","deskbird"],"titleKeywords":["engineer"],"maxItems":25}'
```

```js
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('yadroo/personio-jobs').call({
    companies: ['carbmee', 'stark'],
    postedWithinDays: 30,
    fields: ['companySlug', 'title', 'office', 'createdAt', 'url'],
    maxItems: 50,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

```python
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("yadroo/personio-jobs").call(run_input={
    "companies": ["stark", "carbmee"],
    "mode": "summary",
    "groupBy": "office",
    "maxItems": 30,
})
items = client.dataset(run["defaultDatasetId"]).list_items().items
```

MCP: add `https://mcp.apify.com` to Claude / Cursor / any MCP client and call the `yadroo/personio-jobs` tool with the same JSON input. `fields` keeps the rows small enough for a model context — `["title", "office", "employmentType", "url"]` is usually all an agent needs.

### Pricing

Pay per event: **$0.001 per run start + $0.0012 per dataset row**. Every run is charged the start event, also a run that returns a single row or only a `found: false` marker. A summary group and a marker row are billed like a job row.

| Run | Rows | Cost |
|---|---|---|
| One small career site (`carbmee`, whole board) | 12 | $0.001 + 12 × $0.0012 = **$0.0154** |
| The three prefilled career sites with the default `maxItems: 50` | 50 | $0.001 + 50 × $0.0012 = **$0.061** |
| Department summary of three employers | 20 | $0.001 + 20 × $0.0012 = **$0.025** |
| A watchlist sweep with `postedWithinDays: 7` that finds nothing new | 0 | **$0.001** (the start event only) |

`maxItems` is a hard stop, so a run can never cost more than $0.001 + `maxItems` × $0.0012. Filters are applied before rows are written, so you pay only for the postings you asked for — a `postedWithinDays` sweep over 20 employers that finds three new roles costs $0.0046.

### Limits & FAQ

**Which companies are covered?** Any employer whose career site is hosted on this platform, one slug at a time. There is no search across companies: you bring the list, the actor reads each board live. Career sites on the older `.jobs.personio.de` host are out of scope — the actor reads only `<slug>.jobs.personio.com`.

**How fresh is the data?** It is the employer's own board at the moment of the run. The runs behind this README took 5–8 seconds for one to six career sites.

**What is not in it?** Only what the employer publishes: closed, draft and internal-only postings are absent, and a company between hiring rounds serves an empty board (the run then writes a marker row explaining that, instead of failing). Applicant data, contact persons and anything behind the application form are not read.

**`office` is not a city.** It is the label the employer typed — `Munich HQ (Karlsfeld)`, `Hybrid - Berlin, Germany`, `Remote - Germany`. Filter with a substring (`munich`) rather than an exact name, and expect no normalised country or coordinates.

**Sparse fields.** `salaryMin`, `keywords`, `yearsOfExperience`, `department` and `companyName` depend on how carefully the employer filled its posting form; across the 178 positions read from eight career sites while this actor was built, five named a salary. Filters on a field always drop the postings that leave it empty.

**Descriptions.** `includeDescription` costs no extra request — the texts arrive in the same response — but it makes rows much larger, which is why it is off by default. Some postings carry no sections at all.

**Rate limits.** One request per career site per run, with polite retries and exponential backoff on 429 and 5xx answers, and a normal identifying User-Agent. No proxy, no browser, no login. If you watch many employers, prefer one scheduled run with a long `companies` list over many parallel runs.

**Empty result or error?** A wrong slug, an empty board and a source that is throttling are three different answers: the first two become a `found: false` row with a `message` and a successful run, the third an error naming the career site. A run fails only when no career site at all could be read.

**Can I get only new postings?** Yes — `onlyNew` remembers career site and position id in a named key-value store in your account (`personio-jobs-state`, or `personio-jobs-state-<task id>` for a task) and writes only what was not there before. The first run returns everything it finds, later runs only the additions; parallel runs started in the same second share one memory snapshot and can repeat a posting. [Watching for new postings](#watching-for-new-postings) has the details.

***

Made by **Yadroo**. Sibling actors: [greenhouse-jobs](https://apify.com/yadroo/greenhouse-jobs), [workable-jobs](https://apify.com/yadroo/workable-jobs), [recruitee-jobs](https://apify.com/yadroo/recruitee-jobs), [ashby-job-postings](https://apify.com/yadroo/ashby-job-postings), [hh-kz-vacancies](https://apify.com/yadroo/hh-kz-vacancies).

# Actor input Schema

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

One or more career-site slugs, e.g. `carbmee`, `stark`, `deskbird`. The slug is the label in front of `.jobs.personio.com` on the employer's career site — paste a whole career-site or job URL (`https://carbmee.jobs.personio.com/job/2717004`) and the slug is read out of it. Each entry costs exactly one request that returns the employer's whole board, so there is no paging to pay for. A slug nobody hosts a career site under is answered by the platform with a redirect to its marketing site; that becomes one row with `found: false` instead of a failed run.

## `mode` (type: `string`):

`jobs` writes a row per open position of every career site. `summary` groups the same positions by the field chosen below and writes a count per value - the cheap way to see how an employer is organised, where it hires and which codes its own postings carry before you filter on them. Both modes make the same single request per career site, so a summary costs no extra calls.

## `groupBy` (type: `string`):

Only used when *What to return* is `summary`. A position open in three offices counts once per office, a position with five keywords once per keyword, so the office and keyword counts can exceed the board size; the field `boardJobCount` on every row carries the number of positions the group was counted from. Positions where the field is empty are collected under the value `(none)`, so nothing disappears from the totals.

## `titleKeywords` (type: `array`):

Keep a position when its title contains at least one of these words, case-insensitive, e.g. \["engineer", "scientist"]. Empty = every open position of the career site.

## `excludeTitleKeywords` (type: `array`):

Drop a position when its title contains any of these words, e.g. \["initiativbewerbung", "speculative", "talent pool"]. Applied after *Job title contains any of*. Employers on this platform often keep a permanent open-application posting online - this is how you leave it out of a board count.

## `departments` (type: `array`):

Keep positions whose department contains one of these, case-insensitive, e.g. \["Engineering", "Revenue"]. Departments are free text (seen live: `R&D / Software`, `Founder's Office`, `Delivery`, `Growth`), so run `summary` grouped by department once to read the exact names an employer uses. Positions without a department are dropped by this filter.

## `offices` (type: `array`):

Keep positions with an office that contains one of these, e.g. \["Munich", "Berlin", "Thessaloniki"]. Matched against the main office and every further office, so a posting open in Berlin and Munich is kept by either value. Office is the label the employer typed (`Munich HQ (Karlsfeld)`, `Plant (Gendorf)`), not a normalised city - a substring like `munich` is the safe way to ask.

## `recruitingCategories` (type: `array`):

Keep positions whose recruiting category contains one of these. This is the employer's own bucket for a posting and often stays in its native language (seen live: `Engineering`, `Festangestellte`, `Werkstudenten`, `FTE Engineering/SE/Product`). Use `summary` grouped by recruiting category to read the list of one company before filtering.

## `employmentTypes` (type: `array`):

Keep positions whose employment-type code matches one of these, case-insensitive and with `-`/`_`/space treated as the same character, e.g. \["permanent"] or \["intern", "working\_student"]. Codes seen on live career sites: `permanent`, `fixed_term`, `intern`, `working_student`, `trainee`, `freelance`. The platform publishes no closed list, so run `summary` grouped by employment type when a filter returns fewer rows than you expect.

## `seniorities` (type: `array`):

Keep positions whose seniority code matches one of these, e.g. \["student"], \["entry-level"] or \["experienced"]. Hyphen and underscore are interchangeable here, so `entry_level` and `entry-level` are the same value. Part of the postings leave the field empty - those are dropped by this filter and visible as the `(none)` group of a seniority summary.

## `schedule` (type: `string`):

The source stores a third value next to full-time and part-time for postings an employer opened for either, which is why `full-or-part-time` is a value of its own here instead of a guess. Pick `part-time` and you get the genuinely part-time postings only; pick `full-or-part-time` to find the flexible ones a plain full/part split hides.

## `occupationCategories` (type: `array`):

Keep positions whose platform job family contains one of these, e.g. \["it\_software"], \["sales\_and\_business\_development"], \["marketing\_and\_product"], \["engineering"], \["finance"]. Unlike department this code comes from the platform's own taxonomy, so it is comparable across employers - the right filter when you scan many career sites for the same kind of role. Postings the employer did not classify carry `other`.

## `keywordsAny` (type: `array`):

Keep positions carrying one of these skill keywords, case-insensitive and matched as whole words inside a keyword, e.g. \["python", "kubernetes"]: `SQL` keeps a posting tagged `SQL` or `SQL Server` but not one tagged only `PostgreSQL`, while `software` still finds `Software Development`. Employers fill the keyword list of a posting themselves (seen live: `ROS,python,c++,robotics,perception,UAV,Jetson Nano NX,Linux,NVIDIA`) and many leave it empty, so treat it as a sharp filter on the boards that use it, not as a complete skills index. `summary` grouped by skill keyword lists what a set of companies really tags.

## `onlySalaryDisclosed` (type: `boolean`):

Keep only positions where the employer published a salary figure. The source carries a minimum amount, a currency and a period (`hourly`, `monthly`, `yearly`), and on a small share of postings an upper bound as well - so these rows answer "who discloses pay and from how much", the pay-transparency question. `salaryIsMinimum` tells the two apart: true when the employer named a floor and no ceiling, false when `salaryMax` carries one.

## `postedWithinDays` (type: `integer`):

Keep positions the employer created in the last N days - the "who started hiring recently" filter behind hiring-signal and lead-generation pipelines. The source gives the creation moment with a clock time, converted to UTC here, so windows of a day or two work as well. Empty = no age limit. Note that the date is when the posting was created, not when it was last edited or re-advertised.

## `onlyNew` (type: `boolean`):

Remember career site and position id in a named key-value store in your account (`personio-jobs-state`, or `personio-jobs-state-<task id>` when a task starts the run, so two schedules do not blind each other) and write only positions that were not there on an earlier run. The first run writes everything it finds, later runs write what appeared since; positions cut off by the row limit come back next time. Runs started in the same second share one memory snapshot, so schedule a watchlist sequentially. Only for the job rows mode.

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

Ask the feed for one language version of the board. Titles and description sections come back translated where the employer maintains that translation and in the original language where it does not. Asking for a language can also change the set of positions: a board answered with 13 positions in English and 11 in German on 29.09.2026, because a posting the employer did not publish for a language is left out. `source` (the default) asks for nothing and gives you the complete board in its published languages.

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

Add `descriptionSections` (the employer's own section names with their text, e.g. *Your mission*, *What you need to be successful*), plus `descriptionHtml` and `descriptionText` of the whole posting, to every job row. The texts arrive in the same request, so this costs no extra call - it only makes rows much larger, which is why it is off by default. Some postings, and some language versions of a posting, carry no sections at all; those rows get empty values instead of a guess.

## `sortBy` (type: `string`):

Order of the rows before *Max rows* cuts the list. With several career sites the rows are sorted across all of them, after *Max rows per career site* was applied. In `summary` mode rows are ordered by job count, largest group first.

## `maxItems` (type: `integer`):

Stop after this many rows in total. Boards on this platform are employer-sized: 1-15 positions for a small company, 20-80 for a mid-size one, a few hundred for a large group. Raise it when you watch a long list of career sites.

## `maxItemsPerCompany` (type: `integer`):

Cap the rows taken from each career site before the global *Max rows*, so one large employer cannot fill the whole dataset when you watch a list of companies. Empty = no per-company cap.

## `fields` (type: `array`):

Keep only these fields, in this order, e.g. \["companySlug", "title", "office", "createdAt", "url"]. Empty = every field the mode produces.

## Actor input object example

```json
{
  "companies": [
    "carbmee",
    "stark",
    "deskbird"
  ],
  "mode": "jobs",
  "groupBy": "department",
  "schedule": "any",
  "onlySalaryDisclosed": false,
  "onlyNew": false,
  "language": "source",
  "includeDescription": false,
  "sortBy": "createdDesc",
  "maxItems": 50
}
```

# Actor output Schema

## `results` (type: `string`):

No description

# 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": [
        "carbmee",
        "stark",
        "deskbird"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("yadroo/personio-jobs").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": [
        "carbmee",
        "stark",
        "deskbird",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("yadroo/personio-jobs").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": [
    "carbmee",
    "stark",
    "deskbird"
  ]
}' |
apify call yadroo/personio-jobs --silent --output-dataset

```

## MCP server setup

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

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/Ze5Xq6zXXvngXMM0w/builds/eV4BUkAzcvOthEygk/openapi.json
