How to Scrape Teamtailor Job Listings
TL;DR
Every Teamtailor career site publishes a JSON Feed at /jobs.json with the full job content (content_html) and a schema.org _jobposting object for each item, 100 jobs per page. Follow next_url until it is empty, use the numeric _jobposting.identifier.value as the job ID, and expect no salary, employment type or remote flag in the feed.
Teamtailor is popular with European employers, and its career sites are among the easier ones to scrape. Each site publishes a JSON Feed at a predictable path, and every item already includes the full job description, so there's no second request per job and no API key.
Finding the Career Site Host
A Teamtailor career site lives either on a Teamtailor subdomain, such as acme.teamtailor.com (some regions use a variant like acme.na.teamtailor.com), or on a custom domain such as careers.acme.com that Teamtailor serves for the company. Either way, the feed is on that same host, so what you need is the full hostname, not just a short name.
To find it, open the company's careers page and read the address. If it's a custom domain, the page source and network requests usually still show Teamtailor assets. There's no public directory of Teamtailor customers, so this only helps for companies you already know about.
The Endpoint
curl -H "accept: application/json" "https://acme.teamtailor.com/jobs.json"The response follows the JSON Feed format:
{
"version": "https://jsonfeed.org/version/1.1",
"title": "GIA Legacy Planning",
"home_page_url": "https://gialegacyplanning.na.teamtailor.com/jobs",
"feed_url": "https://gialegacyplanning.na.teamtailor.com/jobs.json",
"next_url": "https://gialegacyplanning.na.teamtailor.com/jobs.json?page=2&per_page=100",
"items": [ /* jobs */ ]
}title is the company name, items is the list of jobs, and next_url is the link to the next page whenever there is one. Each item has id, title, url, date_published, content_html and a _jobposting object.
Pagination
The feed returns 100 jobs per page. When there are more, the response includes a next_url such as ?page=2&per_page=100. Request that URL, add its items, and keep going until a response has no next_url. You follow the links rather than computing offsets.
This matters on larger boards. One career site we checked had over 1,500 open jobs spread across 16 pages, so reading only the first response would miss more than 90% of them.
Because content_html is in the feed, a whole board costs only a handful of requests.
Fetching Jobs (Node/TypeScript)
interface TeamtailorItem {
id: string; // a UUID
title: string;
url: string;
date_published?: string;
content_html?: string;
_jobposting?: {
identifier?: { value?: string | number };
validThrough?: string;
hiringOrganization?: { name?: string };
jobLocation?: Array<{
address?: {
addressLocality?: string | null;
addressRegion?: string | null;
addressCountry?: string | null;
};
}>;
};
}
export async function fetchTeamtailorJobs(host: string) {
let next: string | null = `https://${host}/jobs.json`;
const items: TeamtailorItem[] = [];
while (next) {
const res = await fetch(next, { headers: { accept: "application/json" } });
if (!res.ok) throw new Error(`Teamtailor feed ${res.status} for ${host}`);
const feed = (await res.json()) as { items?: TeamtailorItem[]; next_url?: string };
items.push(...(feed.items ?? []));
next = feed.next_url ?? null;
}
return items.map((item) => {
const jp = item._jobposting ?? {};
const locations = (jp.jobLocation ?? []).map((l) => ({
city: l.address?.addressLocality ?? null,
region: l.address?.addressRegion ?? null,
country: l.address?.addressCountry ?? null, // ISO alpha-2, e.g. "US"
}));
return {
// Prefer the numeric schema.org identifier; the UUID is only a fallback.
id: String(jp.identifier?.value ?? item.id),
title: item.title,
url: item.url,
published: item.date_published,
expires: jp.validThrough,
company: jp.hiringOrganization?.name,
locations,
html: item.content_html ?? "",
};
});
}Fields Worth Knowing
_jobposting.identifier.valueis a numeric ID from the embedded schema.org data. It matches the number at the start of the job's URL slug (for example/jobs/700014-…), so it's the stable ID to use.item.idis a UUID, which is a different format entirely.title,urlandcontent_htmlare what you'd expect: the job title, the public job page, and the description as HTML.date_publishedis a timestamp with a timezone offset, for example2026-09-12T00:00:00-05:00. The same value appears asdatePostedinside_jobposting._jobposting.validThroughis the date the posting is set to close, and it's present on almost every job. It's useful for hiding jobs that are about to expire._jobposting.jobLocationis an array. Each entry has anaddresswithaddressLocality,addressRegionandaddressCountry. The country is already an ISO alpha-2 code such asGBorUS, which saves a lookup. Multi-location jobs have several entries, so don't read only the first one if all locations matter.streetAddress,postalCodeandaddressRegionare oftennulleven when the city and country are filled in._jobposting.hiringOrganization.nameis the company name.
Gotchas
The two IDs differ. If you fall back to the UUID whenever _jobposting is missing, the same job could get a different ID on a later run. Pick the numeric identifier as your source of truth and treat a missing one as something to log, not to silently paper over.
Salary and employment type aren't in the feed. Across the first 500 jobs of a large board, none of the _jobposting objects included baseSalary or employmentType. If a company mentions pay, it's inside the description text, so you'd have to extract it from content_html yourself.
There's no remote or hybrid flag. Look for "remote" or "hybrid" in the title, in the location city text, and in phrases like "fully remote" or "remote-first" in the description. Expect some misses and some false positives, since a description that mentions "hybrid" in passing can trip a naive check.
The company website isn't in the feed. If you need it, the career site's homepage usually links to it, along with social accounts, in the footer. Skip Teamtailor's own links and social domains when you read them.
Custom domains should work, but check. A custom domain serves the same /jobs.json path when Teamtailor hosts it. If one returns HTML instead of JSON, try the company's teamtailor.com subdomain.
Old postings can stay open. Compare date_published and validThrough with today's date before treating a job as fresh.
No webhooks. It's a pull-only feed. Poll on an interval and compare the set of IDs between runs to detect closed jobs.
When This Is Enough
For jobs from one Teamtailor customer, the feed is about as simple as scraping gets: one endpoint, full descriptions, no authentication.
When It Isn't
Across many employers you still deal with inconsistent locations, missing pay and free-text workplace hints, and each platform has its own shape. Compare this with Greenhouse and Lever.
CleanJobData reads Teamtailor feeds together with other platforms and returns one schema, with locations resolved to cities, states and countries using the country codes in the feed. It doesn't invent salary that the feed never carried. Try it in the Playground or see the API Documentation.