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.
Last updated: · Markdown version
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
writescope. employee_numberin 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
export TT=https://app.timetracking.tech/api/v1
export KEY=tt_live_… # write scope
Check employee ids:
tt people list --json | jq -r '.[] | [.employee_number, .first_name, .last_name] | @tsv'.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 AFAS consultant):
{
"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.
- Preview, commit and save:
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
- Option A: import
afas-2026-11.csvin Profit. Option B: post per row. Python sketch:
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)
- 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) andperiod.locked(period final) webhooks, created by an admin. A webhook receiver can download the run withGET /exports/runs/{runId}/fileand start the UpdateConnector push. See Webhook events.- 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.
7. Corrections, conflicts and locked periods
- Only approved, conflict-free days are exported.
- Exported/locked days return
423on 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 alreadyexported.
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.
groupBykeeps 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 dropgroupByand send per-day lines.No multi-employment support: one
employee_numberper person.Send an
Idempotency-Keywith 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 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
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.