# Loket.nl payroll export

> Get approved TimeTracking hours, allowances, expenses and leave into Loket.nl as variable payroll data, via a CSV export mapping or the Loket API.

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

## 1. Summary

| | |
|---|---|
| What syncs | Approved hours per hour type, allowances, approved expenses, approved leave |
| Direction | TimeTracking → Loket.nl |
| Mechanism | CSV export (custom mapping or `preset-generic`) imported as variable data ("mutaties"), or a script that posts the rows to the Loket API |
| Trigger | Weekly close / payroll period close |

**Status:** Recipe — works today with an export file plus a Loket import, or with your own script against the TimeTracking and Loket APIs. There is no native Loket connector.

## 2. Mechanism

Two options:

**A. File import (recommended to start).** TimeTracking renders a CSV with a Loket-shaped export mapping. You import it in Loket.nl as variable data for the payroll period. An admin builds the Loket mapping in the dashboard (copy `preset-generic`, edit columns and codes); `preset-generic` alone lacks amounts and codes.

**B. API push.** A script commits the export (`POST /exports`, `mode: "commit"`) — or reacts to the `export.completed` webhook and downloads the file with `GET /exports/runs/{runId}/file` — parses the CSV, and writes each line to Loket via the Loket.nl REST API. Assumption — Loket.nl offers a REST API (OAuth 2.0) with endpoints for variable wage components per employment and payroll period; endpoint names and required fields must be verified with your Loket consultant or the Loket developer portal.

In both options TimeTracking does the hour-type split (rule pack), so Loket receives final quantities per wage component.

## 3. Prerequisites

- TimeTracking admin; API key with `write` scope (export needs an office-capable credential).
- `employee_number` in TimeTracking = Loket personnel number (werknemersnummer). If one person has several employments in Loket, agree which number to use. Assumption — Loket keys variable data on the employment (dienstverband); verify with your Loket consultant.
- The Loket wage component codes (looncomponenten) for each TimeTracking hour type.
- The exact Loket import layout for variable data (columns, separator, decimal sign, period notation). Assumption — verify with your Loket consultant.
- For option B: Loket API credentials (client id/secret) and the employer id.

## 4. Entity mapping

| Loket.nl object / field | TimeTracking | Notes / external ID |
|---|---|---|
| Werkgever | TimeTracking org | Constant column via `const:<werkgeversnummer>` if the import needs it. |
| Werknemer / dienstverband (personnel number) | `people.employee_number` → `employeeNumber` | Only stable key today. |
| Looncomponent (code) | `hourType` via a `map` table | One code per hour type, allowance, expense kind, leave type. |
| Waarde / aantal (hours) | `minutes` → decimal hours | `hours_decimal`, decimal separator per Loket layout. |
| Bedrag | `amountCents` → `cents_decimal` | Allowances and expenses. |
| Periode | Export range `startsOn`–`endsOn` | Not a PayLine field; add as `const:` or let the import screen pick the period. |
| Verlof | Approved leave → `leave_<code>` | Or keep leave in Loket only and leave those codes unmapped (they will then fail validation if marked required — use a `fallback`). |
| Kostenplaats | `clientName` or `placementRef` | Optional. |

