Personio Jobs Scraper — Company Job Boards avatar

Personio Jobs Scraper — Company Job Boards

Pricing

from $1.45 / 1,000 posting saveds

Go to Apify Store
Personio Jobs Scraper — Company Job Boards

Personio Jobs Scraper — Company Job Boards

Every open position on any company's Personio job page as structured data: title, company, department, offices, employment type, pay where stated, date created, full text on request in the language you choose. One request per board. Incremental mode charges only for what changed. No personal data.

Pricing

from $1.45 / 1,000 posting saveds

Rating

0.0

(0)

Developer

Adderley Data

Adderley Data

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

4 hours ago

Last modified

Share

What does Personio Jobs Scraper do?

Personio Jobs Scraper reads every open position on any company's public Personio job page and returns it as structured data you can load straight into a spreadsheet, a database or a model. If a company's jobs live at <name>.jobs.personio.de or <name>.jobs.personio.com — or on its own domain with Personio behind it — this Actor reads them, given the company's Personio name.

Give it the company's Personio name (acme) or any acme.jobs.personio.de link, and for each job you get the title, the company Personio names with it, the department, every office it lists, the employment type, the pay where the company states it, the date the job was created and a link to the job — and, if you ask for it, the full text of the job in the language you choose.

What makes it different:

  • One request per board. The whole board, and every job's full text with it, comes back in a single call to Personio's public XML feed of open positions — the feed Personio documents for companies that show their jobs on their own website. No page-by-page crawling and nothing to break when a job page is redesigned. A thousand jobs across fifty companies is fifty requests.
  • The language you choose. Personio keeps each job's text per language. The Actor reads English unless you choose another language; titles, offices and the other facts come back either way.
  • Pay as numbers. Where a company fills in Personio's salary information, the amount comes back as min, max, currency and period, with salary.raw written out (from €2,000 per month). Where it does not, but the job's text states a range, that range is read and its sentence kept in salary.raw. Where no pay is stated anywhere, the fields are null; nothing is estimated.
  • Incremental mode. Put the Actor on a schedule and each run returns only jobs that are new, changed, back again or gone. The Actor compares each job with the last run — title, company, offices, department, employment type, pay, the date created and every word of the text. Unchanged jobs are skipped and not charged.
  • One stable schema. Every row has every field, every time. Unknown is null, never a missing key, so nothing downstream breaks on a sparse job. The schema is versioned (job.v1) and every Adderley Data jobs Actor uses it, so a Personio board, a Recruitee board and a job board sit in the same table.
  • No personal data. Personio job text often ends with a section naming the job's contact person, with an email address and a phone number. This Actor never reads that section, never reads the company's search keywords, and has nowhere to put a person. Contact details elsewhere in job text are redacted by default.
  • Bounded cost. You set a maximum number of results; the run stops there. It also stops at the spending limit you set on the run in Apify.

What Personio data can you extract?

