Personio Jobs by Company: Career Site Feed API
Pricing
from $0.84 / 1,000 job row returneds
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.
Pricing
from $0.84 / 1,000 job row returneds
Rating
0.0
(0)
Developer
Samat Makatov
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
a day ago
Last modified
Categories
Share
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
postedWithinDaysandonlyNew, 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:
onlySalaryDisclosedkeeps 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 agenturlso 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 |
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 |
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 |
seniorities | string[] | all | Platform codes: student, entry-level, experienced, executive — see 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 |
keywordsAny | string[] | all | Keep positions carrying one of these skill keywords, matched as whole words — see 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 |
language | string | source | source, en, de, fr, es, it, nl, pt — see 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:
carbmeecarbmee.jobs.personio.comhttps://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-statefor 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
{ "companies": ["carbmee"], "mode": "jobs", "maxItems": 25 }
Engineering roles across several employers
{ "companies": ["carbmee", "stark", "deskbird", "tozero"], "titleKeywords": ["engineer"], "maxItems": 30 }
Working-student and internship postings
{ "companies": ["anton", "tozero", "stark"], "employmentTypes": ["working_student", "intern"], "maxItems": 30 }
IT and software roles on the platform's own taxonomy
{ "companies": ["carbmee", "stark", "deskbird", "personio"], "occupationCategories": ["it_software"], "maxItems": 30 }
Postings tagged with a specific skill
{ "companies": ["optiply", "carbmee", "stark"], "keywordsAny": ["SQL"], "maxItems": 15 }
Hiring signal: what opened in the last 60 days
{ "companies": ["carbmee", "stark", "deskbird", "optiply", "anton", "fact-finder"], "postedWithinDays": 60, "onlyNew": false, "maxItems": 25 }
Positions with a published salary
{ "companies": ["anton", "stark"], "onlySalaryDisclosed": true, "maxItems": 20 }
Open jobs per department
{ "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:
{"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 |
schedule | string|null | yes (job rows) | full-time, part-time, full-or-part-time |
seniority | string|null | no | Platform code, see 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):
{"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
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}'
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();
from apify_client import ApifyClientclient = 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 has the details.
Made by Yadroo. Sibling actors: greenhouse-jobs, workable-jobs, recruitee-jobs, ashby-job-postings, hh-kz-vacancies.