# AFAS Profit payroll export

> Send approved TimeTracking hours, allowances, expenses and leave to AFAS Profit payroll as variable values, via file import or an AFAS UpdateConnector script.

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

## 1. Summary

| | |
|---|---|
| What syncs | Approved hours per hour type, allowances, approved expenses, approved leave |
| Direction | TimeTracking → AFAS Profit (Payroll / HRM) |
| Mechanism | CSV export (custom mapping) imported in Profit, or a script that commits the export and posts to an AFAS UpdateConnector |
| Trigger | Per payroll period, after the weekly close |

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

## 2. Mechanism

**A. File import.** A Profit-shaped export mapping renders one line per employee per variable (looncomponent) per period. You import it as variable values (variabele gegevens) in Profit. Assumption — Profit can import variable payroll values from a CSV; verify the import definition with your AFAS consultant.

**B. UpdateConnector.** A script commits the TimeTracking export and posts each line to the AFAS Profit REST API. Assumption — AFAS Profit exposes an UpdateConnector for variable payroll values (often referred to as `HrVarValue`) at `https://<participant>.rest.afas.online/profitrestservices/connectors/<Connector>`, authenticated with an `AfasToken` header. The connector name, field ids (employee, variable id, value, start date, employer) and required fields differ per environment; verify with your AFAS consultant.

## 3. Prerequisites

- TimeTracking admin; API key with `write` scope.
- `employee_number` in TimeTracking = AFAS employee id (medewerker). If AFAS uses alphanumeric ids, keep them as they are (no numeric validation in your mapping).
- AFAS variable ids (looncomponent codes) per hour type, expense kind and leave type.
- AFAS employer id (werkgever) when the import needs it.
- For option B: an AFAS app connector with the UpdateConnector authorised, plus its token.

## 4. Entity mapping

| AFAS Profit object / field | TimeTracking | Notes / external ID |
|---|---|---|
| Werkgever | TimeTracking org | `const:<employer id>`. |
| Medewerker (employee id) | `employee_number` → `employeeNumber` | Only stable key today. |
| Dienstverband | Not modelled | One active employment per employee assumed. Assumption — verify. |
| Variabele / looncomponent | `hourType` via `map` | `normal`, `overtime_*`, `ort_*`, `holiday`, `leave_*`, `expense_*`, `allowance_*`. |
| Waarde (quantity) | `minutes` → `hours_decimal` | |
| Bedrag | `amountCents` → `cents_decimal` | Expenses and allowances. |
| Begindatum | `date` (first line in group) or a constant period start | See section 10. |
| Kostenplaats | `clientName` / `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 `afas` with the AFAS employee 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 employee ids: `tt people list --json | jq -r '.[] | [.employee_number, .first_name, .last_name] | @tsv'`.

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 AFAS consultant):

```json
{
  "id": "afas-variabel",
  "name": "AFAS Profit — variabele waarden",
  "format": "csv",
  "delimiter": ";",
  "includeHeader": true,
  "encoding": "utf-8",
  "lineEnding": "crlf",
  "groupBy": ["employeeNumber", "hourType"],
  "columns": [
    { "header": "Werkgever", "source": "const:1" },
    { "header": "Medewerker", "source": "employeeNumber" },
    { "header": "Variabele", "source": "hourType", "transform": { "kind": "map", "table": "afas" } },
    { "header": "Waarde", "source": "minutes", "transform": { "kind": "hours_decimal", "decimals": 2, "decimalSeparator": "," } },
    { "header": "Bedrag", "source": "amountCents", "transform": { "kind": "cents_decimal", "decimalSeparator": "," } },
    { "header": "Begindatum", "source": "date", "transform": { "kind": "date", "format": "DD-MM-YYYY" } }
  ],
  "tables": {
    "afas": { "normal": "VAR_UREN", "overtime_125": "VAR_OW125", "ort_evening": "VAR_ORT_AV", "ort_sunday": "VAR_ORT_ZO",
              "holiday": "VAR_FEEST", "expense_km": "VAR_KM", "leave_vacation": "VAR_VAK" }
  },
  "validation": [
    { "column": "Medewerker", "required": true },
    { "column": "Variabele", "required": true }
  ]
}
```

All `VAR_…` codes are placeholders.

3. Preview, commit and save:

```sh
tt export --from 2026-11-01 --to 2026-11-30 --mapping afas-variabel
tt export --from 2026-11-01 --to 2026-11-30 --mapping afas-variabel --commit -o afas-2026-11.csv
```

4. Option A: import `afas-2026-11.csv` in Profit. Option B: post per row. Python sketch:

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

TT = "https://app.timetracking.tech/api/v1"
r = requests.post(f"{TT}/exports",
    headers={"Authorization": f"Bearer {os.environ['TT_API_KEY']}"},
    json={"mappingId": "afas-variabel", "startsOn": "2026-11-01", "endsOn": "2026-11-30", "mode": "commit"})
body = r.json()
if "error" in body:
    raise SystemExit(f"{r.status_code} {body['error']['code']}: {body['error']['message']}")
run = body["data"]
open(f"afas-{run['runId']}.csv", "w", encoding="utf-8").write(run["content"])
for row in csv.DictReader(io.StringIO(run["content"].lstrip("﻿")), delimiter=";"):
    # Assumption: connector name and field ids — verify with your AFAS consultant.
    payload = {"HrVarValue": {"Element": {"Fields": {
        "EmId": row["Medewerker"], "VaId": row["Variabele"], "Va": row["Waarde"].replace(",", "."), "DaBe": row["Begindatum"]}}}}
    # requests.post(f"https://<participant>.rest.afas.online/profitrestservices/connectors/HrVarValue",
    #               headers={"Authorization": "AfasToken <base64 token>"}, json=payload)
```

