# API reference

> Every TimeTracking REST API v1.1 endpoint with parameters, request fields, who may call it, and real captured request/response examples.

URL: https://timetracking.tech/developers/api/reference
Last updated: 2026-10-24

Generated from the live OpenAPI 3.1 spec (`GET /api/v1/openapi.json`, version **1.1.0**, 64 paths, 21 webhook events) and enriched with real responses captured against the API on 2026-10-23 (demo organisation; IDs are real demo IDs, keys and secrets are masked, long lists are trimmed to two items). Field names in **request bodies are camelCase**; field names in **responses are snake_case** database columns unless noted.

```sh
export TT=https://app.timetracking.tech/api/v1
export KEY=tt_live_…   # see ../api/authentication.md
```

Conventions that apply to every endpoint: [authentication](https://timetracking.tech/developers/api/authentication), [errors](https://timetracking.tech/developers/api/errors) (`{ "error": { "code", "message" } }`, 401/403/404/409/422/423/429), [rate limits](https://timetracking.tech/developers/api/rate-limits), [pagination](https://timetracking.tech/developers/api/pagination), [idempotency](https://timetracking.tech/developers/api/idempotency). Every endpoint can return the standard error statuses; only notable cases are shown.

**Who can call it** uses these terms: *read key* = API key with only the `read` scope (acts like an accountant), *write key* = API key with `write` (acts like an office user), *admin key* = API key with `admin` (implies write; acts like an admin, but can never create keys or change roles), *office user* / *admin user* / *employee token* = a signed-in user's access token. See [authentication](https://timetracking.tech/developers/api/authentication#roles-and-what-a-credential-can-do).

## Contents

- **Hours**: [`GET /org/week`](#get-org-week) · [`GET /days`](#get-days) · [`GET /claims`](#get-claims) · [`POST /claims`](#post-claims) · [`POST /claims/batch`](#post-claims-batch) · [`POST /weeks/submit`](#post-weeks-submit) · [`POST /days/approve`](#post-days-approve) · [`GET /conflicts`](#get-conflicts) · [`POST /conflicts/{id}/resolve`](#post-conflicts-id-resolve)
- **Approvals**: [`POST /approval-links`](#post-approval-links) · [`GET /client/approve/{token}`](#get-client-approve-token) · [`POST /client/approve/{token}`](#post-client-approve-token) · [`POST /client/approve/{token}/code`](#post-client-approve-token-code) · [`POST /client/approve/{token}/verify`](#post-client-approve-token-verify)
- **Exports**: [`POST /exports`](#post-exports) · [`GET /exports/runs`](#get-exports-runs) · [`GET /exports/runs/{id}`](#get-exports-runs-id) · [`GET /exports/runs/{id}/file`](#get-exports-runs-id-file) · [`GET /people/{id}/timesheet`](#get-people-id-timesheet) · [`POST /periods/lock`](#post-periods-lock)
- **Master data**: [`POST /import`](#post-import) · [`POST /import/time-entries`](#post-import-time-entries) · [`GET /people`](#get-people) · [`POST /people`](#post-people) · [`GET /people/{id}`](#get-people-id) · [`PATCH /people/{id}`](#patch-people-id) · [`DELETE /people/{id}`](#delete-people-id) · [`PUT /people/by-external/{source}/{ref}`](#put-people-by-external-source-ref) · [`POST /people/batch`](#post-people-batch) · [`GET /clients`](#get-clients) · [`POST /clients`](#post-clients) · [`GET /clients/{id}`](#get-clients-id) · [`PATCH /clients/{id}`](#patch-clients-id) · [`DELETE /clients/{id}`](#delete-clients-id) · [`PUT /clients/by-external/{source}/{ref}`](#put-clients-by-external-source-ref) · [`POST /clients/batch`](#post-clients-batch) · [`GET /placements`](#get-placements) · [`POST /placements`](#post-placements) · [`GET /placements/{id}`](#get-placements-id) · [`PATCH /placements/{id}`](#patch-placements-id) · [`DELETE /placements/{id}`](#delete-placements-id) · [`PUT /placements/by-external/{source}/{ref}`](#put-placements-by-external-source-ref) · [`POST /placements/batch`](#post-placements-batch) · [`POST /people/{id}/access`](#post-people-id-access) · [`DELETE /people/{id}/access`](#delete-people-id-access) · [`POST /people/{id}/anonymize`](#post-people-id-anonymize)
- **Kiosk**: [`POST /people/{id}/pin`](#post-people-id-pin) · [`GET /kiosk-devices`](#get-kiosk-devices) · [`POST /kiosk-devices`](#post-kiosk-devices)
- **Rosters**: [`GET /shifts`](#get-shifts) · [`POST /shifts`](#post-shifts) · [`DELETE /shifts/{id}`](#delete-shifts-id) · [`POST /shifts/publish`](#post-shifts-publish) · [`POST /shifts/copy-week`](#post-shifts-copy-week) · [`POST /shifts/{id}/claim`](#post-shifts-id-claim) · [`POST /shifts/{id}/swap`](#post-shifts-id-swap) · [`GET /shifts/{id}/swap-candidates`](#get-shifts-id-swap-candidates) · [`GET /shift-requests`](#get-shift-requests) · [`POST /shift-requests/{id}/respond`](#post-shift-requests-id-respond) · [`POST /shift-requests/{id}/decide`](#post-shift-requests-id-decide) · [`POST /shift-requests/{id}/cancel`](#post-shift-requests-id-cancel) · [`GET /availability`](#get-availability) · [`POST /availability`](#post-availability)
- **Leave**: [`GET /leave`](#get-leave) · [`POST /leave`](#post-leave) · [`GET /leave/types`](#get-leave-types) · [`GET /leave/balance`](#get-leave-balance) · [`POST /leave/{id}/decide`](#post-leave-id-decide) · [`POST /leave/{id}/cancel`](#post-leave-id-cancel)
- **Expenses**: [`GET /expenses`](#get-expenses) · [`POST /expenses`](#post-expenses) · [`DELETE /expenses/{id}`](#delete-expenses-id) · [`POST /expenses/decide`](#post-expenses-decide) · [`POST /expenses/receipt-upload`](#post-expenses-receipt-upload)
- **Inbox**: [`GET /inbox`](#get-inbox) · [`POST /inbox/items/{id}/apply`](#post-inbox-items-id-apply) · [`POST /inbox/items/{id}/ignore`](#post-inbox-items-id-ignore)
- **Employee**: [`GET /me/week`](#get-me-week) · [`GET /me/shifts`](#get-me-shifts) · [`GET /me/open-shifts`](#get-me-open-shifts) · [`GET /me/shift-requests`](#get-me-shift-requests) · [`GET /me/predictions`](#get-me-predictions) · [`GET /me/rewards`](#get-me-rewards) · [`GET /me/notifications`](#get-me-notifications) · [`POST /me/notifications/read`](#post-me-notifications-read) · [`POST /me/device-token`](#post-me-device-token)
- **Settings**: [`GET /org/reminders`](#get-org-reminders) · [`PUT /org/reminders`](#put-org-reminders)
- **Integrations**: [`GET /api-keys`](#get-api-keys) · [`POST /api-keys`](#post-api-keys) · [`DELETE /api-keys/{id}`](#delete-api-keys-id) · [`GET /webhooks`](#get-webhooks) · [`POST /webhooks`](#post-webhooks) · [`DELETE /webhooks/{id}`](#delete-webhooks-id) · [`GET /webhooks/{id}/deliveries`](#get-webhooks-id-deliveries) · [`POST /webhooks/{id}/test`](#post-webhooks-id-test) · [`POST /webhooks/{id}/rotate-secret`](#post-webhooks-id-rotate-secret) · [`POST /webhook-deliveries/{id}/replay`](#post-webhook-deliveries-id-replay)
- **Reports**: [`GET /reports/hours`](#get-reports-hours)
- **Account**: [`GET /export/account`](#get-export-account)

## Hours

<a id="get-org-week"></a>

### GET /org/week

Hours of all placements for one week, with KPIs and open conflicts.

- **operationId:** `getOrgWeek`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `week` | query | string (date) | no | Any date in the ISO week; defaults to the current week (org timezone). |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/org/week?week=2026-11-30" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "weekStart": "2026-11-30",
    "weekEnd": "2026-12-06",
    "rows": [
      {
        "placementId": "00000000-0000-4000-8000-0000000000e4",
        "personId": "00000000-0000-4000-8000-0000000000d4",
        "personName": "Sanne Janssen",
        "employeeNumber": "1004",
        "clientName": "Bakkerij Ruijters",
        "clientId": "00000000-0000-4000-8000-0000000000c3",
        "approvalMode": "office",
        "days": [
          null,
          {
            "dayId": "0ce9b17d-40ab-4542-937a-de57896a7358",
            "minutes": 480,
            "status": "submitted",
            "conflict": false,
            "clientApproved": false,
            "officeApproved": false
          }
        ],
        "totalMinutes": 930,
        "status": "submitted"
      },
      {
        "placementId": "00000000-0000-4000-8000-0000000000e2",
        "personId": "00000000-0000-4000-8000-0000000000d2",
        "personName": "Fatima El Amrani",
        "employeeNumber": "1002",
        "clientName": "Logistiek Parkstad",
        "clientId": "00000000-0000-4000-8000-0000000000c2",
        "approvalMode": "client",
        "days": [
          null,
          null
        ],
        "totalMinutes": 0,
        "status": "empty"
      }
    ],
    "conflicts": [],
    "kpis": {
      "placements": 4,
      "submitted": 1,
      "approved": 0,
      "conflicts": 0,
      "missing": 3,
      "totalMinutes": 930
    }
  }
}
```

**Late status.** Every row has `late` (boolean) and `kpis.late` counts people. A person is late when they have an active placement in the week, the hours deadline of the payroll period that contains the week has passed in the org timezone (default Monday 09:00 after the period; see [`GET /org/reminders`](#get-org-reminders)), and none of their days in the week is submitted (drafts do not count). `deadline` is `{ "date": "2026-12-07", "minute": 540, "passed": false }`.

<a id="get-days"></a>

### GET /org/today

The office "Today" board: everyone with a published shift, kiosk clock-in, recorded hours or approved leave today (org timezone).

- **operationId:** `getOrgToday`
- **Who can call it:** read key, write key, office user

Each row has `personId`, `name`, `clientName`, `shift` (`{start, end}` in minutes of day, or `null`), `clockIn` (minutes of day of the first kiosk clock-in, or `null`), `minutes` recorded today, `lateBy` (minutes after the shift start) and `state`:

| State | Meaning |
|---|---|
| `notIn` | Shift started more than 5 minutes ago, no clock-in and no hours |
| `late` | Clocked in at a kiosk more than 5 minutes after the shift start, still in |
| `in` | Clocked in at a kiosk, still in |
| `done` | Clocked out, or hours for today are recorded (app, web, import, office) |
| `starts` | Shift later today |
| `leave` | Approved leave today |

Rows are sorted with `notIn` and `late` first. Phone timers are not shown live: their hours appear when they are saved. No location or activity data is used.

```sh
curl -s "$TT/org/today" -H "Authorization: Bearer $KEY"
```

### GET /days

Resolved hours over any date range.

- **operationId:** `listDays`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `from` | query | string (date) | yes | First date (inclusive). |
| `to` | query | string (date) | yes | Last date (inclusive). |
| `status` | query | string | no | Comma list of statuses |
| `person_id` | query | string (uuid) | no | Filter |
| `placement_id` | query | string (uuid) | no | Filter |
| `client_id` | query | string (uuid) | no | Filter |
| `updated_since` | query | string (date-time) | no | Only rows changed at or after this instant (incremental sync). |
| `limit` | query | integer | no | Page size (paginated lists). min 1; max 500; default `100` |
| `cursor` | query | string | no | Opaque cursor from `next_cursor` of the previous page. |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/days?from=2026-11-30&to=2026-12-13&limit=3" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "items": [
      {
        "id": "816b451d-6921-42b9-af02-a1f079af77ce",
        "placement_id": "00000000-0000-4000-8000-0000000000e4",
        "person_id": "00000000-0000-4000-8000-0000000000d4",
        "work_date": "2026-12-03",
        "minutes": 480,
        "start_min": null,
        "end_min": null,
        "break_min": 0,
        "note": null,
        "office_note": null,
        "client_note": null,
        "status": "draft",
        "resolution": "auto_single_source",
        "has_conflict": false,
        "approved_by_office_at": null,
        "approved_by_client": null,
        "approved_by_client_at": null,
        "export_run_id": null,
        "version": 1,
        "updated_at": "2026-10-23T19:08:42.625245+00:00"
      },
      {
        "id": "0ce9b17d-40ab-4542-937a-de57896a7358",
        "placement_id": "00000000-0000-4000-8000-0000000000e4",
        "person_id": "00000000-0000-4000-8000-0000000000d4",
        "work_date": "2026-12-01",
        "minutes": 480,
        "start_min": 420,
        "end_min": 930,
        "break_min": 30,
        "note": "Matches the signed paper timesheet",
        "office_note": null,
        "client_note": null,
        "status": "exported",
        "resolution": "office_choice",
        "has_conflict": false,
        "approved_by_office_at": "2026-10-23T19:08:42.47+00:00",
        "approved_by_client": null,
        "approved_by_client_at": null,
        "export_run_id": "239a1898-fab5-486c-a2be-92d939be90cc",
        "version": 6,
        "updated_at": "2026-10-23T19:08:42.871612+00:00"
      }
    ],
    "next_cursor": "eyJ1IjoiMjAyNi0xMC0yM1QxOTowODo0Mi44NzE2MTIrMDA6MDAiLCJpIjoiODE3MzkzNDctMDMzMC00OWViLWE1NTktODc3Y2IxODg5ZDYzIn0"
  }
}
```

<a id="get-claims"></a>

### GET /claims

Raw claims over a date range.

- **operationId:** `listClaims`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `from` | query | string (date) | yes | First date (inclusive). |
| `to` | query | string (date) | yes | Last date (inclusive). |
| `placement_id` | query | string (uuid) | no | Filter |
| `source` | query | `app` \| `kiosk` \| `mail` \| `import` \| `predicted` \| `manual_office` \| `client_portal` | no | Filter |
| `updated_since` | query | string (date-time) | no | Only rows changed at or after this instant (incremental sync). |
| `limit` | query | integer | no | Page size (paginated lists). min 1; max 500; default `100` |
| `cursor` | query | string | no | Opaque cursor from `next_cursor` of the previous page. |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/claims?from=2026-12-01&to=2026-12-13&placement_id=96574dce-db96-4da1-82cf-de32021472d9&limit=2" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "items": [
      {
        "id": "83b55c9f-e50e-4cdd-9b5f-8e2203614e73",
        "placement_id": "96574dce-db96-4da1-82cf-de32021472d9",
        "work_date": "2026-12-07",
        "source": "import",
        "minutes": 480,
        "start_min": 420,
        "end_min": 930,
        "break_min": 30,
        "note": null,
        "confidence": null,
        "source_ref": "bullhorn-timecard-881",
        "created_at": "2026-10-23T19:22:17.427293+00:00"
      },
      {
        "id": "ee03befd-2b7c-4fa6-a588-6cf1f1924955",
        "placement_id": "96574dce-db96-4da1-82cf-de32021472d9",
        "work_date": "2026-12-08",
        "source": "import",
        "minutes": 480,
        "start_min": null,
        "end_min": null,
        "break_min": 0,
        "note": null,
        "confidence": null,
        "source_ref": "bullhorn-timecard-881",
        "created_at": "2026-10-23T19:22:17.465052+00:00"
      }
    ],
    "next_cursor": "eyJ1IjoiMjAyNi0xMC0yM1QxOToyMjoxNy40NjUwNTIrMDA6MDAiLCJpIjoiZWUwM2JlZmQtMmI3Yy00ZmE2LWE1ODgtNmNmMWYxOTI0OTU1In0"
  }
}
```

<a id="post-claims"></a>

### POST /claims

Record hours (start/end/break or minutes).

- **operationId:** `createClaim`
- **Who can call it:** write key / office user (sources `manual_office`, `mail`, `import`, `client_portal`); employee token (own placements, `app`/`kiosk`)
- **Notes:** Employees may use source app/kiosk for their own placements; office keys may also use mail, import, manual_office. The day is re-resolved immediately (conflict detection).

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | string (uuid) | no |  |
| `placementId` | string (uuid) | yes |  |
| `workDate` | string | yes | pattern `^\d{4}-\d{2}-\d{2}$` |
| `startMin` | integer | no | min 0; max 1439 |
| `endMin` | integer | no | min 0; max 1439 |
| `breakMin` | integer | no | min 0; max 720; default `0` |
| `minutes` | integer | no | min 0; max 1440 |
| `note` | string | no | max length 1000 |
| `source` | `app` \| `kiosk` \| `mail` \| `import` \| `manual_office` \| `client_portal` | no | default `"app"` |
| `confidence` | number | no | min 0; max 1 |
| `sourceRef` | string | no | max length 200 |
| `deviceTime` | string (date-time) | no |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/claims" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"placementId": "00000000-0000-4000-8000-0000000000e4", "workDate": "2026-12-01", "startMin": 420, "endMin": 930, "breakMin": 30, "source": "manual_office", "note": "Entered from paper timesheet"}'
```

Response `200`:

```json
{
  "data": {
    "id": "0ce9b17d-40ab-4542-937a-de57896a7358",
    "org_id": "00000000-0000-4000-8000-000000000001",
    "placement_id": "00000000-0000-4000-8000-0000000000e4",
    "person_id": "00000000-0000-4000-8000-0000000000d4",
    "work_date": "2026-12-01",
    "minutes": 480,
    "start_min": 420,
    "end_min": 930,
    "break_min": 30,
    "note": "Entered from paper timesheet",
    "status": "draft",
    "resolution": "auto_single_source",
    "claim_ids": [
      "7d78dbdb-d70c-4960-8796-4cedfa3454e6"
    ],
    "has_conflict": false,
    "approved_by_office": null,
    "approved_by_office_at": null,
    "approved_by_client": null,
    "approved_by_client_at": null,
    "export_run_id": null,
    "version": 1,
    "updated_at": "2026-10-23T19:08:41.482007+00:00",
    "office_note": null,
    "client_note": null
  }
}
```

**Example — Second source disagrees → conflict**

```sh
curl -s -X POST "$TT/claims" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"placementId": "00000000-0000-4000-8000-0000000000e4", "workDate": "2026-12-01", "minutes": 450, "source": "mail", "sourceRef": "docs-capture"}'
```

Response `200`:

```json
{
  "data": {
    "id": "0ce9b17d-40ab-4542-937a-de57896a7358",
    "org_id": "00000000-0000-4000-8000-000000000001",
    "placement_id": "00000000-0000-4000-8000-0000000000e4",
    "person_id": "00000000-0000-4000-8000-0000000000d4",
    "work_date": "2026-12-01",
    "minutes": null,
    "start_min": 420,
    "end_min": 930,
    "break_min": 30,
    "note": "Entered from paper timesheet",
    "status": "draft",
    "resolution": null,
    "claim_ids": [
      "7d78dbdb-d70c-4960-8796-4cedfa3454e6",
      "ddce5803-8f75-4ace-9d5c-e0bf9fae1d8a"
    ],
    "has_conflict": true,
    "approved_by_office": null,
    "approved_by_office_at": null,
    "approved_by_client": null,
    "approved_by_client_at": null,
    "export_run_id": null,
    "version": 2,
    "updated_at": "2026-10-23T19:08:41.746852+00:00",
    "office_note": null,
    "client_note": null
  }
}
```

**Example — Exported day → refused before anything is stored**

```sh
curl -s -X POST "$TT/claims" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"placementId": "00000000-0000-4000-8000-0000000000e4", "workDate": "2026-12-01", "minutes": 300, "source": "manual_office"}'
```

Response `423`:

```json
{
  "error": {
    "code": "locked",
    "message": "This day is exported or locked; book a correction in an open period"
  }
}
```

<a id="post-claims-batch"></a>

### POST /claims/batch

Record up to 500 claims; per-row results.

- **operationId:** `batchClaims`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `items` | array of object | yes |  |
| `items[].id` | string (uuid) | no |  |
| `items[].placementId` | string (uuid) | yes |  |
| `items[].workDate` | string | yes | pattern `^\d{4}-\d{2}-\d{2}$` |
| `items[].startMin` | integer | no | min 0; max 1439 |
| `items[].endMin` | integer | no | min 0; max 1439 |
| `items[].breakMin` | integer | no | min 0; max 720; default `0` |
| `items[].minutes` | integer | no | min 0; max 1440 |
| `items[].note` | string | no | max length 1000 |
| `items[].source` | `app` \| `kiosk` \| `mail` \| `import` \| `manual_office` \| `client_portal` | no | default `"app"` |
| `items[].confidence` | number | no | min 0; max 1 |
| `items[].sourceRef` | string | no | max length 200 |
| `items[].deviceTime` | string (date-time) | no |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/claims/batch" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"items": [{"placementId": "96574dce-db96-4da1-82cf-de32021472d9", "workDate": "2026-12-07", "startMin": 420, "endMin": 930, "breakMin": 30, "source": "import", "sourceRef": "bullhorn-timecard-881"}, {"placementId": "96574dce-db96-4da1-82cf-de32021472d9", "workDate": "2026-12-08", "minutes": 480, "source": "import", "sourceRef": "bullhorn-timecard-881"}, {"placementId": "96574dce-db96-4da1-82cf-de32021472d9", "workDate": "2026-12-09"}]}'
```

Response `200`:

```json
{
  "data": {
    "ok": 2,
    "failed": 1,
    "results": [
      {
        "index": 0,
        "ok": true,
        "data": {
          "id": "f642c482-ca5b-4d57-bb2a-0b6a31997f83",
          "org_id": "00000000-0000-4000-8000-000000000001",
          "placement_id": "96574dce-db96-4da1-82cf-de32021472d9",
          "person_id": "69a47995-bc6a-4bc9-9b9b-070935b974bb",
          "work_date": "2026-12-07",
          "minutes": 480,
          "start_min": 420,
          "end_min": 930,
          "break_min": 30,
          "note": null,
          "status": "draft",
          "resolution": "auto_single_source",
          "claim_ids": [
            "83b55c9f-e50e-4cdd-9b5f-8e2203614e73"
          ],
          "has_conflict": false,
          "approved_by_office": null,
          "approved_by_office_at": null,
          "approved_by_client": null,
          "approved_by_client_at": null,
          "export_run_id": null,
          "version": 1,
          "updated_at": "2026-10-23T19:22:17.449068+00:00",
          "office_note": null,
          "client_note": null
        }
      },
      {
        "index": 1,
        "ok": true,
        "data": {
          "id": "482b4f99-74ec-472f-b6af-e92444fe2dce",
          "org_id": "00000000-0000-4000-8000-000000000001",
          "placement_id": "96574dce-db96-4da1-82cf-de32021472d9",
          "person_id": "69a47995-bc6a-4bc9-9b9b-070935b974bb",
          "work_date": "2026-12-08",
          "minutes": 480,
          "start_min": null,
          "end_min": null,
          "break_min": 0,
          "note": null,
          "status": "draft",
          "resolution": "auto_single_source",
          "claim_ids": [
            "ee03befd-2b7c-4fa6-a588-6cf1f1924955"
          ],
          "has_conflict": false,
          "approved_by_office": null,
          "approved_by_office_at": null,
          "approved_by_client": null,
          "approved_by_client_at": null,
          "export_run_id": null,
          "version": 1,
          "updated_at": "2026-10-23T19:22:17.495383+00:00",
          "office_note": null,
          "client_note": null
        }
      }
    ]
  }
}
```

<a id="post-weeks-submit"></a>

### POST /weeks/submit

Submit a person's draft days of a week.

- **operationId:** `submitWeek`
- **Who can call it:** write key / office user (with `personId`); employee token (own week)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `weekStart` | string | yes |  |
| `personId` | string (uuid) | no |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/weeks/submit" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"weekStart": "2026-11-30", "personId": "00000000-0000-4000-8000-0000000000d4"}'
```

Response `200`:

```json
{
  "data": {
    "submitted": 2
  }
}
```

<a id="post-days-approve"></a>

### POST /days/approve

Office approval of submitted days.

- **operationId:** `approveDays`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `dayIds` | array of string (uuid) | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/days/approve" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"dayIds": ["0ce9b17d-40ab-4542-937a-de57896a7358", "81739347-0330-49eb-a559-877cb1889d63"]}'
```

Response `200`:

```json
{
  "data": {
    "approved": 2,
    "waitingForClient": 0,
    "skipped": 0
  }
}
```

**Example — Client-approval placement → waiting for client**

```sh
curl -s -X POST "$TT/days/approve" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"dayIds": ["0c0dbf6f-9bc8-48b7-bfe1-cf8f6566a878"]}'
```

Response `200`:

```json
{
  "data": {
    "approved": 0,
    "waitingForClient": 1,
    "skipped": 0
  }
}
```

<a id="get-conflicts"></a>

### GET /conflicts

Open conflicts for a week.

- **operationId:** `listConflicts`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `week` | query | string (date) | no | Any date in the ISO week; defaults to the current week (org timezone). |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/conflicts?week=2026-11-30" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": [
    {
      "id": "c51f9c0d-da32-4edf-86a8-0ba2a86d93a7",
      "dayId": "0ce9b17d-40ab-4542-937a-de57896a7358",
      "workDate": "2026-12-01",
      "personName": "Sanne Janssen",
      "clientName": "Bakkerij Ruijters",
      "candidates": [
        {
          "source": "manual_office",
          "claimId": "7d78dbdb-d70c-4960-8796-4cedfa3454e6",
          "minutes": 480
        },
        {
          "source": "mail",
          "claimId": "ddce5803-8f75-4ace-9d5c-e0bf9fae1d8a",
          "minutes": 450
        }
      ]
    }
  ]
}
```

<a id="post-conflicts-id-resolve"></a>

### POST /conflicts/{id}/resolve

Pick the winning claim of a conflict.

- **operationId:** `resolveConflict`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `claimId` | string (uuid) | yes |  |
| `note` | string | no |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/conflicts/c51f9c0d-da32-4edf-86a8-0ba2a86d93a7/resolve" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"claimId": "7d78dbdb-d70c-4960-8796-4cedfa3454e6", "note": "Matches the signed paper timesheet"}'
```

Response `200`:

```json
{
  "data": {
    "resolved": true
  }
}
```


## Approvals

<a id="post-approval-links"></a>

### POST /approval-links

Create a client approval magic link (emailed to the client contact).

- **operationId:** `createApprovalLink`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `clientId` | string (uuid) | yes |  |
| `startsOn` | string | yes |  |
| `endsOn` | string | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/approval-links" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"clientId": "00000000-0000-4000-8000-0000000000c2", "startsOn": "2026-11-30", "endsOn": "2026-12-06"}'
```

Response `200`:

```json
{
  "data": {
    "url": "http://localhost:3001/approve/<token>",
    "expiresAt": "2026-11-06T19:09:09.202Z",
    "emailed": "ruud@logistiekparkstad.example"
  }
}
```

<a id="get-client-approve-token"></a>

### GET /client/approve/{token}

What the client sees behind a link (public; the token is the credential).

- **operationId:** `getClientApproval`
- **Who can call it:** public — the token in the URL is the credential (no Authorization header)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `token` | path | string | yes | Magic-link token from the client approval URL. |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/client/approve/XDcfG_swBYtm2BDrcnYJZh-9rXKDxEqXUAxVNuxB4Qs"
```

Response `200`:

```json
{
  "data": {
    "linkId": "b374151b-659a-4133-889e-1bd262647f48",
    "requiresCode": true,
    "contactEmailMasked": "r•••@logistiekparkstad.example",
    "orgName": "Jobhunting Uitzenden (demo)",
    "clientName": "Logistiek Parkstad",
    "startsOn": "2026-11-30",
    "endsOn": "2026-12-06",
    "completed": false,
    "rows": [
      {
        "dayId": "0c0dbf6f-9bc8-48b7-bfe1-cf8f6566a878",
        "personName": "Piotr Nowak",
        "roleTitle": "Heftruckchauffeur",
        "workDate": "2026-12-01",
        "minutes": 480,
        "startMin": 360,
        "endMin": 870,
        "breakMin": 30,
        "note": null,
        "status": "submitted",
        "clientApproved": false
      }
    ]
  }
}
```

<a id="post-client-approve-token"></a>

### POST /client/approve/{token}

Submit client decisions with signature (needs x-approval-proof when a code is required).

- **operationId:** `submitClientApproval`
- **Who can call it:** public — the token in the URL is the credential (no Authorization header)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `token` | path | string | yes | Magic-link token from the client approval URL. |
| `x-approval-proof` | header | string | no |  |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `signerName` | string | yes | min length 2; max length 120 |
| `signerRole` | string | no | max length 120 |
| `signature` | string | yes | min length 2; max length 120 |
| `decisions` | array of object | yes | min items 1 |
| `decisions[].dayId` | string (uuid) | yes |  |
| `decisions[].decision` | `approved` \| `corrected` \| `rejected` | yes |  |
| `decisions[].correctedMinutes` | integer | no | min 0; max 1440 |
| `decisions[].reason` | string | no | max length 500 |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/client/approve/XDcfG_swBYtm2BDrcnYJZh-9rXKDxEqXUAxVNuxB4Qs" \
  -H "x-approval-proof: <proof>" \
  -H "content-type: application/json" \
  -d '{"signerName": "Ruud Hendriks", "signerRole": "Operations manager", "signature": "R. Hendriks", "decisions": [{"dayId": "0c0dbf6f-9bc8-48b7-bfe1-cf8f6566a878", "decision": "approved"}]}'
```

Response `200`:

```json
{
  "data": {
    "linkId": "b374151b-659a-4133-889e-1bd262647f48",
    "requiresCode": true,
    "contactEmailMasked": "r•••@logistiekparkstad.example",
    "orgName": "Jobhunting Uitzenden (demo)",
    "clientName": "Logistiek Parkstad",
    "startsOn": "2026-11-30",
    "endsOn": "2026-12-06",
    "completed": true,
    "rows": [
      {
        "dayId": "0c0dbf6f-9bc8-48b7-bfe1-cf8f6566a878",
        "personName": "Piotr Nowak",
        "roleTitle": "Heftruckchauffeur",
        "workDate": "2026-12-01",
        "minutes": 480,
        "startMin": 360,
        "endMin": 870,
        "breakMin": 30,
        "note": null,
        "status": "approved",
        "clientApproved": true
      }
    ]
  }
}
```

**Example — Without `x-approval-proof` when a code is required**

```sh
curl -s -X POST "$TT/client/approve/XDcfG_swBYtm2BDrcnYJZh-9rXKDxEqXUAxVNuxB4Qs" \
  -H "content-type: application/json" \
  -d '{"signerName": "Ruud Hendriks", "signature": "R. Hendriks", "decisions": [{"dayId": "0c0dbf6f-9bc8-48b7-bfe1-cf8f6566a878", "decision": "approved"}]}'
```

Response `403`:

```json
{
  "error": {
    "code": "forbidden",
    "message": "Confirm the code sent by email first"
  }
}
```

<a id="post-client-approve-token-code"></a>

### POST /client/approve/{token}/code

Email a one-time code to the client contact.

- **operationId:** `sendClientCode`
- **Who can call it:** public — token is the credential

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `token` | path | string | yes | Magic-link token from the client approval URL. |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/client/approve/XDcfG_swBYtm2BDrcnYJZh-9rXKDxEqXUAxVNuxB4Qs/code"
```

Response `200`:

```json
{
  "data": {
    "sentTo": "r•••@logistiekparkstad.example"
  }
}
```

<a id="post-client-approve-token-verify"></a>

### POST /client/approve/{token}/verify

Exchange the code for a 2-hour proof.

- **operationId:** `verifyClientCode`
- **Who can call it:** public — token is the credential

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `token` | path | string | yes | Magic-link token from the client approval URL. |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `code` | string | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/client/approve/XDcfG_swBYtm2BDrcnYJZh-9rXKDxEqXUAxVNuxB4Qs/verify" \
  -H "content-type: application/json" \
  -d '{"code": "180275"}'
```

Response `200`:

```json
{
  "data": {
    "proof": "1792789749942.317037da…"
  }
}
```


## Exports

<a id="post-exports"></a>

### POST /exports

Preview or commit a payroll export (mapping id e.g. preset-sdworx).

- **operationId:** `runExport`
- **Who can call it:** write key, admin key, office user (read keys get 403: POST is blocked for read keys, also for preview)
- **Notes:** commit is irreversible: included days become exported.

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `mappingId` | string | yes |  |
| `startsOn` | string | yes |  |
| `endsOn` | string | yes |  |
| `mode` | `preview` \| `commit` | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/exports" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"mappingId": "preset-generic", "startsOn": "2026-11-30", "endsOn": "2026-12-06", "mode": "preview"}'
```

Response `200`:

```json
{
  "data": {
    "content": "﻿employee_number;employee_name;client;placement;date;hour_type;hours\r\n1004;Sanne Janssen;Bakkerij Ruijters;PL-2026-104;2026-12-01;normal;8.00\r\n1004;Sanne Janssen;Bakkerij Ruijters;PL-2026-104;2026-12-02;normal;7.50\r\n",
    "rows": [
      [
        "1004",
        "Sanne Janssen"
      ],
      [
        "1004",
        "Sanne Janssen"
      ]
    ],
    "issues": [],
    "ok": true,
    "dayCount": 2,
    "pendingCount": 1,
    "warnings": [
      {
        "key": "00000000-0000-4000-8000-0000000000e4",
        "date": "2026-12-02",
        "code": "break_too_short",
        "message": "Break 0 min, at least 30 min required",
        "person": "Sanne Janssen"
      }
    ],
    "packName": "NL basis (voorbeeld — afstemmen op CAO)"
  }
}
```

**Example — Commit (irreversible)**

```sh
curl -s -X POST "$TT/exports" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"mappingId": "preset-generic", "startsOn": "2026-11-30", "endsOn": "2026-12-06", "mode": "commit"}'
```

Response `200`:

```json
{
  "data": {
    "runId": "239a1898-fab5-486c-a2be-92d939be90cc",
    "rowCount": 2,
    "contentHash": "1f92f2a1b4c52a8bcc43c40740e3f29b83e475de5ebacce7807c51808a04f6eb",
    "content": "﻿employee_number;employee_name;client;placement;date;hour_type;hours\r\n1004;Sanne Janssen;Bakkerij Ruijters;PL-2026-104;2026-12-01;normal;8.00\r\n1004;Sanne Janssen;Bakkerij Ruijters;PL-2026-104;2026-12-02;normal;7.50\r\n"
  }
}
```

<a id="get-exports-runs"></a>

### GET /exports/runs

Committed export runs.

- **operationId:** `listExportRuns`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `updated_since` | query | string (date-time) | no | Only rows changed at or after this instant (incremental sync). |
| `limit` | query | integer | no | Page size (paginated lists). min 1; max 500; default `100` |
| `cursor` | query | string | no | Opaque cursor from `next_cursor` of the previous page. |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/exports/runs?limit=3" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "items": [
      {
        "id": "239a1898-fab5-486c-a2be-92d939be90cc",
        "mapping_id": null,
        "starts_on": "2026-11-30",
        "ends_on": "2026-12-06",
        "mode": "full",
        "row_count": 2,
        "content_hash": "1f92f2a1b4c52a8bcc43c40740e3f29b83e475de5ebacce7807c51808a04f6eb",
        "created_by": "00000000-0000-4000-8000-0000000000a1",
        "created_at": "2026-10-23T19:08:42.871612+00:00"
      }
    ],
    "next_cursor": null
  }
}
```

<a id="get-exports-runs-id"></a>

### GET /exports/runs/{id}

One export run with pay lines and warnings.

- **operationId:** `getExportRun`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/exports/runs/239a1898-fab5-486c-a2be-92d939be90cc" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "id": "239a1898-fab5-486c-a2be-92d939be90cc",
    "org_id": "00000000-0000-4000-8000-000000000001",
    "mapping_id": null,
    "mapping_snapshot": {
      "id": "preset-generic",
      "name": "Generiek CSV (per dag)",
      "format": "csv",
      "tables": {},
      "columns": [
        {
          "header": "employee_number",
          "source": "employeeNumber"
        },
        {
          "header": "employee_name",
          "source": "employeeName"
        }
      ],
      "encoding": "utf-8-bom",
      "delimiter": ";",
      "lineEnding": "crlf",
      "validation": [
        {
          "column": "employee_number",
          "required": true
        }
      ],
      "includeHeader": true
    },
    "starts_on": "2026-11-30",
    "ends_on": "2026-12-06",
    "mode": "full",
    "row_count": 2,
    "content_hash": "1f92f2a1b4c52a8bcc43c40740e3f29b83e475de5ebacce7807c51808a04f6eb",
    "pay_lines": [
      {
        "date": "2026-12-01",
        "minutes": 480,
        "hourType": "normal",
        "clientName": "Bakkerij Ruijters",
        "employeeName": "Sanne Janssen",
        "placementRef": "PL-2026-104",
        "componentType": "hours",
        "employeeNumber": "1004"
      },
      {
        "date": "2026-12-02",
        "minutes": 450,
        "hourType": "normal",
        "clientName": "Bakkerij Ruijters",
        "employeeName": "Sanne Janssen",
        "placementRef": "PL-2026-104",
        "componentType": "hours",
        "employeeNumber": "1004"
      }
    ],
    "created_by": "00000000-0000-4000-8000-0000000000a1",
    "created_at": "2026-10-23T19:08:42.871612+00:00",
    "warnings": [
      {
        "key": "00000000-0000-4000-8000-0000000000e4",
        "code": "break_too_short",
        "date": "2026-12-02",
        "person": "Sanne Janssen",
        "message": "Break 0 min, at least 30 min required"
      }
    ]
  }
}
```

<a id="get-exports-runs-id-file"></a>

### GET /exports/runs/{id}/file

The committed payroll file exactly as stored: CSV, delimited TXT, fixed-width TXT (`text/plain`) or XML (`application/xml`), per the mapping's `format`. `?as=json` or `?as=xlsx` converts the rows.

- **operationId:** `getExportFile`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/exports/runs/239a1898-fab5-486c-a2be-92d939be90cc/file" \
  -H "Authorization: Bearer $KEY"
```

Response `200` (`text/csv; charset=utf-8`):

```csv
employee_number;employee_name;client;placement;date;hour_type;hours
1004;Sanne Janssen;Bakkerij Ruijters;PL-2026-104;2026-12-01;normal;8.00
1004;Sanne Janssen;Bakkerij Ruijters;PL-2026-104;2026-12-02;normal;7.50
```

<a id="get-people-id-timesheet"></a>

### GET /people/{id}/timesheet

PDF timesheet ("urenstaat") of one worker: every day with hours in the range, client, clock times, break, hours and status, plus a total and signature lines. Returns `application/pdf`. Clients get the same PDF per worker behind their approval link (`/approve/{token}/timesheet/{personId}`) once they approved all of that worker's days.

- **operationId:** `getTimesheetPdf`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |
| `from` | query | string (date) | yes | From |
| `to` | query | string (date) | yes | To (max 62 days after `from`) |

<a id="post-periods-lock"></a>

### POST /periods/lock

Lock a period after export (admin).

- **operationId:** `lockPeriod`
- **Who can call it:** **admin key or admin user** (write keys get 403 `Admin role required`)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `startsOn` | string | yes |  |
| `endsOn` | string | yes |  |
| `kind` | `week` \| `four_weeks` \| `semi_month` \| `month` | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/periods/lock" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"startsOn": "2026-11-30", "endsOn": "2026-12-06", "kind": "week"}'
```

Response `409`:

```json
{
  "error": {
    "code": "conflict",
    "message": "2 day(s) in this period are not exported yet"
  }
}
```


## Master data

<a id="post-import"></a>

### POST /import

Import master data CSV (people, clients, placements).

- **operationId:** `importMasterData`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `csv` | string | yes |  |
| `dryRun` | boolean | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/import" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"csv": "personeelsnummer;voornaam;achternaam;email;telefoon;opdrachtgever;plaatsing;functie;startdatum;einddatum\r\n9901;Docs;Voorbeeld;docs@example.com;+31612345678;VDL Montage;PL-DOCS-1;Monteur;01-11-2026;\r\n;Zonder;Nummer;;;;;;;\r\n", "dryRun": true}'
```

Response `200`:

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

<a id="post-import-time-entries"></a>

### POST /import/time-entries

Import a time-entry CSV exported from Toggl Track, Clockify, TimeChimp or Harvest (see [Importing from other time trackers](https://timetracking.tech/developers/data-in/competitor-import)). Entries become claims with source `import`, summed per placement per day. Dry run by default; re-importing the same file creates no duplicates.

- **operationId:** `importTimeEntries`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `csv` | string | yes | max 5 MB |
| `provider` | string | no | `toggl`, `clockify`, `timechimp` or `harvest`; detected from the header row when omitted |
| `dryRun` | boolean | no | default `true` |
| `personMap` | object | no | source person key (from `unknownPeople[].key`) → person id |

Response `200` (shortened):

```json
{
  "data": {
    "dryRun": true,
    "provider": "toggl",
    "providerName": "Toggl Track",
    "totals": { "rows": 5, "entries": 4, "days": 2, "minutes": 960, "newDays": 2, "imported": 0, "duplicates": 0, "locked": 0, "unmatched": 1, "errors": 1 },
    "days": [{ "personName": "Jan de Vries", "clientName": "VDL Montage", "date": "2027-03-06", "minutes": 480, "startMin": 420, "endMin": 930, "breakMin": 30, "rows": [2, 3], "status": "new" }],
    "unmatched": [{ "row": 5, "person": "P. Nowak <piotr.prive@example.com>", "date": "2027-03-07", "minutes": 240, "reason": "Medewerker niet gevonden (e-mail of naam)" }],
    "errors": [{ "row": 6, "message": "Ongeldige datum 'geen-datum'" }],
    "unknownPeople": [{ "key": "piotr.prive@example.com", "label": "P. Nowak <piotr.prive@example.com>", "rows": 1 }]
  }
}
```

<a id="get-people"></a>

### GET /people

List people (full list, or paginated with limit/cursor/updated_since → { items, next_cursor }).

- **operationId:** `listPersons`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `updated_since` | query | string (date-time) | no | Only rows changed at or after this instant (incremental sync). |
| `limit` | query | integer | no | Page size (paginated lists). min 1; max 500; default `100` |
| `cursor` | query | string | no | Opaque cursor from `next_cursor` of the previous page. |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/people?limit=2" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "items": [
      {
        "id": "00000000-0000-4000-8000-0000000000d1",
        "employee_number": "1001",
        "first_name": "Jan",
        "last_name": "de Vries",
        "email": "jan@demo.timetracking.tech",
        "phone": null,
        "locale": "nl",
        "active": true,
        "user_id": "00000000-0000-4000-8000-0000000000b1",
        "external_source": null,
        "external_ref": null,
        "created_at": "2026-10-23T18:38:58.634749+00:00",
        "updated_at": "2026-10-23T19:09:09.0923+00:00"
      },
      {
        "id": "00000000-0000-4000-8000-0000000000d2",
        "employee_number": "1002",
        "first_name": "Fatima",
        "last_name": "El Amrani",
        "email": "fatima@demo.timetracking.tech",
        "phone": null,
        "locale": "nl",
        "active": true,
        "user_id": "00000000-0000-4000-8000-0000000000b2",
        "external_source": null,
        "external_ref": null,
        "created_at": "2026-10-23T18:38:58.634749+00:00",
        "updated_at": "2026-10-23T19:09:09.0923+00:00"
      }
    ],
    "next_cursor": "eyJ1IjoiMjAyNi0xMC0yM1QxOTowOTowOS4wOTIzKzAwOjAwIiwiaSI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBkMiJ9"
  }
}
```

<a id="post-people"></a>

### POST /people

Create a person (or update when id is given).

- **operationId:** `upsertPerson`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | string (uuid) | no |  |
| `employeeNumber` | string | no | max length 40 |
| `firstName` | string | yes | min length 1; max length 80 |
| `lastName` | string | yes | min length 1; max length 80 |
| `email` | string | no | max length 200 |
| `phone` | string | no | max length 30 |
| `locale` | string | no | max length 10 |
| `active` | boolean | no | default `true` |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/people" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"employeeNumber": "9920", "firstName": "Iris", "lastName": "Docs"}'
```

Response `200`:

```json
{
  "data": {
    "id": "3afdcfed-9246-4f87-a0c4-9f0bd55d0408",
    "employee_number": "9920",
    "first_name": "Iris",
    "last_name": "Docs",
    "email": null,
    "phone": null,
    "locale": null,
    "active": true,
    "user_id": null,
    "external_source": null,
    "external_ref": null,
    "created_at": "2026-10-23T19:40:03.082812+00:00",
    "updated_at": "2026-10-23T19:40:03.082812+00:00"
  }
}
```

<a id="get-people-id"></a>

### GET /people/{id}

Get one person.

- **operationId:** `getPerson`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/people/69a47995-bc6a-4bc9-9b9b-070935b974bb" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "id": "69a47995-bc6a-4bc9-9b9b-070935b974bb",
    "employee_number": "9910",
    "first_name": "Eva",
    "last_name": "Docs-Jansen",
    "email": "eva.docs@example.com",
    "phone": null,
    "locale": "nl",
    "active": true,
    "user_id": null,
    "external_source": "bullhorn",
    "external_ref": "12345",
    "created_at": "2026-10-23T19:22:15.92661+00:00",
    "updated_at": "2026-10-23T19:22:15.991953+00:00"
  }
}
```

<a id="patch-people-id"></a>

### PATCH /people/{id}

Update a person (send the full object).

- **operationId:** `updatePerson`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | string (uuid) | no |  |
| `employeeNumber` | string | no | max length 40 |
| `firstName` | string | yes | min length 1; max length 80 |
| `lastName` | string | yes | min length 1; max length 80 |
| `email` | string | no | max length 200 |
| `phone` | string | no | max length 30 |
| `locale` | string | no | max length 10 |
| `active` | boolean | no | default `true` |

**Example (captured from the API)**

```sh
curl -s -X PATCH "$TT/people/3afdcfed-9246-4f87-a0c4-9f0bd55d0408" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"employeeNumber": "9920", "firstName": "Iris", "lastName": "Docs-Bakker"}'
```

Response `200`:

```json
{
  "data": {
    "id": "3afdcfed-9246-4f87-a0c4-9f0bd55d0408",
    "employee_number": "9920",
    "first_name": "Iris",
    "last_name": "Docs-Bakker",
    "email": null,
    "phone": null,
    "locale": null,
    "active": true,
    "user_id": null,
    "external_source": null,
    "external_ref": null,
    "created_at": "2026-10-23T19:40:03.082812+00:00",
    "updated_at": "2026-10-23T19:40:03.194666+00:00"
  }
}
```

<a id="delete-people-id"></a>

### DELETE /people/{id}

Deactivate a person.

- **operationId:** `deactivatePerson`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Example (captured from the API)**

```sh
curl -s -X DELETE "$TT/people/69a47995-bc6a-4bc9-9b9b-070935b974bb" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "id": "69a47995-bc6a-4bc9-9b9b-070935b974bb",
    "employee_number": "9910",
    "first_name": "Eva",
    "last_name": "Docs-Jansen",
    "email": "eva.docs@example.com",
    "phone": "+31612345678",
    "locale": "nl",
    "active": false,
    "user_id": null,
    "external_source": "bullhorn",
    "external_ref": "12345",
    "created_at": "2026-10-23T19:22:15.92661+00:00",
    "updated_at": "2026-10-23T19:22:16.289696+00:00"
  }
}
```

<a id="put-people-by-external-source-ref"></a>

### PUT /people/by-external/{source}/{ref}

Idempotent upsert of a person by your system's id.

- **operationId:** `upsertPersonByExternal`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `source` | path | string | yes | Your system's name. pattern `^[a-z0-9_-]+$` |
| `ref` | path | string | yes | The record's id in your system (URL-encoded). |
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | string (uuid) | no |  |
| `employeeNumber` | string | no | max length 40 |
| `firstName` | string | yes | min length 1; max length 80 |
| `lastName` | string | yes | min length 1; max length 80 |
| `email` | string | no | max length 200 |
| `phone` | string | no | max length 30 |
| `locale` | string | no | max length 10 |
| `active` | boolean | no | default `true` |

**Example (captured from the API)**

```sh
curl -s -X PUT "$TT/people/by-external/bullhorn/12345" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"employeeNumber": "9910", "firstName": "Eva", "lastName": "Docs", "email": "eva.docs@example.com", "locale": "nl"}'
```

Response `200`:

```json
{
  "data": {
    "id": "69a47995-bc6a-4bc9-9b9b-070935b974bb",
    "org_id": "00000000-0000-4000-8000-000000000001",
    "user_id": null,
    "employee_number": "9910",
    "first_name": "Eva",
    "last_name": "Docs",
    "email": "eva.docs@example.com",
    "phone": null,
    "locale": "nl",
    "active": true,
    "created_at": "2026-10-23T19:22:15.92661+00:00",
    "invited_at": null,
    "updated_at": "2026-10-23T19:22:15.92661+00:00",
    "external_source": "bullhorn",
    "external_ref": "12345",
    "created": true
  }
}
```

**Example — Same call again → update (`created: false`)**

```sh
curl -s -X PUT "$TT/people/by-external/bullhorn/12345" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"employeeNumber": "9910", "firstName": "Eva", "lastName": "Docs-Jansen", "email": "eva.docs@example.com", "locale": "nl"}'
```

Response `200`:

```json
{
  "data": {
    "id": "69a47995-bc6a-4bc9-9b9b-070935b974bb",
    "org_id": "00000000-0000-4000-8000-000000000001",
    "user_id": null,
    "employee_number": "9910",
    "first_name": "Eva",
    "last_name": "Docs-Jansen",
    "email": "eva.docs@example.com",
    "phone": null,
    "locale": "nl",
    "active": true,
    "created_at": "2026-10-23T19:22:15.92661+00:00",
    "invited_at": null,
    "updated_at": "2026-10-23T19:22:15.991953+00:00",
    "external_source": "bullhorn",
    "external_ref": "12345",
    "created": false
  }
}
```

<a id="post-people-batch"></a>

### POST /people/batch

Upsert up to 500 people; rows with externalSource+externalRef upsert by external id.

- **operationId:** `batchPersons`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `items` | array of object | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/people/batch" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"items": [{"externalSource": "bullhorn", "externalRef": "12346", "employeeNumber": "9911", "firstName": "Noah", "lastName": "Docs"}, {"externalSource": "bullhorn", "externalRef": "12347", "firstName": ""}]}'
```

