# Recruitee Jobs API

> Query jobs from Recruitee boards through one API — and how Recruitee's offers endpoint, multi-city locations and string-typed salaries behave.

Source: https://hyperjobs.io/ats/recruitee

Recruitee is collaborative hiring software, popular with small and mid-sized
European companies. Boards sit at `<company>.recruitee.com`, and one
unauthenticated call returns every live offer with its description inline. The
data model is unusually generous — Recruitee publishes structured education,
seniority, category, working hours and salary where most platforms publish a
string — and the one thing it will do to you is rate-limit you.

## Try it

```bash
curl -G https://api.hyperjobs.io/v1/jobs \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "source=recruitee" \
  --data-urlencode "time_frame=7d" \
  --data-urlencode "limit=2"
```

```json title="Illustrative — the shape, not a live response"
{
  "data": [
    {
      "id": "recruitee:heliolabs_senior-frontend-developer",
      "title": "Senior Frontend Developer",
      "company": {
        "slug": "helio-labs",
        "name": "Helio Labs",
        "domain": "heliolabs.nl",
        "website": "https://heliolabs.nl",
        "logo": "https://media.licdn.com/dms/image/…",
        "industry": "Information Technology",
        "employee_count": 70
      },
      "location": {
        "remote": false,
        "countries": ["Germany"],
        "cities": ["Berlin"],
        "regions": ["Berlin"],
        "raw": ["Berlin, Berlin, Germany"]
      },
      "employment_type": ["FULL_TIME"],
      "work_arrangement": "Hybrid",
      "seniority": "Mid-Senior level",
      "education_requirements": ["bachelor degree"],
      "job_language": "en",
      "salary": {
        "min": 5200,
        "max": 6800,
        "currency": "EUR",
        "unit": "MONTH",
        "annual_min": 62400,
        "annual_max": 81600,
        "summary": null
      },
      "skills": ["react", "typescript"],
      "taxonomies": ["Software"],
      "apply_url": "https://heliolabs.recruitee.com/o/senior-frontend-developer/c/new",
      "url": "https://heliolabs.recruitee.com/o/senior-frontend-developer",
      "source": "recruitee",
      "posted_at": "2026-07-10T08:00:00.000Z",
      "expires_at": null,
      "updated_at": "2026-07-14T06:12:55.000Z"
    }
  ],
  "total": 2,
  "limit": 2,
  "offset": 0
}
```

Note `unit: "MONTH"` with `annual_min` filled in beside it. Monthly pay is normal
on European boards, and `min: 5200` is not a low salary. This is the trap
[salary data](/docs/guides/salary) is about.

## How Recruitee boards actually work

One GET, no auth, no pagination, whole board:

```bash
curl "https://<company>.recruitee.com/api/offers/"
```

`<company>` is the subdomain. Response is `{ "offers": [...] }`. The trailing
slash matters more than it should.

### The description is only half the description

`description` holds the body. `requirements` is a **separate HTML block** holding
the qualifications — present on roughly 90–100% of offers. They are two different
fields, and the posting page renders both. Read only `description` and you store a
job ad with its requirements section missing, from a field that looks perfectly
populated.

We concatenate them.

### Structured fields, and they're actually filled

Recruitee's coded fields are close to fully populated where they exist:

| Field | Vocabulary |
| --- | --- |
| `education_code` | `bachelor_degree`, `master_degree`, `high_school`, … — near 100% |
| `category_code` | `healthcare`, `engineering`, `information_technology`, … — near 100% |
| `experience_code` | `entry_level`, `mid_level`, `experienced`, `manager`, … |
| `employment_type_code` | `fulltime`, `parttime`, `contract`, … |
| `max_hours_per_week` | ~98% on EU boards |

`experience_code` is Recruitee's own vocabulary, not LinkedIn's, so
`experienced` has to be mapped rather than passed through.

### Three workplace booleans

`remote`, `hybrid` and `on_site` all ship, all near-100% present. They are not
mutually exclusive: **a hybrid job also reads `remote: true`** on some boards. So
hybrid has to win first, then remote, then on_site — check `remote` first and
every hybrid role is mislabelled. Older boards omit the newer flags entirely and
only carry `on_site`, where `on_site: false` is the only remote signal available.

### The language is in the key-set

`translations` is an object keyed by language code. Exactly one key means the
posting is in that language — a genuinely structured language signal, on roughly
95% of offers, hiding in the shape of an object rather than in a field.

## What bites you

<Warning title="Salary bounds are strings">
  Recruitee returns `{"min": "1400", "max": "2100"}` — quoted. Arithmetic on them
  concatenates instead of adding, and a strict schema rejects the whole offer.
  Coerce before you use them.
</Warning>

**Roughly 30% of offers are multi-city, and the flat fields carry only one.**
`city` / `state_code` / `country` describe **one** of a posting's locations. The
full set is in `locations[]`, each entry with a full region *name* rather than a
code. Worse, on multi-city offers the display `location` string is frequently a
useless `Remote job`. Read the flat fields and you lose most of a posting's
geography while everything still looks populated. Older boards omit `locations[]`,
so you need the flat fields as a fallback and the array as the primary.

**`country_code` is ISO-2 and will be read as a US state.** `Berlin, DE` parsed
naively becomes Berlin, Delaware. Resolve the code to a canonical country name
before you build the string.

**Slugs are only unique within a company.** Two companies both have an offer
slugged `sales-manager`. A key of `recruitee:<slug>` collides across boards — ours
is scoped to `recruitee:<company>_<slug>`.

**`close_at` exists but is nearly always null** — around 2% of offers. A null
expiry doesn't mean the job runs forever; it means Recruitee didn't say. Expiry
here comes from [board diffing](/docs/reference/coverage), not from the field.

**The requirements bullets are mostly not skills.** They read like
`Je bent van onbesproken gedrag (politiescreening)` — whole sentences, not tags.
Harvesting every `<li>` as a skill fills your skill index with prose. We keep only
bullets short enough to plausibly be a term.

## Or use ours

Recruitee rate-limits per IP, and we measured **38% of requests lost to 429s at
concurrency 5**. That's why it sits in the proxy-only tier here alongside
[Workable](/ats/workable) — it works direct at low volume and falls over at scale,
which is the failure mode you don't discover until you've built the thing.

Past the fetch: description and requirements joined, `locations[]` read so
multi-city offers keep their cities, ISO codes resolved to real country names, the
hybrid-first boolean order, salary bounds coerced from strings and normalised so a
monthly EUR band is comparable to an annual USD one, company-scoped ids, and the
[company join](/docs/objects/company). We track 1,344 Recruitee boards.

Recruitee is attempted every third cycle — see
[coverage](/docs/reference/coverage) — so a new posting can take a few hours to
land.

[Quickstart](/docs/quickstart) · [Pricing](/pricing)
