Quick answer
Teamtailor powers the career sites of thousands of European scale-ups, and unlike most ATS platforms it publishes each customer's open roles as a JSON Feed at https://{subdomain}.teamtailor.com/jobs.json — no key, no login, no browser. The catch is that the useful structured data isn't in the feed envelope at all: each feed item wraps a nested schema.org JobPosting object, and the fields you actually want (location, hiring organization, publication date) live one level down inside it. The Teamtailor Jobs Scraper flattens that nesting across as many company subdomains as you hand it, at $0.0015 per job row plus a $0.005 run-start charge — $1.505 for 1,000 postings.
The feed envelope isn't the job — the nested JobPosting is 📦
A JSON Feed item gives you title, url, and content_html. That's a blog-post shape, because JSON Feed was designed for blogs. Teamtailor reuses it for jobs and puts the real hiring data in an embedded schema.org JobPosting payload attached to each item.
So title at the item level and title inside the JobPosting are two different things that can disagree, and everything a recruiter or market analyst actually filters on — jobLocation.address.addressLocality, addressRegion, addressCountry, hiringOrganization.name, datePublished — exists only in the nested object. A scraper that reads the envelope and stops gets you a list of links and some HTML blobs. We read both levels and emit a flat row: location_locality, location_region, location_country, hiring_organization_name, and a resolved date_published that falls back to the item-level date when the JobPosting omits it.
job_id arrives as a string that isn't always a number 🔢
Teamtailor's job identifiers come through as strings, and the obvious move — int(value) — throws the moment a subdomain sends something non-numeric. That's a per-item parse error, and in a naive implementation it takes down the whole run: you asked for 40 companies, one of them had one odd ID, and you got zero rows.
We validate before coercing: the value has to be a digit string (leading - allowed) before it becomes an integer, and anything else resolves to null rather than raising. The row still ships with its title, URL, and location intact — a missing ID is a degraded row, not a dead run. This is the same rule we apply everywhere: one bad item skips itself, it never kills the batch.
description_text is stripped HTML, and description_html is opt-in ✂️
Every posting's body arrives as content_html — recruiter-authored markup, which in practice means inconsistent tags, nested lists, inline styles, and a lot of . If you're feeding a model, matching keywords, or loading a warehouse column, you want text.
So description_text is always populated: tags stripped, whitespace collapsed to single spaces. The raw markup is available too, but only when you ask for it via includeHtml — because on a 1,000-row pull the HTML is usually the single largest thing in your dataset, and most pipelines throw it away. Defaulting it off keeps your storage and your token bills smaller.
What we handle for you 🛡️
A public JSON endpoint is not the same thing as a stable one, and this is where a weekend script diverges from a product:
- Pagination is merged, not truncated. Feeds page; we walk them and merge items into one response per company, so a 400-role employer doesn't silently become a 20-role one.
-
Rate limits are obeyed, not guessed. When the server sends a
Retry-Afterheader, we use that number instead of an invented backoff, and we rotate both the proxy session and the browser-impersonation profile between attempts. - Per-company fault isolation. Hand us 50 subdomains and one has been renamed, offboarded, or is briefly 503-ing — that company is logged and skipped, and the other 49 still return rows.
-
The cap is applied after parsing.
maxJobsPerCompanytruncates the parsed result set, so you get the first N valid rows rather than N attempts, some of which were duds.
Who this is for
Recruiting-intelligence and job-board teams aggregating live European tech roles — Teamtailor's customer base skews toward Nordic and EU scale-ups that don't appear in US-centric ATS feeds.
Sales teams selling into hiring companies — an open role is a budget signal, and hiring_organization_name plus posting velocity per subdomain is a cleaner buying trigger than firmographics.
Labour-market researchers who need typed, deduplicated postings with real location granularity rather than a scraped string that says "Remote (EU) — or Stockholm".
Frequently asked questions
Do I need a Teamtailor API key?
No. The jobs.json feed is public per career site. You supply company subdomains; we handle the fetching, pacing, and parsing.
What happens if one of my companies doesn't exist?
That subdomain is logged and skipped. Every other company in the same run still returns its rows — a typo in a 50-company list costs you one company, not the run.
Why is description_html empty by default?
It's opt-in via includeHtml. Raw markup is usually the biggest field in the dataset and most pipelines discard it, so we don't ship it unless you ask.
Why is job_id sometimes null?
Because the feed sent an identifier that isn't a valid integer. We keep the row and null the field rather than failing the item — job_url is always present and is a reliable unique key.
What does 5,000 job postings cost?
$7.505 — 5,000 × $0.0015, plus the $0.005 run-start charge.
Try it
Live on the Apify Store: Teamtailor Jobs Scraper.
Give it a list of Teamtailor subdomains and get back flat, typed job rows with real location fields and clean description text. Pay-per-event, no subscription, no card required to try.
Built by Devil Scrapes — we build scrapers for the targets that fight back.
Top comments (0)