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

URL: https://timetracking.tech/developers/integrations/payroll/personio
Last updated: 2026-10-24

## 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

```sh
export TT=https://app.timetracking.tech/api/v1
export KEY=tt_live_…        # write scope
```

1. Leave types and their ids (real capture, abridged):

```sh
curl -s "$TT/leave/types" -H "Authorization: Bearer $KEY"
```

```json
{ "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]" } ] }
```

2. Register an approved Personio absence (office key → status `approved` immediately):

```sh
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.

3. Push approved hours to Personio (TypeScript sketch). `GET /days` returns real clock times when they were recorded:

```ts
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);
```

4. Run nightly employee upserts:

```sh
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

```text
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.
```
