# Exact Online payroll and project hours

> Send approved TimeTracking hours to Exact Online, either as payroll variable data (Exact Online Loonadministratie) or as project time transactions for invoicing.

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

## 1. Summary

| | |
|---|---|
| What syncs | Approved hours (per hour type), allowances, approved expenses; optionally per client for invoicing |
| Direction | TimeTracking → Exact Online |
| Mechanism | A. CSV export (custom mapping) imported in Exact Online payroll. B. Script: commit export → Exact Online REST API (project time transactions) |
| Trigger | Payroll period close (A) or weekly (B) |

**Status:** Recipe — works today with an export file or your own script. No native Exact Online connector.

## 2. Mechanism

**A. Payroll (Exact Online Loonadministratie / Payroll NL).** A custom export mapping writes one line per employee per wage component per period. Assumption — Exact Online payroll can import variable payroll mutations (looncomponenten per employee per period) from a CSV/Excel template; verify the template with your Exact consultant.

**B. Project hours (for invoicing clients).** A script commits the TimeTracking export with a per-day mapping that includes `clientName` and `placementRef`, then posts each line to Exact Online. Assumption — Exact Online exposes `TimeTransactions` in its REST API (`/api/v1/{division}/project/TimeTransactions`, OAuth 2.0), keyed on Employee, Account (customer), Project/Item, Date and Quantity (hours). Verify required fields (Item, HourStatus) with your Exact consultant.

## 3. Prerequisites

- TimeTracking admin; API key with `write` scope.
- `employee_number` = Exact Online employee code.
- For A: Exact payroll wage component codes per hour type.
- For B: Exact Online app registration (client id/secret, redirect URI), division number, and a mapping client → Exact Account code (store it in TimeTracking `clients.external_ref`), placement → Exact Project code (store it in placement `ref` if unique), hour type → Exact Item (hour type item).

## 4. Entity mapping

| Exact Online object / field | TimeTracking | Notes / external ID |
|---|---|---|
| Employee (code) | `employee_number` → `employeeNumber` | Only stable key today. |
| Account (customer code) | `clients.external_ref` | Not a PayLine field; look it up via `GET /clients` by `clientName`. |
| Project | `placements.ref` → `placementRef` | Or a project per client. |
| Item (hour type, e.g. normal/overtime) | `hourType` via `map` | |
| Quantity | `minutes` → `hours_decimal` (decimal point for the API) | |
| Date | PayLine `date` | No `groupBy` for project hours. |
| Wage component (payroll) | `hourType` via `map` | Option A. |
| Amount | `amountCents` | Expenses (A: wage component; B: separate cost transactions). |

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 source `exact` with the Exact employee code, Account code and project code as refs.

## 5. Step-by-step setup

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

1. Store the Exact Account code on each client (send all fields; `POST /clients` with `id` replaces the record):

```sh
curl -s -X POST "$TT/clients" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"id":"00000000-0000-4000-8000-0000000000c3","name":"Bakkerij Ruijters","externalRef":"20031","approvalMode":"office"}'
```

Note: `mailDomains` is replaced too; include it if the client has inbound mail domains (comma-separated string).

2. 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 Exact consultant):

```json
{
  "id": "exact-project",
  "name": "Exact Online — projecturen",
  "format": "csv", "delimiter": ";", "includeHeader": true, "encoding": "utf-8", "lineEnding": "lf",
  "columns": [
    { "header": "Employee", "source": "employeeNumber" },
    { "header": "Client", "source": "clientName" },
    { "header": "Project", "source": "placementRef" },
    { "header": "Date", "source": "date", "transform": { "kind": "date", "format": "YYYY-MM-DD" } },
    { "header": "Item", "source": "hourType", "transform": { "kind": "map", "table": "items", "fallback": "UREN" } },
    { "header": "Hours", "source": "minutes", "transform": { "kind": "hours_decimal", "decimals": 2 } }
  ],
  "tables": { "items": { "normal": "UREN", "overtime_125": "UREN125", "ort_sunday": "UREN-ZO" } },
  "validation": [{ "column": "Employee", "required": true }, { "column": "Project", "required": true }]
}
```

Item codes are placeholders.

3. Commit and push (Python sketch):

