Google Calendar integration
Put published TimeTracking shifts into Google Calendar with the shift.published webhook, GET /shifts and the Google Calendar API.
Last updated: · Markdown version
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
- TimeTracking fires
shift.publishedwith{ "weekStart": "2026-12-07", "count": 1 }when the office publishes a roster week. - Your receiver (or iPaaS) verifies the signature and calls
GET /shifts?week=<weekStart>(a read-only key is enough). - For each shift, create or update a Google Calendar event (
events.insert/events.patch) keyed by the shift id inextendedProperties.private.ttShiftId. - 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
adminto create the webhook (eventshift.published). - A TimeTracking API key with scope
read(forGET /shiftsandGET /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
- Create the webhook in TimeTracking for
shift.published; save thewhsec_…secret. - Deploy the receiver:
// 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; }
}
}
- Wire
verify+onShiftPublished(JSON.parse(raw).data.weekStart)into your HTTP handler and return200fast (TimeTracking times out after 10 s; do the Google calls asynchronously if the roster is large). - Add a cleanup step: list events in the week with
privateExtendedProperty=ttShiftIdand delete those whose id is not in the currentGET /shiftsresult.
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
cancelledand disappears fromGET /shifts; no webhook fires. Only the poll removes the event. - Editing a published shift does not fire
shift.publishedunless 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
401and 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.publishedcarries onlyweekStartandcount; there are no events for shift edits or cancellations.GET /shiftsreturns one ISO week per call.- Shift rows include person names but not emails; map
placement.person_idwithGET /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.
A native per-employee calendar feed is Planned.
11. Build it with Claude
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.