Response `200`:

```json
{
  "data": {
    "ok": 1,
    "failed": 1,
    "results": [
      {
        "index": 0,
        "ok": true,
        "data": {
          "id": "02595da9-06ea-45d1-a5e8-927d854a2c46",
          "org_id": "00000000-0000-4000-8000-000000000001",
          "user_id": null,
          "employee_number": "9911",
          "first_name": "Noah",
          "last_name": "Docs",
          "email": null,
          "phone": null,
          "locale": null,
          "active": true,
          "created_at": "2026-10-23T19:22:17.348383+00:00",
          "invited_at": null,
          "updated_at": "2026-10-23T19:22:17.348383+00:00",
          "external_source": "bullhorn",
          "external_ref": "12346",
          "created": true
        }
      },
      {
        "index": 1,
        "ok": false,
        "error": {
          "code": "invalid",
          "message": "firstName: Too small: expected string to have >=1 characters; lastName: Invalid input: expected string, received undefined"
        }
      }
    ]
  }
}
```

<a id="get-clients"></a>

### GET /clients

List clients (full list, or paginated with limit/cursor/updated_since → { items, next_cursor }).

- **operationId:** `listClients`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `updated_since` | query | string (date-time) | no | Only rows changed at or after this instant (incremental sync). |
| `limit` | query | integer | no | Page size (paginated lists). min 1; max 500; default `100` |
| `cursor` | query | string | no | Opaque cursor from `next_cursor` of the previous page. |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/clients?limit=2" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "items": [
      {
        "id": "00000000-0000-4000-8000-0000000000c1",
        "name": "VDL Montage",
        "external_ref": null,
        "external_source": null,
        "approval_mode": "both",
        "mail_domains": [
          "vdl-montage.example"
        ],
        "created_at": "2026-10-23T18:38:58.634749+00:00",
        "updated_at": "2026-10-23T19:09:09.0923+00:00"
      },
      {
        "id": "00000000-0000-4000-8000-0000000000c2",
        "name": "Logistiek Parkstad",
        "external_ref": null,
        "external_source": null,
        "approval_mode": "client",
        "mail_domains": [
          "logistiekparkstad.example"
        ],
        "created_at": "2026-10-23T18:38:58.634749+00:00",
        "updated_at": "2026-10-23T19:09:09.0923+00:00"
      }
    ],
    "next_cursor": "eyJ1IjoiMjAyNi0xMC0yM1QxOTowOTowOS4wOTIzKzAwOjAwIiwiaSI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBjMiJ9"
  }
}
```

<a id="post-clients"></a>

### POST /clients

Create a client (or update when id is given).

- **operationId:** `upsertClient`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | string (uuid) | no |  |
| `name` | string | yes | min length 1; max length 160 |
| `externalRef` | string | no | max length 80 |
| `approvalMode` | `office` \| `client` \| `both` | no | default `"office"` |
| `mailDomains` | string | no |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="get-clients-id"></a>

### GET /clients/{id}

Get one client.

- **operationId:** `getClient`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/clients/a3254709-d152-49b4-ad5e-51d50147910a" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "id": "a3254709-d152-49b4-ad5e-51d50147910a",
    "name": "Docs Demo Metaal BV",
    "external_ref": "CC-77",
    "external_source": "bullhorn",
    "approval_mode": "office",
    "mail_domains": [],
    "created_at": "2026-10-23T19:22:15.841586+00:00",
    "updated_at": "2026-10-23T19:22:15.847919+00:00"
  }
}
```

