Google Calendar Webhooks

This service requires a Google Workspace Service Account with delegated access.

Google Calendar webhooks keep imported Google Workspace calendars moving toward near-real-time sync with Tempr's internal calendar models.

This page is for internal Tempr team members who can open a terminal in the Tempr app workspace and manage environment configuration.

What The Integration Does

Google Calendar push notifications are thin webhooks. Google does not send the changed event payload. It only tells Tempr that a watched calendar resource changed.

Google sends notifications to:

POST /integrations/google/calendar/webhooks/callback

When a notification is accepted, Tempr:

  1. Reads the X-Goog-* channel headers.
  2. Finds the matching CalendarSource by Google channel and resource IDs.
  3. Validates the channel token that Tempr generated during subscription setup.
  4. Records the latest Google message number and receive timestamp.
  5. Ignores Google's initial sync notification.
  6. Queues ImportGoogleCalendarPage with useSyncToken=true.
  7. Pulls Google event deltas through GoogleCalendarImportService.
  8. Updates CalendarEvent.php records through the existing import path.

Google Calendar webhooks tell Tempr when to pull. They are not the source of event data. The source event data is still fetched through the Google Calendar API using the stored sync token.

Important Models And Classes

AreaClass Or Table
Internal event modelsrc/Modules/Calendar/Models/CalendarEvent.php
Source calendar statesrc/Modules/Calendar/Models/CalendarSource.php
Webhook receiverGoogleCalendarWebhookController
Subscription lifecycleGoogleCalendarWebhookSubscriptionManager
Google API gatewayGoogleCalendarGateway
Delta import jobImportGoogleCalendarPage
Delta import serviceGoogleCalendarImportService

Webhook channel state is stored on cal_calendar_sources.

Important webhook fields:

  • webhook_channel_id
  • webhook_resource_id
  • webhook_resource_uri
  • webhook_token_hash
  • webhook_address
  • webhook_expires_at
  • webhook_last_message_number
  • webhook_last_received_at

Required Environment Variables

Google Calendar imports already require service account credentials and Workspace impersonation.

.env
CALENDAR_GOOGLE_CREDENTIALS_JSON=/absolute/path/to/google-service-token.json
CALENDAR_GOOGLE_DEFAULT_USER=admin-or-calendar-user@example.com
CALENDAR_GOOGLE_WORKSPACE_CUSTOMER=my_customer
CALENDAR_GOOGLE_PAGE_SIZE=100
CALENDAR_IMPORT_QUEUE=default

Webhook-specific values:

.env
CALENDAR_GOOGLE_WEBHOOK_URL=https://your-public-host.example.com/integrations/google/calendar/webhooks/callback
CALENDAR_GOOGLE_WEBHOOK_TTL_SECONDS=604800
CALENDAR_GOOGLE_WEBHOOK_RENEWAL_THRESHOLD=86400

CALENDAR_GOOGLE_WEBHOOK_URL must be publicly reachable by Google over HTTPS. For local testing, use the active tunnel URL.

CALENDAR_GOOGLE_WEBHOOK_TTL_SECONDS requests a channel lifetime. Google may return a shorter expiration.

CALENDAR_GOOGLE_WEBHOOK_RENEWAL_THRESHOLD controls how soon before expiration Tempr should replace the channel.

Delegated Access Scopes

Google Calendar webhook setup uses the same delegated service account calendar client as the import service.

Delegate these OAuth scopes to the service account client ID:

https://www.googleapis.com/auth/calendar.readonly
https://www.googleapis.com/auth/calendar.calendarlist.readonly

Scope usage:

ScopeUsed for
calendar.readonlyCreating Google Calendar events.watch channels and pulling event deltas after notifications.
calendar.calendarlist.readonlyRequired by the current shared Calendar client used by the import path.

If this environment also runs Workspace-wide imports with calendar:import-google --workspace, include this additional Directory scope:

https://www.googleapis.com/auth/admin.directory.user.readonly

The Directory scope is not used by webhook callbacks or channel renewal directly.

After changing .env, clear cached config:

php artisan optimize:clear

Initial Setup

Import Google Calendars First

Webhook subscriptions attach to existing Google CalendarSource records. Import calendars before creating channels:

php artisan calendar:import-google --user=scheduler@example.com --sync

