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.
Last updated: · Markdown version
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
writescope (export needs an office-capable credential). employee_numberin 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
export TT=https://app.timetracking.tech/api/v1
export KEY=tt_live_… # write scope
- Check that every paid person has an
employee_number:
tt people list --json | jq -r '.[] | select(.active and (.employee_number==null)) | "\(.first_name) \(.last_name)"'
- Start with the generic preset to see your data:
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):
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.
- Create the mapping yourself (admin, dashboard): open the export mappings screen (
/export/mappings, linked from Export), copypreset-genericorpreset-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 asmappingIdtoPOST /exportsandtt export --mapping. Example JSON for the editor (assumed layout — verify with your Loket consultant):
{
"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.
- Preview with your mapping's id (shown here as
loket-variabel; in practice the UUID from/export/mappings/<uuid>), checkissues, then commit:
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
Import
loket-w49.csvin Loket.nl for the right payroll period.Option B (API push) sketch in TypeScript:
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);
- 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 withGET /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) andGET /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.
7. Corrections, conflicts and locked periods
- Only
approved, conflict-free days are exported.pendingCountin 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}/fileand retry only the Loket side. Do not commit again; a retry with the sameIdempotency-Keyreturns 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.
groupBykeeps the first line's date; if Loket needs a per-day file, omitgroupBy.No
periodfield in PayLine; use a constant or select the period in Loket.Send an
Idempotency-KeyonPOST /exportscommits; 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 byGET /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 JSONcontentand the dashboard download stay UTF-8.
Planned (not in API v1.1) — export-mapping CRUD via the API.
11. Build it with Claude
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.