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.

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

  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:
// 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; }
  }
}
  1. 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).
  2. 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.

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.