How to Scrape Recruitee Job Listings
TL;DR
Recruitee exposes every open job for a company in one unauthenticated request to https://{slug}.recruitee.com/api/offers/, with the full HTML description and requirements included and no per-job detail call. Locations are structured, salary is often empty, and dates and category fields use machine codes and non-ISO formats you need to handle.
Recruitee is popular with small and mid-sized European companies, and every Recruitee careers page is backed by a public JSON endpoint. It's one of the friendlier applicant tracking system APIs to work with: a single request returns every open job with its full description, and there's no key to obtain.
Finding the Company Slug
Recruitee careers pages live on a per-company subdomain: https://acme.recruitee.com. The slug is the subdomain (acme). Many companies also serve the page from their own domain, such as careers.acme.com. In that case, open the network tab and look for calls to *.recruitee.com, or check the page source. There's no public directory mapping companies to slugs, so finding boards at scale is a separate problem from fetching one you already know.
The Endpoint
curl -H "accept: application/json" "https://acme.recruitee.com/api/offers/"The response is an object with a single offers array:
{
"offers": [
{
"id": 2745701,
"slug": "senior-product-designer",
"title": "Senior Product Designer",
"published_at": "2026-09-15 18:50:08 UTC",
"location": "Remote job",
"city": "Montréal",
"state_name": "Quebec",
"country": "Canada",
"country_code": "CA",
"remote": true,
"hybrid": false,
"on_site": false,
"employment_type_code": "fulltime_permanent",
"experience_code": "experienced",
"department": "Product",
"company_name": "Petal",
"careers_url": "https://petalmd.recruitee.com/o/senior-product-designer",
"salary": { "min": null, "max": null, "period": null, "currency": null },
"description": "<p>…</p>",
"requirements": "<p>…</p>",
"locations": [{ "city": "Montréal", "state": "Quebec", "state_code": "QC", "country": "Canada", "country_code": "CA" }]
}
]
}There's no pagination and no per-job detail request. A board with seven openings returned all seven in one response, complete with descriptions, so the number of requests per company is one. If you scrape a very large board, compare the length of offers with the count shown on the careers page as a sanity check.
Fetching Jobs (Node/TypeScript)
interface Offer {
id: number;
slug: string;
title: string;
description?: string;
requirements?: string;
location?: string;
city?: string;
state_name?: string;
country?: string;
country_code?: string;
remote?: boolean;
hybrid?: boolean;
published_at?: string;
careers_url?: string;
company_name?: string;
department?: string;
employment_type_code?: string;
experience_code?: string;
salary?: { min?: string | number | null; max?: string | number | null; currency?: string | null; period?: string | null };
locations?: Array<{ city?: string; state?: string; state_code?: string; country?: string; country_code?: string }>;
}
export async function fetchRecruiteeJobs(slug: string) {
const res = await fetch(`https://${slug}.recruitee.com/api/offers/`, {
headers: { accept: "application/json" },
});
if (!res.ok) throw new Error(`Recruitee ${res.status} for ${slug}`);
const { offers = [] } = (await res.json()) as { offers?: Offer[] };
return offers.map((o) => ({
id: String(o.id),
title: o.title,
company: o.company_name,
department: o.department,
remote: Boolean(o.remote),
hybrid: Boolean(o.hybrid),
// "2026-09-15 18:50:08 UTC" isn't ISO 8601; normalize before parsing
publishedAt: o.published_at ? new Date(o.published_at.replace(" UTC", "Z").replace(" ", "T")) : null,
locations: (o.locations ?? []).map((l) => ({
city: l.city, state: l.state, country: l.country, countryCode: l.country_code,
})),
// description and requirements are separate HTML fields
html: [o.description, o.requirements].filter(Boolean).join("\n"),
url: o.careers_url ?? `https://${slug}.recruitee.com/o/${o.slug}`,
}));
}Fields Worth Knowing
idis numeric. Convert it to a string for a stable key.slugbuilds a fallback job URL,https://{slug}.recruitee.com/o/{offer.slug}.descriptionandrequirementsare both HTML and are separate fields. A lot of the useful detail lives inrequirements, so reading onlydescriptioncan drop half the posting. Join the two.careers_urlis the public page for the job and the best apply link. It can be on the company's own domain rather thanrecruitee.com, so prefer it over a constructed URL.published_atlooks like2026-09-15 18:50:08 UTC. That isn't valid ISO 8601, sonew Date()may fail in some runtimes unless you reformat it as shown above.created_atandupdated_atuse the same format.locations[]is the cleanest location source. Each entry hascity,state,state_code,countryandcountry_code, and a job that's open in several offices lists all of them.locationis a top-level string, and it can be a label rather than a place. On a remote job it was literally"Remote job", whilecity,state_nameandcountrystill held the company's office. Don't parse the string as an address; use the structured fields.remote,hybridandon_siteare booleans. They're real data, but employers don't always fill them in, so also look for "remote" or "hybrid" in the title.salaryis an object withmin,max,currencyandperiod, but on many jobs it's present with every valuenull. Check the values, not just the object. When it is filled in, the numbers can be strings, andperiodcan be month, year or hour, so ranges aren't comparable until you convert them.employment_type_codeis a machine code such asfulltime_permanent,fulltime_fixed_term,parttime_permanent,parttime_fixed_term,internship,freelanceortemporary. Map these to readable labels yourself.experience_codeis also a code, with values such asentry_level,mid_level,experienced,managerandexecutive. Other values may exist, so handle unknown codes instead of assuming the list is complete.department,category_code(for exampledesign) andeducation_codeare also available for filtering.company_nameis on every offer, so you get the display name without another request.
Gotchas
Custom domains hide the slug. The API lives on the recruitee.com subdomain even when candidates only ever see the company's own domain. If a guessed slug returns a non-200 response, confirm it against the careers page.
Experience codes are coarse. A blind underscore-to-space conversion turns experienced into "experienced", which isn't a seniority level by itself. Map codes explicitly to the levels you use.
Old postings can stay open. Boards can keep listings live long after they were posted. Check published_at before treating a job as fresh.
location and locations[] can disagree for multi-office postings. Prefer the array.
No webhooks and no published rate limits. It's a pull-only endpoint. Poll on an interval, compare the set of id values between runs to detect closed jobs, and back off on 429 responses.
When This Is Enough
For one company's jobs, such as a "work with us" widget, the offers endpoint is a great fit: free, unauthenticated and complete in one call. The extra work shows up when you cover many employers across several platforms and want one consistent shape, with codes mapped to labels, dates parsed and locations resolved once.
How CleanJobData Handles Recruitee
CleanJobData reads Recruitee boards alongside other platforms such as Greenhouse, Lever and Workable, and returns them in one schema: resolved cities, states and countries, a remote type, an employment type, an experience level and company details. Salary is only present when the employer filled it in, and a company website isn't part of Recruitee's offers, so those fields can be empty. See how we normalized four ATS platforms for the general approach.
Try it against live data in the CleanJobData Playground or read the API Documentation.