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
shift.publishedwebhook ({weekStart, count}) reaches an Azure Function, Logic App or Power Automate flow.- Verify
tt-signature, thenGET /shifts?week=<weekStart>(a read key is enough). - Upsert events through Microsoft Graph:
POST /users/{id}/calendars/{calendarId}/eventsorPATCH /users/{id}/events/{eventId}. Store the shift id intransactionId(idempotent create) and in a single-value extended property. - 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
adminto create theshift.publishedwebhook; an API key with scopereadforGET /shifts. - Org timezone (default
Europe/Amsterdam; Graph nameW. 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
- Create the webhook in TimeTracking (admin, event
shift.published). - 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.
- 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.