# Easyflex payroll and placements

> Sync Easyflex flex workers and placements into TimeTracking and send approved hours back to Easyflex per placement, using CSV import, the REST API and an hours file.

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

## 1. Summary

| | |
|---|---|
| What syncs | In: flex workers, clients (inleners), placements. Out: approved hours per placement per hour type, expenses, leave |
| Direction | Easyflex → TimeTracking (master data), TimeTracking → Easyflex (hours) |
| Mechanism | Master data: CSV import (`POST /import`) or REST upserts. Hours: CSV export with a custom mapping imported as an Easyflex hours file, or a script against Easyflex web services |
| Trigger | Master data daily or on change; hours per payroll week |

**Status:** Recipe — works today with files and your own script. No native Easyflex connector.

## 2. Mechanism

Easyflex is a staffing back office: it owns flex workers, placements (plaatsingen) and payroll per placement. TimeTracking needs the same placements so hours are recorded per placement.

1. **Master data in.** Export flex workers and placements from Easyflex to CSV and import them with [CSV import](https://timetracking.tech/developers/data-in/csv-import) (`personeelsnummer`, `opdrachtgever`, `plaatsing`, `startdatum` …). People are matched on `employee_number`, placements on `ref`, clients on name. For a scripted sync, use the by-external upserts and batch endpoints instead (step 2b).
2. **Hours out.** After approval, a custom export mapping writes one line per placement per day (or per week) per hour type with the Easyflex placement number. You import it in Easyflex as an hours file.

Assumption — Easyflex accepts hours imports per placement (plaatsingnummer) with an hour code or wage component (uurcode / looncomponent) and supports SOAP/XML web services for flex workers, placements and hours. File layout, web service names and authentication must be verified with your Easyflex consultant.

## 3. Prerequisites

- TimeTracking admin; API key with `write` scope.
- Easyflex flex worker number (registratienummer) stored as TimeTracking `employee_number`.
- Easyflex placement number stored as TimeTracking placement `ref`.
- Easyflex hour codes per TimeTracking hour type.
- Rule pack aligned with the CAO (ABU/NBBU or inlenersbeloning). Assumption — some agencies let Easyflex calculate surcharges itself; then export only worked hours per day with times and map every hour type to the base code (see section 10).

## 4. Entity mapping

| Easyflex object / field | TimeTracking | Notes / external ID |
|---|---|---|
| Flexkracht registratienummer | `people.employee_number` | CSV header must be `personeelsnummer`, `werknemersnummer`, `nummer` or `employee_number`; `registratienummer` is not an accepted alias, so rename it. Only stable key today. |
| Voornaam / achternaam / e-mail / mobiel | `first_name`, `last_name`, `email`, `phone` | CSV aliases `voornaam`, `achternaam`, `email`, `telefoon`/`mobiel`. |
| Relatie / inlener | `clients.name` | Import matches clients by name (case-insensitive). `external_ref` can be set via `POST /clients`. |
| Plaatsing (nummer) | `placements.ref` | CSV `plaatsing` / `plaatsingsnummer`. Used to update existing placements. |
| Functie | `placements.role_title` | CSV `functie`. |
| Begindatum / einddatum | `starts_on` / `ends_on` | `DD-MM-JJJJ` or `YYYY-MM-DD`. |
| Uurcode / looncomponent | PayLine `hourType` via `map` | |
| Uren | `minutes` → `hours_decimal` | |
| Datum | PayLine `date` | Do not `groupBy` if Easyflex needs per-day lines. |
| Declaraties | `expense_<kind>` lines, `amountCents` | |

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 `easyflex` with the registratienummer, relatie number and plaatsingnummer as refs. Easyflex stays the source of truth.

## 5. Step-by-step setup

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

1. Export placements from Easyflex to CSV and rename headers to TimeTracking aliases:

```text
personeelsnummer;voornaam;achternaam;email;telefoon;opdrachtgever;plaatsing;functie;startdatum;einddatum
104577;Piotr;Nowak;piotr@example.com;+48500100200;Logistiek Parkstad;PL-2026-103;Heftruckchauffeur;01-10-2026;
```

2. Dry run, then import:

```sh
tt import easyflex-plaatsingen.csv            # dry run
tt import easyflex-plaatsingen.csv --commit
```

Real dry-run response shape:

```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": [] } }
```

2b. For a scripted daily sync, upsert by Easyflex ids instead of the CSV import (idempotent, safe to rerun):

```sh
# client
curl -s -X PUT "$TT/clients/by-external/easyflex/REL-3301" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"name":"Logistiek Parkstad","externalRef":"REL-3301","approvalMode":"client"}'
# flex worker
curl -s -X PUT "$TT/people/by-external/easyflex/104577" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"employeeNumber":"104577","firstName":"Piotr","lastName":"Nowak","email":"piotr@example.com","locale":"pl"}'
# placement, linked by the external refs of the same source
curl -s -X PUT "$TT/placements/by-external/easyflex/PL-2026-103" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"personExternalRef":"104577","clientExternalRef":"REL-3301","ref":"PL-2026-103","roleTitle":"Heftruckchauffeur","startsOn":"2026-10-01"}'
```

Real response shape (placement upsert, demo data):

```json
{ "data": { "id": "96574dce-db96-4da1-82cf-de32021472d9", "person_id": "69a47995-bc6a-4bc9-9b9b-070935b974bb",
  "client_id": "a3254709-d152-49b4-ad5e-51d50147910a", "ref": "PL-555", "role_title": "Lasser", "starts_on": "2026-12-01",
  "ends_on": null, "active": true, "external_source": "bullhorn", "external_ref": "PL-555", "created": true } }
```

For volume, send up to 500 rows per call to `POST /people/batch` or `POST /placements/batch` with `externalSource` and `externalRef` on each row; the response lists `{index, ok, data | error}` per row and one bad row never aborts the batch. Ended placements: `DELETE /placements/{id}` deactivates (`active: false`). Send an `Idempotency-Key` header on writes so a network retry replays the stored response (`idempotent-replayed: true`).

3. 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 Easyflex consultant):

