# Payroll files

> End-to-end payroll export with the TimeTracking API: preview, commit, fetching the committed file, automating on export.completed, locking, and reconciling with GET /days.

URL: https://timetracking.tech/developers/data-out/payroll-files
Last updated: 2026-10-24

This page covers the full flow from approved hours to a file in your payroll system.

```
approve days ─► POST /exports (preview) ─► human checks ─► POST /exports (commit) ─► export.completed
           └─► GET /exports/runs/{id}/file ─► payroll import ─► (admin) POST /periods/lock ─► period.locked
```

## 1. Preview

```sh
curl -s -X POST "$TT/exports" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"mappingId":"preset-sdworx","startsOn":"2026-11-30","endsOn":"2026-12-06","mode":"preview"}'
```

| Response field | Meaning |
|---|---|
| `content` | The file as it would be written (with BOM when the mapping says so) |
| `rows` | Formatted data rows, without the header |
| `issues[]` | `{row, column, message}` validation problems. **Any issue blocks commit** |
| `ok` | `true` when there are no issues |
| `dayCount` | Approved days that would be exported |
| `pendingCount` | Days in range still `draft`/`submitted`. **Not included**; approve them first or export them in a later run |
| `warnings[]` | Rule-pack compliance warnings (`break_too_short`, `rest_too_short`, `shift_too_long`); informational |
| `packName` | Rule pack used |

Preview needs a write or admin key (or an office user). Read-only keys get 403 `API key is read-only`, because every `POST` is blocked for them.

What gets included in an export:

- approved, conflict-free days in the range that were not exported before, split into hour types;
- `per_day` placement allowances on worked days;
- approved expenses with `work_date` in the range;
- approved leave on working days (Mon–Fri, excluding public holidays) that was not exported before.

## 2. Commit (irreversible)

```sh
curl -s -X POST "$TT/exports" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"mappingId":"preset-generic","startsOn":"2026-11-30","endsOn":"2026-12-06","mode":"commit"}'
```

Real response:

```json
{
  "data": {
    "runId": "239a1898-fab5-486c-a2be-92d939be90cc",
    "rowCount": 2,
    "contentHash": "1f92f2a1b4c52a8bcc43c40740e3f29b83e475de5ebacce7807c51808a04f6eb",
    "content": "﻿employee_number;employee_name;client;placement;date;hour_type;hours\r\n1004;Sanne Janssen;Bakkerij Ruijters;PL-2026-104;2026-12-01;normal;8.00\r\n1004;Sanne Janssen;Bakkerij Ruijters;PL-2026-104;2026-12-02;normal;7.50\r\n"
  }
}
```

Effects:

- Included days → `exported` (with `export_run_id`).
- Included expenses → `exported`.
- Leave days are recorded as exported.
- An `export.completed` webhook fires.
- The run stores the content, its SHA-256 `contentHash`, the pay lines, the warnings and a snapshot of the mapping.

| Error | Cause |
|---|---|
| 422 `Nothing approved to export in this period` | No new approved days, expenses or leave in range (also on a second commit) |
| 422 `Export has N validation issue(s)` | Fix the mapping (missing codes) or the data (e.g. missing employee numbers) |
| 409 `This period was just exported; refresh before exporting again` | Same range committed less than a minute ago |
| 404 `Export mapping not found` | Unknown `mappingId` |

Send an `Idempotency-Key` with every commit. A network retry with the same key returns the original `{runId, …}` (header `idempotent-replayed: true`) instead of attempting a second export. Without a key, check `GET /exports/runs` before you try again.

**One export per day.** A day is exported once, by one mapping. If you need two files (for example payroll and client invoicing), build the second one from `GET /days` or from the run's `pay_lines` instead of a second commit.

## 3. Fetch the file

```sh
curl -s "$TT/exports/runs?limit=5" -H "Authorization: Bearer $KEY"
```

```json
{ "data": { "items": [
  { "id": "239a1898-fab5-486c-a2be-92d939be90cc", "mapping_id": null, "starts_on": "2026-11-30", "ends_on": "2026-12-06", "mode": "full",
    "row_count": 2, "content_hash": "1f92f2a1b4c52a8bcc43c40740e3f29b83e475de5ebacce7807c51808a04f6eb",
    "created_by": "00000000-0000-4000-8000-0000000000a1", "created_at": "2026-10-23T19:08:42.871612+00:00" }
], "next_cursor": null } }
```

`mapping_id` is `null` for presets. The preset id is in `mapping_snapshot.id` of the run detail.

```sh
curl -s "$TT/exports/runs/239a1898-fab5-486c-a2be-92d939be90cc/file" -H "Authorization: Bearer $KEY" -o export-2026-11-30_2026-12-06.csv
```

