# Slack notifications

> Post TimeTracking events (submitted, approved, conflicts, exports, leave) to Slack channels through a signature-verifying relay and Slack incoming webhooks.

URL: https://timetracking.tech/developers/integrations/chat/slack
Last updated: 2026-10-24

## 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](https://timetracking.tech/developers/integrations/ipaas/zapier), [Make](https://timetracking.tech/developers/integrations/ipaas/make), [n8n](https://timetracking.tech/developers/integrations/ipaas/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:

```js
// 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](https://timetracking.tech/developers/webhooks/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

```text
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.
```