```python
import csv, io, os, requests

TT = "https://app.timetracking.tech/api/v1"
H = {"Authorization": f"Bearer {os.environ['TT_API_KEY']}"}
accounts = {c["name"]: c["external_ref"] for c in requests.get(f"{TT}/clients", headers=H).json()["data"]}
run = requests.post(f"{TT}/exports", headers=H, json={
    "mappingId": "exact-project", "startsOn": "2026-11-30", "endsOn": "2026-12-06", "mode": "commit"}).json()
if "error" in run:
    raise SystemExit(run["error"])
open("exact-w49.csv", "w", encoding="utf-8").write(run["data"]["content"])
for row in csv.DictReader(io.StringIO(run["data"]["content"].lstrip("﻿")), delimiter=";"):
    tx = {"Employee": row["Employee"], "Account": accounts.get(row["Client"]), "Project": row["Project"],
          "Date": row["Date"], "Item": row["Item"], "Quantity": float(row["Hours"])}
    # Assumption: resolve codes to Exact GUIDs, then
    # POST https://start.exactonline.nl/api/v1/{division}/project/TimeTransactions (verify fields with Exact consultant)
```

4. Option A: an admin builds a payroll mapping in the dashboard (same shape as the [Loket example](https://timetracking.tech/developers/integrations/payroll/loket#5-step-by-step-setup), with your Exact wage component codes), then `tt export --from 2026-11-01 --to 2026-11-30 --mapping exact-payroll --commit -o exact-loon-2026-11.csv` and import the file in Exact payroll.

5. An admin locks the period in the dashboard after Exact processed it.

## 6. Sync schedule and triggering events

- A: per payroll period. B: weekly after approval (invoicing).
- `export.completed` `{runId,startsOn,endsOn,rows,mapping}` → download with `GET /exports/runs/{runId}/file`; `period.locked` `{startsOn,endsOn,kind}`. Admin creates webhooks.
- For invoicing without a payroll commit, read approved day values directly: `GET /days?from=…&to=…&status=approved,exported&client_id=<id>` (read key; real minutes, start/end, per placement). Hour types are not split there; map everything to one Exact item or use the export.
- Note: one commit moves days to `exported`. If you need both payroll (A) and invoicing (B) from the same days, do one commit and derive both files from the saved content of a mapping that holds all fields, because a second mapping cannot re-export the same days.

## 7. Corrections, conflicts and locked periods

- Only approved, conflict-free days are exported.
- Exported/locked days are immutable (`423`). Book corrections in an open week; they become new Exact transactions.
- Push failures after commit: retry from the saved file, never re-commit.

## 8. Testing checklist

- [ ] Every client has an Exact Account code in `external_ref`; every placement `ref` is a valid Exact project.
- [ ] Item mapping covers all hour types in the preview.
- [ ] Test push in an Exact test division.
- [ ] Payroll and invoicing both use the same committed run (no double commit).

## 9. Troubleshooting

| HTTP / code | Message | Fix |
|---|---|---|
| 403 `forbidden` | API key is read-only | `write` key. |
| 404 `not_found` | Export mapping not found | Check mapping id. |
| 409 `conflict` | This period was just exported; refresh before exporting again | Avoid double commit. |
| 422 `invalid` | Export has N validation issue(s) | Missing employee number or placement ref. |
| 422 `invalid` | Nothing approved to export in this period | Approve first; or days already exported by another mapping. |
| 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. |

## 10. Limits and known gaps

- Exports are exclusive: committed days cannot be exported again with another mapping.
- `clients.external_ref` is not a PayLine source; join it yourself via `GET /clients`.
- Mappings are edited by admins in the dashboard only; there is no mapping API.
- 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.

- 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 TimeTracking → Exact Online project-hours push in Python.
- Load clients via GET /api/v1/clients?limit=500 (follow next_cursor; external_ref = Exact Account code).
- For invoicing only, prefer GET /api/v1/days?status=approved,exported&client_id=… over committing an export.
- Preview POST /api/v1/exports (mappingId "exact-project"); on --commit, commit once and save content + runId.
- Post each line to Exact Online TimeTransactions (OAuth 2.0, division from env). Mark field names TODO
  ("verify with Exact consultant"); resolve codes to GUIDs with cached lookups.
- Make the push resumable from the saved file; log failures per line.
```
