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.

Personio HR and payroll

Sync employees and absences from Personio into TimeTracking and push approved TimeTracking hours to Personio attendances with a script or iPaaS.

Last updated: · Markdown version

1. Summary

What syncs In: employees, approved absences. Out: approved worked hours as attendances
Direction Personio → TimeTracking (people, leave); TimeTracking → Personio (attendances)
Mechanism Script or iPaaS using the Personio API and the TimeTracking REST API
Trigger Employees/absences nightly; attendances after weekly approval

Status: Recipe — works today with your own script or iPaaS. No native Personio connector.

2. Mechanism

Assumption — the Personio API (v1) authenticates with client id/secret (POST https://api.personio.de/v1/auth → bearer token), lists employees (GET /v1/company/employees), lists and creates absences/time-offs (/v1/company/time-offs) and creates attendances (POST /v1/company/attendances with employee, date, start_time, end_time, break). Personio is introducing a v2 API; verify endpoints, scopes and field names with your Personio consultant or the Personio developer docs.

  1. Employees in. Nightly, upsert Personio employees as TimeTracking people with PUT /people/by-external/personio/{employeeId} (or POST /people/batch, ≤ 500 rows), idempotent and keyed on the Personio id.
  2. Absences in (optional). Approved Personio time-offs become TimeTracking leave (POST /leave, office keys auto-approve), so rosters and exports know about them. Alternatively keep leave in Personio only and do not register it in TimeTracking.
  3. Attendances out. After the office approves days, a script reads them with GET /days?status=approved,…&updated_since=… (per-day minutes, start/end, break) and posts them to Personio as attendances.

3. Prerequisites

  • TimeTracking API key with write scope.
  • Personio API credentials with read access to employees/time-offs and write access to attendances.
  • A Personio employee attribute that equals TimeTracking employee_number (e.g. the Personio employee id or a custom "Personalnummer" attribute).
  • Mapping Personio time-off types ↔ TimeTracking leave types (GET /leave/types).
  • Placements in TimeTracking for every person who records hours (people without a placement cannot record hours).

4. Entity mapping

Personio object / field TimeTracking Notes / external ID
Employee id people.external_source = personio, external_ref = Personio id PUT /people/by-external/personio/{id}.
Personalnummer attribute people.employee_number Used by payroll exports.
first_name, last_name, email firstName, lastName, email Upsert body is the full object; omitted optional fields become null.
status (active/inactive) active DELETE /people/{id} deactivates (active: false, emits person.updated).
Department / cost center Client or placement ref Placements are person × client; model internal departments as clients if needed.
Time-off (type, start_date, end_date) leave (leaveTypeId, startsOn, endsOn, minutesPerDay) Half days: set minutesPerDay (e.g. 240).
Attendance (date, start_time, end_time, break) GET /days item (work_date, start_min, end_min, break_min, minutes) Days recorded as totals only have start_min/end_min null.

External IDs: upsert people idempotently with PUT /{people|clients|placements}/by-external/{source}/{ref} (or POST /…/batch with externalSource + externalRef per row, up to 500 rows). The response includes external_source, external_ref and created: true|false.

5. Step-by-step setup

export TT=https://app.timetracking.tech/api/v1
export KEY=tt_live_…        # write scope
  1. Leave types and their ids (real capture, abridged):
curl -s "$TT/leave/types" -H "Authorization: Bearer $KEY"
{ "data": [
  { "id": "ef33b5fc-c116-434c-a70c-4c4a1ee1bd1a", "code": "vacation", "name": "Vakantie", "paid": true, "needs_approval": true, "export_code": "[CODE_VAKANTIE]" },
  { "id": "57533dc1-5168-493e-bab5-70e8586dbc45", "code": "sick", "name": "Ziek", "paid": true, "needs_approval": false, "export_code": "[CODE_ZIEK]" } ] }
  1. Register an approved Personio absence (office key → status approved immediately):
curl -s -X POST "$TT/leave" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"personId":"<person-id>","leaveTypeId":"ef33b5fc-c116-434c-a70c-4c4a1ee1bd1a","startsOn":"2026-12-14","endsOn":"2026-12-15","minutesPerDay":480,"note":"personio:time-off:123456"}'

An overlapping request returns 409 There is already leave in this period — use that as a duplicate guard.

  1. Push approved hours to Personio (TypeScript sketch). GET /days returns real clock times when they were recorded:
