Google Calendar
Two-way sync between your Kinderpedia activities calendar and your Google Calendar. Each staff member connects their own account — there's no school-level Google Calendar.
Connecting
Integrations → Google Calendar → Connect opens Google's OAuth consent. Scopes requested:
calendar.events— create / read / update events on your calendarsuserinfo.email— bind the connection to your Google identityuserinfo.profile— display name in the tile
After consent, Google hands back an access token + refresh token + your primary email. Stored encrypted on your personal integration record — one per user per school, so the calendar overlay shows your events only.
The tile flips green. From now on, the activities calendar (/activities-calendar) shows a toggle to overlay your Google events alongside your Kinderpedia activities.
Read overlay (Google → app)
When the toggle is on, the calendar fetches your Google events for the same date range and renders them in your chosen overlay colour (default #4285F4 Google blue).
You can customise the overlay colour in Integrations → Google Calendar → Configure — your choice is saved per user.
Push (app → Google)
Three ways to push a Kinderpedia activity to your Google Calendar:
Manual per-activity
On any activity detail panel, click Send to Google. We POST events.insert with the activity's title, description, time, and (if a meeting URL is set) conferenceData.entryPoints[]. Google returns the new event ID — we link it to the activity so subsequent edits/deletes stay in sync.
If you have multiple calendars, a KpSelect appears so you can pick which calendar to push to.
Auto-push
Set Auto-push new activities in the integration config. From then on, every new activity you create is auto-pushed in the background. Useful for staff who live in Google Calendar and only check Kinderpedia for the lead context.
This only auto-pushes activities you create. Other users' activities are not touched.
Bulk push
Bulk push walks the next 90 days of your unlinked, upcoming activities and pushes them in one batch (capped at 100 per call). Useful for first-time setup after a long manual-only period.
Watch / two-way sync (Google → app changes)
When you connect, we register a watch channel with Google via events.watch:
POST /calendars/{calId}/events/watch
{
"id": "<channelId>",
"type": "web_hook",
"address": "https://<your-domain>/api/integrations/google-calendar/webhook",
"token": "<hmac>",
"expiration": <ms_since_epoch>
}When events on your Google calendar change, Google sends a push to /api/integrations/google-calendar/webhook on your Kinderpedia domain. Our handler:
- Validates
X-Goog-Channel-TokenHMAC (constant-time compare). - Skips
X-Goog-Resource-State == sync(Google's initial ack — no events to fetch). - Calls
events.listwith the storedsync_tokento incrementally fetch changes since last poll. - Reconciles events carrying our
extendedProperties.private.kp_activity_idmarker into the correspondinglead_activitiesrows.
Critical caveat: native Google events (ones we didn't push) are NEVER pulled into Kinderpedia. Only events tagged with the kp_activity_id marker round-trip. This is deliberate — we don't want random work-meetings cluttering up the lead activities view.
If a Google event we pushed is deleted on Google, we clear the link locally (google_event_id = null) but keep the Kinderpedia activity intact. Use this as a soft delete: cancel on Google, the activity remains for record-keeping.
Watch lifecycle
Watch channels have a max TTL of 7 days. The app:google-calendar:renew-watches cron (every 6h) renews any channel expiring in <24h. On disconnect, we POST /channels/stop to release the channel cleanly.
If renewal fails (revoked refresh token, calendar deleted, …), the user_integration goes error with last_error = 'watch_renewal_failed'. The user sees a red tile on next visit; re-connecting fixes it.
Conflict warnings
Drag an activity onto a slot that's already occupied by a Google event → KpToast.warning("This conflicts with: <event title>"). Non-blocking — you can still save, but the warning surfaces the collision so you can re-pick.
Meeting links
If an activity has meeting_provider = google_meet, the push call sets conferenceData.createRequest. Google mints a fresh Meet link and we capture hangoutLink back onto the activity. See Online meetings on activities.
Multi-calendar support
Google users often have multiple calendars (personal, work, school-specific). The integration config lets you pick a default calendar for auto-push, and per-activity the picker overrides this.
We don't impose a "primary calendar" — every push you do can target a different one if you want.
Token refresh
Google access tokens are short (1h). GoogleCalendarTokenManager::getAccessToken() runs on every API call with a 60s safety window — under, we refresh. On refresh failure, the integration goes error and the next Read call returns empty (no infinite retry loops).
What data Google sees
Google sees the event's title, description, location, time, recurrence, attendees (if you add them), and the conferenceData block. They do NOT see your linked-lead PII unless you embed it in the description. We don't auto-embed PII — only the activity's own title/description.
Common errors
| Symptom | Likely cause |
|---|---|
| Connect fails with "redirect_uri_mismatch" | Google OAuth redirect URI not registered on the Google Cloud project — contact your Kinderpedia admin. |
| Webhook events never arrive | Your Kinderpedia webhook URL isn't publicly reachable, or watch creation was skipped on connect. |
| Push returns "invalid_grant" | Refresh token revoked (user changed Google password, or 6-month inactivity). Re-connect. |
| Multi-calendar dropdown empty | Google API quota hit, or calendars.list returned only the primary. Wait + re-try. |
| Activities show on app but not on Google | Auto-push not enabled, or your account is in error state. Check the tile. |