# Recruitee Jobs API: How to Scrape Job Listings

> How Recruitee's public offers API works: find the company subdomain, make one curl call for every open job, and handle its salary, date and category quirks.

Source: https://cleanjobdata.com/articles/how-to-scrape-recruitee-job-listings
Published: 2026-09-19

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.

To get a company's jobs from Recruitee, call `https://{slug}.recruitee.com/api/offers/` — no API key, and every open job comes back in one request with its full description. 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.

That's the DIY route, and it works for a handful of companies you already know. If you'd rather not build and maintain a Recruitee scraper, CleanJobData already ingests ATS platforms and company career sites and serves the jobs normalized through one REST API — see the [Jobs API docs](https://cleanjobdata.com/docs/api/jobs).

## 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

```bash
curl -H "accept: application/json" "https://acme.recruitee.com/api/offers/"
```

The response is an object with a single `offers` array:

```json
{
  "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)

```ts
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](https://cleanjobdata.com/articles/how-to-scrape-greenhouse-job-listings), [Lever](https://cleanjobdata.com/articles/how-to-scrape-lever-job-listings) and [Workable](https://cleanjobdata.com/articles/how-to-scrape-workable-job-listings), 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 normalize jobs from many ATS platforms](https://cleanjobdata.com/articles/how-we-normalize-ats-job-data) for the general approach.

Try it against live data in the [CleanJobData Playground](https://cleanjobdata.com/playground) or read the [API Documentation](https://cleanjobdata.com/docs).

## Skip the scraper: get this data from the CleanJobData API

If you'd rather not maintain a Recruitee collector, you can query normalized jobs from ATS platforms and company career sites with a single request. The response has structured city/state/country locations, parsed salary when the employer publishes one, a remote type, and company metadata, and you page through results with the cursor in `pagination.next_page`.

```bash
curl "https://api.cleanjobdata.com/jobs?title=engineer&remote=true&limit=20" \
  -H "Authorization: Bearer $CLEANJOBDATA_API_KEY"
```

Jobs from ATS-hosted career sites come back in the same schema as every other source, so there's no per-platform parsing on your side. Use `company_website_url` or `employer_id` to scope to one employer. Coverage depends on which employers are in the dataset, so check that the companies you care about appear before you rely on it.

- [Jobs API reference](https://cleanjobdata.com/docs/api/jobs) for every filter and response field
- [Playground](https://cleanjobdata.com/playground) to run queries without writing code
- [Pricing](https://cleanjobdata.com/pricing) — the Trial is free with no card required
