Google Calendar
Set up and use the Google Calendar integration for two-way sync between Event Schedule and Google Calendar.
Prerequisites
- A Google Cloud Console project
- Google Calendar API enabled on that project
- OAuth 2.0 credentials of type "Web application"
- For near-real-time inbound sync, the app reachable at a public HTTPS URL on a domain you have verified with Google (Google will not create a change channel that points at a private address, a plain-HTTP address or an unverified domain)
- The Laravel scheduler cron, which drives the inbound poll and the channel renewals
Google Calendar sync is a free feature, and a selfhosted install resolves to the Enterprise tier, so nothing on this page is held back by a plan. It does need the environment variables below: without them the Connect button has no credentials to use.
Installs without a public HTTPS URL still sync both ways. Instead of near-real-time webhook notifications, inbound changes are picked up by the 15-minute google:sync polling fallback, which needs only the scheduler cron.
Setup Instructions
1. Google Cloud Console Setup
- Go to the Google Cloud Console
- Create a new project or select an existing one
- Enable the Google Calendar API:
- Go to "APIs & Services" > "Library"
- Search for "Google Calendar API"
- Click on it and press "Enable"
2. OAuth Consent Screen and Scopes
Configure the consent screen and add the scopes Event Schedule requests. Anything missing here shows up later as a failed sync rather than a failed sign-in.
https://www.googleapis.com/auth/calendar.eventsto create, update and delete eventshttps://www.googleapis.com/auth/calendar.readonlyto list calendars and read events for inbound syncopenid,emailandprofileto identify the connecting account
While the project is in testing mode, add each account that will connect a calendar as a test user. Event Schedule always requests offline access and forces the consent prompt, so a refresh token is issued on every connect.
3. OAuth 2.0 Credentials
- Go to "APIs & Services" > "Credentials"
- Click "Create Credentials" > "OAuth 2.0 Client IDs"
- Choose "Web application" as the application type
- Add authorized redirect URIs:
- For development:
http://localhost:8000/google-calendar/callback - For production:
https://yourdomain.com/google-calendar/callback
- For development:
- Save the credentials and note down the Client ID and Client Secret
The same Client ID and Client Secret power the optional "Sign in with Google" flow. If you want that too, register its redirect URIs on the same OAuth client: /auth/google/callback, /auth/google/connect/callback and /auth/google/set-password/callback. Signing in with Google and connecting Google Calendar are separate actions, and a user can do either one without the other.
4. Environment Configuration
Add the following environment variables to your .env file:
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
GOOGLE_REDIRECT_URI=https://yourdomain.com/google-calendar/callback
GOOGLE_WEBHOOK_SECRET=a_long_random_string
Variable Reference
| Variable | Description |
|---|---|
GOOGLE_CLIENT_ID |
The Client ID of the "Web application" OAuth client |
GOOGLE_CLIENT_SECRET |
The Client Secret of the same OAuth client |
GOOGLE_REDIRECT_URI |
Must exactly match a redirect URI registered on the OAuth client ({APP_URL}/google-calendar/callback) |
GOOGLE_WEBHOOK_SECRET |
Required for near-real-time inbound sync; not needed for polling-only installs. Any long random string. It is sent to Google as the channel token and echoed back on every notification |
Set GOOGLE_WEBHOOK_SECRET to a long random value. Google echoes it back as the X-Goog-Channel-Token header on every change notification, and Event Schedule rejects any notification whose value does not match. Leave it empty and inbound changes only arrive on the 15-minute poll.
5. Scheduler Cron
Inbound polling and channel renewal run through the Laravel scheduler, so the standard cron entry has to be in place:
* * * * * php artisan schedule:run
Features
How Sync Works
- Connect an account: Each user connects their own Google account from Settings → Google Settings, in the Google Calendar block. The tokens are stored on that user record
- Pick a calendar and a direction: The schedule owner chooses one of the connected account's calendars and a sync direction on Integrations → Google Calendar of the schedule edit page. The setting belongs to the schedule, so two schedules on the same account can behave differently
- Outbound: Publishing, editing, cancelling or deleting an event pushes the change to the selected calendar, with no extra step
- Inbound: Google change notifications post to the webhook endpoint, and Event Schedule reads the changes with an incremental sync
- Polling fallback: The 15-minute
google:synccommand catches anything the notifications miss, and is the only inbound path on installs without a public URL - Channel renewal: The daily
google:refresh-webhookscommand replaces change channels within three days of expiring
Sync Direction
Each schedule picks one of four options. Saving the schedule is what applies the choice and sets up the change channel.
| Option | What it does | Change channel |
|---|---|---|
| To Google Calendar | Published Event Schedule events appear in Google Calendar | Not needed, and an existing one is removed |
| From Google Calendar | Events from Google Calendar are imported into Event Schedule | Created, so edits arrive quickly |
| Bidirectional Sync | Both of the above. New events, edits and deletions travel in both directions | Created |
| No Sync | Google Calendar synchronization is off for the schedule. Nothing is pushed, and inbound notifications are ignored. Events already on the calendar are left alone | Any existing channel stays registered but is no longer acted on |
Event Information Synced
A Google Calendar entry created by Event Schedule carries:
- The event name, as the Google event title
- The event description. If the schedule has a Calendar Description Template set on Integrations → Advanced, the rendered template is sent instead (see the available variables). On an update with no template and an empty description, no description is sent, so notes you typed on the Google copy survive
- Start and end times in the schedule's timezone. The end is the start plus the event duration, and two hours when the event has no stored duration
- Location, taken from the venue's best available address. Events with no venue are sent without a location
- Google visibility: public for normal events, private for unlisted ones
Only the fields above leave Event Schedule: images, ticket types, prices and attendees stay here. Draft events are never pushed, so an event first appears on the calendar when you publish it. Event Schedule also does not send a recurrence rule, so a recurring event becomes a single Google entry on the series start date rather than a repeating series. Use the schedule's iCal feed or the .ics download when you need every date of a series in a calendar app.
Importing From Google
Inbound sync expands Google's recurring events first, so each occurrence arrives as its own event. Imported events:
- Arrive already approved, and use the schedule's slug pattern and default category
- Take their name, description, start time and duration from the Google entry. The description is converted from HTML to Markdown
- Convert the Google location into a venue, reusing one of your existing venues when the name or address matches and creating one when nothing matches
- Are matched to an existing event by name and start time when there is no stored mapping yet, so an event you pushed out does not come back as a duplicate
An event created by an appointment booking is owned by Event Schedule. Inbound sync never rewrites its name, description or time, so moving the Google copy will not move a customer's booking.
Per-Event Sync Status
The Google Calendar section of the event edit page shows the state of this event on the calendar of the schedule you are editing it under:
| Status | Description |
|---|---|
| Not synced to Google Calendar | This event has no copy on the calendar yet. The button reads "Sync to Google Calendar" |
| Synced to Google Calendar | The event has a copy on the calendar, and Event Schedule remembers which calendar it lives on. The button reads "Remove from Google Calendar" |
| Section hidden | The whole section is absent when the schedule has no calendar selected, or its direction does not include To Google Calendar |
When an Event Is Deleted in the Connected Calendar
Schedules that sync inbound also choose When an event is deleted in the connected calendar, shown right under the sync direction once the direction is From Google Calendar or Bidirectional Sync. The setting is shared with the Outlook Calendar integration, so changing it on either tab changes it for both.
| Setting | What happens in Event Schedule |
|---|---|
| Keep it here | Nothing. The event stays exactly as it is. This is the default |
| Mark as cancelled | The event is marked cancelled rather than removed, so the record and its history survive. This is reversible, and is the right choice when tickets have been sold |
| Delete it here | The event is deleted. Events with ticket sales or ad boost spend are marked cancelled instead, so their records are never destroyed |
Only a real deletion in Google Calendar triggers the policy, and only through the incremental sync, so the schedule needs at least one completed inbound sync first.
If the deleted copy belonged to a schedule that shares the event with others, that schedule is simply detached and the event stays intact for everyone else.
Personal Calendar Sync for Members
A team member who is not the schedule owner sees a Sync to My Calendar block on the schedule's Google Calendar tab. Choosing one of their own calendars mirrors the schedule's events into it, in addition to whatever the owner has configured, and the member gets the same create, update and delete operations even when the owner has left the schedule on No Sync. Turning it back off removes the copies the member received.
The member needs their own connected Google account, and the block has its own Save button, separate from the schedule form. Turning it on does not backfill: events already on the schedule reach the member's calendar the next time they are edited. This is a Google-only feature, since the Outlook and CalDAV integrations sync the owner's calendar only. Adding team members beyond the owner is an Enterprise feature on the hosted service, and a selfhosted install resolves to Enterprise.
Real-Time Sync and Polling Fallback
- Saving a schedule with an inbound direction creates a Google change channel that posts to the webhook endpoint, so calendar edits show up within moments. If the channel cannot be created the save still succeeds, the failure is logged, and inbound changes fall back to the poll
- The 15-minute
google:synccommand polls for changes as a fallback, and is the main path on installs with no public URL. It uses the schedule owner's connected account - The daily
google:refresh-webhookscommand replaces channels that are within three days of expiring - Inbound sync is incremental: Event Schedule stores Google's sync cursor, so each run fetches only what changed. If Google rejects the stored cursor, which happens after a long gap or a calendar switch, one full sync runs to rebuild it
- The first full sync covers a window from 30 days ago to 365 days ahead
- Inbound work is serialized per schedule, so the webhook and the poll cannot import the same event twice
Usage
Step by Step
1. Connect Google Calendar
- Go to your settings page (
/settings) - Open the "Google Settings" section
- Under "Google Calendar", click "Connect Google Calendar"
- Authorize the application in the Google consent flow
The "Google Account" block above it is the separate sign-in connection. Connecting there does not connect a calendar, and you can use either one without the other.
2. Choose a Calendar and Sync Direction
- Edit the schedule and open the Integrations section
- Select the "Google Calendar" tab
- Pick the calendar to sync with under "Select Google Calendar"
- Choose a sync direction: To Google Calendar, From Google Calendar, Bidirectional Sync or No Sync
- Save the schedule. Saving is what applies the selection and sets up the change channel
- Turning sync on does not push the events you already have. Use step 5 or step 6 for those
3. Set the Deletion Policy
- With From Google Calendar or Bidirectional Sync selected, the "When an event is deleted in the connected calendar" options appear
- Choose Keep it here, Mark as cancelled, or Delete it here. Keep it here is the default
- Save the schedule
4. Sync a Single Event
- Open any event's edit page
- Go to the "Google Calendar" section. It appears once the schedule has a calendar selected and pushes to Google
- Click "Sync to Google Calendar", or "Remove from Google Calendar" to take the copy back off the calendar
5. Sync the Whole Schedule
- Open the schedule and use the "Actions" menu. "Sync Events" is listed once your Google account is connected and the schedule has a calendar selected
- Choose "Sync Events" to run the schedule's saved sync direction now. A schedule left on No Sync is pushed to Google instead, and To Google Calendar is then saved as its direction
- A push only creates events that are missing from the calendar; it does not rewrite copies that are already there
- An inbound direction also imports from the calendar, exactly as the scheduled poll would
6. Resync Everything to Google
- Use this after switching Google accounts or target calendars. Save the schedule first, because the resync pushes to the saved calendar. The button stays disabled while the dropdown differs from the saved calendar
- On the schedule's Google Calendar tab, click "Resync to Google Calendar". Only the schedule owner sees this button, and the request is refused unless the direction includes To Google Calendar
- Events already sitting on the saved calendar are left alone. An event whose copy is on a different calendar has that old copy deleted and a fresh one created, so switching calendars does not leave duplicates behind
- It only ever pushes to Google and never imports, and it only covers published events
- The resync runs in the background in batches and can take a few minutes on a large schedule, so it needs a queue worker. Clicking it again is safe: it picks up only the work still outstanding
7. Mirror a Schedule into Your Own Calendar
- As a team member who does not own the schedule, connect your own Google Calendar first
- Open the schedule's Integrations > Google Calendar tab and find "Sync to My Calendar"
- Pick one of your calendars under "Select Your Calendar" and click Save. That block saves on its own, so you do not need to save the schedule
- Choosing "No Sync" removes the events again
Automatic Sync
Once a schedule pushes to Google, its events are synced automatically when they are:
- Published, whether that is a new event or a draft you just published
- Edited, which updates the existing Google entry in place
- Deleted, cancelled or turned back into a draft, which removes the copy from the calendar. Restoring a cancelled event puts it back
Drafts are never pushed, so an event only reaches the calendar once it is published. Members with personal calendar sync receive the same create, update and delete operations even when the owner has not turned on sync for the schedule.
For Developers
Sync Helpers on the Event Model
Outbound sync and status checks go through the Event model:
// Push to every schedule that syncs to Google, plus members with personal sync
$event->syncToGoogleCalendar('create'); // or 'update', 'delete'
// Is there a Google copy for a given schedule?
$event->isSyncedToGoogleCalendarForRole($role->id);
$event->isSyncedToGoogleCalendarForSubdomain($subdomain);
// 'not_connected', 'not_synced' or 'synced'
$event->getGoogleCalendarSyncStatus($user, $role->id);
How the Work Is Dispatched
The mapping between an event and its Google copy lives in the calendar_syncs table, one row per user, event and schedule, together with the calendar the copy was created on.
SyncEventToGoogleCalendarperforms one create, update or delete. Saving an event runs it inline, so the calendar is up to date by the time the save finishes and a queue worker is not requiredForceResyncGoogleCalendarbacks the "Resync to Google Calendar" button and is queued. It handles a small batch of events per run and dispatches a follow-up while any remain, so a large schedule finishes across several runs instead of timing out- Inbound sync is serialized per schedule with a lock, so the webhook and the 15-minute poll cannot import the same event twice
Everyday sync does not need a queue worker. Run one (php artisan queue:work) if you want the bulk resync, since that job is queued and will otherwise sit unprocessed.
API Endpoints
These are the application's own session-authenticated routes, not part of the public REST API. Every route except the two webhook routes requires a signed-in user with a verified email address. The webhook routes are public, and are authenticated by the channel token instead.
| Endpoint | Description |
|---|---|
GET /google-calendar/redirect |
Start the OAuth flow |
GET /google-calendar/callback |
OAuth callback |
GET /google-calendar/reauthorize |
Re-run consent to obtain a refresh token |
GET /google-calendar/disconnect |
Disconnect Google Calendar, remove change channels and clear sync records |
GET /google-calendar/calendars |
Get the connected account's calendars |
POST /google-calendar/sync/{subdomain} |
Sync a schedule in the direction given by sync_direction (to, from or both), and optionally save that direction and the deletion policy |
POST /google-calendar/force-sync-to-google/{subdomain} |
Queue a full push of a schedule to Google. Owner only, and rate limited to 5 requests per minute |
POST /google-calendar/member-sync/{subdomain} |
Turn a member's personal calendar sync on or off |
POST /google-calendar/sync-event/{subdomain}/{eventId} |
Sync one event |
DELETE /google-calendar/unsync-event/{subdomain}/{eventId} |
Remove one event from Google Calendar |
GET /google-calendar/webhook |
Channel verification challenge, echoed back to Google. Rate limited to 10 requests per minute |
POST /google-calendar/webhook |
Change notification handler, authenticated by the channel token. Rate limited to 60 requests per minute |
Scheduled Commands
These Artisan commands keep inbound sync and the change channels healthy:
| Command | Frequency | Description |
|---|---|---|
google:sync |
Every 15 minutes | Polls Google for changes on every schedule whose direction is From Google Calendar or Bidirectional Sync, using each schedule owner's account. Accepts --role= with a schedule id to limit it to one schedule |
google:refresh-webhooks |
Daily | Replaces change channels within three days of expiring. Accepts --force to rebuild them all, and --role= with a schedule id or subdomain |
Troubleshooting
Common Issues
"Google Calendar not connected"
- Connect the Google account first, from Settings > Google Settings
- Connecting a Google account for sign-in is not the same thing; the calendar block has its own Connect button
- Check that
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRETandGOOGLE_REDIRECT_URIare set and that the redirect URI matches the one registered in Google Cloud Console
Repeated re-authorization, or "token refresh failed"
- This means no refresh token was stored. Reconnect the account, which forces the consent prompt again
- Revoking access to the app in the Google Account security settings and connecting again clears a stuck grant
Events do not appear in Google Calendar
- The schedule's sync direction has to be To Google Calendar or Bidirectional Sync, and the schedule has to be saved after the change
- Events that already existed when you turned sync on are not pushed in bulk. Use "Sync Events" from the schedule's Actions menu, the per-event button, or "Resync to Google Calendar"
- Draft events are never pushed. Publish the event first
- Confirm the Google Calendar API is enabled on the project and that the account granted the calendar scopes
- Check the logs for the failing call
The "Google Calendar" section is missing on the event edit page
- It only appears when the schedule has a calendar selected and pushes to Google
- Select a calendar on Integrations > Google Calendar and save the schedule
Google Calendar changes do not reach Event Schedule
- Inbound sync needs From Google Calendar or Bidirectional Sync
- For real-time notifications, the app needs a public HTTPS URL on a domain verified with Google, and
GOOGLE_WEBHOOK_SECRETmust be set; notifications with a mismatched token are rejected. When Google refuses the channel the save still succeeds and the error is only visible in the log - Without a public URL, rely on the 15-minute
google:syncpoll and confirm the scheduler cron is running - Run
php artisan google:sync --role=with the schedule id to test a single schedule by hand - Inbound sync runs on the schedule owner's Google account, so it stops if the owner disconnects even when other members are still connected
"Resync to Google Calendar" is greyed out or refused
- The button is disabled while the calendar dropdown differs from the saved calendar. Save the schedule first
- Only the schedule owner sees the button, and the server refuses the request unless the direction includes To Google Calendar
- The job is queued, so it needs a queue worker to run
Inbound sync stopped after a while
- Change channels expire. The daily
google:refresh-webhookscommand renews them, so make sure the scheduler is running php artisan google:refresh-webhooks --forcerebuilds them immediately
Leftover events after switching calendars or accounts
- Save the schedule with the new calendar selected, then run "Resync to Google Calendar" so old copies are removed and fresh ones are created
- Events synced before Event Schedule started recording which calendar each copy lived on cannot be cleaned up automatically. Delete those few leftovers in Google Calendar by hand
- Nothing is removed from the calendar of an account that has already been disconnected, because the app no longer holds a token for it
Deleting in Google removed nothing here, or removed too much
- The deletion policy defaults to Keep it here, which is why nothing changes locally
- Deletions arrive only through the incremental sync, so the schedule must have completed at least one inbound sync first
Logs
Sync operations are logged in the application logs. Check storage/logs/laravel.log for detailed information about sync operations, and storage/logs/scheduler.log for the scheduled sync and channel-renewal runs.
Security Considerations
- Token Storage: Google access and refresh tokens are stored per user and encrypted at rest with the install's
APP_KEY, and they are hidden from the model's serialized output - Scope Limitation: Only the calendar scopes the integration needs are requested, plus
openid,emailandprofileto identify the account - OAuth State Check: The connect flow carries a random state value that is verified on the callback, so a forged callback is rejected
- Webhook Authentication:
GOOGLE_WEBHOOK_SECRETis the channel token Google echoes back, and mismatched notifications are rejected. Both webhook routes are rate limited - User Authorization: Users can only sync events belonging to schedules they are a member of, and only the schedule owner can trigger a full resync
- Token Refresh: Access tokens are refreshed automatically before each call that needs one
- Clean Disconnect: Disconnecting removes the change channels and turns sync off for the schedules the user owns, clears their sync cursors, and deletes the stored tokens, the user's event mappings and their calendar selection on every schedule they belong to. It does not delete anything already on the Google calendar
- Audit Trail: Connecting, disconnecting, syncing a schedule and toggling personal member sync are all written to the audit log
- Secrets in .env: Keep the client secret and webhook secret in
.env, and never commit them to source control