For all Workspace users:

php artisan calendar:import-google --workspace --queue --sync

Create Or Renew Webhook Channels

Run:

php artisan calendar:google-webhooks:sync

This command:

  • Finds Google calendar sources in cal_calendar_sources.
  • Creates a Google events.watch channel when no active channel exists.
  • Renews channels that are close to expiration.
  • Stores the returned Google resource ID and expiration on the source.

Restrict setup to specific source IDs:

php artisan calendar:google-webhooks:sync --source=12 --source=13

Force replacement of existing channels:

php artisan calendar:google-webhooks:sync --force

Delete saved channels and ask Google to stop notifications:

php artisan calendar:google-webhooks:sync --delete

Scheduler Setup

Google Calendar channels expire and do not renew themselves. Schedule renewal regularly.

Recommended Laravel scheduler entry:

$schedule->command('calendar:google-webhooks:sync')->hourly()->withoutOverlapping();

Also make sure the queue worker is running for CALENDAR_IMPORT_QUEUE, because webhook callbacks only enqueue delta imports.

Runtime Flow

Subscription Creation

GoogleCalendarWebhookSubscriptionManager creates:

  • a UUID channel ID
  • a random channel token
  • a public callback address
  • a requested expiration timestamp

It sends those values to:

POST https://www.googleapis.com/calendar/v3/calendars/{calendarId}/events/watch

Tempr stores only a SHA-256 hash of the channel token.

Initial Sync Message

After a watch channel is created, Google sends a notification with:

X-Goog-Resource-State: sync

Tempr accepts this request and records channel metadata, but does not queue an import. This message means the channel is active; it is not an event change.

Event Change Message

For normal change notifications, Tempr queues:

ImportGoogleCalendarPage::dispatch(
    $source->external_owner_email,
    $source->getKey(),
    $source->external_calendar_id,
    null,
    true,
);

The final true tells the import service to use the saved Google sync token. That keeps CalendarEvent.php records in sync through the same code path as manual Google imports.

Out-Of-Order Messages

Google message numbers increase per channel, but they are not guaranteed to be sequential. Tempr stores the latest message number and ignores callbacks that are older than or equal to the last processed number.

Verification

Verify the route exists:

php artisan route:list --name=calendar.google.webhooks

Expected route:

POST integrations/google/calendar/webhooks/callback

Run focused tests:

php artisan test tests/Feature/Calendar/GoogleCalendarWebhookTest.php tests/Feature/Calendar/GoogleCalendarImportTest.php

Check webhook channel state:

php artisan tinker
TemprApp\Server\Modules\Calendar\Models\CalendarSource::query()
    ->where('source', 'google')
    ->get([
        'id',
        'external_owner_email',
        'external_calendar_id',
        'webhook_channel_id',
        'webhook_resource_id',
        'webhook_expires_at',
        'webhook_last_message_number',
        'webhook_last_received_at',
    ]);

Troubleshooting

Webhook Returns 403

The channel ID, resource ID, or channel token did not match a saved CalendarSource.

Check that:

  • calendar:google-webhooks:sync ran successfully.
  • Google is calling the current CALENDAR_GOOGLE_WEBHOOK_URL.
  • The callback belongs to the same environment that created the watch channel.
  • The source still has webhook_channel_id, webhook_resource_id, and webhook_token_hash.

If the channel state is stale, recreate it:

php artisan calendar:google-webhooks:sync --delete
php artisan calendar:google-webhooks:sync

Webhook Returns 204 But Events Do Not Change

The webhook receiver only queues work. Check the queue worker and failed jobs.

Common causes:

  • Queue worker is not running for CALENDAR_IMPORT_QUEUE.
  • The saved Google sync token expired and the import job needs to fall back to a full sync.
  • Google credentials cannot impersonate external_owner_email.
  • The webhook was only the initial sync message.

Google Stops Sending Notifications

Google Calendar channels expire. Check webhook_expires_at.

Run:

php artisan calendar:google-webhooks:sync --force

If the public URL changed, update CALENDAR_GOOGLE_WEBHOOK_URL, clear config, and recreate channels:

php artisan optimize:clear
php artisan calendar:google-webhooks:sync --delete
php artisan calendar:google-webhooks:sync