<a id="patch-clients-id"></a>

### PATCH /clients/{id}

Update a client (send the full object).

- **operationId:** `updateClient`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | string (uuid) | no |  |
| `name` | string | yes | min length 1; max length 160 |
| `externalRef` | string | no | max length 80 |
| `approvalMode` | `office` \| `client` \| `both` | no | default `"office"` |
| `mailDomains` | string | no |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="delete-clients-id"></a>

### DELETE /clients/{id}

Delete a client without placements.

- **operationId:** `deactivateClient`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Example (captured from the API)**

```sh
curl -s -X DELETE "$TT/clients/a3254709-d152-49b4-ad5e-51d50147910a" \
  -H "Authorization: Bearer $KEY"
```

Response `409`:

```json
{
  "error": {
    "code": "conflict",
    "message": "Client has placements; deactivate those instead"
  }
}
```

**Example — Client without placements**

```sh
curl -s -X DELETE "$TT/clients/af8223bd-de80-4773-8aae-27394604b69a" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "deleted": true
  }
}
```

<a id="put-clients-by-external-source-ref"></a>

### PUT /clients/by-external/{source}/{ref}

Idempotent upsert of a client by your system's id.

- **operationId:** `upsertClientByExternal`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `source` | path | string | yes | Your system's name. pattern `^[a-z0-9_-]+$` |
| `ref` | path | string | yes | The record's id in your system (URL-encoded). |
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | string (uuid) | no |  |
| `name` | string | yes | min length 1; max length 160 |
| `externalRef` | string | no | max length 80 |
| `approvalMode` | `office` \| `client` \| `both` | no | default `"office"` |
| `mailDomains` | string | no |  |

