This repository connects Node.js to the Teamtailor Jobs Scraper with one ES module and the official apify-client package. Pass an array of Teamtailor handles, the part before .teamtailor.com in an employer's careers address, and the run resolves to plain objects: a numeric job_id, title, apply link, ISO dates, city, region and country code, and a description in text and, if requested, HTML. Apify performs the requests and retries, which keeps your Express route, BullMQ worker or scheduled cloud function small. Typical consumers are niche job boards, recruiting CRMs and sales tools that treat new openings as a buying signal.
Actor: Teamtailor Jobs Scraper Β· Publisher: Devil Scrapes Β· Also in Python: teamtailor-jobs-scraper-python
- Handles or links, your choice: each entry in
companiescan be a bare handle likeoatlyor a fullhttps://oatly.teamtailor.com/jobslink; the Actor reduces both to the handle. - Every page of the feed: when an employer's job list spans several pages, the Actor follows them, up to ten pages per company, before applying your cap.
- Dates that sort correctly:
date_publishedandvalid_throughare ISO 8601 with the employer's UTC offset kept, and the publish date falls back to the posting's own date when the feed omits it. - Location split into parts: city, region and country arrive as three columns taken from the posting's first listed location, with the country as a two-letter code.
- Readable text by default:
description_textis the posting with tags removed and whitespace collapsed; setincludeHtmlto also receive the original markup indescription_html. - Two names per row:
companyechoes the handle you sent, which stays stable for joins, whilecompany_namecarries the display name the career site publishes. - A dead handle costs nothing extra: a company that returns 404 simply contributes zero rows, and one that keeps failing after retries is logged and skipped while the rest of the list finishes.
- Transient errors retried: timeouts, rate limits and server errors get up to five attempts with growing pauses, and a
Retry-Afterheader is respected.
Run the module nightly for the employers your board covers, upsert rows by job_id and delete the ones that stopped appearing. Since closed roles leave the feed, the absence of a job_id is your expiry signal.
Compare today's job_id set with the previous run and POST only the newcomers to Slack, Teams or a CRM endpoint. A sales team watching target accounts hears about a new head of data before the press release.
Store rows in Postgres or a document database and expose /jobs?company=storytel&country=SE. Your front end filters on location_country and date_published without calling Teamtailor at request time.
With includeHtml on, description_html keeps the employer's headings and lists. Sanitise it on the server and render it in a Next.js or Astro page so the posting reads the way the company wrote it.
You need an Apify account and an API token from Console β Settings β Integrations. Apify's free plan includes monthly usage credit; this Actor's event prices are listed under Pricing below.
git clone https://github.com/devilscrapes/teamtailor-jobs-scraper-javascript.git
cd teamtailor-jobs-scraper-javascript
npm install
export APIFY_TOKEN=your_token_here
node index.js # uses input.json
node index.js my-input.json --out data --max-charge 2The script starts the Teamtailor Jobs Scraper, waits for it to finish, prints a link to the run in Apify Console and saves every result to results.json and results.csv. It exits with code 1 and the run's status message if the run fails.
Inside an existing service, these lines are all you need to copy. Awaiting call() waits for the run to end, and listItems() hands back the dataset's rows.
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: 'YOUR_APIFY_TOKEN' });
const run = await client.actor('DevilScrapes/teamtailor-jobs-scraper').call({"companies": ["oatly", "storytel"], "maxJobsPerCompany": 5, "includeHtml": false, "proxyConfiguration": {"useApifyProxy": true}});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items);index.js loads input.json and passes it on as it is. Two employers, Oatly and Storytel, are capped at five roles each, so a smoke test returns no more than ten objects for about 21.5 cents. In your own code, build companies from a database table of tracked employers; entries may be bare handles or https://<handle>.teamtailor.com links, but branded career domains are refused at validation time.
{
"companies": [
"oatly",
"storytel"
],
"maxJobsPerCompany": 5,
"includeHtml": false,
"proxyConfiguration": {
"useApifyProxy": true
}
}| Field | Type | Required | Default | Allowed values |
|---|---|---|---|---|
companies |
array | yes | ["oatly", "storytel"] |
β |
maxJobsPerCompany |
integer | no | 100 |
β |
includeHtml |
boolean | no | false |
β |
proxyConfiguration |
object | no | {"useApifyProxy": true} |
β |
Every object is one vacancy. company, job_id, job_title, job_url, date_published, description_text and scraped_at are guaranteed; the remaining keys may be null. Dates carry their UTC offset, so new Date() parses them correctly, and location_country is a two-letter code you can map to a flag or a locale.
| Field | Type | Always present |
|---|---|---|
company |
string | yes |
company_name |
string or null | no |
job_id |
integer | yes |
job_title |
string | yes |
job_url |
string | yes |
date_published |
string | yes |
valid_through |
string or null | no |
location_locality |
string or null | no |
location_region |
string or null | no |
location_country |
string or null | no |
hiring_organization_name |
string or null | no |
description_html |
string or null | no |
description_text |
string | yes |
scraped_at |
string | yes |
| Event | Price (USD) |
|---|---|
| Actor start | $0.2 |
| Result emitted | $0.0015 |
Prices as configured on 2026-08-20. The Actor page always shows the current price. Runs are billed to the Apify account that owns the token. Use --max-charge to cap what a single run can cost.
This script turns two saved runs into a webhook payload. It reads previous.json if present, keeps only postings whose job_id is new, and either prints the payload or POSTs it to WEBHOOK_URL with the global fetch in Node 20. No packages beyond Node.js itself.
import { readFile, writeFile } from 'node:fs/promises';
const current = JSON.parse(await readFile('results.json', 'utf8'));
const previous = JSON.parse(await readFile('previous.json', 'utf8').catch(() => '[]'));
const seen = new Set(previous.map((job) => job.job_id));
const fresh = current
.filter((job) => !seen.has(job.job_id))
.sort((a, b) => new Date(b.date_published) - new Date(a.date_published))
.map((job) => ({
company: job.company_name ?? job.company,
title: job.job_title,
where: [job.location_locality, job.location_country].filter(Boolean).join(', '),
url: job.job_url,
}));
const payload = { text: `${fresh.length} new Teamtailor roles`, jobs: fresh };
if (process.env.WEBHOOK_URL && fresh.length > 0) {
const response = await fetch(process.env.WEBHOOK_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
console.log(`webhook answered ${response.status}`);
} else {
console.log(JSON.stringify(payload, null, 2));
}
await writeFile('previous.json', JSON.stringify(current));No, the run still finishes as succeeded. Check the status message, which reads like Done β 0 of 3 companies returned data due to request failures in that case, or compare the row count with what you expected before you delete anything downstream.
Yes, companies accepts up to 1,000 entries and they are processed one after another. For very long lists, consider splitting them across several runs so each finishes within your platform's time limits.
The client package includes declarations for runs and datasets. For the vacancies, write a TeamtailorJob interface from the output table, with job_id as number, the seven guaranteed keys required and the rest as string | null.
No. Every Teamtailor career site already publishes its open roles as a machine-readable job feed, and that feed is what the Actor reads. Your Apify token is the only secret the module needs, and it belongs in an environment variable, not in source control.
Set APIFY_TOKEN first: export the token in the same shell, or put it in a local.envfile your shell loads. Never commit it.- Run finished with
FAILEDorABORTED: open the printed Console link; the run log says why. Input validation errors name the field. - Fewer rows than expected: a
--max-chargecap also limits how many results can be charged. Raise the cap or lower the limits in your input. - Something looks wrong in the data: open an issue on the Actor page so the maintainers see it.
- Teamtailor Jobs Scraper on Apify Store
- Python version of this example
- All Devil Scrapes Actors
- Apify web scraping cookbook
Related Actors:
- Personio Jobs Scraper β vacancies from Personio career pages, common among DACH employers.
- Recruitee Jobs Scraper β open roles from Recruitee-hosted career sites.
- Workable Jobs Scraper β job listings from companies that hire through Workable.
MIT licensed example code. The Actor itself runs on Apify and is maintained by Devil Scrapes. Not affiliated with Teamtailor.