FieldWhat it holds
idStable across runs: source:market:sourceJobId. Use it as your primary key.
titleJob title as listed.
company.nameThe hiring company, where the listing names one.
advertiser.nameThe business that placed the listing — often a recruitment agency. Never a person.
location.rawLocation text as listed. Several locations are joined with |.
location.suburbSuburb, when the listing states one.
location.cityCity or area, when the listing states one.
location.regionState or region, e.g. VIC.
location.postcodePostcode, when the source provides it.
location.countryISO 3166-1 alpha-2 country code.
workArrangementon_site, hybrid, remote or unknown.
employmentTypesNormalised: full_time, part_time, contract, casual, temporary, internship, volunteer.
salary.rawThe salary text exactly as shown, or null when the listing shows none.
salary.minLower bound as a number, when the text contains one.
salary.maxUpper bound as a number. Equal to min for a single figure.
salary.currencyISO 4217. Taken from the text, otherwise the market default.
salary.periodhour, day, week, month or year; null when the text does not say.
salary.includesSupertrue / false when the text says so ("plus super", "inc. super"); otherwise null.
classificationsThe source's category and subcategory pairs.
teaserThe short summary shown on the results page.
bulletPointsSelling points shown on the results page.
postedAtWhen the listing was posted, ISO 8601 UTC.
updatedAtThe source's own last-modified time, ISO 8601 UTC. Published by ATS and API sources; null where the site does not show one.
expiresAtExpiry, ISO 8601 UTC, where the source states one.
isPromotedtrue for paid placements. A listing shown both promoted and organic is returned once.
urlLink to the listing.
descriptionNull unless requested. text, optional sanitised html, and contactsRedacted.
changeTypeIncremental runs: NEW, UPDATED, REAPPEARED, EXPIRED (or UNCHANGED if you ask for those). Otherwise null.
firstSeenAtIncremental runs: when this monitor first saw the listing.
contentHashSHA-256 over the fields that define a change. Compare it to detect edits yourself.
scrapedAtWhen this row was produced, ISO 8601 UTC.
sourceSource key, e.g. seek.
marketMarket key, e.g. au, nz.
sourceJobIdThe source's own identifier for the listing.
company.sourceCompanyIdThe source's identifier for the company, when exposed.
company.urlThe company's page on the source site, when exposed.
advertiser.sourceAdvertiserIdThe source's identifier for the advertiser.
schemaVersionAlways job.v1. Breaking changes ship as job.v2 in a new Actor version, never silently.

How Personio's fields fill the schema:

  • company.name is the company Personio names with each job — the subcompany that advertises it, such as Example Freight GmbH. Where Personio names none, it is the board's Personio name. company.sourceCompanyId is the board's Personio name and company.url its job page on Personio.
  • url is the job's own page on Personio, /job/<id> on the board's host, with the language the run read (?language=en), in the form Personio itself names as the page's canonical address.
  • location.raw is the job's primary office, then every additional office, as the company names them in Personio (Hamburg, Head Office - Dublin, Remote), joined with |. Personio gives office names, not addresses, so city, region and postcode are null. location.country is filled only where an office's name states the country in words (Hybrid - Barcelona, Spain gives ES); it is never worked out from a city.
  • classifications holds the job's department as category. subcategory is null.
  • employmentTypes comes from Personio's schedule and employment type together: full-time gives full_time, part-time part_time, full-or-part-time both; freelance adds contract, temporary adds temporary, an internship adds internship. A permanent job, a working-student job and a traineeship add nothing the schedule does not say.
  • workArrangement is unknown: Personio's feed has no field for it. An office named Remote or Hybrid - London is still in location.raw, and the Locations filter reads it.
  • postedAt is when the job was created in Personio. Personio's feed carries no last-modified time and no closing date, so updatedAt and expiresAt are null.
  • market is always global: a Personio board is the company's, not a country's.

How much does it cost to scrape Personio job boards?

You pay per job saved to your dataset — $1.75 per 1,000 jobs on Apify's Starter plan — plus $0.005 each time a run starts. There is no monthly rental.

Apify planPricePer listing
Free$1.75 per 1,000 listings$0.00175
Starter (Bronze)$1.75 per 1,000 listings$0.00175
Scale (Silver)$1.60 per 1,000 listings$0.00160
Business (Gold)$1.45 per 1,000 listings$0.00145

Plus $0.005 per run start. Compute and proxy are included in these prices.

What you runCost (USD, Starter plan)
100 listings, one run$0.18
1,000 listings, one run$1.75
10,000 listings, one run$17.50
50,000 listings, one run$87.50
A daily incremental monitor finding about 150 new or changed listings a day, for a month$8.03

Descriptions cost nothing extra here: they arrive in the same request as the listing. Use incremental mode for anything you run more than once — after the first run you pay only for what changed.

