Personio Jobs by Company: Career Site Feed API avatar

Personio Jobs by Company: Career Site Feed API

Pricing

from $0.84 / 1,000 job row returneds

Go to Apify Store
Personio Jobs by Company: Career Site Feed API

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

Samat Makatov

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

14 days ago

Last modified

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

FieldTypeDefaultAllowed values / notes
companiesstring[]requiredCareer-site slugs, e.g. ["carbmee", "stark", "deskbird"]. A full career-site or job URL works too — the slug is read out of it. An entry that is no slug becomes a found: false row and the others still run. See Finding the slug
modestringjobsjobs = a row per open position · summary = a row per group with its open-job count
groupBystringdepartmentOnly in summary mode: department, office, recruitingCategory, employmentType, schedule, seniority, occupationCategory, occupation, keyword
titleKeywordsstring[]allKeep a position whose title contains any of these words (case-insensitive)
excludeTitleKeywordsstring[]noneDrop a position whose title contains any of these words
departmentsstring[]allDepartment contains any of these. Free text per employer — see Vocabularies
officesstring[]allOffice contains any of these; matched against the main and the further offices
recruitingCategoriesstring[]allRecruiting category contains any of these. Free text per employer
employmentTypesstring[]allPlatform codes, whole-value match: permanent, fixed_term, intern, working_student, … see Employment type
senioritiesstring[]allPlatform codes: student, entry-level, experienced, executive — see Seniority
schedulestringanyany, full-time, part-time, full-or-part-time
occupationCategoriesstring[]allPlatform job family, e.g. it_software — see Occupation category
keywordsAnystring[]allKeep positions carrying one of these skill keywords, matched as whole words — see Skill keywords
onlySalaryDisclosedbooleanfalseKeep only positions with a published salary figure
postedWithinDaysintegerno limit1–3650. Keep positions created in the last N days (UTC)
onlyNewbooleanfalseWrite only positions this actor has not returned before (jobs mode) — see Watching for new postings
languagestringsourcesource, en, de, fr, es, it, nl, pt — see Language
includeDescriptionbooleanfalseAdd descriptionSections, descriptionHtml, descriptionText to every row
sortBystringcreatedDesccreatedDesc, createdAsc, titleAsc, departmentAsc, officeAsc, feedOrder
maxItemsinteger501–5000 rows in total
maxItemsPerCompanyintegerno cap1–2000 rows per career site, applied before maxItems
fieldsstring[]allKeep only these output columns, in this order; companySlug and found are always kept. See Choosing columns

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. Only that redirect means "no career site": a redirect anywhere else (another host, a moved feed) is reported as a site that could not be read, so a change on the platform's side never passes for a wrong slug. A redirect that stays on the career site's own host is followed.

An entry that is no slug at all (Not A Slug!, a URL on another host, a career site on the older .jobs.personio.de host) does not stop the run: it becomes one found: false row whose companySlug is the entry as typed and whose message names the fix, and the other entries are read. Only when no entry is a slug does the run stop before any request.

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 named in the status message, the run log and the SUMMARY record; a value that resembles nothing stays as typed and simply matches nothing — the status message says so — 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.

CodeMeaning
permanentOpen-ended contract — the bulk of every board
fixed_termContract with an end date
internInternship
working_studentWorking student next to university studies (common in DACH)
traineeTrainee or apprenticeship programme
freelanceFreelance 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. A part of a code is enough: finance keeps accounting_and_finance. 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, one record per career site, 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 stored are remembered: positions cut off by maxItems, by your maximum cost per run or by the run timeout come back on the next run. A found: false row (a slug without a career site, a site that could not be read, an entry that is no slug, an empty board) is written once and then remembered too, so a watchlist does not pay for the same notice on every run — the status message and SUMMARY still name those sites each time. 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 keys it holds.

Choosing columns

