# Webhook events

> Every TimeTracking webhook event with when it fires, its data fields, a real captured payload, and which API call to make next.

URL: https://timetracking.tech/developers/webhooks/events
Last updated: 2026-10-24

All events share the envelope `{ id, type, created, data }` (see the [overview](https://timetracking.tech/developers/webhooks/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`](#timesheetsubmitted) | A person's draft days of a week are submitted | `personId`, `weekStart`, `dayIds[]` |
| [`timesheet.approved`](#timesheetapproved) | The office approves days and at least one became `approved` | `dayIds[]`, `approved` |
| [`timesheet.client_approved`](#timesheetclient_approved) | A client submits decisions on an approval link | `linkId`, `signer`, `days` |
| [`conflict.created`](#conflictcreated) | Claims for a day start to disagree | `timesheetDayId`, `placementId`, `workDate`, `candidates[]` |
| [`claim.created`](#claimcreated) | Any claim is recorded (API, batch, kiosk, mail, app) | `placementId`, `workDate`, `source`, `minutes`, `dayId`, `dayStatus` |
| [`export.completed`](#exportcompleted) | An export is committed | `runId`, `startsOn`, `endsOn`, `rows`, `mapping` |
| [`period.locked`](#periodlocked) | An admin locks a period | `startsOn`, `endsOn`, `kind` |
| [`leave.requested`](#leaverequested) | An employee requests leave that needs approval | `leaveRequestId`, `personId` |
| [`leave.decided`](#leavedecided) | Leave is approved or rejected | `leaveRequestId`, `decision` |
| [`leave.cancelled`](#leavecancelled) | Leave is cancelled | `leaveRequestId` |
| [`expense.submitted`](#expensesubmitted) | An expense is created with status `submitted` | `expenseId`, `personId` |
| [`expense.decided`](#expensedecided) | Expenses are approved or rejected | `expenseIds[]`, `decision` |
| [`shift.published`](#shiftpublished) | Draft shifts of a week are published | `weekStart`, `count` |
| [`shift.claimed`](#shiftclaimed) | A worker claims an open shift (assigned, or a request for the planner) | `shiftId`, `personId`, `status` (`assigned`/`pending`), `placementId` or `requestId` |
| [`shift.swap_requested`](#shiftswap_requested) | A worker asks a colleague to take over a shift | `requestId`, `shiftId`, `from`, `to`, `exchangeShiftId` |
| [`shift.request_decided`](#shiftrequest_decided) | The planner approves or rejects a claim or swap | `requestId`, `kind`, `shiftId`, `decision` |
| [`person.created`](#personcreated) | A person is created (`POST /people`, `PUT …/by-external/…`, batch) | `personId` |
| [`person.updated`](#personupdated) | A person is updated (`POST` with `id`, `PATCH`, by-external, batch) or deactivated (`DELETE`) | `personId` (+ `active:false` on deactivate) |
| [`placement.created`](#placementcreated) | A placement is created (any API create path) | `placementId` |
| [`placement.updated`](#placementupdated) | A placement is updated or deactivated | `placementId` (+ `active:false` on deactivate) |
| [`client.created`](#clientcreated) | A client is created (any API create path) | `clientId` |
| [`client.updated`](#clientupdated) | A client is updated | `clientId` |
| [`conflict.resolved`](#conflictresolved) | The office picks the winning claim of a conflict | `conflictId`, `claimId` |
| [`webhook.test`](#webhooktest) | `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.

```json
{ "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.

```json
{ "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.

```json
{ "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.

```json
{ "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.

```json
{ "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.

```json
{ "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

```json
{ "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.

```json
{ "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

```json
{ "id": "evt_077c9bea68be4553d3d9c769", "type": "leave.decided", "created": "2026-10-23T19:09:36.588Z",
  "data": { "decision": "approved", "leaveRequestId": "c7aecb37-052b-48ef-a0df-7d8fbf52d63b" } }
```

### leave.cancelled

```json
{ "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.

```json
{ "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

```json
{ "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).

```json
{ "id": "evt_eb4940a8ca64fbbe7ffeaa8a", "type": "shift.published", "created": "2026-10-23T19:09:36.873Z",
  "data": { "count": 1, "weekStart": "2026-12-07" } }
```

### shift.claimed

```json
{ "type": "shift.claimed", "data": { "shiftId": "…", "personId": "…", "status": "assigned", "placementId": "…" } }
```

### shift.swap_requested

```json
{ "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=…`.

```json
{ "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}`.

```json
{ "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):

```json
{ "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}`):

```json
{ "id": "evt_e4f48fbf78c9be44ab7d6bac", "type": "person.updated", "created": "2026-10-23T19:40:03.809Z",
  "data": { "active": false, "personId": "3afdcfed-9246-4f87-a0c4-9f0bd55d0408" } }
```

### placement.created

```json
{ "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`:

```json
{ "id": "evt_c037fec7428357de877f07d7", "type": "placement.updated", "created": "2026-10-23T19:40:03.765Z",
  "data": { "active": false, "placementId": "d9f082a9-b0c9-41ba-8109-d7976ad95c5f" } }
```

### client.created

```json
{ "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:

```json
{ "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`:

```json
{ "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

```json
{ "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.