**Example (captured from the API)**

```sh
curl -s -X PUT "$TT/clients/by-external/bullhorn/CC-77" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"name": "Docs Demo Metaal BV", "approvalMode": "office"}'
```

Response `200`:

```json
{
  "data": {
    "id": "a3254709-d152-49b4-ad5e-51d50147910a",
    "org_id": "00000000-0000-4000-8000-000000000001",
    "name": "Docs Demo Metaal BV",
    "external_ref": "CC-77",
    "approval_mode": "office",
    "mail_domains": [],
    "period_kind": null,
    "created_at": "2026-10-23T19:22:15.841586+00:00",
    "updated_at": "2026-10-23T19:22:15.841586+00:00",
    "external_source": "bullhorn",
    "created": true
  }
}
```

<a id="post-clients-batch"></a>

### POST /clients/batch

Upsert up to 500 clients; rows with externalSource+externalRef upsert by external id.

- **operationId:** `batchClients`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `items` | array of object | yes |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="get-placements"></a>

### GET /placements

List placements (full list, or paginated with limit/cursor/updated_since → { items, next_cursor }).

- **operationId:** `listPlacements`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `updated_since` | query | string (date-time) | no | Only rows changed at or after this instant (incremental sync). |
| `limit` | query | integer | no | Page size (paginated lists). min 1; max 500; default `100` |
| `cursor` | query | string | no | Opaque cursor from `next_cursor` of the previous page. |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/placements?limit=2" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "items": [
      {
        "id": "00000000-0000-4000-8000-0000000000e1",
        "person_id": "00000000-0000-4000-8000-0000000000d1",
        "client_id": "00000000-0000-4000-8000-0000000000c1",
        "site_id": null,
        "ref": "PL-2026-101",
        "external_source": null,
        "external_ref": null,
        "role_title": "Monteur",
        "starts_on": "2026-09-01",
        "ends_on": null,
        "active": true,
        "created_at": "2026-10-23T18:38:58.634749+00:00",
        "updated_at": "2026-10-23T19:09:09.0923+00:00"
      },
      {
        "id": "00000000-0000-4000-8000-0000000000e2",
        "person_id": "00000000-0000-4000-8000-0000000000d2",
        "client_id": "00000000-0000-4000-8000-0000000000c2",
        "site_id": null,
        "ref": "PL-2026-102",
        "external_source": null,
        "external_ref": null,
        "role_title": "Orderpicker",
        "starts_on": "2026-09-01",
        "ends_on": null,
        "active": true,
        "created_at": "2026-10-23T18:38:58.634749+00:00",
        "updated_at": "2026-10-23T19:09:09.0923+00:00"
      }
    ],
    "next_cursor": "eyJ1IjoiMjAyNi0xMC0yM1QxOTowOTowOS4wOTIzKzAwOjAwIiwiaSI6IjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBlMiJ9"
  }
}
```

<a id="post-placements"></a>

### POST /placements

Create a placement (or update when id is given).

- **operationId:** `upsertPlacement`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | string (uuid) | no |  |
| `personId` | string (uuid) | yes |  |
| `clientId` | string (uuid) | yes |  |
| `ref` | string | no | max length 80 |
| `roleTitle` | string | no | max length 120 |
| `startsOn` | string | yes | pattern `^\d{4}-\d{2}-\d{2}$` |
| `endsOn` | string or string | no |  |
| `active` | boolean | no | default `true` |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="get-placements-id"></a>

### GET /placements/{id}

Get one placement.

- **operationId:** `getPlacement`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/placements/96574dce-db96-4da1-82cf-de32021472d9" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

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

<a id="patch-placements-id"></a>

### PATCH /placements/{id}

Update a placement (send the full object).

- **operationId:** `updatePlacement`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | string (uuid) | no |  |
| `personId` | string (uuid) | yes |  |
| `clientId` | string (uuid) | yes |  |
| `ref` | string | no | max length 80 |
| `roleTitle` | string | no | max length 120 |
| `startsOn` | string | yes | pattern `^\d{4}-\d{2}-\d{2}$` |
| `endsOn` | string or string | no |  |
| `active` | boolean | no | default `true` |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="delete-placements-id"></a>

### DELETE /placements/{id}

Deactivate a placement.

- **operationId:** `deactivatePlacement`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Example (captured from the API)**

```sh
curl -s -X DELETE "$TT/placements/96574dce-db96-4da1-82cf-de32021472d9" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "id": "96574dce-db96-4da1-82cf-de32021472d9",
    "person_id": "69a47995-bc6a-4bc9-9b9b-070935b974bb",
    "client_id": "a3254709-d152-49b4-ad5e-51d50147910a",
    "site_id": null,
    "ref": "PL-555",
    "external_source": "bullhorn",
    "external_ref": "PL-555",
    "role_title": "Lasser",
    "starts_on": "2026-12-01",
    "ends_on": null,
    "active": false,
    "created_at": "2026-10-23T19:22:16.09915+00:00",
    "updated_at": "2026-10-23T19:22:16.105978+00:00"
  }
}
```

<a id="put-placements-by-external-source-ref"></a>

### PUT /placements/by-external/{source}/{ref}

Idempotent upsert of a placement by your system's id.

- **operationId:** `upsertPlacementByExternal`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `source` | path | string | yes | Your system's name. pattern `^[a-z0-9_-]+$` |
| `ref` | path | string | yes | The record's id in your system (URL-encoded). |
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | string (uuid) | no |  |
| `personId` | string (uuid) | yes |  |
| `clientId` | string (uuid) | yes |  |
| `ref` | string | no | max length 80 |
| `roleTitle` | string | no | max length 120 |
| `startsOn` | string | yes | pattern `^\d{4}-\d{2}-\d{2}$` |
| `endsOn` | string or string | no |  |
| `active` | boolean | no | default `true` |

**Example (captured from the API)**

```sh
curl -s -X PUT "$TT/placements/by-external/bullhorn/PL-555" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"personExternalRef": "12345", "clientExternalRef": "CC-77", "ref": "PL-555", "roleTitle": "Lasser", "startsOn": "2026-12-01"}'
```

Response `200`:

```json
{
  "data": {
    "id": "96574dce-db96-4da1-82cf-de32021472d9",
    "org_id": "00000000-0000-4000-8000-000000000001",
    "person_id": "69a47995-bc6a-4bc9-9b9b-070935b974bb",
    "client_id": "a3254709-d152-49b4-ad5e-51d50147910a",
    "site_id": null,
    "ref": "PL-555",
    "role_title": "Lasser",
    "starts_on": "2026-12-01",
    "ends_on": null,
    "active": true,
    "created_at": "2026-10-23T19:22:16.09915+00:00",
    "updated_at": "2026-10-23T19:22:16.09915+00:00",
    "external_source": "bullhorn",
    "external_ref": "PL-555",
    "created": true
  }
}
```

<a id="post-placements-batch"></a>

### POST /placements/batch

Upsert up to 500 placements; rows with externalSource+externalRef upsert by external id.

- **operationId:** `batchPlacements`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `items` | array of object | yes |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="post-people-id-access"></a>

### POST /people/{id}/access

Give app access (email-code login).

- **operationId:** `grantAppAccess`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="delete-people-id-access"></a>

### DELETE /people/{id}/access

Revoke app access.

- **operationId:** `revokeAppAccess`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="post-people-id-anonymize"></a>

### POST /people/{id}/anonymize

GDPR erasure of a person (admin; owner protected).

- **operationId:** `anonymizePerson`
- **Who can call it:** **admin key or admin user**

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._


## Kiosk

<a id="post-people-id-pin"></a>

### POST /people/{id}/pin

Set a person's kiosk PIN (6 digits).

- **operationId:** `setKioskPin`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `pin` | string | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/people/<id>/pin" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"pin": "1234"}'
```

