# Microsoft 365 (Outlook) calendar integration

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

URL: https://timetracking.tech/developers/integrations/calendar/microsoft-365
Last updated: 2026-10-24

## 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:

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

3. 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](https://timetracking.tech/developers/integrations/calendar/google-calendar#10-limits-and-known-gaps): 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](https://timetracking.tech/developers/webhooks/retries).

## 11. Build it with Claude

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