Skip to content

Request an integration

Tell us which system you want to connect. We review every request weekly and publish a step-by-step guide or build the connection.

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

  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.
// 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

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.