Response `422`:

```json
{
  "error": {
    "code": "invalid",
    "message": "PIN must be 6 digits"
  }
}
```

<a id="get-kiosk-devices"></a>

### GET /kiosk-devices

List kiosk tablets.

- **operationId:** `listKioskDevices`
- **Who can call it:** write key, admin key, office user

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/kiosk-devices" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": []
}
```

<a id="post-kiosk-devices"></a>

### POST /kiosk-devices

Create a kiosk tablet; returns its token once.

- **operationId:** `createKioskDevice`
- **Who can call it:** **admin key or admin user**

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `name` | string | yes |  |
| `clientId` | string (uuid) or null | no |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._


## Rosters

<a id="get-shifts"></a>

### GET /shifts

Roster week with planning issues.

- **operationId:** `getRoster`
- **Who can call it:** read key (GET), write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `week` | query | string (date) | no | Any date in the ISO week; defaults to the current week (org timezone). |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/shifts?week=2026-12-07" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "weekStart": "2026-12-07",
    "weekEnd": "2026-12-13",
    "shifts": [
      {
        "id": "52457e4d-25a3-4ad7-8129-f778f817a96d",
        "org_id": "00000000-0000-4000-8000-000000000001",
        "client_id": "00000000-0000-4000-8000-0000000000c3",
        "placement_id": "00000000-0000-4000-8000-0000000000e4",
        "site_id": null,
        "work_date": "2026-12-08",
        "start_min": 420,
        "end_min": 930,
        "break_min": 30,
        "role_title": "Productiemedewerker",
        "note": null,
        "status": "published",
        "published_at": "2026-10-23T19:09:36.863+00:00",
        "created_by": "00000000-0000-4000-8000-0000000000a1",
        "created_at": "2026-10-23T19:09:36.792606+00:00",
        "updated_at": "2026-10-23T19:09:36.792+00:00",
        "client": {
          "name": "Bakkerij Ruijters"
        },
        "placement": {
          "person": {
            "last_name": "Janssen",
            "first_name": "Sanne"
          },
          "person_id": "00000000-0000-4000-8000-0000000000d4"
        }
      }
    ],
    "placements": [
      {
        "id": "00000000-0000-4000-8000-0000000000e1",
        "starts_on": "2026-09-01",
        "ends_on": null,
        "role_title": "Monteur",
        "person": {
          "id": "00000000-0000-4000-8000-0000000000d1",
          "last_name": "de Vries",
          "first_name": "Jan"
        },
        "client": {
          "id": "00000000-0000-4000-8000-0000000000c1",
          "name": "VDL Montage"
        }
      },
      {
        "id": "00000000-0000-4000-8000-0000000000e2",
        "starts_on": "2026-09-01",
        "ends_on": null,
        "role_title": "Orderpicker",
        "person": {
          "id": "00000000-0000-4000-8000-0000000000d2",
          "last_name": "El Amrani",
          "first_name": "Fatima"
        },
        "client": {
          "id": "00000000-0000-4000-8000-0000000000c2",
          "name": "Logistiek Parkstad"
        }
      }
    ],
    "issues": []
  }
}
```

