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.

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.

Last updated: · Markdown version

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

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)

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:

{
  "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

curl -s "$TT/exports/runs?limit=5" -H "Authorization: Bearer $KEY"
{ "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.

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

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

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.

6. Reconcile

To check that payroll received everything:

# 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 · Loket · Nmbrs · AFAS · Visma Raet · Easyflex · Mysolution · Planbition · DATEV · Personio · 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, the approval page offers "Urenstaat (PDF)" per worker (/approve/{token}/timesheet/{personId}, same email-code check as the page).