Webhook events
Every TimeTracking webhook event with when it fires, its data fields, a real captured payload, and which API call to make next.
Last updated: · Markdown version
All events share the envelope { id, type, created, data } (see the overview). The payloads below were captured from the API on 2026-10-23, except period.locked, expense.decided, leave.cancelled and conflict.resolved, which are taken from the source code with illustrative IDs. data mostly carries IDs. Fetch details with the follow-up call.
| Event | Fires when | data |
|---|---|---|
timesheet.submitted |
A person's draft days of a week are submitted | personId, weekStart, dayIds[] |
timesheet.approved |
The office approves days and at least one became approved |
dayIds[], approved |
timesheet.client_approved |
A client submits decisions on an approval link | linkId, signer, days |
conflict.created |
Claims for a day start to disagree | timesheetDayId, placementId, workDate, candidates[] |
claim.created |
Any claim is recorded (API, batch, kiosk, mail, app) | placementId, workDate, source, minutes, dayId, dayStatus |
export.completed |
An export is committed | runId, startsOn, endsOn, rows, mapping |
period.locked |
An admin locks a period | startsOn, endsOn, kind |
leave.requested |
An employee requests leave that needs approval | leaveRequestId, personId |
leave.decided |
Leave is approved or rejected | leaveRequestId, decision |
leave.cancelled |
Leave is cancelled | leaveRequestId |
expense.submitted |
An expense is created with status submitted |
expenseId, personId |
expense.decided |
Expenses are approved or rejected | expenseIds[], decision |
shift.published |
Draft shifts of a week are published | weekStart, count |
shift.claimed |
A worker claims an open shift (assigned, or a request for the planner) | shiftId, personId, status (assigned/pending), placementId or requestId |
shift.swap_requested |
A worker asks a colleague to take over a shift | requestId, shiftId, from, to, exchangeShiftId |
shift.request_decided |
The planner approves or rejects a claim or swap | requestId, kind, shiftId, decision |
person.created |
A person is created (POST /people, PUT …/by-external/…, batch) |
personId |
person.updated |
A person is updated (POST with id, PATCH, by-external, batch) or deactivated (DELETE) |
personId (+ active:false on deactivate) |
placement.created |
A placement is created (any API create path) | placementId |
placement.updated |
A placement is updated or deactivated | placementId (+ active:false on deactivate) |
client.created |
A client is created (any API create path) | clientId |
client.updated |
A client is updated | clientId |
conflict.resolved |
The office picks the winning claim of a conflict | conflictId, claimId |
webhook.test |
POST /webhooks/{id}/test |
message |
Not covered by events: deleting a client (
DELETE /clients/{id}), office rejection of days, shift edits or cancellations, export previews, and changes made by the CSV import (not verified). Reconcile those periodically, for example withGET /days?updated_since=…andGET /people?updated_since=….
timesheet.submitted
Fires from POST /weeks/submit, from the employee app, and from the office submitting on someone's behalf. dayIds lists only the days that changed status.
{ "id": "evt_10ca0b5fcd86bb8b45466b43", "type": "timesheet.submitted", "created": "2026-10-23T19:08:42.312Z",
"data": { "personId": "00000000-0000-4000-8000-0000000000d4", "weekStart": "2026-11-30",
"dayIds": ["0ce9b17d-40ab-4542-937a-de57896a7358", "81739347-0330-49eb-a559-877cb1889d63"] } }
Next: GET /days?from=2026-11-30&to=2026-12-06&person_id=… or GET /org/week?week=2026-11-30.
timesheet.approved
Fires from POST /days/approve when approved > 0. dayIds is the requested list. It can include days that were skipped or are waiting for the client. Re-read the days to see each one's status.
{ "id": "evt_de85f4a8d52c9ed2d64ae9ea", "type": "timesheet.approved", "created": "2026-10-23T19:08:42.490Z",
"data": { "dayIds": ["0ce9b17d-40ab-4542-937a-de57896a7358", "81739347-0330-49eb-a559-877cb1889d63"], "approved": 2 } }
timesheet.client_approved
Fires once per client submission on a magic link. days is the number of decisions submitted, including corrections and rejections.
{ "id": "evt_62a0c1069a969f976e27028f", "type": "timesheet.client_approved", "created": "2026-10-23T19:09:10.063Z",
"data": { "linkId": "b374151b-659a-4133-889e-1bd262647f48", "signer": "Ruud Hendriks", "days": 1 } }
Next: GET /days?from=…&to=…&client_id=…&updated_since=<created>. signer is typed by the client: treat it as untrusted text.
conflict.created
Fires the first time a day goes into conflict. Updated candidates on an already-open conflict do not fire it again.
{ "id": "evt_29fce0f77911141bd9a9cc03", "type": "conflict.created", "created": "2026-10-23T19:08:41.789Z",
"data": { "timesheetDayId": "0ce9b17d-40ab-4542-937a-de57896a7358", "placementId": "00000000-0000-4000-8000-0000000000e4", "workDate": "2026-12-01",
"candidates": [ { "source": "manual_office", "claimId": "7d78dbdb-d70c-4960-8796-4cedfa3454e6", "minutes": 480 },
{ "source": "mail", "claimId": "ddce5803-8f75-4ace-9d5c-e0bf9fae1d8a", "minutes": 450 } ] } }
Next: GET /conflicts?week=<workDate> gives the conflict id you need for POST /conflicts/{id}/resolve.
claim.created
Fires for every successfully stored claim, including each row of /claims/batch, kiosk clock-outs and parsed client emails. It does not fire when a claim is refused or deduplicated. dayStatus is the status after re-resolution. This event can be high-volume.
{ "id": "evt_3778577a50b23dec3a19b401", "type": "claim.created", "created": "2026-10-23T19:22:17.604Z",
"data": { "dayId": "498795f4-4027-4888-84a6-885952f0ab8b", "source": "manual_office", "minutes": 450, "workDate": "2026-12-10",
"dayStatus": "draft", "placementId": "96574dce-db96-4da1-82cf-de32021472d9" } }
export.completed
Fires after a successful commit. mapping is the mapping's name, not its id.
{ "id": "evt_9087e71890de14cd0197877a", "type": "export.completed", "created": "2026-10-23T19:08:42.950Z",
"data": { "runId": "239a1898-fab5-486c-a2be-92d939be90cc", "startsOn": "2026-11-30", "endsOn": "2026-12-06", "rows": 2, "mapping": "Generiek CSV (per dag)" } }
Next: GET /exports/runs/{runId}/file returns the payroll file. GET /exports/runs/{runId} returns pay lines and warnings.
period.locked
{ "id": "evt_5a0c…", "type": "period.locked", "created": "2026-12-14T09:00:00.000Z",
"data": { "startsOn": "2026-11-30", "endsOn": "2026-12-06", "kind": "week" } }
kind is week, four_weeks, semi_month or month.
leave.requested
Only fires when the request needs approval (status requested). Office-registered leave and leave types without approval (e.g. sick) are approved immediately and do not fire it.
{ "id": "evt_adf3646a71dc1f178bcce6c0", "type": "leave.requested", "created": "2026-10-23T19:09:36.470Z",
"data": { "personId": "00000000-0000-4000-8000-0000000000d1", "leaveRequestId": "c7aecb37-052b-48ef-a0df-7d8fbf52d63b" } }
Next: GET /leave?personId=…&status=requested (needs a write key).
leave.decided
{ "id": "evt_077c9bea68be4553d3d9c769", "type": "leave.decided", "created": "2026-10-23T19:09:36.588Z",
"data": { "decision": "approved", "leaveRequestId": "c7aecb37-052b-48ef-a0df-7d8fbf52d63b" } }
leave.cancelled
{ "id": "evt_…", "type": "leave.cancelled", "created": "2026-12-01T10:00:00.000Z",
"data": { "leaveRequestId": "c7aecb37-052b-48ef-a0df-7d8fbf52d63b" } }
expense.submitted
Fires from POST /expenses when the new expense has status submitted (typically created by an employee). Office-created expenses are approved directly and do not fire it.
{ "id": "evt_331f51a0d991d2539e4e215f", "type": "expense.submitted", "created": "2026-10-23T19:09:36.677Z",
"data": { "personId": "00000000-0000-4000-8000-0000000000d1", "expenseId": "f63b3afc-61e0-4eaf-acfc-0f4bfc70db3f" } }
expense.decided
{ "id": "evt_…", "type": "expense.decided", "created": "2026-10-23T19:09:36.700Z",
"data": { "expenseIds": ["f63b3afc-61e0-4eaf-acfc-0f4bfc70db3f"], "decision": "approved" } }
shift.published
data has no shift list. Fetch the roster with GET /shifts?week=<weekStart> (write key).
{ "id": "evt_eb4940a8ca64fbbe7ffeaa8a", "type": "shift.published", "created": "2026-10-23T19:09:36.873Z",
"data": { "count": 1, "weekStart": "2026-12-07" } }
shift.claimed
{ "type": "shift.claimed", "data": { "shiftId": "…", "personId": "…", "status": "assigned", "placementId": "…" } }
shift.swap_requested
{ "type": "shift.swap_requested", "data": { "requestId": "…", "shiftId": "…", "from": "<personId>", "to": "<personId>", "exchangeShiftId": null } }
shift.request_decided
Fetch the result with GET /shift-requests?status=all or GET /shifts?week=….
{ "type": "shift.request_decided", "data": { "requestId": "…", "kind": "swap", "shiftId": "…", "decision": "approved" } }
person.created
Fires for every API create path. Payloads carry only the ID; fetch the person with GET /people/{personId}.
{ "id": "evt_681bbc4c65b5af62809ae32a", "type": "person.created", "created": "2026-10-23T19:40:03.093Z",
"data": { "personId": "3afdcfed-9246-4f87-a0c4-9f0bd55d0408" } }
person.updated
Fires on every update (POST /people with id, PATCH /people/{id}, a by-external upsert of an existing person, batch rows):
{ "id": "evt_388f31e873fbd015ec0cb9cc", "type": "person.updated", "created": "2026-10-23T19:40:03.206Z",
"data": { "personId": "3afdcfed-9246-4f87-a0c4-9f0bd55d0408" } }
and on deactivation (DELETE /people/{id}):
{ "id": "evt_e4f48fbf78c9be44ab7d6bac", "type": "person.updated", "created": "2026-10-23T19:40:03.809Z",
"data": { "active": false, "personId": "3afdcfed-9246-4f87-a0c4-9f0bd55d0408" } }
placement.created
{ "id": "evt_febc02162832dc4d0637a466", "type": "placement.created", "created": "2026-10-23T19:40:03.384Z",
"data": { "placementId": "d9f082a9-b0c9-41ba-8109-d7976ad95c5f" } }
placement.updated
Updates send {placementId}. Deactivation (DELETE /placements/{id}) adds active: false:
{ "id": "evt_c037fec7428357de877f07d7", "type": "placement.updated", "created": "2026-10-23T19:40:03.765Z",
"data": { "active": false, "placementId": "d9f082a9-b0c9-41ba-8109-d7976ad95c5f" } }
client.created
{ "id": "evt_ed2de7ca4b59400a48963ace", "type": "client.created", "created": "2026-10-23T19:40:02.801Z",
"data": { "clientId": "39e82286-92b9-4005-9af9-e4ca52750b6f" } }
client.updated
Fires on POST /clients with id, PATCH /clients/{id}, and by-external upserts of an existing client:
{ "id": "evt_1b3fec4a18aee1ec02bcdb6e", "type": "client.updated", "created": "2026-10-23T19:40:02.846Z",
"data": { "clientId": "39e82286-92b9-4005-9af9-e4ca52750b6f" } }
conflict.resolved
Fires from POST /conflicts/{id}/resolve:
{ "id": "evt_…", "type": "conflict.resolved", "created": "2026-12-02T08:30:00.000Z",
"data": { "conflictId": "c51f9c0d-da32-4edf-86a8-0ba2a86d93a7", "claimId": "7d78dbdb-d70c-4960-8796-4cedfa3454e6" } }
Next: GET /days?from=<date>&to=<date>&placement_id=… returns the day with its new value.
webhook.test
{ "id": "evt_78eae536b3c2ecb793b5ca22", "type": "webhook.test", "created": "2026-10-23T19:22:20.468Z",
"data": { "message": "Test event from TimeTracking" } }
To receive test events, subscribe to webhook.test or simply call POST /webhooks/{id}/test, which queues one for that webhook.
Machine-readable
The OpenAPI spec (GET /api/v1/openapi.json) lists all 21 events under webhooks, with the shared WebhookEvent schema.