<a id="post-shifts"></a>

### POST /shifts

Create or update a shift (draft).

- **operationId:** `upsertShift`
- **Who can call it:** read key (GET), write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | string (uuid) | no |  |
| `clientId` | string (uuid) | yes |  |
| `placementId` | string (uuid) or null | no |  |
| `siteId` | string (uuid) or null | no |  |
| `workDate` | string | yes | pattern `^\d{4}-\d{2}-\d{2}$` |
| `startMin` | integer | yes | min 0; max 1439 |
| `endMin` | integer | yes | min 0; max 1439 |
| `breakMin` | integer | no | min 0; max 720; default `0` |
| `roleTitle` | string | no | max length 120 |
| `note` | string | no | max length 500 |
| `requiresApproval` | boolean | no | open shifts only: claims wait for the planner instead of "first claim wins" |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/shifts" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"clientId": "00000000-0000-4000-8000-0000000000c3", "placementId": "00000000-0000-4000-8000-0000000000e4", "workDate": "2026-12-08", "startMin": 420, "endMin": 930, "breakMin": 30, "roleTitle": "Productiemedewerker"}'
```

Response `200`:

```json
{
  "data": {
    "id": "52457e4d-25a3-4ad7-8129-f778f817a96d",
    "org_id": "00000000-0000-4000-8000-000000000001",
    "client_id": "00000000-0000-4000-8000-0000000000c3",
    "placement_id": "00000000-0000-4000-8000-0000000000e4",
    "site_id": null,
    "work_date": "2026-12-08",
    "start_min": 420,
    "end_min": 930,
    "break_min": 30,
    "role_title": "Productiemedewerker",
    "note": null,
    "status": "draft",
    "published_at": null,
    "created_by": "00000000-0000-4000-8000-0000000000a1",
    "created_at": "2026-10-23T19:09:36.792606+00:00",
    "updated_at": "2026-10-23T19:09:36.792+00:00"
  }
}
```

<a id="delete-shifts-id"></a>

### DELETE /shifts/{id}

Delete a draft or cancel a published shift.

- **operationId:** `deleteShift`
- **Who can call it:** read key (GET), write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="post-shifts-publish"></a>

### POST /shifts/publish

Publish all draft shifts of a week.

- **operationId:** `publishShifts`
- **Who can call it:** read key (GET), write key / office user

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `weekStart` | string | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/shifts/publish" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"weekStart": "2026-12-07"}'
```

Response `200`:

```json
{
  "data": {
    "published": 1
  }
}
```

<a id="post-shifts-copy-week"></a>

### POST /shifts/copy-week

Copy previous week's shifts as drafts.

- **operationId:** `copyShiftWeek`
- **Who can call it:** read key (GET), write key / office user

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `weekStart` | string | yes |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="post-shifts-id-claim"></a>

### POST /shifts/{id}/claim

Signed-in employee: claim an open shift (a published shift without a placement). Eligible: an active placement at the shift's client covering the date, with the same role when both name one. Without planner approval the first claim wins (one conditional update; a second claim gets `409`). With `requiresApproval` the claim becomes a request for the planner. Refused with `409` when it conflicts with leave, another shift, or the 11-hour rest rule.

