Skip to content

Request an integration

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

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 with GET /days?updated_since=… and GET /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.