External IDs: upsert people, clients and placements 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`. Use for example source `loket` with the Loket employment id as `ref`.

## 5. Step-by-step setup

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

1. Check that every paid person has an `employee_number`:

```sh
tt people list --json | jq -r '.[] | select(.active and (.employee_number==null)) | "\(.first_name) \(.last_name)"'
```

2. Start with the generic preset to see your data:

```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":"preview"}' | jq -r '.data.content'
```

Real output (demo data):

```text
employee_number;employee_name;client;placement;date;hour_type;hours
1004;Sanne Janssen;Bakkerij Ruijters;PL-2026-104;2026-12-01;normal;8.00
1004;Sanne Janssen;Bakkerij Ruijters;PL-2026-104;2026-12-02;normal;7.50
```

`preset-generic` has no amount column: expenses and allowances appear with `hours` 0.00 and lose their money amount. Use a custom mapping for real Loket files.

3. Create the mapping yourself (admin, dashboard): open the export mappings screen (`/export/mappings`, linked from **Export**), copy `preset-generic` or `preset-sdworx`, edit columns, code tables and validation, and optionally set it as the org default. Saved mappings are validated; max 50 per org. The mapping id is the UUID in its URL (`/export/mappings/<uuid>`): pass it as `mappingId` to `POST /exports` and `tt export --mapping`. Example JSON for the editor (assumed layout — verify with your Loket consultant):

```json
{
  "id": "loket-variabel",
  "name": "Loket.nl — variabele gegevens",
  "format": "csv",
  "delimiter": ";",
  "includeHeader": true,
  "encoding": "utf-8",
  "lineEnding": "crlf",
  "groupBy": ["employeeNumber", "hourType"],
  "columns": [
    { "header": "Werkgever", "source": "const:123456" },
    { "header": "Werknemernummer", "source": "employeeNumber" },
    { "header": "Looncomponent", "source": "hourType", "transform": { "kind": "map", "table": "loket", "fallback": "" } },
    { "header": "Aantal", "source": "minutes", "transform": { "kind": "hours_decimal", "decimals": 2, "decimalSeparator": "," } },
    { "header": "Bedrag", "source": "amountCents", "transform": { "kind": "cents_decimal", "decimalSeparator": "," } }
  ],
  "tables": {
    "loket": {
      "normal": "1001", "overtime_125": "1125", "overtime_150": "1150",
      "ort_evening": "1201", "ort_night": "1202", "ort_saturday": "1203", "ort_sunday": "1204", "holiday": "1205",
      "expense_km": "4010", "expense_public_transport": "4011", "expense_parking": "4012",
      "leave_vacation": "3001", "leave_sick": "3002"
    }
  },
  "validation": [
    { "column": "Werknemernummer", "required": true },
    { "column": "Looncomponent", "required": true },
    { "column": "Aantal", "min": 0, "max": 400 }
  ]
}
```

The codes `1001`… are placeholders. Use your own Loket codes.

4. Preview with your mapping's id (shown here as `loket-variabel`; in practice the UUID from `/export/mappings/<uuid>`), check `issues`, then commit:

```sh
tt export --from 2026-11-30 --to 2026-12-06 --mapping loket-variabel            # preview
tt export --from 2026-11-30 --to 2026-12-06 --mapping loket-variabel --commit -o loket-w49.csv
```

5. Import `loket-w49.csv` in Loket.nl for the right payroll period.

6. Option B (API push) sketch in TypeScript:

```ts
const TT = "https://app.timetracking.tech/api/v1";
const res = await fetch(`${TT}/exports`, {
  method: "POST",
  headers: { authorization: `Bearer ${process.env.TT_API_KEY}`, "content-type": "application/json" },
  body: JSON.stringify({ mappingId: "loket-variabel", startsOn: "2026-11-30", endsOn: "2026-12-06", mode: "commit" }),
});
const { data, error } = await res.json();
if (error) throw new Error(`${res.status} ${error.code}: ${error.message}`);
const [header, ...lines] = data.content.replace(/^﻿/, "").trim().split("\r\n").map((l: string) => l.split(";"));
for (const cols of lines) {
  const row = Object.fromEntries(header.map((h: string, i: number) => [h, cols[i]]));
  // Assumption: post to the Loket variable-data endpoint for this employment and period.
  // await loket.post(`/…/variablewagecomponents`, { … row … });
}
console.log("run", data.runId, data.contentHash);
```

7. After Loket processed the payroll, an admin locks the period in the dashboard.

## 6. Sync schedule and triggering events

- Run per payroll period (week, 4 weeks or month) after the weekly close.
- `export.completed` `{runId,startsOn,endsOn,rows,mapping}` tells downstream tools a run exists. It does not carry the file: fetch it with `GET /exports/runs/{runId}/file`.
- Committed files stay retrievable: `GET /exports/runs` (paginated `{items, next_cursor}`), `GET /exports/runs/{id}` (metadata, `mapping_snapshot`, `pay_lines`, `warnings`) and `GET /exports/runs/{id}/file` (the exact committed file, `text/csv`). A read-only key is enough.
- `period.locked` `{startsOn,endsOn,kind}` marks the period final.
- Webhooks are set up by an admin in the dashboard. See [Webhook events](https://timetracking.tech/developers/webhooks/events).

## 7. Corrections, conflicts and locked periods

- Only `approved`, conflict-free days are exported. `pendingCount` in the preview shows what is left out.
- Exported/locked days cannot change (`423 This day is exported or locked; book a correction in an open period`). Record a correction in an open period; it reaches Loket in the next run.
- For option B: if the Loket push fails halfway, the TimeTracking run is already committed. Re-download the file with `GET /exports/runs/{runId}/file` and retry only the Loket side. Do not commit again; a retry with the same `Idempotency-Key` returns the stored commit response (`idempotent-replayed: true`).

## 8. Testing checklist

- [ ] Every personnel number in the file exists in Loket for the payroll period.
- [ ] Every hour type in the preview maps to a Loket code (no empty `Looncomponent`).
- [ ] Decimal separator and header names match the Loket import screen.
- [ ] Test import in a Loket test period, then compare totals per employee with the TimeTracking week overview.
- [ ] Expenses and leave arrive with the right code and amount.

## 9. Troubleshooting

| HTTP / code | Message | Fix |
|---|---|---|
| 401 `unauthenticated` | Invalid or revoked API key | New key. |
| 403 `forbidden` | API key is read-only | Use a `write` key for `POST /exports`. |
| 404 `not_found` | Export mapping not found | Use the UUID from `/export/mappings/<uuid>` or a preset id. |
| 409 `conflict` | This period was just exported; refresh before exporting again | Do not double-commit. |
| 422 `invalid` | Export has N validation issue(s) | Preview, fix `issues` (missing number or code). |
| 422 `invalid` | Nothing approved to export in this period | Approve first or range already exported. |
| 423 `locked` | This day is exported or locked; book a correction in an open period | Correct in an open period. |
| 429 `rate_limited` | Rate limit … exceeded | Wait 60 s (`Retry-After`). |

## 10. Limits and known gaps

- Mappings are edited by admins in the dashboard only; there is no mapping API.
- `groupBy` keeps the first line's date; if Loket needs a per-day file, omit `groupBy`.
- No `period` field in PayLine; use a constant or select the period in Loket.
- Send an `Idempotency-Key` on `POST /exports` commits; without one, the API still refuses the same range within a minute (`409`).

- Encoding: a mapping with `encoding: "windows-1252"` is delivered as Windows-1252 (Latin-1) only by `GET /exports/runs/{id}/file` (`content-type: text/csv; charset=windows-1252`, no BOM). Characters outside Latin-1 (€, curly quotes, em dash) become `?`. The preview/commit JSON `content` and the dashboard download stay UTF-8.

> **Planned (not in API v1.1)** — export-mapping CRUD via the API.

## 11. Build it with Claude

```text
Build a small Node script "tt-to-loket" that:
1. Calls POST https://app.timetracking.tech/api/v1/exports with mode "preview" for a given startsOn/endsOn and
   mappingId (default "loket-variabel"), prints issues/warnings/pendingCount and stops if ok is false.
2. Only with --commit: calls mode "commit", saves data.content to loket-<start>_<end>.csv and run.json
   (runId, rowCount, contentHash). Never commits twice for the same range; to recover a lost file use
   GET /api/v1/exports/runs/{runId}/file.
3. Optional --push: parses the CSV and posts each row to the Loket.nl API (ask me for the exact endpoint and
   fields; mark them as TODO until verified with our Loket consultant).
Use TT_API_KEY from the environment. Handle 409/422/423/429 (respect Retry-After) with clear messages.
```