const TT = "https://app.timetracking.tech/api/v1";
const h = { authorization: `Bearer ${process.env.TT_API_KEY}` }; // a read key is enough
const hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
let cursor: string | null = null;
const attendances = [];
do {
  const q = new URLSearchParams({ from: "2026-11-30", to: "2026-12-06", status: "approved,exported,locked", limit: "500",
    ...(process.env.SINCE ? { updated_since: process.env.SINCE } : {}), ...(cursor ? { cursor } : {}) });
  const { data } = await (await fetch(`${TT}/days?${q}`, { headers: h })).json();
  for (const d of data.items) {
    if (d.minutes == null) continue;
    const start = d.start_min ?? 8 * 60;                       // totals-only days: synthetic 08:00 start
    const end = d.end_min ?? start + d.minutes + (d.break_min ?? 0);
    attendances.push({ dayId: d.id, version: d.version, personId: d.person_id, date: d.work_date,
      start_time: hhmm(start), end_time: hhmm(end), break: d.break_min ?? 0 });
  }
  cursor = data.next_cursor;
} while (cursor);
// Map person_id → Personio employee (via GET /people/{id} → employee_number or external_ref),
// then POST /v1/company/attendances (Assumption: verify fields with Personio docs). Store dayId → attendance id.
console.log(attendances);
  1. Run nightly employee upserts:
curl -s -X PUT "$TT/people/by-external/personio/123456" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"employeeNumber":"1004","firstName":"Sanne","lastName":"Janssen","email":"sanne@example.com","locale":"nl"}'

The response has "created": true the first time and false afterwards. Deactivated Personio employees: DELETE /people/{id}.

6. Sync schedule and triggering events

Flow Trigger
Employees, absences → TimeTracking Nightly cron
Attendances → Personio timesheet.approved webhook {dayIds, approved} or weekly cron after the close
Leave decided in TimeTracking (if employees request leave there) leave.decided {leaveRequestId, decision} → create the Personio time-off

timesheet.approved carries day ids only; read values with GET /days?updated_since=<last run> (or the date range). Webhooks are managed by an admin; failed deliveries can be inspected and replayed (GET /webhooks/{id}/deliveries, POST /webhook-deliveries/{id}/replay). leave.cancelled {leaveRequestId} tells you to remove a mirrored Personio time-off.

7. Corrections, conflicts and locked periods

  • A changed value on an approved day sends it back to submitted; re-push it after re-approval and update the Personio attendance (store the Personio attendance id per day id on your side).
  • Exported/locked days are final in TimeTracking (423). Corrections are booked in an open week; mirror them in Personio.
  • Do not let employees record the same hours in Personio and TimeTracking.

8. Testing checklist

  • Every Personio employee maps to exactly one TimeTracking person.
  • One approved Personio vacation appears as approved leave in TimeTracking (and not twice after a rerun).
  • Approved days appear as Personio attendances with the right date and duration.
  • Re-approval after a change updates, not duplicates, the attendance.

9. Troubleshooting

HTTP / code Message Fix
409 conflict There is already leave in this period Duplicate or overlapping absence; skip.
404 not_found Leave type not found / Person not found Wrong id or other org.
422 invalid End date is before start date Fix dates.
403 forbidden API key is read-only write key.
403 forbidden No employee profile in this organisation You called a /me/* endpoint with an API key; use org endpoints.
429 rate_limited Rate limit … exceeded Wait 60 s.

10. Limits and known gaps

  • Days recorded as totals (minutes only) have no clock times; the sketch synthesizes 08:00 + duration for them.
  • No API to create or edit leave types.
  • Leave cannot be updated via API; cancel (POST /leave/{id}/cancel) and recreate.

Planned (not in API v1.1) — leave updates and leave-type management via the API.

11. Build it with Claude

Build a Personio ↔ TimeTracking sync in TypeScript.
- Nightly: GET Personio employees (mark endpoint TODO: verify with Personio docs) → PUT /api/v1/people/by-external/personio/{id}
  (full object); inactive employees → DELETE /api/v1/people/{id}.
- Nightly: approved Personio time-offs → POST /api/v1/leave (map types via GET /api/v1/leave/types); treat 409 as "already there".
- Hourly: GET /api/v1/days?status=approved,exported,locked&updated_since=<last run>&limit=500 (follow next_cursor) → Personio
  attendances with start_min/end_min/break_min; keep dayId → attendanceId so reruns update instead of duplicate.
Respect 429 Retry-After. Never send personal data in URLs.