# Recruitee integration

> Create TimeTracking people and placements when a Recruitee candidate is hired, using a Recruitee webhook and idempotent external-ID upserts.

URL: https://timetracking.tech/developers/integrations/ats-crm/recruitee
Last updated: 2026-10-24

## 1. Summary

**Status:** Recipe (no native connector).

**Direction:** Recruitee to TimeTracking (one-way).

When a candidate moves to *Hired*, upsert the person, the client and the placement in TimeTracking. Recruitee has no worked hours, so nothing flows back.

## 2. Mechanism

- **Recruitee side.** A webhook on candidate stage changes (payload with candidate, offer and the new stage), or polling `GET /c/{company_id}/candidates`.
- **Glue.** A small HTTPS receiver or iPaaS ([Zapier](https://timetracking.tech/developers/integrations/ipaas/zapier), [Make](https://timetracking.tech/developers/integrations/ipaas/make), [n8n](https://timetracking.tech/developers/integrations/ipaas/n8n)).
- **TimeTracking side.** `PUT /people/by-external/recruitee/{candidateId}`, `PUT /clients/by-external/recruitee/{key}`, `PUT /placements/by-external/recruitee/{candidateId}-{offerId}`. Upserts are idempotent, so redelivered webhooks are harmless.

Assumption — verify with your Recruitee admin: event names and payload fields (`candidate`, `offer`, `details.to_stage`) depend on the Recruitee API version; client and start date are often custom fields.

## 3. Prerequisites

- Recruitee admin access for webhooks and an API token.
- A convention for where client and start date live (offer department, tag or custom field).
- TimeTracking API key with scope `write`.

## 4. Entity mapping

| Recruitee object.field | TimeTracking field | Notes |
|---|---|---|
| `candidate.id` | person external ref (`recruitee/{id}`) | |
| `candidate.name` | `firstName` / `lastName` | One name field: split on the first space; the office corrects. |
| `candidate.emails[0]`, `phones[0]` | `email`, `phone` | |
| Payroll number | `employeeNumber` | Usually unknown at hire; the office adds it before the first export. |
| Offer department / custom "Client" | client external ref (slug) + `name` | |
| `offer.title` | `roleTitle` | |
| Custom "Start date" | `startsOn` | |
| `candidate.id` + `offer.id` | placement external ref `"{candidateId}-{offerId}"`, `ref` | |

**External-ID strategy.** `source` = `recruitee`. Because upserts are full replaces, a re-hire event must not wipe the employee number the office added later: read `GET /people/{id}` (or the upsert response from earlier) and resend `employeeNumber`.

## 5. Step-by-step setup

1. Create a TimeTracking API key (`write`).
2. Deploy the receiver below (or the same steps in your iPaaS).
3. Add the Recruitee webhook for candidate stage changes.
4. Hire a test candidate; check `GET /placements?limit=5`.

```ts
// recruitee-hired.ts (Node 20). Secure with Recruitee's webhook signature if available, or a secret path.
import { createServer } from "node:http";
const TT = "https://app.timetracking.tech/api/v1";
const H = { authorization: `Bearer ${process.env.TT_API_KEY}`, "content-type": "application/json" };
const put = async (path: string, body: unknown) => {
  const r = await fetch(TT + path, { method: "PUT", headers: H, body: JSON.stringify(body) });
  const j = await r.json(); if (!r.ok) throw new Error(`${path} ${r.status} ${JSON.stringify(j.error)}`); return j.data;
};
const slug = (s: string) => s.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 120);

createServer(async (req, res) => {
  let raw = ""; for await (const c of req) raw += c;
  const evt = JSON.parse(raw);
  if (evt?.payload?.details?.to_stage?.name !== "Hired") { res.writeHead(204).end(); return; } // assumption
  const cand = evt.payload.candidate, offer = evt.payload.offer;
  const clientName = offer.department ?? "Unknown client";                                     // your convention
  const clientRef = slug(clientName);
  await put(`/clients/by-external/recruitee/${encodeURIComponent(clientRef)}`, { name: clientName });
  const [firstName, ...rest] = String(cand.name).trim().split(" ");
  await put(`/people/by-external/recruitee/${cand.id}`, {
    firstName, lastName: rest.join(" ") || "-", email: cand.emails?.[0], phone: cand.phones?.[0],
  });
  await put(`/placements/by-external/recruitee/${cand.id}-${offer.id}`, {
    personExternalRef: String(cand.id), clientExternalRef: clientRef, ref: `RC-${cand.id}-${offer.id}`,
    roleTitle: offer.title?.slice(0, 120), startsOn: evt.payload.start_date ?? new Date().toISOString().slice(0, 10),
  });
  res.writeHead(200).end("ok");
}).listen(8080);
```

## 6. Sync schedule and triggering events

| Trigger | Action |
|---|---|
| Recruitee stage change to *Hired* | Upsert client, person, placement. |
| Daily poll (optional) | Re-upsert hires of the last 7 days to catch missed webhooks (idempotent). |

## 7. Corrections, conflicts and locked periods

- Hire reverted: `DELETE /placements/{id}` (deactivates). Do not set `endsOn` before existing hours (`409`).
- Hours, conflicts and locks live in TimeTracking only.

## 8. Testing checklist

- [ ] First hire returns `created: true`; a redelivered webhook returns `created: false`.
- [ ] "van der Berg" style names are split sensibly or flagged.
- [ ] Re-upserting the person does not wipe the employee number (your code resends it).
- [ ] Export validation flags missing employee numbers before payroll.

## 9. Troubleshooting

| Error | Fix |
|---|---|
| `422` on people | Empty `firstName`/`lastName` or invalid email. |
| `422` "Person and client must exist …" | Person/client upsert failed earlier or used another `source`. |
| `422` on placements | `startsOn` not `YYYY-MM-DD`. |
| `403` read-only | Use a `write` key. |
| `429` | Wait `Retry-After`. |

## 10. Limits and known gaps

- Employee numbers are usually unknown at hire; exports need them.
- Full-replace upserts can clear office-maintained fields unless you resend them.

## 11. Build it with Claude

```text
Build a webhook receiver (TypeScript, Vercel or Cloudflare Workers) that turns a Recruitee "candidate moved to
Hired" webhook into TimeTracking records. Ask me for a sample payload and where client name and start date live.
TimeTracking: https://app.timetracking.tech/api/v1, Bearer $TT_API_KEY. Use idempotent upserts with source
"recruitee": PUT /clients/by-external/recruitee/{slug} {name},
PUT /people/by-external/recruitee/{candidateId} {firstName, lastName, email, phone, employeeNumber?},
PUT /placements/by-external/recruitee/{candidateId}-{offerId} {personExternalRef, clientExternalRef, ref,
roleTitle, startsOn}. Upserts are full replaces: fetch GET /people/{id} first and keep employeeNumber.
Return 200 fast, log failures, never delete data.
```
