Webhooks overview
How TimeTracking webhooks work: creating a subscription, the delivery request, the event envelope, latency, testing, inspecting deliveries, replay and secret rotation.
Last updated: · Markdown version
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
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"]}'
{ "data": { "id": "2c799e37-907b-4d22-a297-9f4a31e7c0f4", "secret": "whsec_…" } }
- The
secretis shown once. Store it. You need it to verify signatures. urlmust behttps://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.eventsneeds at least one event from the event catalogue.
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
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 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.
# 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"
{ "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).
# 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)
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, Make and n8n guides.
Next
- Event catalogue with payloads
- Verify signatures (Node, Python, PHP, C#, Go, Ruby)
- Retries and failure handling