```json
{
  "id": "easyflex-uren",
  "name": "Easyflex — urenbestand per plaatsing",
  "format": "csv",
  "delimiter": ";",
  "includeHeader": true,
  "encoding": "utf-8",
  "lineEnding": "crlf",
  "columns": [
    { "header": "Registratienummer", "source": "employeeNumber" },
    { "header": "Plaatsingnummer", "source": "placementRef" },
    { "header": "Datum", "source": "date", "transform": { "kind": "date", "format": "DD-MM-YYYY" } },
    { "header": "Uurcode", "source": "hourType", "transform": { "kind": "map", "table": "uurcode" } },
    { "header": "Uren", "source": "minutes", "transform": { "kind": "hours_decimal", "decimals": 2, "decimalSeparator": "," } },
    { "header": "Bedrag", "source": "amountCents", "transform": { "kind": "cents_decimal", "decimalSeparator": "," } }
  ],
  "tables": { "uurcode": { "normal": "100", "overtime_125": "125", "overtime_150": "150", "ort_evening": "201",
                           "ort_night": "202", "ort_saturday": "203", "ort_sunday": "204", "holiday": "205",
                           "expense_km": "KM", "expense_public_transport": "OV" } },
  "validation": [
    { "column": "Registratienummer", "required": true },
    { "column": "Uurcode", "required": true },
    { "column": "Uren", "min": 0, "max": 24 }
  ]
}
```

Leave lines have no placement (`placementRef` empty). If Easyflex needs a placement for leave, keep leave in Easyflex and leave `leave_*` unmapped — then remove `Uurcode` `required` or add a `fallback`.

4. Weekly: preview, commit, import:

```sh
tt export --week 2026-11-30 --mapping easyflex-uren
tt export --week 2026-11-30 --mapping easyflex-uren --commit -o easyflex-w49.csv
```

5. After Easyflex processed the week, an admin locks it in the dashboard.

## 6. Sync schedule and triggering events

| Flow | Schedule | Event |
|---|---|---|
| Placements Easyflex → TimeTracking | Daily, or when a placement starts/ends | Upserts emit `person.created`/`person.updated` and `placement.created`/`placement.updated` |
| Hours TimeTracking → Easyflex | Weekly after close | `export.completed` → download with `GET /exports/runs/{runId}/file` |
| Period final | After Easyflex payroll | `period.locked` |

## 7. Corrections, conflicts and locked periods

- Shortening a placement fails with `409` "N registration(s) exist after <date>" when hours exist after the new end date.
- Only approved, conflict-free days are exported; exported/locked days return `423` on new claims.
- Corrections for an exported week: record them in the current open week; they reach Easyflex in the next file with their own date.
- Reconcile before export: `GET /days?from=2026-11-30&to=2026-12-06&status=submitted,approved&client_id=<id>` (read key) lists day values per placement with status and conflict flag.

## 8. Testing checklist

- [ ] Dry-run import has no failed rows and `unmappedHeaders: []`.
- [ ] Every active Easyflex placement exists in TimeTracking with the same `ref`.
- [ ] Hours file: every line has `Plaatsingnummer`; codes mapped.
- [ ] Test import in Easyflex; compare per-placement totals.

## 9. Troubleshooting

| HTTP / code | Message | Fix |
|---|---|---|
| 200 import row `ok:false` | personeelsnummer ontbreekt / naam ontbreekt / startdatum ontbreekt of is ongeldig (gebruik DD-MM-JJJJ) | Fix the CSV row. |
| 409 `conflict` | N registration(s) exist after <date> | Hours exist after the new end date; correct hours first. |
| 403 `forbidden` | API key is read-only | `write` key. |
| 404 `not_found` | Export mapping not found | Check mapping id (UUID from `/export/mappings/<uuid>`). |
| 422 `invalid` | Person and client must exist (by id or external ref) before the placement | Upsert person and client (same `source`) first. |
| 422 `invalid` | Export has N validation issue(s) | Preview and fix. |
| 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

- Clients are matched by name in the CSV import; renaming a client in Easyflex creates a new client. The by-external upserts avoid this.
- Neither import nor upserts deactivate missing records; call `DELETE /people/{id}` / `DELETE /placements/{id}` yourself.
- Mappings are edited by admins in the dashboard only; there is no mapping API.
- If Easyflex calculates surcharges itself, use the `plain` rule pack so TimeTracking exports only `normal` hours.

- 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 an Easyflex ↔ TimeTracking sync script.
1. Preferred: upsert clients, people and placements with PUT /api/v1/{clients|people|placements}/by-external/easyflex/{ref}
   (or the /batch endpoints, 500 rows max). Fallback: read an Easyflex placements CSV export, rename columns to the TimeTracking import aliases
   (personeelsnummer, voornaam, achternaam, email, telefoon, opdrachtgever, plaatsing, functie, startdatum, einddatum),
   and call POST /api/v1/import with dryRun true; show failed rows. Only import with --commit.
2. Weekly: POST /api/v1/exports with mappingId "easyflex-uren", preview then (only with --commit) commit, and save the CSV.
3. Report placements that exist in Easyflex but not in TimeTracking (match on placement ref) and vice versa.
Mark any Easyflex web-service call as TODO (verify with Easyflex consultant). Use TT_API_KEY from the environment.
```