5. After AFAS processed the payroll, an admin locks the period in the dashboard.

## 6. Sync schedule and triggering events

- Monthly or 4-weekly per AFAS payroll run.
- `export.completed` (run created) and `period.locked` (period final) webhooks, created by an admin. A webhook receiver can download the run with `GET /exports/runs/{runId}/file` and start the UpdateConnector push. See [Webhook events](https://timetracking.tech/developers/webhooks/events).
- 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.

## 7. Corrections, conflicts and locked periods

- Only approved, conflict-free days are exported.
- Exported/locked days return `423` on new claims. Book corrections in an open period.
- If the UpdateConnector rejects rows after commit, retry the AFAS side from the saved file or from `GET /exports/runs/{runId}/file`. Never commit the same TimeTracking range again expecting the same rows: committed items are already `exported`.

## 8. Testing checklist

- [ ] All employee ids exist in AFAS; all variables mapped.
- [ ] Decimal sign accepted (`,` in file; UpdateConnector usually wants `.` — convert, as in the sketch).
- [ ] Test run in an AFAS test environment; totals per employee match TimeTracking.
- [ ] Re-running the AFAS push from the saved file does not duplicate values (check AFAS behaviour).

## 9. Troubleshooting

| HTTP / code | Message | Fix |
|---|---|---|
| 401 `unauthenticated` | Invalid or revoked API key | New key. |
| 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) | Preview and fix. |
| 422 `invalid` | Nothing approved to export in this period | Approve first. |
| 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

- Mappings are edited by admins in the dashboard only; there is no mapping API.
- `groupBy` keeps the first line's date; use a constant period start (`const:01-11-2026`) if AFAS expects one, which then needs a mapping per period — or drop `groupBy` and send per-day lines.
- No multi-employment support: one `employee_number` per person.

- Send an `Idempotency-Key` with the commit (`POST /exports`) so a network retry replays the stored response instead of failing.

- 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
Write a Python script that exports one month from TimeTracking and pushes it to AFAS Profit.
- Preview first (POST /exports mode "preview", mappingId from --mapping); abort if ok is false and print issues.
- With --commit: commit once, save content + runId + contentHash to disk.
- With --push: post each CSV row to an AFAS UpdateConnector. Leave connector name and field ids as clearly marked
  TODOs ("verify with AFAS consultant"); convert decimal comma to dot.
- Resume a failed push from the saved file (or GET /api/v1/exports/runs/{runId}/file) without re-committing.
TT_API_KEY and AFAS_TOKEN come from environment variables.
```