fields keeps only the columns you list, in your order; companySlug and found are always kept so every row still says where it came from and whether it is a real result. Names are checked before the first request: another letter case or a small typo is corrected (Title → title, offfice → office), a name that is a column of the other mode only (jobCount in jobs mode) or of no mode is left out, and both are named in the status message and SUMMARY. When none of the names is a column, the run stops before any request with the list of valid names — rows holding only the two always-columns would be billed for nothing. Asking for descriptionText, descriptionHtml or descriptionSections switches includeDescription on.

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"
}
FieldTypeAlways filledMeaning
companySlugstringyesThe career site the row came from; for an entry that is no slug, the entry as typed
companyNamestring|nullnoLegal entity the employer attached to the posting; empty when it publishes under one name only
positionIdstringyes (job rows)Position id on the career site; the id in url
titlestringyes (job rows)Job title as published
departmentstring|nullnoThe employer's own team name
recruitingCategorystring|nullnoThe employer's own posting category
officestring|nullyes (job rows)Main office label as the employer typed it
additionalOfficesstring[]yesFurther offices of the same posting, main one excluded
officesstring[]yesMain office plus further offices — what the office filter matches
officeCountintegeryesLength of offices
employmentTypestring|nullyes (job rows)Platform code, see Employment type
schedulestring|nullyes (job rows)full-time, part-time, full-or-part-time
senioritystring|nullnoPlatform code, see Seniority
yearsOfExperiencestring|nullnoBand the employer picked, e.g. 2-5, lt-1
occupationstring|nullyes (job rows)Narrow platform job code
occupationCategorystring|nullyes (job rows)Platform job family
keywordsstring[]noEvery skill keyword the employer attached, in source order; empty on postings without any
salaryMinnumber|nullnoPublished amount, a lower bound
salaryMaxnumber|nullnoUpper bound where the employer published one
salaryCurrencystring|nullnoISO currency code, e.g. EUR
salaryCurrencySymbolstring|nullnoSymbol as published, e.g. €
salaryPeriodstring|nullnohourly, monthly or yearly
salaryIsMinimumbooleanyestrue when the figure is a floor with no ceiling
salaryDisclosedbooleanyestrue when the employer published pay at all
createdAtstring|nullyes (job rows)Creation moment, ISO 8601 UTC
daysSincePostedinteger|nullyes (job rows)Whole days since createdAt, never negative
languagestring|nullnoThe language version asked for; empty when the board was read as published
foundbooleanyesfalse on a row that explains a missing result: a slug without a career site, a site that could not be read, an entry that is no slug, an empty board
messagestring|nullnoWhy a row is a found: false row
urlstring|nullyesPublic job page with the application form (career site on summary and found: false rows; empty for an entry that is no slug)
boardUrlstring|nullyesThe career site the row came from (empty for an entry that is no slug)
fetchedAtstringyesMoment 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. The same goes for a career site that could not be read while others could, and for an entry that is no slug. A career site that is served but has no open position gets such a row only when the empty boards are the run's whole answer — nothing else to write and no other career site of the run with a position; otherwise the status message and SUMMARY.emptyBoards name it and no row is billed for it.

Every run also writes a SUMMARY record into the run's key-value store (linked from the run's output as summary): the career sites requested, found, empty and not read (with the reason), the entries that were no slug, positions per board, how many rows the filters cut, every correction that was applied, rows delivered and why the run stopped (stoppedBy: your spending limit, the run timeout, an abort), the request count and — with onlyNew — the state store it used, how many keys it held before the run and how many it holds now.

Dataset views: Open positions (slug, title, department, office, employment type, working time, seniority, created, days online, salary from, job page, message) · Level, pay and skills · Open jobs per group. The dataset schema describes every column with a title, a description and an example, so agents can read the output shape without a run.

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 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. Bronze −10 %, Silver −20 %, Gold and above −30 % on the row price; the start event is the same on every plan; platform usage is included. Every run is charged the start event, also a run that returns a single row or only a found: false row. A summary group and a found: false row are billed like a job row; an empty board is billed only when empty boards are the run's whole answer, and a run that fails because the source answered none of its career sites bills no row at all.

RunRowsCost
One small career site (carbmee, whole board)12$0.001 + 12 × $0.0012 = $0.0154
The three prefilled career sites with the default maxItems: 5050$0.001 + 50 × $0.0012 = $0.061
Department summary of three employers20$0.001 + 20 × $0.0012 = $0.025
A watchlist sweep with postedWithinDays: 7 that finds nothing new0$0.001 (the start event only)

maxItems is a hard stop, so a run can never cost more than $0.001 + maxItems × $0.0012. Your maximum cost per run is respected too: the run stops writing at the last row it pays for and ends with the status "Stopped at your spending limit: N rows delivered" — with onlyNew, the rows it did not deliver come back on the next run. 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.

How many career sites per run? There is no fixed cap on the list; each career site took about half a second in the runs behind this README (up to eight sites per run). A run that nears its timeout stops starting new sites, writes what it has read and ends successfully with "Stopped before the run timeout"; the sites it did not reach are named in the status message and SUMMARY.companiesNotRead. When three career sites in a row give no usable answer, the source is taken as down and the rest of the list is not tried in that run.

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 status message names it, and when empty boards are the run's whole answer a found: false row explains it — the run does not fail). 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. A wrong slug becomes a found: false row with a message and a successful run — also when every slug of the run is wrong. An empty board is named in the status message and SUMMARY, and gets a found: false row only when empty boards are the run's whole answer. A career site that could not be read (throttling, server errors, a check page instead of the feed, a redirect off the career site) gets a found: false row whose message carries the error, and the status message names it. A run fails only when the source answered none of the career sites it tried — then no row is written or billed.

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 — a sweep that finds nothing new costs the start event only; 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.