How to scrape a Personio job page

  1. Find the company's Personio name. It is the first part of the address in https://acme.jobs.personio.de: here, acme. A job link (acme.jobs.personio.de/job/<id>) works too, on .de or .com.
  2. Open the Actor in Apify Console and go to the Input tab. Paste one name or link per line into Job boards. Up to 500 boards per run.
  3. Choose the Language of the job text if it is not English — de for German, for example.
  4. Optionally filter: Title keywords, Locations, Departments, Created within (days).
  5. Set Maximum results. This is also your cost cap.
  6. Press Start. When the run finishes, open the Output tab and export as JSON, CSV, Excel, XML or HTML, or read the dataset through the Apify API.

A job that matches more than one board or filter is returned once.

Input

FieldTypeDefaultWhat it does
boardsarray—One entry per company: its Personio name (in https://acme.jobs.personio.de it is "acme"), or any link on that page — the job page itself, one of its jobs, or the XML feed. Links on acme.jobs.personio.com work too, and rows then link to that host. A company that shows its jobs on its own domain (careers.example.com) still has a Personio name, and that name is needed: the company's own domain does not say which Personio account is behind it. Up to 500 boards per run; each is one request.
languagestring"en"The language the job text is read in, as a two-letter code: "en" for English, "de" for German, and so on. Personio keeps each job's text per language and returns no text for a job not written in the language asked for; titles, offices and the other facts come back either way. English by default.
keywordsarray—Keep jobs whose title contains every word of any keyword, in any order — "engineer data" matches "Senior Data Engineer". Leave empty for all titles.
locationsarray—Keep jobs with an office whose name contains this text ("Munich", "Remote"), or open in this country where an office's name states it ("Spain" for "Hybrid - Barcelona, Spain"). Every office a job lists counts. Leave empty for all locations.
departmentsarray—Keep jobs whose department contains this text, e.g. "Engineering". Leave empty for all departments.
postedWithinDaysinteger—Keep jobs created on Personio in the last N days. Leave empty for any time.
maxResultsinteger100The run stops once this many jobs are saved. You are charged per job saved, so this is also your cost cap.
includeDescriptionbooleanfalseOn: the full job text — each section of it under its own heading — comes back with each row, read from the same request as the listing, so it costs no extra requests. Off: listing fields only.
descriptionFormattext, text_and_html"text"Plain text, or plain text plus sanitised HTML.
redactContactsbooleantrueOn by default: email addresses and phone numbers inside description text are replaced with [redacted]. A section Personio headed as the contact person is never included, under any setting, and this Actor never outputs recruiter names or contact fields.
incrementalbooleanfalseRemember what earlier runs saw and save only jobs that are new, changed or gone. Unchanged jobs are skipped and not charged. Put the Actor on a schedule with this on.
stateKeystring—Optional name for this monitor, e.g. "competitor-engineering". Runs with the same key share memory. Left empty, a key is derived from the boards, the language and the filters themselves.
emitExpiredbooleantrueIncremental mode only. When a complete run no longer finds a job it saw before, save one row with changeType EXPIRED.
emitUnchangedbooleanfalseIncremental mode only. Saves (and charges for) every job, labelled UNCHANGED where nothing moved.
proxyConfigurationobject{"useApifyProxy":true}Apify Proxy, automatic group, is the default and is what this Actor is tested with.
maxConcurrencyinteger4Parallel requests. The default is deliberately modest.
maxRequestsPerMinuteinteger90An upper bound on request rate across the whole run.

A typical input:

{
"boards": [
"jtl-software-gmbh"
],
"maxResults": 100
}

The prefilled board, jtl-software-gmbh, is one company's public Personio job page, used here only as an example of a public Personio board. This Actor is not affiliated with that company. The whole board comes in one request.

Output

One row per job. This is a synthetic example in the exact shape the Actor returns:

{
"schemaVersion": "job.v1",
"id": "personio:global:2900001",
"source": "personio",
"market": "global",
"sourceJobId": "2900001",
"url": "https://example-freight.jobs.personio.de/job/2900001?language=en",
"title": "Data Analyst",
"company": {
"name": "Example Freight GmbH",
"sourceCompanyId": "example-freight",
"url": "https://example-freight.jobs.personio.de"
},
"advertiser": {
"name": "Example Freight GmbH",
"sourceAdvertiserId": "example-freight"
},
"location": {
"raw": "Hamburg | Remote - Germany",
"suburb": null,
"city": null,
"region": null,
"postcode": null,
"country": "DE"
},
"workArrangement": "unknown",
"employmentTypes": [
"full_time"
],
"salary": {
"raw": "from €4,200 per month",
"min": 4200,
"max": null,
"currency": "EUR",
"period": "month",
"includesSuper": null
},
"classifications": [
{
"category": "Data",
"subcategory": null
}
],
"teaser": null,
"bulletPoints": [],
"postedAt": "2026-09-20T22:14:05.000Z",
"updatedAt": null,
"expiresAt": null,
"isPromoted": false,
"description": null,
"changeType": "NEW",
"firstSeenAt": "2026-09-21T19:30:12.000Z",
"contentHash": "fd1bf65f21870a3b0e01a2db4ced8bb1609550fd58db24e02eacbdbbd61faed3",
"scrapedAt": "2026-09-21T19:30:12.000Z"
}

The Output tab has two table views: Overview (the fields most people want, flattened) and Changes (for incremental runs).

Incremental mode: monitor new roles across companies

Turn on Incremental mode and run the same input on a schedule — hourly, daily, weekly. The Actor keeps a small record of what it has seen and every row tells you what happened:

changeTypeMeaning
NEWFirst time this monitor has seen the job
UPDATEDSeen before, and the title, company, offices, department, employment type, pay, date created or any word of the job's text has changed
REAPPEAREDWas reported as expired and is back
EXPIREDSeen before and no longer on the board. One row, once
UNCHANGEDOnly if you turn on Also save unchanged jobs

How it behaves, so there are no surprises:

  • The first run returns everything as NEW. From the second run you pay only for the difference.
  • A change is found by comparing the job itself. An edit to the text counts: a corrected typo is reported as UPDATED and charged like any other change. A change of formatting alone does not count, and neither does a new order of jobs on the board or a change to a field the Actor does not deliver, such as the company's search keywords.
  • EXPIRED is only ever reported by a complete run. If a run hits your result cap or your spending limit, or a board cannot be read, nothing is declared expired — a job on a board the run never read is not gone.
  • Each board is read in one response, so a run compares whole boards, never pages read seconds apart.
  • Runs share memory when they share a State key. Leave it empty and the key is derived from the boards, the language and the filters themselves, so the same input always continues the same monitor. A board given by name and by a .com link is the same board. Name the key (competitor-engineering) if you want to change filters later without starting again.
  • A job not seen for 45 days is forgotten.

Descriptions and contact details

Full descriptions are off by default. Turn on Include full descriptions and each row carries description.text (and sanitised description.html if you choose that format): every section of the job's text under its own heading, in the order Personio gives them. Because Personio returns the text in the same response as the listing, this costs no extra requests and no extra time.

A job has text only in the languages its company wrote it in. Read a board in a language a job was not written in and that job comes back with its facts and description set to null. On many German companies' boards the text is German only: choose de for those.

Personio job text often closes with a section headed with the job's contact person — "Ihr/e Ansprechpartner/in", "Ansprechpartner:in", "Contact" and the like — holding a recruiter's name, email address and phone number. That section is never read, under any setting. Elsewhere in the text, with Redact contact details on — the default — email addresses and phone numbers are replaced with [redacted] and description.contactsRedacted is true; so is a personal profile address (linkedin.com/in/…). Neither description.text nor the optional HTML carries the address behind a link: the HTML keeps each link's words and drops its address, and drops images. The Actor never returns recruiter names or contact details as fields, and the schema has nowhere to put a person. If your use case is contacting individuals, this is the wrong tool.

What people use it for

  • Competitor and market hiring signals. Which companies are opening which roles, in which departments and offices, and how often — a daily monitor across a list of boards is one scheduled run.
  • Hiring data from German-speaking companies. Most Personio boards we have read publish in German by default; the language setting reads each board in German, English or another language its company writes in.
  • Job aggregators and alert products. A clean feed of new jobs from a curated list of employers, deduplicated and labelled by change.
  • Sales and partnership research at company level. Growth signals from hiring, without collecting anything about the individuals involved.
  • Research and teaching. A clean, repeatable dataset with a documented schema.

Using the API

Run it from code with the Apify client, using your own API token:

import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('adderleydata/personio-jobs-scraper').call({"boards":["jtl-software-gmbh"],"maxResults":100});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items.length, items[0]?.location);
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("adderleydata/personio-jobs-scraper").call(run_input={"boards":["jtl-software-gmbh"],"maxResults":100})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
print(item["title"], item["location"]["raw"], item["postedAt"])