- **operationId:** `claimOpenShift`
- **Who can call it:** employee user token only (an API key has no employee profile → 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `note` | string | no | max length 500 |

Response `200`:

```json
{ "data": { "status": "assigned", "shiftId": "…", "placementId": "…" } }
```

or, when the planner approves: `{ "data": { "status": "pending", "shiftId": "…", "requestId": "…" } }`.

<a id="post-shifts-id-swap"></a>

### POST /shifts/{id}/swap

Signed-in employee: ask a colleague to take over one of your published, upcoming shifts, optionally in exchange for one of the colleague's shifts. The colleague must have a matching placement and no leave/overlap/rest conflict. Creates a request with status `pending_colleague`.

- **operationId:** `requestShiftSwap`
- **Who can call it:** employee user token only (an API key has no employee profile → 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `colleaguePersonId` | string (uuid) | yes |  |
| `colleagueShiftId` | string (uuid) | no | the colleague's shift you take in exchange |
| `note` | string | no | max length 500 |

<a id="get-shifts-id-swap-candidates"></a>

### GET /shifts/{id}/swap-candidates

Signed-in employee: colleagues who could take over this shift (`personId`, `name` as first name + initial) and their upcoming shifts you could take in exchange.

- **operationId:** `getSwapCandidates`
- **Who can call it:** employee user token only (an API key has no employee profile → 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

<a id="get-shift-requests"></a>

### GET /shift-requests

Open-shift claims and swap requests that are still open (`pending_colleague`, `pending_planner`), oldest first. Requests waiting for the planner include `issues` (leave, overlap, rest, outside placement) for the person who would get the shift. `?status=all` adds requests from the last 30 days.

- **operationId:** `listShiftRequests`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `status` | query | string | no | `open` (default) or `all` |

<a id="post-shift-requests-id-respond"></a>

### POST /shift-requests/{id}/respond

The colleague accepts (→ `pending_planner`) or declines (→ `declined`) a swap request addressed to them.

- **operationId:** `respondShiftSwap`
- **Who can call it:** employee user token only (an API key has no employee profile → 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `accept` | boolean | yes |  |

<a id="post-shift-requests-id-decide"></a>

### POST /shift-requests/{id}/decide

Planner decision on a request with status `pending_planner`. Approving a claim assigns the open shift and rejects the other open claims on it; approving a swap moves the shift(s) in one transaction. Conflicts are checked again; `409` when the roster changed or a rule is broken. Emits `shift.request_decided`.

- **operationId:** `decideShiftRequest`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `decision` | string | yes | `approved` or `rejected` |
| `note` | string | no | max length 500 |

<a id="post-shift-requests-id-cancel"></a>

### POST /shift-requests/{id}/cancel

The requester (or the office) withdraws an open request.

- **operationId:** `cancelShiftRequest`
- **Who can call it:** the requesting employee, or write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

<a id="get-availability"></a>

### GET /availability

Availability of the signed-in person (or personId for office).

- **operationId:** `getAvailability`
- **Who can call it:** employee token (own) or write key / office user with `personId`

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `personId` | query | string (uuid) | no | Person |
| `from` | query | string (date) | no | From |
| `to` | query | string (date) | no | To |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="post-availability"></a>

### POST /availability

Set availability for a day.

- **operationId:** `setAvailability`
- **Who can call it:** employee token (own) or write key / office user with `personId`

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `personId` | string (uuid) | no |  |
| `workDate` | string | yes | pattern `^\d{4}-\d{2}-\d{2}$` |
| `available` | boolean | yes |  |
| `note` | string | no | max length 300 |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._


## Leave

<a id="get-leave"></a>

### GET /leave

List leave requests.

- **operationId:** `listLeave`
- **Who can call it:** read key (list), write key / office user (all people); employee token (own)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `personId` | query | string (uuid) | no | Filter |
| `from` | query | string (date) | no | From |
| `to` | query | string (date) | no | To |
| `status` | query | string | no | requested|approved|rejected|cancelled |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/leave?from=2026-12-01&to=2026-12-31" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": [
    {
      "id": "52666acf-c11c-47bc-a1e8-326776da1f2a",
      "org_id": "00000000-0000-4000-8000-000000000001",
      "person_id": "00000000-0000-4000-8000-0000000000d4",
      "leave_type_id": "ef33b5fc-c116-434c-a70c-4c4a1ee1bd1a",
      "starts_on": "2026-12-14",
      "ends_on": "2026-12-15",
      "minutes_per_day": 480,
      "status": "approved",
      "note": "Docs example",
      "decided_by": "00000000-0000-4000-8000-0000000000a1",
      "decided_at": "2026-10-23T19:09:10.242+00:00",
      "decision_note": null,
      "created_by": "00000000-0000-4000-8000-0000000000a1",
      "created_at": "2026-10-23T19:09:10.24124+00:00",
      "updated_at": "2026-10-23T19:09:10.24124+00:00",
      "external_source": null,
      "external_ref": null,
      "type": {
        "code": "vacation",
        "name": "Vakantie",
        "paid": true
      },
      "person": {
        "last_name": "Janssen",
        "first_name": "Sanne"
      }
    }
  ]
}
```

<a id="post-leave"></a>

### POST /leave

Request or register leave.

- **operationId:** `requestLeave`
- **Who can call it:** read key (list), write key / office user (all people); employee token (own)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `personId` | string (uuid) | no |  |
| `leaveTypeId` | string (uuid) | yes |  |
| `startsOn` | string | yes | pattern `^\d{4}-\d{2}-\d{2}$` |
| `endsOn` | string | yes | pattern `^\d{4}-\d{2}-\d{2}$` |
| `minutesPerDay` | integer | no | min 0; max 1440; default `480` |
| `note` | string | no | max length 500 |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/leave" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"leaveTypeId": "ef33b5fc-c116-434c-a70c-4c4a1ee1bd1a", "startsOn": "2026-12-21", "endsOn": "2026-12-23", "minutesPerDay": 480, "note": "Christmas"}'
```

Response `200`:

```json
{
  "data": {
    "id": "c7aecb37-052b-48ef-a0df-7d8fbf52d63b",
    "org_id": "00000000-0000-4000-8000-000000000001",
    "person_id": "00000000-0000-4000-8000-0000000000d1",
    "leave_type_id": "ef33b5fc-c116-434c-a70c-4c4a1ee1bd1a",
    "starts_on": "2026-12-21",
    "ends_on": "2026-12-23",
    "minutes_per_day": 480,
    "status": "requested",
    "note": "Christmas",
    "decided_by": null,
    "decided_at": null,
    "decision_note": null,
    "created_by": "00000000-0000-4000-8000-0000000000b1",
    "created_at": "2026-10-23T19:09:36.457189+00:00",
    "updated_at": "2026-10-23T19:09:36.457189+00:00",
    "external_source": null,
    "external_ref": null
  }
}
```

<a id="get-leave-types"></a>

### GET /leave/types

Leave types.

- **operationId:** `listLeaveTypes`
- **Who can call it:** read key (list), write key / office user (all people); employee token (own)

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/leave/types" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": [
    {
      "id": "0666d17e-f288-4a92-bdb5-de4dc1ec4c83",
      "org_id": "00000000-0000-4000-8000-000000000001",
      "code": "special",
      "name": "Bijzonder verlof",
      "paid": true,
      "needs_approval": true,
      "export_code": "[CODE_BIJZONDER]"
    },
    {
      "id": "6ad1e945-9392-4c4c-a069-85a8d6af6205",
      "org_id": "00000000-0000-4000-8000-000000000001",
      "code": "unpaid",
      "name": "Onbetaald verlof",
      "paid": false,
      "needs_approval": true,
      "export_code": null
    }
  ]
}
```

<a id="get-leave-balance"></a>

### GET /leave/balance

Leave balance per type (minutes).

- **operationId:** `getLeaveBalance`
- **Who can call it:** read key (list), write key / office user (all people); employee token (own)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `personId` | query | string (uuid) | no | Person |
| `year` | query | integer | no | Year |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/leave/balance?personId=00000000-0000-4000-8000-0000000000d4&year=2026" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": [
    {
      "leaveTypeId": "0666d17e-f288-4a92-bdb5-de4dc1ec4c83",
      "code": "special",
      "name": "Bijzonder verlof",
      "allowance": null,
      "taken": 0,
      "remaining": null
    },
    {
      "leaveTypeId": "6ad1e945-9392-4c4c-a069-85a8d6af6205",
      "code": "unpaid",
      "name": "Onbetaald verlof",
      "allowance": null,
      "taken": 0,
      "remaining": null
    }
  ]
}
```

<a id="post-leave-id-decide"></a>

### POST /leave/{id}/decide

Approve or reject leave.

- **operationId:** `decideLeave`
- **Who can call it:** write key / office user (employees may cancel their own leave)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `decision` | `approved` \| `rejected` | yes |  |
| `note` | string | no |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/leave/c7aecb37-052b-48ef-a0df-7d8fbf52d63b/decide" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"decision": "approved", "note": "Enjoy"}'
```

Response `200`:

```json
{
  "data": {
    "decided": "approved"
  }
}
```

<a id="post-leave-id-cancel"></a>

### POST /leave/{id}/cancel

Cancel leave.

- **operationId:** `cancelLeave`
- **Who can call it:** write key / office user (employees may cancel their own leave)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._


## Expenses

<a id="get-expenses"></a>

### GET /expenses

List expenses.

- **operationId:** `listExpenses`
- **Who can call it:** read key (list), write key / office user (all people); employee token (own)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `from` | query | string (date) | no | From |
| `to` | query | string (date) | no | To |
| `personId` | query | string (uuid) | no | Person |
| `status` | query | string | no | Status |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/expenses?from=2026-12-01&to=2026-12-31" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": [
    {
      "id": "8917409f-9a1e-4a54-8356-073e6a93153d",
      "org_id": "00000000-0000-4000-8000-000000000001",
      "person_id": "00000000-0000-4000-8000-0000000000d4",
      "placement_id": null,
      "work_date": "2026-12-08",
      "kind": "parking",
      "km": null,
      "minutes": null,
      "amount_cents": 750,
      "description": null,
      "receipt_path": null,
      "status": "approved",
      "decided_by": null,
      "decision_note": null,
      "export_run_id": null,
      "created_by": "00000000-0000-4000-8000-0000000000a1",
      "created_at": "2026-10-23T19:09:10.391294+00:00",
      "updated_at": "2026-10-23T19:09:10.391294+00:00",
      "external_source": null,
      "external_ref": null,
      "person": {
        "last_name": "Janssen",
        "first_name": "Sanne"
      },
      "placement": null
    },
    {
      "id": "a925ddbe-8614-4e13-8df6-ca3219ed285d",
      "org_id": "00000000-0000-4000-8000-000000000001",
      "person_id": "00000000-0000-4000-8000-0000000000d4",
      "placement_id": "00000000-0000-4000-8000-0000000000e4",
      "work_date": "2026-12-08",
      "kind": "km",
      "km": 42,
      "minutes": null,
      "amount_cents": 0,
      "description": "Client visit",
      "receipt_path": null,
      "status": "approved",
      "decided_by": null,
      "decision_note": null,
      "export_run_id": null,
      "created_by": "00000000-0000-4000-8000-0000000000a1",
      "created_at": "2026-10-23T19:09:10.358841+00:00",
      "updated_at": "2026-10-23T19:09:10.358841+00:00",
      "external_source": null,
      "external_ref": null,
      "person": {
        "last_name": "Janssen",
        "first_name": "Sanne"
      },
      "placement": {
        "client": {
          "name": "Bakkerij Ruijters"
        }
      }
    }
  ]
}
```

<a id="post-expenses"></a>

### POST /expenses

Record mileage, travel time or an expense.

- **operationId:** `createExpense`
- **Who can call it:** read key (list), write key / office user (all people); employee token (own)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Optional on writes: the first successful response is replayed for 24h for the same key. min length 8; max length 200 |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `personId` | string (uuid) | no |  |
| `placementId` | string (uuid) | no |  |
| `workDate` | string | yes | pattern `^\d{4}-\d{2}-\d{2}$` |
| `kind` | `km` \| `travel_time` \| `public_transport` \| `parking` \| `meal` \| `other` | yes |  |
| `km` | number | no | min 0; max 5000 |
| `minutes` | integer | no | min 0; max 1440 |
| `amountCents` | integer | no | min 0; max 10000000 |
| `description` | string | no | max length 500 |
| `receiptPath` | string | no | max length 300 |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/expenses" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"personId": "00000000-0000-4000-8000-0000000000d4", "workDate": "2026-12-08", "kind": "parking", "amountCents": 750}'
```

Response `200`:

```json
{
  "data": {
    "id": "8917409f-9a1e-4a54-8356-073e6a93153d",
    "org_id": "00000000-0000-4000-8000-000000000001",
    "person_id": "00000000-0000-4000-8000-0000000000d4",
    "placement_id": null,
    "work_date": "2026-12-08",
    "kind": "parking",
    "km": null,
    "minutes": null,
    "amount_cents": 750,
    "description": null,
    "receipt_path": null,
    "status": "approved",
    "decided_by": null,
    "decision_note": null,
    "export_run_id": null,
    "created_by": "00000000-0000-4000-8000-0000000000a1",
    "created_at": "2026-10-23T19:09:10.391294+00:00",
    "updated_at": "2026-10-23T19:09:10.391294+00:00",
    "external_source": null,
    "external_ref": null
  }
}
```

<a id="delete-expenses-id"></a>

### DELETE /expenses/{id}

Delete or withdraw an expense.

- **operationId:** `deleteExpense`
- **Who can call it:** read key (list), write key / office user (all people); employee token (own)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="post-expenses-decide"></a>

### POST /expenses/decide

Approve or reject expenses.

- **operationId:** `decideExpenses`
- **Who can call it:** read key (list), write key / office user (all people); employee token (own)

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `ids` | array of string (uuid) | yes |  |
| `decision` | `approved` \| `rejected` | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/expenses/decide" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"ids": ["f63b3afc-61e0-4eaf-acfc-0f4bfc70db3f"], "decision": "approved"}'
```

Response `200`:

```json
{
  "data": {
    "updated": 1
  }
}
```

<a id="post-expenses-receipt-upload"></a>

### POST /expenses/receipt-upload

Signed URL to upload a receipt (employees).

- **operationId:** `createReceiptUpload`
- **Who can call it:** read key (list), write key / office user (all people); employee token (own)

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `fileName` | string | yes |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._


## Inbox

<a id="get-inbox"></a>

### GET /inbox

Inbound client timesheets: documents and rows to review.

- **operationId:** `getInbox`
- **Who can call it:** read key, write key, office user

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/inbox" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "documents": [
      {
        "id": "17f5debc-557c-43f8-a53a-64751982df26",
        "sender": "planning@vdl-montage.example",
        "subject": "Urenstaat week 43",
        "status": "done",
        "summary": "2 dag(en) automatisch verwerkt",
        "received_at": "2026-10-26T08:15:00+00:00",
        "attachments": [
          {
            "name": "week43.csv",
            "path": "00000000-0000-4000-8000-000000000001/6ffe82f9-aac2-4e51-8fed-7e90d49aac98-week43.csv",
            "size": 135,
            "type": "text/csv"
          }
        ],
        "client": {
          "name": "VDL Montage"
        }
      },
      {
        "id": "aebb71dc-a16e-4167-8259-48278f5baedd",
        "sender": "ruud@logistiekparkstad.example",
        "subject": "correctie",
        "status": "needs_review",
        "summary": "0 dag(en) automatisch verwerkt · 1 ter controle",
        "received_at": "2026-10-23T18:39:17.165+00:00",
        "attachments": [],
        "client": {
          "name": "Logistiek Parkstad"
        }
      }
    ],
    "items": []
  }
}
```

<a id="post-inbox-items-id-apply"></a>

### POST /inbox/items/{id}/apply

Apply a reviewed row as client hours.

- **operationId:** `applyInboxItem`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `placementId` | string (uuid) | yes |  |
| `workDate` | string | yes |  |
| `minutes` | integer | yes | min -9007199254740991; max 9007199254740991 |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="post-inbox-items-id-ignore"></a>

### POST /inbox/items/{id}/ignore

Dismiss a row.

- **operationId:** `ignoreInboxItem`
- **Who can call it:** write key / office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `note` | string | no |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._


## Employee

<a id="get-me-week"></a>

### GET /me/week

Signed-in employee: placements and days of a week.

- **operationId:** `getMyWeek`
- **Who can call it:** employee user token only (an API key has no employee profile → 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `week` | query | string (date) | no | Any date in the ISO week; defaults to the current week (org timezone). |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/me/week?week=2026-12-07" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "person": {
      "id": "00000000-0000-4000-8000-0000000000d1",
      "firstName": "Jan",
      "lastName": "de Vries",
      "locale": "nl"
    },
    "isOffice": false,
    "weekStart": "2026-12-07",
    "weekEnd": "2026-12-13",
    "placements": [
      {
        "id": "00000000-0000-4000-8000-0000000000e1",
        "ref": "PL-2026-101",
        "roleTitle": "Monteur",
        "clientName": "VDL Montage",
        "startsOn": "2026-09-01",
        "endsOn": null,
        "site": null
      }
    ],
    "days": []
  }
}
```

<a id="get-me-shifts"></a>

### GET /me/shifts

Signed-in employee: published shifts.

