# Google Calendar integration

> Put published TimeTracking shifts into Google Calendar with the shift.published webhook, GET /shifts and the Google Calendar API.

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

## 1. Summary

**Status:** Recipe. A native calendar sync (subscribe-able calendar per employee) is **Planned**.

**Direction:** TimeTracking to Google Calendar (one-way). Calendar events never create hours in TimeTracking.

What syncs: published shifts (roster) become events in a shared "Roster" calendar, or in each employee's own calendar.

## 2. Mechanism

1. TimeTracking fires `shift.published` with `{ "weekStart": "2026-12-07", "count": 1 }` when the office publishes a roster week.
2. Your receiver (or iPaaS) verifies the signature and calls `GET /shifts?week=<weekStart>` (a read-only key is enough).
3. For each shift, create or update a Google Calendar event (`events.insert` / `events.patch`) keyed by the shift id in `extendedProperties.private.ttShiftId`.
4. Events whose shift no longer appears in `GET /shifts` (cancelled shifts are excluded) are deleted.

## 3. Prerequisites

- An admin user (dashboard *Regels & koppelingen*) or an API key with scope `admin` to create the webhook (event `shift.published`).
- A TimeTracking API key with scope `read` (for `GET /shifts` and `GET /people/{id}`).
- Google Cloud project with the Calendar API enabled; a service account with domain-wide delegation (Google Workspace) to write to employee calendars, or edit access to one shared calendar.
- The org timezone (shift times are minutes after midnight in the org's local time, default `Europe/Amsterdam`).

## 4. Entity mapping

| TimeTracking (`GET /shifts` row) | Google Calendar event | Notes |
|---|---|---|
| `id` | `extendedProperties.private.ttShiftId` and a deterministic event `id` (`tt` + uuid without dashes, lowercase) | Google event ids allow `a-v` and `0-9`; a hex uuid fits. |
| `work_date` + `start_min` | `start.dateTime` | `start_min` 420 = 07:00. Use `start.timeZone` = org timezone. |
| `work_date` + `end_min` | `end.dateTime` | If `end_min <= start_min` the shift ends the next day. |
| `break_min` | `description` | "Break 30 min". |
| `role_title`, `client.name` | `summary` | e.g. "Productiemedewerker · Bakkerij Ruijters". |
| `note` | `description` | Treat as untrusted text. |
| `placement.person_id` | calendar owner / attendee | Look up the email via `GET /people/{id}`. Open shifts (`placement_id: null`) go to the shared calendar only. |
| `status` | event `status` | Only `draft` and `published` are returned; sync `published` only. |

**External-ID strategy.** The TimeTracking shift `id` is stable; derive the Google event id from it so re-runs are idempotent (`events.insert` returns `409` if it exists, then `events.patch`).

## 5. Step-by-step setup

1. Create the webhook in TimeTracking for `shift.published`; save the `whsec_…` secret.
2. Deploy the receiver:

```ts
// shifts-to-gcal.ts (Node 20). npm i googleapis
import { createHmac, timingSafeEqual } from "node:crypto";
import { google } from "googleapis";
const TT = "https://app.timetracking.tech/api/v1";
const TZ = "Europe/Amsterdam";
const H = { authorization: `Bearer ${process.env.TT_API_KEY}` };

export 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 exp = Buffer.from(createHmac("sha256", secret).update(`${p.t}.${raw}`).digest("hex"), "hex");
  const got = Buffer.from(p.v1, "hex");
  return exp.length === got.length && timingSafeEqual(exp, got);
}

const hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
const addDay = (d: string) => new Date(Date.parse(`${d}T00:00:00Z`) + 86400000).toISOString().slice(0, 10);

export async function onShiftPublished(weekStart: string) {
  const roster = await (await fetch(`${TT}/shifts?week=${weekStart}`, { headers: H })).json();
  const auth = new google.auth.GoogleAuth({ scopes: ["https://www.googleapis.com/auth/calendar"] });
  const cal = google.calendar({ version: "v3", auth });
  const calendarId = process.env.GCAL_ID!; // shared roster calendar
  for (const s of roster.data.shifts.filter((x: any) => x.status === "published")) {
    const endDate = s.end_min <= s.start_min ? addDay(s.work_date) : s.work_date;
    const person = s.placement?.person ? `${s.placement.person.first_name} ${s.placement.person.last_name}` : "Open shift";
    const body = {
      id: `tt${s.id.replace(/-/g, "")}`,
      summary: `${person} · ${s.role_title ?? ""} · ${s.client?.name ?? ""}`.trim(),
      description: `Break ${s.break_min} min${s.note ? `\n${s.note}` : ""}`,
      start: { dateTime: `${s.work_date}T${hhmm(s.start_min)}:00`, timeZone: TZ },
      end: { dateTime: `${endDate}T${hhmm(s.end_min)}:00`, timeZone: TZ },
      extendedProperties: { private: { ttShiftId: s.id } },
    };
    try { await cal.events.insert({ calendarId, requestBody: body }); }
    catch (e: any) { if (e.code === 409) await cal.events.patch({ calendarId, eventId: body.id, requestBody: body }); else throw e; }
  }
}
```

3. Wire `verify` + `onShiftPublished(JSON.parse(raw).data.weekStart)` into your HTTP handler and return `200` fast (TimeTracking times out after 10 s; do the Google calls asynchronously if the roster is large).
4. Add a cleanup step: list events in the week with `privateExtendedProperty=ttShiftId` and delete those whose id is not in the current `GET /shifts` result.

## 6. Sync schedule and triggering events

| Trigger | Action |
|---|---|
| `shift.published` webhook | Upsert events for that week; delete removed ones. |
| Hourly or nightly poll of the current + next 2 weeks | Catches cancellations and edits (there is no `shift.cancelled` or `shift.updated` event). |

## 7. Corrections, conflicts and locked periods

- A published shift that is deleted in TimeTracking becomes `cancelled` and disappears from `GET /shifts`; no webhook fires. Only the poll removes the event.
- Editing a published shift does not fire `shift.published` unless the week is published again.
- Shifts are plans, not hours. Worked hours, conflicts and locked periods do not affect the calendar.

## 8. Testing checklist

- [ ] Publishing a week creates one event per published shift with the correct local times.
- [ ] A night shift (22:00–06:00) ends on the next day.
- [ ] Re-publishing does not duplicate events.
- [ ] A cancelled shift is removed after the next poll.
- [ ] An invalid signature returns `401` and creates nothing.

## 9. Troubleshooting

| Problem | Fix |
|---|---|
| `401` on `GET /shifts` | Key wrong or revoked. |
| `403` "Admin role required" creating the webhook | Use an `admin`-scope key or the dashboard. |
| Events off by one or two hours | Set `timeZone` to the org timezone; do not append `Z`. |
| No webhook received | Delivery runs every 5 minutes; URL must be public HTTPS and answer 2xx within 10 s; redirects count as failure. |
| Google `409` duplicate | Expected on re-run; patch instead. |

## 10. Limits and known gaps

- `shift.published` carries only `weekStart` and `count`; there are no events for shift edits or cancellations.
- `GET /shifts` returns one ISO week per call.
- Shift rows include person names but not emails; map `placement.person_id` with `GET /people/{id}`.

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).

A native per-employee calendar feed is **Planned**.

## 11. Build it with Claude

```text
Build a TypeScript service (Cloud Run or Vercel) that syncs TimeTracking roster shifts to Google Calendar.
Trigger: TimeTracking webhook shift.published {data:{weekStart,count}}, verify header tt-signature
"t=<unix>,v1=<hex HMAC-SHA256(secret, `${t}.${rawBody}`)>" (5 min tolerance). Then GET
https://app.timetracking.tech/api/v1/shifts?week=<weekStart> (Bearer read key) →
data.shifts[] {id, work_date, start_min, end_min, break_min, role_title, note, status, client{name},
placement{person_id, person{first_name,last_name}}}. Times are minutes after midnight in Europe/Amsterdam;
end_min <= start_min means next day. Upsert Google events with id "tt"+uuid-without-dashes, delete events
whose shift is gone, and add an hourly poll for this and the next two weeks. Include tests.
```
