# Webhooks overview

> How TimeTracking webhooks work: creating a subscription, the delivery request, the event envelope, latency, testing, inspecting deliveries, replay and secret rotation.

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

A webhook is an HTTPS `POST` that TimeTracking sends to your endpoint when something happens: a timesheet is submitted, a conflict appears or is resolved, a person or placement changes, an export is committed, and so on (21 event types). Use webhooks instead of polling.

## Who can manage webhooks

Webhooks are managed by an **admin**: in the dashboard ("Regels & koppelingen" → "Webhooks"), with an API key that has the `admin` scope, or with an admin user access token. Read and write keys get `403 {"code":"forbidden","message":"Admin role required"}` on every `/webhooks` endpoint. The examples below use `$ADMIN_USER_TOKEN`; an admin key works the same way.

## Create a subscription

```sh
curl -s -X POST "$TT/webhooks" -H "Authorization: Bearer $ADMIN_USER_TOKEN" -H "content-type: application/json" \
  -d '{"url":"https://hooks.example.com/timetracking","events":["timesheet.approved","export.completed","conflict.created"]}'
```

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

- The `secret` is shown **once**. Store it. You need it to [verify signatures](https://timetracking.tech/developers/webhooks/verify-signatures).
- `url` must be `https://` and must resolve to a **public** address. Loopback, private (10/8, 172.16/12, 192.168/16), link-local, CGNAT and multicast addresses are rejected, and credentials in the URL are not allowed. The check runs again before **every** delivery, because DNS can change.
- `events` needs at least one event from the [event catalogue](https://timetracking.tech/developers/webhooks/events).

List: `GET /webhooks` → `[{id, url, events, active, created_at}]`. Delete: `DELETE /webhooks/{id}` → `{deleted: true}`. A webhook can't be edited; to change its URL or events, create a new one and delete the old one.

## The delivery request

```http
POST /timetracking HTTP/1.1
Host: hooks.example.com
content-type: application/json
tt-signature: t=1792790000,v1=7e81359509a29cb02c86ac686c67bf4434826027d10933a89cee5dba3e60d62f
user-agent: TimeTracking-Webhooks/1

{"id":"evt_9087e71890de14cd0197877a","data":{"rows":2,"runId":"239a1898-fab5-486c-a2be-92d939be90cc","endsOn":"2026-12-06","mapping":"Generiek CSV (per dag)","startsOn":"2026-11-30"},"type":"export.completed","created":"2026-10-23T19:08:42.950Z"}
```

### Envelope

| Field | Type | Notes |
|---|---|---|
| `id` | string `evt_` + 24 hex | Unique per event. A replay keeps the same `id`, so **dedupe on it** |
| `type` | string | Event name, e.g. `export.completed` |
| `created` | ISO 8601 UTC | When the event happened |
| `data` | object | Event-specific. **Mostly IDs.** Fetch details with the API |

Key order in the JSON is not guaranteed. Parse it as JSON, and verify the signature over the **raw** bytes.

### Your response

- Return any **2xx** within **10 seconds** to acknowledge. Acknowledge first, process asynchronously.
- Any 3xx, 4xx or 5xx, a timeout or a connection error counts as failed and is retried (7 attempts in total over about 21 hours). **Redirects are not followed.**
- See [retries](https://timetracking.tech/developers/webhooks/retries) for the schedule.

## Latency and ordering

- Events are queued immediately and delivered by a scheduler that runs **every 5 minutes**. Expect up to about 5 minutes of delay.
- Deliveries are sent in parallel batches. **Ordering is not guaranteed.** Use `created`, and re-read state from the API when order matters.
- Each delivery is claimed atomically, so an event is not sent twice by concurrent workers. A manual replay does send it again.

## Test, inspect, replay, rotate (v1.1)

All of these need an admin key or an admin user token.

```sh
# Queue a webhook.test event for one webhook
curl -s -X POST "$TT/webhooks/$HOOK_ID/test" -H "Authorization: Bearer $ADMIN_USER_TOKEN"
# → {"data":{"queued":true}}

# Recent deliveries (newest first, limit ≤ 200, default 50)
curl -s "$TT/webhooks/$HOOK_ID/deliveries?limit=20" -H "Authorization: Bearer $ADMIN_USER_TOKEN"
```

```json
{ "data": [
  { "id": 21, "event": "webhook.test", "status": "pending", "attempts": 0, "last_status_code": null, "last_error": null,
    "next_attempt_at": "2026-10-23T19:22:20.469944+00:00", "created_at": "2026-10-23T19:22:20.469944+00:00",
    "payload": { "id": "evt_78eae536b3c2ecb793b5ca22", "data": { "message": "Test event from TimeTracking" }, "type": "webhook.test", "created": "2026-10-23T19:22:20.468Z" } }
] }
```

`status` is `pending` (queued or waiting for a retry), `delivered` or `failed` (gave up, or the webhook is inactive).

```sh
# Re-queue one delivery (e.g. after fixing your endpoint); attempts reset to 0
curl -s -X POST "$TT/webhook-deliveries/21/replay" -H "Authorization: Bearer $ADMIN_USER_TOKEN"
# → {"data":{"requeued":true}}

# Issue a new signing secret (returned once)
curl -s -X POST "$TT/webhooks/$HOOK_ID/rotate-secret" -H "Authorization: Bearer $ADMIN_USER_TOKEN"
# → {"data":{"secret":"whsec_…"}}
```

**Secret rotation has no overlap.** Deliveries sent after the rotation are signed with the new secret only. For a seamless rotation, have your verifier accept both the old and the new secret for a short window. Rotate, deploy the new secret, then remove the old one.

## Minimal receiver (Node + Express)

```js
import express from "express";
import { verifyTimeTrackingSignature } from "./verify.js"; // see verify-signatures.md

const app = express();
const seen = new Set(); // use a database table in production

app.post("/timetracking", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifyTimeTrackingSignature(raw, req.get("tt-signature"), process.env.TT_WEBHOOK_SECRET)) return res.sendStatus(401);
  const event = JSON.parse(raw);
  res.sendStatus(200);                     // acknowledge fast
  if (seen.has(event.id)) return;          // dedupe (replays keep the id)
  seen.add(event.id);
  queue.push(event);                       // process asynchronously, re-read details via the API
});
app.listen(3000);
```

## No-code receivers

Zapier, Make and n8n can receive webhooks with their generic webhook trigger. Verify the signature in a code step before you trust the payload. See the [Zapier](https://timetracking.tech/developers/integrations/ipaas/zapier), [Make](https://timetracking.tech/developers/integrations/ipaas/make) and [n8n](https://timetracking.tech/developers/integrations/ipaas/n8n) guides.

## Next

- [Event catalogue with payloads](https://timetracking.tech/developers/webhooks/events)
- [Verify signatures](https://timetracking.tech/developers/webhooks/verify-signatures) (Node, Python, PHP, C#, Go, Ruby)
- [Retries and failure handling](https://timetracking.tech/developers/webhooks/retries)
