Recruitee integration
Create TimeTracking people and placements when a Recruitee candidate is hired, using a Recruitee webhook and idempotent external-ID upserts.
Last updated: · Markdown version
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, Make, 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
- Create a TimeTracking API key (
write). - Deploy the receiver below (or the same steps in your iPaaS).
- Add the Recruitee webhook for candidate stage changes.
- Hire a test candidate; check
GET /placements?limit=5.
// 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 setendsOnbefore existing hours (409). - Hours, conflicts and locks live in TimeTracking only.
8. Testing checklist
- First hire returns
created: true; a redelivered webhook returnscreated: 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
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.