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.
- Employees in. Nightly, upsert Personio employees as TimeTracking people with
PUT /people/by-external/personio/{employeeId}(orPOST /people/batch, ≤ 500 rows), idempotent and keyed on the Personio id. - 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. - 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
writescope. - 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
- 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]" } ] }
- Register an approved Personio absence (office key → status
approvedimmediately):
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.
- Push approved hours to Personio (TypeScript sketch).
GET /daysreturns 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);
- 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 (
minutesonly) 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.