# 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.

URL: https://timetracking.tech/developers/data-in/csv-import
Last updated: 2026-10-24

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](https://timetracking.tech/developers/api/reference#put-people-by-external-source-ref)).

Ways to run it:

- Dashboard: "Importeren" (upload, preview, then apply).
- API: `POST /import {csv, dryRun}` (write key).
- CLI: `tt import FILE.csv` (dry run), then `tt 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 | `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):

```csv
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 `active` flag 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.

```sh
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:

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

- `csv` is 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

```python
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](https://timetracking.tech/developers/help/office/import).
