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.

Microsoft 365 (Outlook) calendar integration

Put published TimeTracking shifts into Outlook calendars with the shift.published webhook, GET /shifts and Microsoft Graph.

Last updated: · Markdown version

1. Summary

Status: Recipe. A native calendar sync is Planned.

Direction: TimeTracking to Outlook (one-way).

Published roster shifts become Outlook events, either in a shared mailbox calendar ("Rooster") or in each employee's calendar.

2. Mechanism

  1. shift.published webhook ({weekStart, count}) reaches an Azure Function, Logic App or Power Automate flow.
  2. Verify tt-signature, then GET /shifts?week=<weekStart> (a read key is enough).
  3. Upsert events through Microsoft Graph: POST /users/{id}/calendars/{calendarId}/events or PATCH /users/{id}/events/{eventId}. Store the shift id in transactionId (idempotent create) and in a single-value extended property.
  4. Poll to remove events for cancelled shifts (they disappear from GET /shifts; there is no cancel event).

Power Automate alone cannot compute HMAC signatures without custom code; use an Azure Function for verification, or accept the risk only on an unguessable URL (not recommended).

3. Prerequisites

  • Entra ID app registration with application permission Calendars.ReadWrite (admin consent), optionally restricted with an application access policy to the roster mailbox.
  • An admin user (dashboard Regels & koppelingen) or an API key with scope admin to create the shift.published webhook; an API key with scope read for GET /shifts.
  • Org timezone (default Europe/Amsterdam; Graph name W. Europe Standard Time).

4. Entity mapping

TimeTracking (GET /shifts row) Graph event Notes
id transactionId + extended property ttShiftId transactionId makes repeated creates idempotent.
work_date + start_min start {dateTime, timeZone} timeZone: "W. Europe Standard Time".
work_date + end_min end Next day when end_min <= start_min.
role_title, client.name, person name subject
break_min, note body (text) Note is untrusted user text.
placement.person_id target user mailbox Resolve email via GET /people/{id}; must match the user's UPN or a mapping table.

5. Step-by-step setup

  1. Create the webhook in TimeTracking (admin, event shift.published).
  2. Azure Function (Node 20) skeleton:
import { app, HttpRequest } from "@azure/functions";
import { createHmac, timingSafeEqual } from "node:crypto";
const TT = "https://app.timetracking.tech/api/v1";

function verify(raw: string, header: string | null, secret: string) {
  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 hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}:00`;
const nextDay = (d: string) => new Date(Date.parse(`${d}T00:00:00Z`) + 864e5).toISOString().slice(0, 10);

app.http("ttShifts", { methods: ["POST"], authLevel: "anonymous", handler: async (req: HttpRequest) => {
  const raw = await req.text();
  if (!verify(raw, req.headers.get("tt-signature"), process.env.TT_WEBHOOK_SECRET!)) return { status: 401 };
  const evt = JSON.parse(raw);
  if (evt.type !== "shift.published") return { status: 204 };
  const roster = await (await fetch(`${TT}/shifts?week=${evt.data.weekStart}`, { headers: { authorization: `Bearer ${process.env.TT_API_KEY}` } })).json();
  const token = await getGraphToken(); // client-credentials flow (MSAL)
  for (const s of roster.data.shifts.filter((x: any) => x.status === "published")) {
    const event = {
      subject: `${s.placement?.person ? `${s.placement.person.first_name} ${s.placement.person.last_name}` : "Open shift"} · ${s.client?.name ?? ""}`,
      body: { contentType: "text", content: `Break ${s.break_min} min${s.note ? `\n${s.note}` : ""}` },
      start: { dateTime: `${s.work_date}T${hhmm(s.start_min)}`, timeZone: "W. Europe Standard Time" },
      end: { dateTime: `${s.end_min <= s.start_min ? nextDay(s.work_date) : s.work_date}T${hhmm(s.end_min)}`, timeZone: "W. Europe Standard Time" },
      transactionId: s.id,
    };
    await fetch(`https://graph.microsoft.com/v1.0/users/${process.env.ROSTER_MAILBOX}/events`, {
      method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(event),
    });
  }
  return { status: 200 };
} });
declare function getGraphToken(): Promise<string>;

Note: transactionId prevents duplicates on create but does not update an existing event. For edits, find the event by the extended property and PATCH it.

  1. Add a timer-triggered function (hourly) that re-reads the current and next two weeks and deletes events whose shift id is gone.

6. Sync schedule and triggering events

Trigger Action
shift.published Create events for the week.
Hourly timer Update edits, delete cancelled shifts.

7. Corrections, conflicts and locked periods

Cancelled or edited shifts fire no webhook; the timer handles them. Worked hours, conflicts and locked periods do not touch calendars.

8. Testing checklist

  • Publishing a week creates events with correct local times (check DST weeks, for example the week of 2026-10-25).
  • Re-delivery of the same webhook creates no duplicate.
  • Night shifts end the next day.
  • Cancelled shift removed within one timer run.

9. Troubleshooting

Problem Fix
Graph 403 ErrorAccessDenied Missing Calendars.ReadWrite consent or blocked by application access policy.
TimeTracking 401 on /shifts Key wrong or revoked.
Times shifted by 1–2 h Use Windows timezone name in timeZone; no Z suffix.
No webhook 5-minute delivery cycle; public HTTPS; 2xx within 10 s.

10. Limits and known gaps

Same as Google Calendar: only shift.published exists, it carries no shift list, and GET /shifts returns one week per call.

Shift edits and cancellations still fire no webhook (no shift.updated/shift.cancelled events exist, also not in API v1.1); keep the poll. As an admin you can inspect deliveries (GET /webhooks/{id}/deliveries), send a test event (POST /webhooks/{id}/test) and replay a failed delivery (POST /webhook-deliveries/{id}/replay). See Retries.

11. Build it with Claude

Build an Azure Functions (Node 20, TypeScript) app: HTTP trigger for the TimeTracking webhook
shift.published (verify tt-signature t=<unix>,v1=hex HMAC-SHA256(secret,"<t>.<raw body>")), then
GET https://app.timetracking.tech/api/v1/shifts?week=<weekStart> with a Bearer write key, and upsert Outlook
events via Microsoft Graph (client credentials, Calendars.ReadWrite) in mailbox $ROSTER_MAILBOX using
transactionId = shift id and an extended property ttShiftId for updates. Add an hourly timer that deletes
events whose shift disappeared. Times are minutes after midnight in W. Europe Standard Time; end <= start means
next day. Include Bicep or az CLI deployment steps and unit tests.