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:
- Reads the
X-Goog-*channel headers. - Finds the matching
CalendarSourceby Google channel and resource IDs. - Validates the channel token that Tempr generated during subscription setup.
- Records the latest Google message number and receive timestamp.
- Ignores Google's initial
syncnotification. - Queues
ImportGoogleCalendarPagewithuseSyncToken=true. - Pulls Google event deltas through
GoogleCalendarImportService. - Updates
CalendarEvent.phprecords 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
| Area | Class Or Table |
|---|---|
| Internal event model | src/Modules/Calendar/Models/CalendarEvent.php |
| Source calendar state | src/Modules/Calendar/Models/CalendarSource.php |
| Webhook receiver | GoogleCalendarWebhookController |
| Subscription lifecycle | GoogleCalendarWebhookSubscriptionManager |
| Google API gateway | GoogleCalendarGateway |
| Delta import job | ImportGoogleCalendarPage |
| Delta import service | GoogleCalendarImportService |
Webhook channel state is stored on cal_calendar_sources.
Important webhook fields:
webhook_channel_idwebhook_resource_idwebhook_resource_uriwebhook_token_hashwebhook_addresswebhook_expires_atwebhook_last_message_numberwebhook_last_received_at
Required Environment Variables
Google Calendar imports already require service account credentials and Workspace impersonation.
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:
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:
| Scope | Used for |
|---|---|
calendar.readonly | Creating Google Calendar events.watch channels and pulling event deltas after notifications. |
calendar.calendarlist.readonly | Required 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.watchchannel 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:syncran 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, andwebhook_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
syncmessage.
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