The response is `text/csv; charset=utf-8` (or `charset=windows-1252` for mappings with that encoding, see below) with `content-disposition: attachment; filename="export-2026-11-30_2026-12-06.csv"`. It holds the bytes exactly as committed: verify them against `content_hash` with `sha256sum`. Save the response as **bytes**. Some HTTP clients strip a UTF-8 BOM when they decode text. Read keys can fetch runs and files.

**Windows-1252 mappings.** When the run's mapping has `encoding: "windows-1252"`, this endpoint transcodes the file: the BOM is removed, every character outside U+0000–U+00FF (for example `€`, curly quotes `‘ ’ “ ”`, the em dash `—`, Polish `ł`) becomes `?`, and the bytes are Latin-1. In that case `content_hash` refers to the stored UTF-8 content, not the downloaded bytes. The `content` in the commit response and the dashboard download stay UTF-8.

`GET /exports/runs/{id}` returns metadata plus `mapping_snapshot`, `pay_lines` and `warnings` (no `content`).

## 4. Automate on `export.completed`

```js
// after verifying the signature (see ../webhooks/verify-signatures.md)
if (event.type === "export.completed") {
  const res = await fetch(`${TT}/exports/runs/${event.data.runId}/file`, { headers: { authorization: `Bearer ${KEY}` } });
  const bytes = Buffer.from(await res.arrayBuffer());
  await uploadToPayrollSftp(`timetracking-${event.data.startsOn}_${event.data.endsOn}.csv`, bytes);
}
```

Keep a human in the loop for the **commit**. Automating the delivery of an already committed file is safe and idempotent: dedupe on `runId`.

## 5. Lock the period

After payroll has processed the file, an **admin** locks the period in "Export" → "Periode vergrendelen", or over the API with an admin key or admin user token:

```sh
curl -s -X POST "$TT/periods/lock" -H "Authorization: Bearer $ADMIN_KEY" -H "content-type: application/json" \
  -d '{"startsOn":"2026-11-30","endsOn":"2026-12-06","kind":"week"}'
# → {"data":{"days":7}}
```

The lock refuses with 409 while days with hours are not yet exported, or expenses are still submitted or approved. See [periods and locking](https://timetracking.tech/developers/concepts/periods-and-locking).

## 6. Reconcile

To check that payroll received everything:

```sh
# Everything exported in a month, with export_run_id per day
curl -s "$TT/days?from=2026-12-01&to=2026-12-31&status=exported,locked&limit=500" -H "Authorization: Bearer $KEY"
# Anything left behind (not approved yet)
curl -s "$TT/days?from=2026-12-01&to=2026-12-31&status=draft,submitted,approved&limit=500" -H "Authorization: Bearer $KEY"
```

Sum `minutes` per `person_id` and compare with the payroll system's totals. Remember that `GET /days` shows resolved worked minutes. The split into hour types exists only in a run's `pay_lines`.

## Per-system guides

Payroll guides with mappings, entity tables and test checklists: [SD Worx](https://timetracking.tech/developers/integrations/payroll/sd-worx) · [Loket](https://timetracking.tech/developers/integrations/payroll/loket) · [Nmbrs](https://timetracking.tech/developers/integrations/payroll/nmbrs) · [AFAS](https://timetracking.tech/developers/integrations/payroll/afas) · [Visma Raet](https://timetracking.tech/developers/integrations/payroll/visma-raet) · [Easyflex](https://timetracking.tech/developers/integrations/payroll/easyflex) · [Mysolution](https://timetracking.tech/developers/integrations/payroll/mysolution) · [Planbition](https://timetracking.tech/developers/integrations/payroll/planbition) · [DATEV](https://timetracking.tech/developers/integrations/payroll/datev) · [Personio](https://timetracking.tech/developers/integrations/payroll/personio) · [Exact Online](https://timetracking.tech/developers/integrations/payroll/exact-online).

## PDF timesheets (urenstaten)

For a printable record per worker, download a PDF timesheet: every day with hours in the range, client, clock times, break, hours, status and the total, with signature lines (or the client's signature when approved through a link).

- Dashboard: "Export" → "Urenstaten (PDF)" lists everyone with hours in the period.
- API: `GET /people/{id}/timesheet?from=YYYY-MM-DD&to=YYYY-MM-DD` (read key; max 62 days) returns `application/pdf`.
- Clients: after approving all of a worker's days behind a [client approval link](https://timetracking.tech/developers/data-in/client-approval-links), the approval page offers "Urenstaat (PDF)" per worker (`/approve/{token}/timesheet/{personId}`, same email-code check as the page).