- **operationId:** `getMyShifts`
- **Who can call it:** employee user token only (an API key has no employee profile → 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `from` | query | string (date) | no | From |
| `to` | query | string (date) | no | To |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="get-me-open-shifts"></a>

### GET /me/open-shifts

Signed-in employee: open shifts you may claim, today up to 60 days ahead. Each item has `placementId` (the placement the claim would use), `requiresApproval` and `requested` (you already asked).

- **operationId:** `getMyOpenShifts`
- **Who can call it:** employee user token only (an API key has no employee profile → 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `from` | query | string (date) | no | From (not before today) |
| `to` | query | string (date) | no | To |

<a id="get-me-shift-requests"></a>

### GET /me/shift-requests

Signed-in employee: your claims and swap requests, and swap requests addressed to you (`incoming: true`), from the last 30 days.

- **operationId:** `getMyShiftRequests`
- **Who can call it:** employee user token only (an API key has no employee profile → 403)

<a id="get-me-predictions"></a>

### GET /me/predictions

Signed-in employee: suggested hours for days without hours.

- **operationId:** `getMyPredictions`
- **Who can call it:** employee user token only (an API key has no employee profile → 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `today` | query | string (date) | no | Reference date |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="get-me-rewards"></a>

### GET /me/rewards

Signed-in employee: points and on-time streak.

- **operationId:** `getMyRewards`
- **Who can call it:** employee user token only (an API key has no employee profile → 403)

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="get-me-notifications"></a>

### GET /me/notifications

Signed-in employee: in-app notifications, newest first, and the unread count.

- **operationId:** `listMyNotifications`
- **Who can call it:** employee user token only (an API key has no employee profile → 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `limit` | query | integer | no | 1–100, default 30. |

Response `200`:

```json
{
  "data": {
    "items": [
      {
        "id": "6f0c…",
        "kind": "hours.late",
        "title": "Je uren van 26 okt – 1 nov zijn te laat",
        "body": "Hoi Jan, je hebt je uren van 26 okt – 1 nov nog niet ingediend. Dien ze nu in.",
        "data": { "periodStart": "2026-10-26", "periodEnd": "2026-11-01", "url": "/me?week=2026-10-26" },
        "readAt": null,
        "createdAt": "2026-11-02T08:00:12Z"
      }
    ],
    "unread": 1
  }
}
```

Title and body are written in the employee's language when the notification is created. Kinds: `hours.late` (hours past the deadline, sent automatically at the deadline).

<a id="post-me-notifications-read"></a>

### POST /me/notifications/read

Signed-in employee: mark notifications as read. Without `ids`, every unread notification is marked.

- **operationId:** `markMyNotificationsRead`
- **Who can call it:** employee user token only

| Field | Type | Required | Constraints |
|---|---|---|---|
| `ids` | array of uuid | no | max 100 |

Response `200`: `{ "data": { "updated": 1 } }`

<a id="post-me-device-token"></a>

### POST /me/device-token

Register a push token.

- **operationId:** `registerDeviceToken`
- **Who can call it:** employee user token only (an API key has no employee profile → 403)

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `token` | string | yes |  |
| `platform` | `ios` \| `android` \| `web` | yes |  |

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._


## Settings

<a id="get-org-reminders"></a>

### GET /org/reminders

Hours deadline and late-reminder policy, and whether the WhatsApp provider is configured.

- **operationId:** `getReminderSettings`
- **Who can call it:** write key, office user

Response `200`:

```json
{
  "data": {
    "timezone": "Europe/Amsterdam",
    "deadline": { "weekday": 1, "minute": 540 },
    "policy": { "enabled": true, "whatsappEnabled": true, "whatsappDelayMinutes": 90 },
    "whatsappConfigured": false
  }
}
```

`deadline.weekday` is the ISO weekday (1 = Monday) after the payroll period ends; `minute` is the local minute of day. Reminders: an in-app notification at the deadline, then a WhatsApp message `whatsappDelayMinutes` later if the hours are still missing and the person has a phone number. Never by email. No reminders between 21:00 and 07:00 local, at most 3 per person per day, one per channel per period, none for deadlines older than 7 days. Without a configured provider WhatsApp reminders are recorded as skipped (`whatsappConfigured: false`).

<a id="put-org-reminders"></a>

### PUT /org/reminders

Set the hours deadline and reminder policy. Returns the new settings.

- **operationId:** `updateReminderSettings`
- **Who can call it:** **admin user or admin key**

| Field | Type | Required | Constraints |
|---|---|---|---|
| `deadlineWeekday` | integer | yes | 1–7 (1 = Monday) |
| `deadlineTime` | string | yes | `HH:MM`, local time |
| `enabled` | boolean | yes | in-app reminders (master switch) |
| `whatsappEnabled` | boolean | yes | WhatsApp follow-up |
| `whatsappDelayMinutes` | integer | yes | 0–1440 |

## Integrations

<a id="get-api-keys"></a>

### GET /api-keys

List API keys (admin users).

- **operationId:** `listApiKeys`
- **Who can call it:** **admin user or admin key**

_No captured example for this endpoint; see the request schema above and the [live spec](https://app.timetracking.tech/api/v1/openapi.json)._

<a id="post-api-keys"></a>

### POST /api-keys

Create an API key (admin users; keys cannot create keys).

- **operationId:** `createApiKey`
- **Who can call it:** **admin user token only** — no API key can create keys (403 `Not available with an API key`)

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `name` | string | yes |  |
| `scopes` | array of `read` \| `write` | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/api-keys" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"name": "docs-v12 admin", "scopes": ["read", "admin"]}'
```

Response `200`:

```json
{
  "data": {
    "key": "tt_live_…"
  }
}
```

<a id="delete-api-keys-id"></a>

### DELETE /api-keys/{id}

Revoke an API key.

- **operationId:** `revokeApiKey`
- **Who can call it:** **admin user or admin key**

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Example (captured from the API)**

```sh
curl -s -X DELETE "$TT/api-keys/<id>" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "revoked": true
  }
}
```

<a id="get-webhooks"></a>

### GET /webhooks

List webhooks.

- **operationId:** `listWebhooks`
- **Who can call it:** **admin key or admin user** (write keys get 403)

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/webhooks" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": [
    {
      "id": "2c799e37-907b-4d22-a297-9f4a31e7c0f4",
      "url": "https://example.com/tt-docs-capture",
      "events": [
        "timesheet.submitted",
        "timesheet.approved"
      ],
      "active": true,
      "created_at": "2026-10-23T19:08:40.943918+00:00"
    }
  ]
}
```

<a id="post-webhooks"></a>

### POST /webhooks

Create a webhook; returns the signing secret once.

- **operationId:** `createWebhook`
- **Who can call it:** **admin key or admin user** (write keys get 403)

**Request body** (JSON)

| Field | Type | Required | Constraints |
|---|---|---|---|
| `url` | string (uri) | yes |  |
| `events` | array of `timesheet.submitted` \| `timesheet.approved` \| `timesheet.client_approved` \| `conflict.created` \| `export.completed` \| `period.locked` \| `leave.requested` \| `leave.decided` \| `expense.submitted` \| `shift.published` \| `person.created` \| `person.updated` \| `placement.created` \| `placement.updated` \| `claim.created` \| `expense.decided` \| `leave.cancelled` \| `webhook.test` \| `client.created` \| `client.updated` \| `conflict.resolved` | yes | min items 1 |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/webhooks" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"url": "https://example.com/tt-docs-capture", "events": ["timesheet.submitted", "timesheet.approved", "timesheet.client_approved", "conflict.created", "export.completed", "period.locked", "leave.requested", "leave.decided", "expense.submitted", "shift.published"]}'
```

Response `200`:

```json
{
  "data": {
    "id": "2c799e37-907b-4d22-a297-9f4a31e7c0f4",
    "secret": "whsec_…"
  }
}
```

<a id="delete-webhooks-id"></a>

### DELETE /webhooks/{id}

Delete a webhook.

- **operationId:** `deleteWebhook`
- **Who can call it:** **admin key or admin user** (write keys get 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Example (captured from the API)**

```sh
curl -s -X DELETE "$TT/webhooks/<id>" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "deleted": true
  }
}
```

<a id="get-webhooks-id-deliveries"></a>

### GET /webhooks/{id}/deliveries

Recent deliveries with status codes.

- **operationId:** `listWebhookDeliveries`
- **Who can call it:** **admin key or admin user** (write keys get 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |
| `limit` | query | integer | no | Max 200 |

**Example (captured from the API)**

```sh
curl -s -X GET "$TT/webhooks/682094f0-0ab9-4667-980d-808bb575fe5d/deliveries?limit=50" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": [
    {
      "id": 21,
      "event": "webhook.test",
      "status": "pending",
      "attempts": 0,
      "last_status_code": null,
      "last_error": null,
      "next_attempt_at": "2026-10-23T19:22:20.469944+00:00",
      "created_at": "2026-10-23T19:22:20.469944+00:00",
      "payload": {
        "id": "evt_78eae536b3c2ecb793b5ca22",
        "data": {
          "message": "Test event from TimeTracking"
        },
        "type": "webhook.test",
        "created": "2026-10-23T19:22:20.468Z"
      }
    },
    {
      "id": 20,
      "event": "person.updated",
      "status": "pending",
      "attempts": 0,
      "last_status_code": null,
      "last_error": null,
      "next_attempt_at": "2026-10-23T19:22:20.127386+00:00",
      "created_at": "2026-10-23T19:22:20.127386+00:00",
      "payload": {
        "id": "evt_be726cdb624908aa0d34a2ab",
        "data": {
          "active": false,
          "personId": "69a47995-bc6a-4bc9-9b9b-070935b974bb"
        },
        "type": "person.updated",
        "created": "2026-10-23T19:22:20.124Z"
      }
    }
  ]
}
```

<a id="post-webhooks-id-test"></a>

### POST /webhooks/{id}/test

Queue a webhook.test event.

- **operationId:** `testWebhook`
- **Who can call it:** **admin key or admin user** (write keys get 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/webhooks/682094f0-0ab9-4667-980d-808bb575fe5d/test" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "queued": true
  }
}
```

<a id="post-webhooks-id-rotate-secret"></a>

### POST /webhooks/{id}/rotate-secret

Issue a new signing secret (returned once).

- **operationId:** `rotateWebhookSecret`
- **Who can call it:** **admin key or admin user** (write keys get 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string (uuid) | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/webhooks/682094f0-0ab9-4667-980d-808bb575fe5d/rotate-secret" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "secret": "whsec_…"
  }
}
```

<a id="post-webhook-deliveries-id-replay"></a>

### POST /webhook-deliveries/{id}/replay

Re-queue a delivery.

- **operationId:** `replayWebhookDelivery`
- **Who can call it:** **admin key or admin user** (write keys get 403)

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | integer | yes |  |

**Example (captured from the API)**

```sh
curl -s -X POST "$TT/webhook-deliveries/21/replay" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "requeued": true
  }
}
```

## Reports

<a id="get-reports-hours"></a>

### GET /reports/hours

Hours per person, client or placement over a date range (max 366 days). Counts resolved days with minutes > 0; `minutesByStatus` splits the total over the statuses, `conflictDays` counts days still in conflict. The same numbers are on the office page **Rapportages** (`/reports`), which also shows roster vs worked, open/late submissions, expenses and anomalies, and downloads them as CSV or XLSX.

- **operationId:** `getHoursReport`
- **Who can call it:** read key, write key, office user

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `group_by` | query | string (`person` \| `client` \| `placement`) | no | Default `person`. Anything else → 422. |
| `from` | query | string (date) | yes | First date (inclusive). |
| `to` | query | string (date) | yes | Last date (inclusive); at most 366 days after `from`. |
| `client_id` | query | string (uuid) | no | Filter. |
| `person_id` | query | string (uuid) | no | Filter. |
| `placement_id` | query | string (uuid) | no | Filter. |
| `status` | query | string | no | `draft`, `submitted`, `approved`, `exported`, `locked` or `conflict`. |

**Example**

```sh
curl -s "$TT/reports/hours?group_by=placement&from=2026-09-14&to=2026-09-18" \
  -H "Authorization: Bearer $KEY"
```

Response `200`:

```json
{
  "data": {
    "from": "2026-09-14",
    "to": "2026-09-18",
    "group_by": "placement",
    "total_minutes": 2940,
    "rows": [
      {
        "id": "5f0c…",
        "name": "Rapport Tester",
        "sub": "Rapport A",
        "personId": "1b7e…",
        "clientId": "c2d4…",
        "placementId": "5f0c…",
        "days": 4,
        "minutes": 1740,
        "minutesByStatus": { "draft": 1740, "submitted": 0, "approved": 0, "exported": 0, "locked": 0 },
        "conflictDays": 0
      }
    ]
  }
}
```

## Account

<a id="get-export-account"></a>

### GET /export/account

Full account export (open data): a ZIP with every table of your organisation as `data/<table>.csv` and `data/<table>.json` (people, clients, placements, time_claims, timesheet_days, approvals, leave, expenses, shifts, export_runs, audit_log, …), `data/pay_lines.*` (pay lines flattened from the export runs), `schema.json` (tables, columns, keys) and `README.md` explaining the files and conventions. The response is streamed (`Content-Type: application/zip`, not the JSON envelope; errors still use the JSON envelope).

Secrets never leave: columns and JSON keys `kiosk_pin_hash`, `key_hash`, `token_hash`, `email_code_hash`, `secret`, `token`, `password`, `body_text`, `body_html` are removed everywhere, including inside `audit_log.before/after`; caches, rate counters and device push tokens are not exported. Each download is written to the audit log (`account.export`). Owners and admins can download the same ZIP in the web app under **Instellingen → Volledige account-export**.

- **operationId:** `exportAccount`
- **Who can call it:** **admin key or owner/admin user** (read and write keys get 403)

**Example**

```sh
curl -s -o account.zip "$TT/export/account" \
  -H "Authorization: Bearer $ADMIN_KEY"
unzip -l account.zip | head
```
