DEV Community

Cover image for How to scrape Teamtailor career-site jobs in Python — no API key
Freshactors
Freshactors

Posted on

How to scrape Teamtailor career-site jobs in Python — no API key

Teamtailor powers the career sites of thousands of companies — it's especially big among Nordic and European scale-ups (Polestar, Oatly, Instabee, and many more). Here's the part most people miss: every Teamtailor career site already serves a public JSON feed of its published jobs — no API key, no login, no headless browser. Even better, a single request returns the company's full published board, descriptions included. In this tutorial we'll pull a company's whole job board as clean structured JSON in a few lines of Python.

The endpoint

Every Teamtailor career site serves a machine-readable version of its jobs page at the .json suffix:

GET https://{company}.teamtailor.com/jobs.json
Enter fullscreen mode Exit fullscreen mode

That returns a JSON Feed of every published posting — title, URL, publish date, the full description, and an embedded schema.org JobPosting with locations. One GET, the whole board. Custom-domain career sites (e.g. careers.oatly.com/jobs.json) serve the same feed.

(The bare /jobs URL also answers Accept: application/json content negotiation, but its CDN intermittently serves a cached HTML variant regardless of your headers — use the explicit .json suffix.)

So why not just requests.get() it yourself? You can — but then you own the parser: stripping the HTML descriptions, flattening the schema.org location objects, de-duplicating multi-location postings, and fixing it the day the feed shape shifts and your pipeline goes quietly empty. A cleaner path: hand a list of companies to an actor that returns one stable schema — the same schema as Greenhouse, Lever, Workable, SmartRecruiters, and Recruitee. Here's how with the Teamtailor Jobs Scraper.

Step 1 — Install the Apify client

pip install apify-client
Enter fullscreen mode Exit fullscreen mode

Read your Apify API token (Console → Settings → Integrations) from an environment variable:

export APIFY_TOKEN="apify_api_xxx"
Enter fullscreen mode Exit fullscreen mode

Step 2 — Run the actor with a list of companies

companies accepts subdomains (polestar), {name}.teamtailor.com URLs, or full career-site URLs — custom domains work too.

import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

run_input = {
    "companies": ["polestar", "https://oatly.teamtailor.com"],
    "includeDescription": True,
    "maxJobsPerCompany": 100,
}

run = client.actor("freshactors/teamtailor-jobs-scraper").call(run_input=run_input)
print("Dataset id:", run["defaultDatasetId"])
Enter fullscreen mode Exit fullscreen mode

Step 3 — Read the normalized output

Every posting comes back in the same shape, with null (never missing keys) where Teamtailor's feed lacks a field:

for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(f'{item["company"]:<10} {item["title"]}  ({item.get("location") or "n/a"})')
Enter fullscreen mode Exit fullscreen mode

A single record (a real one, from Polestar's board):

{
  "_type": "job",
  "_schemaVersion": "1.0",
  "_source": "teamtailor",
  "company": "polestar",
  "jobId": "7610594",
  "title": "Customer Engagement Advisor | Denmark",
  "location": "Copenhagen, Nordics & UK, DK",
  "allLocations": ["Copenhagen, Nordics & UK, DK"],
  "country": "DK",
  "url": "https://polestar.teamtailor.com/jobs/7610594-customer-engagement-advisor-denmark",
  "applyUrl": "https://polestar.teamtailor.com/jobs/7610594-customer-engagement-advisor-denmark",
  "postedAt": "2026-04-21T14:35:06.000Z",
  "descriptionText": "Din mulighed... (full description as clean text)",
  "_scrapedAt": "2026-06-10T11:29:56.613Z"
}
Enter fullscreen mode Exit fullscreen mode

This is the same record shape the Greenhouse & Lever, Workable, SmartRecruiters, and Recruitee scrapers emit, so Teamtailor companies drop into the same multi-ATS pipeline with zero special-casing. (Fields Teamtailor's public feed doesn't expose — department, commitment — come back as null, never missing.)

Step 4 — Lighter output

The descriptions arrive in the same request, so they're free. If you only need metadata (titles, locations, dates) — say, for a hiring-signal dashboard — set includeDescription: False to trim the payload:

run_input = {
    "companies": ["polestar", "instabee"],
    "includeDescription": False,   # smaller records; same cost & speed
    "maxJobsPerCompany": 100,
}
Enter fullscreen mode Exit fullscreen mode

Prefer Node.js?

npm install apify-client
Enter fullscreen mode Exit fullscreen mode
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });

const run = await client.actor('freshactors/teamtailor-jobs-scraper').call({
    companies: ['polestar', 'https://oatly.teamtailor.com'],
    includeDescription: true,
    maxJobsPerCompany: 100,
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const job of items) console.log(`${job.company}${job.title} (${job.location ?? 'n/a'})`);
Enter fullscreen mode Exit fullscreen mode

What about cost?

Pay-per-event: $0.02 per company career site fetched and $0.0005 per job posting returned. So 5 companies returning 100 postings total is 5 × $0.02 + 100 × $0.0005 = $0.15 — full descriptions included. No subscription.

Why use the actor instead of the feed directly?

You can call the feed yourself. The reason to use the actor is maintenance: it normalizes everything into one schema (shared with our four other ATS scrapers), cleans the HTML descriptions, handles custom domains and multi-location postings, isolates per-company failures (a dead board never kills your run), and is monitored by a daily canary — so a silent feed change doesn't quietly empty your pipeline.

The actor is here: Teamtailor Jobs Scraper on Apify. Point it at your target companies and consume one normalized JSON feed.

Happy scraping.

Top comments (0)