Germany's federal employment agency (Bundesagentur für Arbeit) runs the country's biggest job board, with hundreds of thousands of open positions. If you're building a job alert, a salary analysis or a dashboard of who's hiring in your city, you don't need to scrape HTML. The website runs on a JSON API you can call yourself.
This post covers:
- Calling the API directly with Python (
requests) and Node.js (fetch). - The pitfalls you'll hit once you go past a quick demo.
- A ready-made Apify Actor that handles them, if you'd rather not maintain that part yourself.
Disclosure: I built the Actor in part 3 (Arbeitsagentur Jobs API on Apify). Parts 1 and 2 work without it.
1. The raw API
The job search at arbeitsagentur.de/jobsuche calls this endpoint:
GET https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v6/jobs Header: X-API-Key: jobboerse-jobsuche The X-API-Key isn't a personal secret. It's the public client ID the agency's own web app sends, and the community-maintained bundesAPI/jobsuche-api project documents it too. You don't need to sign up for anything.
Useful query parameters:
| Parameter | Meaning |
|---|---|
was | What: keywords or a job title (Python, Pflegefachkraft, …) |
wo | Where: a city or postcode |
umkreis | Radius in km around wo |
veroeffentlichtseit | Only postings from the last n days (0–100) |
size, page | Paging (page starts at 1) |
Python
import base64 import requests BASE = "https://rest.arbeitsagentur.de/jobboerse/jobsuche-service" HEADERS = {"X-API-Key": "jobboerse-jobsuche"} # public client id used by the official web app def search(was, wo, umkreis=25, veroeffentlichtseit=7, size=50, max_pages=3): for page in range(1, max_pages + 1): r = requests.get(f"{BASE}/pc/v6/jobs", headers=HEADERS, timeout=30, params={ "was": was, "wo": wo, "umkreis": umkreis, "veroeffentlichtseit": veroeffentlichtseit, "size": size, "page": page, }) r.raise_for_status() data = r.json() jobs = data.get("ergebnisliste", []) yield from jobs if page * size >= int(data.get("maxErgebnisse", 0)) or not jobs: break for job in search("Python", "Berlin", max_pages=1, size=5): loc = job.get("stellenlokationen", [{}])[0].get("adresse", {}) ref = job["referenznummer"] print(job["stellenangebotsTitel"], "|", job.get("firma"), "|", loc.get("ort"), "|", f"https://www.arbeitsagentur.de/jobsuche/jobdetail/{ref}") # Details for a single posting: the reference number goes base64-encoded into the path detail_id = base64.b64encode(ref.encode()).decode() d = requests.get(f"{BASE}/pc/v4/jobdetails/{detail_id}", headers=HEADERS, timeout=30) print("detail status:", d.status_code, "| keys:", sorted(d.json())[:8] if d.ok else d.text[:100]) Output (03.11.2026, shortened):
Senior Python Developer – Microservices & Data Pipelines (m/w/d) | BWI GmbH | Berlin | https://www.arbeitsagentur.de/jobsuche/jobdetail/12336-a26f539j0456738-S Versuchsingenieur (m/w/d) | FERCHAU GmbH Niederlassung Berlin | Berlin | https://www.arbeitsagentur.de/jobsuche/jobdetail/12265-485458_JB5289084-S ... detail status: 200 | keys: ['aenderungsdatum', 'allianzpartnerName', 'allianzpartnerUrl', ...] Node.js (18+, no dependencies)
const BASE = 'https://rest.arbeitsagentur.de/jobboerse/jobsuche-service'; const headers = { 'X-API-Key': 'jobboerse-jobsuche' }; const params = new URLSearchParams({ was: 'Python', wo: 'Berlin', umkreis: '25', veroeffentlichtseit: '7', size: '5', page: '1', }); const res = await fetch(`${BASE}/pc/v6/jobs?${params}`, { headers }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); console.log(`${data.maxErgebnisse} results in total`); for (const job of data.ergebnisliste ?? []) { const city = job.stellenlokationen?.[0]?.adresse?.ort; console.log(`${job.stellenangebotsTitel} | ${job.firma} | ${city}`); } Each item in ergebnisliste has fields such as stellenangebotsTitel (title), firma (employer), referenznummer (reference number), stellenlokationen[].adresse (plz, ort, region), veroeffentlichungszeitraum.von (published date), arbeitszeitVollzeit (full-time) and homeofficemoeglich (remote possible). For the full description and salary, call the details endpoint /pc/v4/jobdetails/{base64(referenznummer)}.
2. Pitfalls once you go beyond a demo
A one-off query is easy. A pipeline that runs every day for months hits these:
- Result cap. The API returns at most about 10,000 results per query (page × size). A broad search like "all IT jobs in Germany" gets cut off silently. The fix is to split the query, for example by federal state or by location, and deduplicate the results.
- German field names, nested structures.
stellenangebotsTitel,stellenlokationen[0].adresse.ort,veroeffentlichungszeitraum.von… You'll want to map these to a flat, stable schema before anything downstream depends on them. - Details cost one request per job. Descriptions and salaries need a second call for every posting, so you need concurrency limits and retries.
- "Only new since yesterday". For alerts you have to store the reference numbers you've already seen.
- It's not an officially supported public API. Search (
v6) and details (v4) already use different versions. Pin the paths and alert on errors. - Contact data is personal data. Descriptions often contain recruiters' e-mail addresses and phone numbers. If you don't need them, don't store them (GDPR).
If you only need a few hundred jobs now and then, the script above is enough. Copy it and move on.
3. Ready-made option: the Apify Actor
I needed this pipeline myself, so I packaged it as an Apify Actor. It:
- automatically splits nationwide searches over 10,000 results by federal state,
- maps the output to flat, English field names, with salary as
{type, period, min, max}, - fetches details concurrently (optional,
includeDetails), - remembers postings it has already seen (
onlyNew), and - by default replaces e-mail addresses and phone numbers in descriptions with placeholders.
You run it with the official apify-client and get a dataset back.
Get a token from a free Apify account (Console → Settings → API & Integrations) and keep it in an environment variable:
export APIFY_TOKEN=your_token_here Python (pip install apify-client)
"""Fetch German job listings (Bundesagentur für Arbeit) and save them as CSV. Actor: https://apify.com/paperlinenstudio/arbeitsagentur-jobs-api Usage: APIFY_TOKEN=... python jobs_to_csv.py """ import csv import os from apify_client import ApifyClient client = ApifyClient(os.environ["APIFY_TOKEN"]) run = client.actor("paperlinenstudio/arbeitsagentur-jobs-api").call( run_input={ "keywords": ["Python Entwickler"], "locations": ["Berlin"], "radiusKm": 25, "publishedWithinDays": 7, # only jobs from the last week "maxItems": 20, } ) jobs = list(client.dataset(run.default_dataset_id).iterate_items()) with open("jobs.csv", "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["title", "employer", "city", "salary_min", "salary_max", "published", "url"]) for job in jobs: salary = job.get("salary") or {} writer.writerow([ job["title"], job["employer"], (job.get("location") or {}).get("city"), salary.get("min"), salary.get("max"), job["publishedFrom"], job["url"], ]) print(f"Saved {len(jobs)} jobs to jobs.csv") for job in jobs[:3]: print("-", job["title"], "|", job["employer"]) Saved 2 jobs to jobs.csv - Pythonentwickler:in (Schwerpunkt Api- und GIS-Entwicklung) | aconium GmbH Hauptsitz Berlin - DevOps Softwareentwickler (m/w/d) | FERCHAU GmbH Niederlassung Berlin Where the employer gives a salary, it comes back as numbers (the second job: 50000–75000), so there's no free text to parse.
Node.js (npm i apify-client)
// Fetch German job listings (Bundesagentur für Arbeit) and save them as CSV. // Usage: APIFY_TOKEN=... node jobs_to_csv.mjs import { writeFileSync } from 'node:fs'; import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('paperlinenstudio/arbeitsagentur-jobs-api').call({ keywords: ['Python Entwickler'], locations: ['Berlin'], radiusKm: 25, publishedWithinDays: 7, maxItems: 20, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const esc = (v) => `"${String(v ?? '').replaceAll('"', '""')}"`; const header = ['title', 'employer', 'city', 'salary_min', 'salary_max', 'published', 'url']; const rows = items.map((j) => [ j.title, j.employer, j.location?.city, j.salary?.min, j.salary?.max, j.publishedFrom, j.url, ].map(esc).join(',')); writeFileSync('jobs.csv', [header.join(','), ...rows].join('\n') + '\n'); console.log(`Saved ${items.length} jobs to jobs.csv`); for (const j of items.slice(0, 5)) console.log(`- ${j.title} | ${j.employer}`); Inputs worth knowing
{ "keywords": ["Data Engineer", "Data Scientist"], "locations": ["München", "Hamburg"], "radiusKm": 30, "publishedWithinDays": 1, "workingTime": ["full_time", "remote"], "includeDetails": true, "onlyNew": true, "maxItems": 500 } Add a daily schedule in the Apify Console and you have a job alert. Point a webhook at Slack, a Google Sheet or your own endpoint and you're done.
Wrap-up
- Small and occasional: call
rest.arbeitsagentur.dedirectly with the snippets from part 1. - Recurring, large or needs a clean schema: use the Arbeitsagentur Jobs API Actor or borrow its ideas (state splitting, seen-ID store, field mapping).
All the Actor examples are also in a small MIT-licensed examples repo. Questions or feature requests are welcome in the comments.



