Slack notifications
Post TimeTracking events (submitted, approved, conflicts, exports, leave) to Slack channels through a signature-verifying relay and Slack incoming webhooks.
Last updated: · Markdown version
1. Summary
Status: Recipe (no Slack app).
Direction: TimeTracking to Slack (one-way notifications).
Examples: "3 days submitted by Sanne Janssen for week 49", "Conflict: Sanne Janssen 2026-12-01, app 480 min vs mail 450 min", "Export completed: 2 rows, Generiek CSV", "Leave request from Jan de Vries".
2. Mechanism
TimeTracking webhooks cannot post to Slack directly: Slack expects its own message format, and you must verify the tt-signature. Put a small relay in between:
TimeTracking webhook → relay (verify, enrich via API, format Block Kit) → Slack incoming webhook URL
The relay can be a serverless function or an iPaaS scenario (Zapier, Make, n8n).
3. Prerequisites
- Slack: a Slack app with Incoming Webhooks enabled, one webhook URL per channel (
https://hooks.slack.com/services/T…/B…/…). - TimeTracking: an admin user (dashboard Regels & koppelingen) or an API key with scope
adminto create the webhook; a read key to enrich events (allGETendpoints, including/leaveand/expenses).
4. Entity mapping
TimeTracking event (data) |
Enrichment call | Slack message |
|---|---|---|
timesheet.submitted {personId, weekStart, dayIds} |
GET /people/{id} (name) |
"Hours submitted: Sanne Janssen, week of 2026-11-30 (2 days)" |
timesheet.approved {dayIds, approved} |
none | "2 days approved" |
timesheet.client_approved {linkId, signer, days} |
none | "Client sign-off by Ruud Hendriks (1 day)" |
conflict.created {timesheetDayId, placementId, workDate, candidates} |
GET /placements/{id} (person, client) |
"Conflict for Sanne Janssen at Bakkerij Ruijters on 2026-12-01: manual_office 8:00 vs mail 7:30" |
export.completed {runId, startsOn, endsOn, rows, mapping} |
none | "Payroll export 2026-11-30–2026-12-06: 2 rows (Generiek CSV)" |
period.locked {startsOn, endsOn, kind} |
none | "Period locked" |
leave.requested {leaveRequestId, personId} |
GET /leave + GET /people/{id} |
"Leave request: Jan de Vries, 21–23 Dec" |
leave.decided {leaveRequestId, decision} |
optional GET /leave |
"Leave approved" |
expense.submitted {expenseId, personId} |
GET /expenses |
"Expense: €12.40 public transport" |
shift.published {weekStart, count} |
none | "Roster week 2026-12-07 published (1 shift)" |
claim.created {placementId, workDate, source, minutes, dayId, dayStatus} |
GET /placements/{id} |
"Client hours received by mail: Sanne Janssen 2026-12-01 7:30" (filter on source: "mail" to avoid noise) |
expense.decided {expenseIds, decision} |
none | "2 expenses approved" |
leave.cancelled {leaveRequestId} |
optional GET /leave |
"Leave cancelled" |
person.created / person.updated {personId} or {personId, active:false} |
GET /people/{id} |
"New worker: Eva Docs" |
client.created / client.updated {clientId} |
GET /clients/{id} |
"New client: Docs Demo Metaal BV" |
conflict.resolved {conflictId, claimId} |
optional GET /claims |
"Conflict resolved" |
placement.created / placement.updated |
GET /placements/{id} |
"Placement ended: …" |
webhook.test {message} |
none | "Test event from TimeTracking" |
No IDs need to be stored; the mapping is stateless.
5. Step-by-step setup
- Create the Slack incoming webhook URL.
- As a TimeTracking admin, create a webhook (dashboard Regels & koppelingen → Webhooks) pointing to the relay, with the events you want. Save the secret.
- Deploy the relay:
// slack-relay.mjs — Vercel/Netlify style handler (Node 20). Needs the RAW body for verification.
import { createHmac, timingSafeEqual } from "node:crypto";
const TT = "https://app.timetracking.tech/api/v1";
function verify(raw, header, secret) {
const p = Object.fromEntries(String(header ?? "").split(",").map((x) => x.trim().split("=", 2)));
if (!p.t || !p.v1 || Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;
const a = Buffer.from(createHmac("sha256", secret).update(`${p.t}.${raw}`).digest("hex"), "hex");
const b = Buffer.from(p.v1, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
const tt = async (path) => (await (await fetch(TT + path, { headers: { authorization: `Bearer ${process.env.TT_API_KEY}` } })).json()).data;
const h = (min) => `${Math.floor(min / 60)}:${String(min % 60).padStart(2, "0")}`;
// Names and notes come from employees and clients: escape Slack mrkdwn control characters.
const esc = (s) => String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
async function text(evt) {
const d = evt.data;
switch (evt.type) {
case "timesheet.submitted": {
const p = await tt(`/people/${d.personId}`);
return `Hours submitted: *${esc(`${p?.first_name} ${p?.last_name}`)}*, week of ${d.weekStart} (${d.dayIds.length} days)`;
}
case "conflict.created": {
const pl = await tt(`/placements/${d.placementId}`);
const c = d.candidates.map((x) => `${x.source} ${h(x.minutes)}`).join(" vs ");
return `:warning: Conflict for *${esc(`${pl?.person?.first_name} ${pl?.person?.last_name}`)}* at ${esc(pl?.client?.name)} on ${d.workDate}: ${c}`;
}
case "export.completed": return `Payroll export ${d.startsOn} – ${d.endsOn}: ${d.rows} rows (${esc(d.mapping)})`;
case "timesheet.approved": return `${d.approved} day(s) approved`;
case "timesheet.client_approved": return `Client sign-off by ${esc(d.signer)} (${d.days} day(s))`;
case "period.locked": return `:lock: Period ${d.startsOn} – ${d.endsOn} locked (${d.kind})`;
case "shift.published": return `Roster week ${d.weekStart} published (${d.count} shifts)`;
case "expense.decided": return `${d.expenseIds.length} expense(s) ${d.decision}`;
case "leave.cancelled": return `Leave request cancelled`;
case "webhook.test": return esc(d.message);
default: return `${evt.type}`;
}
}
export default async function handler(req, res) {
const raw = await new Promise((r) => { let s = ""; req.on("data", (c) => (s += c)); req.on("end", () => r(s)); });
if (!verify(raw, req.headers["tt-signature"], process.env.TT_WEBHOOK_SECRET)) return res.status(401).end();
const evt = JSON.parse(raw);
await fetch(process.env.SLACK_WEBHOOK_URL, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ text: await text(evt) }) });
res.status(200).end();
}
export const config = { api: { bodyParser: false } };
6. Sync schedule and triggering events
Event-driven. Deliveries run every 5 minutes, so messages arrive up to ~5 minutes after the action. Subscribe only to what a channel needs (for example conflict.created + export.completed for #payroll).
7. Corrections, conflicts and locked periods
Notifications are informational. A conflict is resolved in TimeTracking (dashboard, CLI tt conflicts resolve, or POST /conflicts/{id}/resolve); conflict.resolved {conflictId, claimId} fires when it is resolved.
8. Testing checklist
- Record two different claims for the same placement/day (for example
manual_office480 andmail450 on a test day): a conflict message appears. - A request with a wrong signature gets
401. - Names with
<or&render safely. - Relay answers within 10 s.
9. Troubleshooting
| Problem | Fix |
|---|---|
| Nothing arrives | Webhook inactive or URL not public HTTPS; redirects (3xx) count as failures. Check retries in Retries. |
401 from relay |
Body was parsed before verification; verify the raw bytes. |
Slack invalid_payload |
Body must be JSON with text or blocks. |
403 creating the webhook with a key |
Use an admin-scope key. |
10. Limits and known gaps
- 21 event types; no "export failed" or "missing hours" events.
- Payloads contain IDs only; each enrichment costs one
GET /{resource}/{id}call. - No interactive approve buttons: Slack actions would need a Slack app calling
POST /days/approve; possible, but not covered here.
Use POST /webhooks/{id}/test (admin) to send a webhook.test event while building the relay, and POST /webhook-deliveries/{id}/replay to resend a failed delivery (same event id, so dedupe on it).
11. Build it with Claude
Build a Slack relay for TimeTracking webhooks as a Cloudflare Worker (TypeScript). Verify header
tt-signature "t=<unix>,v1=<hex>" where v1 = HMAC-SHA256(secret, `${t}.${rawBody}`), 5-minute tolerance, using
Web Crypto. Events: timesheet.submitted {personId, weekStart, dayIds}, timesheet.approved {dayIds, approved},
timesheet.client_approved {linkId, signer, days}, conflict.created {timesheetDayId, placementId, workDate,
candidates[{claimId, source, minutes}]}, export.completed {runId, startsOn, endsOn, rows, mapping},
period.locked, leave.requested, leave.decided, expense.submitted, shift.published, claim.created, expense.decided,
leave.cancelled, person.created/updated, placement.created/updated, client.created/updated, conflict.resolved, webhook.test. Enrich names via
GET https://app.timetracking.tech/api/v1/people/{id} and /placements/{id} (Bearer read key, { data }, snake_case).
Format Block Kit messages, route event types to different Slack webhook URLs, escape user text. Add tests.