CSV master-data import
Exact CSV format for importing people, clients and placements into TimeTracking: header aliases (Dutch and English), date formats, matching rules, dry run and the POST /import response.
Last updated: · Markdown version
You can import people, clients and placements from one CSV file. Use it for onboarding and for systems that only export spreadsheets. For ongoing system-to-system syncs, prefer PUT /people|/clients|/placements/by-external/{source}/{ref} and the batch endpoints (see the API reference).
Ways to run it:
- Dashboard: "Importeren" (upload, preview, then apply).
- API:
POST /import {csv, dryRun}(write key). - CLI:
tt import FILE.csv(dry run), thentt import FILE.csv --commit.
Format
- One header row, then one row per person (with an optional placement).
- The delimiter is detected automatically.
;(Excel NL default),,and tab all work. - Encoding: UTF-8. A BOM is allowed.
- Header names are matched case-insensitively against the aliases below. Unknown headers are ignored and reported in
unmappedHeaders.
| Field | Accepted headers | Required | Notes |
|---|---|---|---|
| Employee number | employee_number, personeelsnummer, werknemersnummer, nummer |
yes | Matching key for people |
| First name | first_name, voornaam |
yes | |
| Last name | last_name, achternaam |
yes | |
email, e-mail, emailadres |
no | Needed later for app access | |
| Phone | phone, telefoon, mobiel |
no | |
| Client | client, opdrachtgever, klant, inlener |
no | Present → a placement is created or updated |
| Placement ref | placement_ref, plaatsing, plaatsingsnummer, placement |
no | Matching key for placements |
| Role | role, functie |
no | role_title |
| Start date | start_date, startdatum, begindatum |
when a client is given | DD-MM-YYYY, D-M-YYYY, DD/MM/YYYY, DD.MM.YYYY or YYYY-MM-DD |
| End date | end_date, einddatum |
no | Same formats; must not be before the start date |
Template (the same as the dashboard download):
personeelsnummer;voornaam;achternaam;email;telefoon;opdrachtgever;plaatsing;functie;startdatum;einddatum
1005;Ahmed;Bakker;ahmed@example.com;+31612345678;VDL Montage;PL-2026-105;Monteur;01-11-2026;
Matching rules
| Entity | Matched on | When found | When not found |
|---|---|---|---|
| Person | employee_number within the org |
first name, last name, email and phone are overwritten (empty cells become empty) | created |
| Client | name, case-insensitive exact match |
reused | created with that name (approval mode office) |
| Placement | ref within the org (only when a ref is given) |
person, client, role and dates are overwritten | created. Rows without a ref always create a new placement, so re-importing them duplicates placements |
Notes:
- The import does not set
external_source/external_ref. Renaming a client in the CSV creates a second client. - A person's
activeflag is not changed by the import.
Dry run (default)
dryRun defaults to true on the API. Nothing is written, and the response shows what would happen.
curl -s -X POST "$TT/import" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
--data-binary @- <<'JSON'
{"dryRun": true, "csv": "personeelsnummer;voornaam;achternaam;email;telefoon;opdrachtgever;plaatsing;functie;startdatum;einddatum\r\n9901;Docs;Voorbeeld;docs@example.com;+31612345678;VDL Montage;PL-DOCS-1;Monteur;01-11-2026;\r\n;Zonder;Nummer;;;;;;;\r\n"}
JSON
Real response:
{
"data": {
"dryRun": true,
"rows": [
{ "row": 2, "ok": true, "person": "Docs Voorbeeld", "client": "VDL Montage" },
{ "row": 3, "ok": false, "message": "personeelsnummer ontbreekt" }
],
"people": { "created": 1, "updated": 0 },
"clients": { "created": 0 },
"placements": { "created": 1, "updated": 0 },
"unmappedHeaders": []
}
}
row is the 1-based line number in the file (the header is row 1). Row messages are in Dutch:
| Message | Meaning |
|---|---|
personeelsnummer ontbreekt |
Employee number missing |
naam ontbreekt |
First or last name missing |
startdatum ontbreekt of is ongeldig (gebruik DD-MM-JJJJ) |
A client is given but the start date is missing or unreadable |
einddatum ongeldig |
End date unreadable |
einddatum ligt vóór startdatum |
End date before start date |
To apply, send the same request with "dryRun": false. Valid rows are written and invalid rows are skipped and reported. There is no transaction across rows.
Limits
csvis at most 5,000,000 characters per request.- Rows are processed one by one. Large files (thousands of rows) take a while; split them, or use the batch endpoints.
- No XLSX upload yet. Save as CSV first.
Building a CSV in code
import csv, io, json, requests
rows = [{"personeelsnummer": "1006", "voornaam": "Lotte", "achternaam": "Peeters", "email": "lotte@example.com",
"opdrachtgever": "VDL Montage", "plaatsing": "PL-2026-106", "functie": "Monteur", "startdatum": "2026-11-02"}]
buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=list(rows[0]), delimiter=";")
w.writeheader(); w.writerows(rows)
r = requests.post(f"{TT}/import", headers={"Authorization": f"Bearer {KEY}"}, json={"csv": buf.getvalue(), "dryRun": True})
print(json.dumps(r.json()["data"], indent=2))
See also the office help page Import.