API reference
Every TimeTracking REST API v1.1 endpoint with parameters, request fields, who may call it, and real captured request/response examples.
Last updated: · Markdown version
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.
export TT=https://app.timetracking.tech/api/v1
export KEY=tt_live_… # see ../api/authentication.md
Conventions that apply to every endpoint: authentication, errors ({ "error": { "code", "message" } }, 401/403/404/409/422/423/429), rate limits, pagination, 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.
Contents
- Hours:
GET /org/week·GET /days·GET /claims·POST /claims·POST /claims/batch·POST /weeks/submit·POST /days/approve·GET /conflicts·POST /conflicts/{id}/resolve - Approvals:
POST /approval-links·GET /client/approve/{token}·POST /client/approve/{token}·POST /client/approve/{token}/code·POST /client/approve/{token}/verify - Exports:
POST /exports·GET /exports/runs·GET /exports/runs/{id}·GET /exports/runs/{id}/file·GET /people/{id}/timesheet·POST /periods/lock - Master data:
POST /import·POST /import/time-entries·GET /people·POST /people·GET /people/{id}·PATCH /people/{id}·DELETE /people/{id}·PUT /people/by-external/{source}/{ref}·POST /people/batch·GET /clients·POST /clients·GET /clients/{id}·PATCH /clients/{id}·DELETE /clients/{id}·PUT /clients/by-external/{source}/{ref}·POST /clients/batch·GET /placements·POST /placements·GET /placements/{id}·PATCH /placements/{id}·DELETE /placements/{id}·PUT /placements/by-external/{source}/{ref}·POST /placements/batch·POST /people/{id}/access·DELETE /people/{id}/access·POST /people/{id}/anonymize - Kiosk:
POST /people/{id}/pin·GET /kiosk-devices·POST /kiosk-devices - Rosters:
GET /shifts·POST /shifts·DELETE /shifts/{id}·POST /shifts/publish·POST /shifts/copy-week·POST /shifts/{id}/claim·POST /shifts/{id}/swap·GET /shifts/{id}/swap-candidates·GET /shift-requests·POST /shift-requests/{id}/respond·POST /shift-requests/{id}/decide·POST /shift-requests/{id}/cancel·GET /availability·POST /availability - Leave:
GET /leave·POST /leave·GET /leave/types·GET /leave/balance·POST /leave/{id}/decide·POST /leave/{id}/cancel - Expenses:
GET /expenses·POST /expenses·DELETE /expenses/{id}·POST /expenses/decide·POST /expenses/receipt-upload - Inbox:
GET /inbox·POST /inbox/items/{id}/apply·POST /inbox/items/{id}/ignore - Employee:
GET /me/week·GET /me/shifts·GET /me/open-shifts·GET /me/shift-requests·GET /me/predictions·GET /me/rewards·GET /me/notifications·POST /me/notifications/read·POST /me/device-token - Settings:
GET /org/reminders·PUT /org/reminders - Integrations:
GET /api-keys·POST /api-keys·DELETE /api-keys/{id}·GET /webhooks·POST /webhooks·DELETE /webhooks/{id}·GET /webhooks/{id}/deliveries·POST /webhooks/{id}/test·POST /webhooks/{id}/rotate-secret·POST /webhook-deliveries/{id}/replay - Reports:
GET /reports/hours - Account:
GET /export/account
Hours
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)
curl -s -X GET "$TT/org/week?week=2026-11-30" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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), and none of their days in the week is submitted (drafts do not count). deadline is { "date": "2026-12-07", "minute": 540, "passed": false }.
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.
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)
curl -s -X GET "$TT/days?from=2026-11-30&to=2026-12-13&limit=3" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
}
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)
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:
{
"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"
}
}
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)
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:
{
"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
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:
{
"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
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:
{
"error": {
"code": "locked",
"message": "This day is exported or locked; book a correction in an open period"
}
}
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)
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:
{
"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
}
}
]
}
}
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)
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:
{
"data": {
"submitted": 2
}
}
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)
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:
{
"data": {
"approved": 2,
"waitingForClient": 0,
"skipped": 0
}
}
Example — Client-approval placement → waiting for client
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:
{
"data": {
"approved": 0,
"waitingForClient": 1,
"skipped": 0
}
}
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)
curl -s -X GET "$TT/conflicts?week=2026-11-30" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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
}
]
}
]
}
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)
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:
{
"data": {
"resolved": true
}
}
Approvals
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)
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:
{
"data": {
"url": "http://localhost:3001/approve/<token>",
"expiresAt": "2026-11-06T19:09:09.202Z",
"emailed": "ruud@logistiekparkstad.example"
}
}
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)
curl -s -X GET "$TT/client/approve/XDcfG_swBYtm2BDrcnYJZh-9rXKDxEqXUAxVNuxB4Qs"
Response 200:
{
"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
}
]
}
}
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)
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:
{
"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
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:
{
"error": {
"code": "forbidden",
"message": "Confirm the code sent by email first"
}
}
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)
curl -s -X POST "$TT/client/approve/XDcfG_swBYtm2BDrcnYJZh-9rXKDxEqXUAxVNuxB4Qs/code"
Response 200:
{
"data": {
"sentTo": "r•••@logistiekparkstad.example"
}
}
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)
curl -s -X POST "$TT/client/approve/XDcfG_swBYtm2BDrcnYJZh-9rXKDxEqXUAxVNuxB4Qs/verify" \
-H "content-type: application/json" \
-d '{"code": "180275"}'
Response 200:
{
"data": {
"proof": "1792789749942.317037da…"
}
}
Exports
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)
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:
{
"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)
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:
{
"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"
}
}
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)
curl -s -X GET "$TT/exports/runs?limit=3" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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
}
}
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)
curl -s -X GET "$TT/exports/runs/239a1898-fab5-486c-a2be-92d939be90cc" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
]
}
}
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)
curl -s -X GET "$TT/exports/runs/239a1898-fab5-486c-a2be-92d939be90cc/file" \
-H "Authorization: Bearer $KEY"
Response 200 (text/csv; charset=utf-8):
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
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) |
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)
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:
{
"error": {
"code": "conflict",
"message": "2 day(s) in this period are not exported yet"
}
}
Master data
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)
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:
{
"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": []
}
}
POST /import/time-entries
Import a time-entry CSV exported from Toggl Track, Clockify, TimeChimp or Harvest (see Importing from other time trackers). 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):
{
"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 }]
}
}
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)
curl -s -X GET "$TT/people?limit=2" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
}
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)
curl -s -X POST "$TT/people" \
-H "Authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d '{"employeeNumber": "9920", "firstName": "Iris", "lastName": "Docs"}'
Response 200:
{
"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"
}
}
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)
curl -s -X GET "$TT/people/69a47995-bc6a-4bc9-9b9b-070935b974bb" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
}
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)
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:
{
"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"
}
}
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)
curl -s -X DELETE "$TT/people/69a47995-bc6a-4bc9-9b9b-070935b974bb" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
}
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)
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:
{
"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)
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:
{
"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
}
}
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)
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:
{
"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"
}
}
]
}
}
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)
curl -s -X GET "$TT/clients?limit=2" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
}
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.
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)
curl -s -X GET "$TT/clients/a3254709-d152-49b4-ad5e-51d50147910a" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
}
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.
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)
curl -s -X DELETE "$TT/clients/a3254709-d152-49b4-ad5e-51d50147910a" \
-H "Authorization: Bearer $KEY"
Response 409:
{
"error": {
"code": "conflict",
"message": "Client has placements; deactivate those instead"
}
}
Example — Client without placements
curl -s -X DELETE "$TT/clients/af8223bd-de80-4773-8aae-27394604b69a" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"data": {
"deleted": true
}
}
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)
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:
{
"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
}
}
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.
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)
curl -s -X GET "$TT/placements?limit=2" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
}
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.
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)
curl -s -X GET "$TT/placements/96574dce-db96-4da1-82cf-de32021472d9" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
}
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.
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)
curl -s -X DELETE "$TT/placements/96574dce-db96-4da1-82cf-de32021472d9" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
}
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)
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:
{
"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
}
}
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.
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.
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.
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.
Kiosk
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)
curl -s -X POST "$TT/people/<id>/pin" \
-H "Authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d '{"pin": "1234"}'
Response 422:
{
"error": {
"code": "invalid",
"message": "PIN must be 6 digits"
}
}
GET /kiosk-devices
List kiosk tablets.
- operationId:
listKioskDevices - Who can call it: write key, admin key, office user
Example (captured from the API)
curl -s -X GET "$TT/kiosk-devices" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"data": []
}
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.
Rosters
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)
curl -s -X GET "$TT/shifts?week=2026-12-07" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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": []
}
}
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)
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:
{
"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"
}
}
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.
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)
curl -s -X POST "$TT/shifts/publish" \
-H "Authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d '{"weekStart": "2026-12-07"}'
Response 200:
{
"data": {
"published": 1
}
}
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.
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:
{ "data": { "status": "assigned", "shiftId": "…", "placementId": "…" } }
or, when the planner approves: { "data": { "status": "pending", "shiftId": "…", "requestId": "…" } }.
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 |
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 |
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 |
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 |
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 |
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 |
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.
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.
Leave
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 |
Example (captured from the API)
curl -s -X GET "$TT/leave?from=2026-12-01&to=2026-12-31" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
}
]
}
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)
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:
{
"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
}
}
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)
curl -s -X GET "$TT/leave/types" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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
}
]
}
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)
curl -s -X GET "$TT/leave/balance?personId=00000000-0000-4000-8000-0000000000d4&year=2026" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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
}
]
}
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)
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:
{
"data": {
"decided": "approved"
}
}
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.
Expenses
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)
curl -s -X GET "$TT/expenses?from=2026-12-01&to=2026-12-31" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
}
}
]
}
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)
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:
{
"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
}
}
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.
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)
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:
{
"data": {
"updated": 1
}
}
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.
Inbox
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)
curl -s -X GET "$TT/inbox" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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": []
}
}
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.
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.
Employee
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)
curl -s -X GET "$TT/me/week?week=2026-12-07" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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": []
}
}
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.
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 |
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)
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.
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.
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:
{
"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).
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 } }
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.
Settings
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:
{
"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).
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
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.
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)
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:
{
"data": {
"key": "tt_live_…"
}
}
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)
curl -s -X DELETE "$TT/api-keys/<id>" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"data": {
"revoked": true
}
}
GET /webhooks
List webhooks.
- operationId:
listWebhooks - Who can call it: admin key or admin user (write keys get 403)
Example (captured from the API)
curl -s -X GET "$TT/webhooks" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
]
}
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)
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:
{
"data": {
"id": "2c799e37-907b-4d22-a297-9f4a31e7c0f4",
"secret": "whsec_…"
}
}
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)
curl -s -X DELETE "$TT/webhooks/<id>" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"data": {
"deleted": true
}
}
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)
curl -s -X GET "$TT/webhooks/682094f0-0ab9-4667-980d-808bb575fe5d/deliveries?limit=50" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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"
}
}
]
}
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)
curl -s -X POST "$TT/webhooks/682094f0-0ab9-4667-980d-808bb575fe5d/test" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"data": {
"queued": true
}
}
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)
curl -s -X POST "$TT/webhooks/682094f0-0ab9-4667-980d-808bb575fe5d/rotate-secret" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"data": {
"secret": "whsec_…"
}
}
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)
curl -s -X POST "$TT/webhook-deliveries/21/replay" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"data": {
"requeued": true
}
}
Reports
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
curl -s "$TT/reports/hours?group_by=placement&from=2026-09-14&to=2026-09-18" \
-H "Authorization: Bearer $KEY"
Response 200:
{
"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
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
curl -s -o account.zip "$TT/export/account" \
-H "Authorization: Bearer $ADMIN_KEY"
unzip -l account.zip | head