Schedules, webhooks and the Make, Zapier, n8n and Google Sheets integrations all work the way they do for any Apify Actor. The Actor runs with limited permissions and is priced per event, so AI agents can call it through Apify's MCP server as well.

The Actor reads Personio's public XML feed of open positions — the feed Personio documents for companies that list their jobs on their own website, with no login, no key and no rate tricks — and returns facts about job postings. It does not log in, does not solve CAPTCHAs, does not submit applications and does not collect personal information.

What you do with the data is your responsibility. Each company's job text is its own copyright — analyse it, do not republish it. If your project touches personal information, privacy law applies to you wherever you are. This is general information, not legal advice.

Questions

Where do I find a company's Personio name? Open any job on the company's Personio job page. If the address is acme.jobs.personio.de/job/<id>, the name is acme; paste the whole link or just the name.

The company's careers site is on its own domain. Many companies show their Personio jobs at an address such as careers.example.com. That address does not say which Personio account is behind it, so the Actor asks for the Personio name instead of guessing, and never sends a request to a domain that is not a Personio job page. If the careers site links to <name>.jobs.personio.de anywhere, that is it. An address like acme.personio.de, without jobs, is the company's Personio login, not its job page.

A board I gave came back as "not found". Personio answers a name it does not know by sending the request to its own website, personio.com, and the Actor reads where the request ended up before anything else: the board is reported as not found once, not retried and not counted as a block, and the other boards in the run are unaffected. That holds even when Personio's website answers with a 429 challenge page instead of its home page. A name whose job feed answers an empty "404 Not Found" is reported the same way, as a company that publishes no feed under that name. Either way, check the name: it is the part before .jobs.personio.de in the company's Personio job links.

Does it need a Personio login or API key? No. The XML feed of open positions is public.

Why is description null for some jobs? The job has no text in the language the run read. Choose the language its company writes in — de for most German companies — or leave descriptions off.

Why is employmentTypes sometimes empty? It is empty when Personio states neither a schedule nor an employment type it recognises — nothing is guessed.

Why is workArrangement always unknown? Personio's feed has no field for it. Where a company names an office Remote or Hybrid - London, that name is in location.raw, and the Locations filter finds it.

Can I get recruiter emails or phone numbers? No, by design.

How current is the data? It is read from Personio while your run is in progress. postedAt is when the job was created in Personio; scrapedAt records when the row was produced.

The field I need is not there. Open an issue on the Issues tab. Fields are added to the schema without breaking existing ones.

Support

Use the Issues tab on this page. We read it every day. Include the run ID and what you expected to see.

Other Adderley Data Actors

Every Actor in a vertical returns the same fields, so adding a source needs no new code on your side.

About

Made by Adderley Data, Melbourne — https://adderleydata.com. Not affiliated with, endorsed by or sponsored by Personio. Personio is a trade mark of its owner and is used here only to describe what this Actor reads.