Skip to content

Request an integration

Tell us which system you want to connect. We review every request weekly and publish a step-by-step guide or build the connection.

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

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

{
  "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.

  1. 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
  1. Option A: import afas-2026-11.csv in 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)
  1. 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.
  • 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

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.