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.

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 admin to create the webhook; a read key to enrich events (all GET endpoints, including /leave and /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

  1. Create the Slack incoming webhook URL.
  2. As a TimeTracking admin, create a webhook (dashboard Regels & koppelingen → Webhooks) pointing to the relay, with the events you want. Save the secret.
  3. 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

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_office 480 and mail 450 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.