How to Scrape Recruitee Job Listings

CleanJobData Engineering

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

  • id is numeric. Convert it to a string for a stable key. slug builds a fallback job URL, https://{slug}.recruitee.com/o/{offer.slug}.
  • description and requirements are both HTML and are separate fields. A lot of the useful detail lives in requirements, so reading only description can drop half the posting. Join the two.
  • careers_url is the public page for the job and the best apply link. It can be on the company's own domain rather than recruitee.com, so prefer it over a constructed URL.
  • published_at looks like 2026-09-15 18:50:08 UTC. That isn't valid ISO 8601, so new Date() may fail in some runtimes unless you reformat it as shown above. created_at and updated_at use the same format.
  • locations[] is the cleanest location source. Each entry has city, state, state_code, country and country_code, and a job that's open in several offices lists all of them.
  • location is a top-level string, and it can be a label rather than a place. On a remote job it was literally "Remote job", while city, state_name and country still held the company's office. Don't parse the string as an address; use the structured fields.
  • remote, hybrid and on_site are booleans. They're real data, but employers don't always fill them in, so also look for "remote" or "hybrid" in the title.
  • salary is an object with min, max, currency and period, but on many jobs it's present with every value null. Check the values, not just the object. When it is filled in, the numbers can be strings, and period can be month, year or hour, so ranges aren't comparable until you convert them.
  • employment_type_code is a machine code such as fulltime_permanent, fulltime_fixed_term, parttime_permanent, parttime_fixed_term, internship, freelance or temporary. Map these to readable labels yourself.
  • experience_code is also a code, with values such as entry_level, mid_level, experienced, manager and executive. Other values may exist, so handle unknown codes instead of assuming the list is complete.
  • department, category_code (for example design) and education_code are also available for filtering.
  • company_name is 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.