This document describes the Google Calendar integration in NetSendo, providing technical details for developers and AI agents building or maintaining this feature.
The Google Calendar integration enables users to:
- Sync CRM tasks to Google Calendar as events (two-way)
- Real-time synchronization via Google push notifications (webhooks)
- Create Google Meet meetings automatically for tasks
- Invite attendees via calendar event invitations
- Custom color coding by task type in Google Calendar
- Conflict detection when events are modified in both systems
src/app/
├── Http/Controllers/
│ ├── GoogleCalendarController.php # OAuth flow & calendar settings
│ └── Webhooks/
│ └── GoogleCalendarController.php # Push notification webhook handler
├── Models/
│ ├── GoogleIntegration.php # Stores Google API credentials per user
│ └── UserCalendarConnection.php # Stores OAuth tokens & sync settings
├── Services/
│ ├── GoogleCalendarOAuthService.php # OAuth authorization & token refresh
│ └── GoogleCalendarService.php # Calendar API operations (CRUD, watch)
├── Jobs/
│ ├── SyncTaskToCalendar.php # Syncs task changes to Calendar
│ └── ProcessCalendarWebhook.php # Processes incoming Calendar webhooks
├── Console/Commands/
│ ├── RefreshCalendarChannels.php # Refreshes expiring webhook channels
│ ├── SyncOrphanedCalendarEvents.php # Syncs orphaned events
│ └── SyncPendingCalendarTasks.php # Syncs pending tasks
src/resources/js/Pages/
├── Marketplace/
│ └── GoogleCalendar.vue # Integration info page in Marketplace
├── Settings/
│ └── Calendar/
│ └── Index.vue # Configuration & account connection UI
src/database/migrations/
├── 2025_12_18_125000_create_google_integrations_table.php
├── 2026_01_25_000006_add_google_calendar_sync_to_crm_tasks.php
├── 2026_01_25_000007_create_user_calendar_connections_table.php
├── 2026_01_25_000008_add_selected_calendar_to_crm_tasks.php
├── 2026_01_26_000001_add_google_meet_fields_to_crm_tasks.php
└── 2026_01_27_122845_add_task_type_colors_to_user_calendar_connections_table.php
Stores Google Cloud OAuth credentials per user (for self-hosted OAuth apps).
| Column | Type | Description |
|---|---|---|
id |
bigint | Primary key |
user_id |
foreignId | References users.id (cascade on delete) |
name |
string | Friendly name (e.g., "My Google App") |
client_id |
string | Google OAuth Client ID |
client_secret |
string | Google OAuth Client Secret |
status |
string | active or inactive |
created_at |
timestamp | Record creation timestamp |
updated_at |
timestamp | Record update timestamp |
Stores OAuth connections and sync settings per user.
| Column | Type | Description |
|---|---|---|
id |
bigint | Primary key |
user_id |
foreignId | References users.id |
google_integration_id |
foreignId | References google_integrations.id |
access_token |
text | Encrypted OAuth access token |
refresh_token |
text | Encrypted OAuth refresh token |
token_expires_at |
timestamp | Token expiration time |
calendar_id |
string | Target calendar ID (default: primary) |
connected_email |
string | Connected Google account email |
channel_id |
string | Push notification channel ID |
resource_id |
string | Google resource ID for webhook |
channel_expires_at |
timestamp | Push channel expiration (max 7 days) |
is_active |
boolean | Connection active status |
auto_sync_tasks |
boolean | Auto-sync enabled |
sync_settings |
json | Additional sync preferences |
task_type_colors |
json | Custom colors per task type |
sync_token |
string | Incremental sync token |
last_synced_at |
timestamp | Last sync timestamp |
| Column | Type | Description |
|---|---|---|
google_calendar_event_id |
string | Google Calendar event ID |
google_calendar_id |
string | Calendar ID where event was created |
google_calendar_synced_at |
timestamp | Last sync timestamp |
google_calendar_etag |
string | Event ETag for conflict detection |
sync_to_calendar |
boolean | Whether task should sync to Calendar |
selected_calendar_id |
string | User-selected calendar for this task |
has_conflict |
boolean | Whether a sync conflict exists |
conflict_data |
json | Local vs remote conflict data |
google_meet_link |
string | Google Meet video call link |
google_meet_id |
string | Google Meet conference ID |
include_google_meet |
boolean | Whether to include Google Meet |
attendee_emails |
json | List of attendee emails |
attendees_data |
json | Attendees with response status |
Google OAuth credentials are stored per user in the google_integrations table. This allows each user to configure their own Google Cloud Project OAuth credentials.
The GoogleCalendarOAuthService uses these credentials:
public function getAuthorizationUrl(GoogleIntegration $integration, string $state): string
{
$params = [
'client_id' => $integration->client_id,
'redirect_uri' => route('settings.calendar.callback'),
// ...
];
}private const SCOPES = [
'https://www.googleapis.com/auth/calendar', // Full calendar access
'https://www.googleapis.com/auth/calendar.events', // Event management
'https://www.googleapis.com/auth/userinfo.email', // Get user email
'openid', // OpenID Connect
];| Endpoint | URL |
|---|---|
| Authorization | https://accounts.google.com/o/oauth2/v2/auth |
| Token Exchange | https://oauth2.googleapis.com/token |
| User Info | https://www.googleapis.com/oauth2/v2/userinfo |
| Revoke Token | https://oauth2.googleapis.com/revoke |
- User enters credentials in Settings → Integrations
- User clicks "Connect" → Redirects to Google authorization
- Google prompts consent with calendar scopes
- User authorizes → Redirects to
/settings/calendar/callback - Callback exchanges code for access + refresh tokens
- Tokens are encrypted and stored in
user_calendar_connections - Push notification channel is automatically set up
All routes are defined in src/routes/web.php:
// Marketplace info page
Route::get('/marketplace/google-calendar', fn() => Inertia::render('Marketplace/GoogleCalendar'))
->name('marketplace.google-calendar');
// Calendar settings routes
Route::prefix('settings/calendar')->name('settings.calendar.')->group(function () {
Route::get('/', [GoogleCalendarController::class, 'index'])->name('index');
Route::get('/connect/{integration}', [GoogleCalendarController::class, 'connect'])->name('connect');
Route::get('/callback', [GoogleCalendarController::class, 'callback'])->name('callback');
Route::post('/disconnect/{connection}', [GoogleCalendarController::class, 'disconnect'])->name('disconnect');
Route::put('/settings/{connection}', [GoogleCalendarController::class, 'updateSettings'])->name('settings');
Route::post('/sync/{connection}', [GoogleCalendarController::class, 'syncNow'])->name('sync');
Route::post('/bulk-sync/{connection}', [GoogleCalendarController::class, 'bulkSync'])->name('bulk-sync');
Route::post('/refresh-channel/{connection}', [GoogleCalendarController::class, 'refreshChannel'])->name('refresh-channel');
Route::get('/status', [GoogleCalendarController::class, 'syncStatus'])->name('status');
Route::put('/task-colors/{connection}', [GoogleCalendarController::class, 'updateTaskColors'])->name('task-colors');
});
// Webhook (no auth required)
Route::post('/webhooks/google-calendar', [Webhooks\GoogleCalendarController::class, 'handle'])
->name('webhooks.google-calendar');When a CRM task is created/updated with sync_to_calendar = true:
SyncTaskToCalendarjob is dispatched- Job creates/updates Google Calendar event
- Event includes: title, description, time, color, reminders
- If
include_google_meet = true, Google Meet link is created - If attendees exist, calendar invitations are sent
- Event ID and ETag are saved to task for future updates
When a Google Calendar event changes:
- Google sends push notification to
/webhooks/google-calendar ProcessCalendarWebhookjob is dispatched- Job fetches recent events from Google Calendar API
- For NetSendo events (via
extendedProperties.private.netsendo_task_id):- Updates corresponding CRM task
- Handles cancellations
- For external events (if
import_external_eventsenabled):- Creates new CRM tasks from calendar events
When both local and remote changes occur:
- Service compares ETags during update
- If ETag mismatch (HTTP 412), conflict is detected
- Task is marked with
has_conflict = true conflict_datastores both versions for user resolution
When syncing a task to Google Calendar:
$payload = [
'summary' => $task->title,
'description' => $this->buildEventDescription($task),
'start' => [
'dateTime' => $startTime->toRfc3339String(),
'timeZone' => $userTimezone,
],
'end' => [
'dateTime' => $endTime->toRfc3339String(),
'timeZone' => $userTimezone,
],
'colorId' => '9', // Mapped from task type color
'reminders' => [...],
'extendedProperties' => [
'private' => [
'netsendo_task_id' => $task->id,
'netsendo_task_type' => $task->type,
],
],
// Optional: Google Meet
'conferenceData' => [
'createRequest' => [
'requestId' => 'netsendo-meet-' . $task->id,
'conferenceSolutionKey' => ['type' => 'hangoutsMeet'],
],
],
// Optional: Attendees
'attendees' => [
['email' => '[email protected]', 'displayName' => 'John Doe'],
],
];{Task Description}
📹 Zoom Meeting:
{Zoom Join URL}
📝 Notes:
{Task Notes}
---
Type: Meeting
Priority: High
Status: Pending
Contact: John Doe
Deal: Enterprise Deal
🔗 Managed by NetSendo CRM
Custom colors can be set per task type:
| Task Type | Default Hex | Google Color ID |
|---|---|---|
call |
#8B5CF6 |
3 (Grape) |
email |
#3B82F6 |
9 (Blueberry) |
meeting |
#EF4444 |
11 (Tomato) |
task |
#10B981 |
10 (Basil) |
follow_up |
#F59E0B |
5 (Banana) |
Google Calendar has 11 predefined colors. NetSendo maps hex colors to the nearest Google color using RGB distance calculation.
Google Calendar API uses push notifications for real-time sync.
public function watchCalendar(UserCalendarConnection $connection): array
{
$response = Http::withToken($accessToken)
->post(self::CALENDAR_API_URL . "/calendars/{$calendarId}/events/watch", [
'id' => 'netsendo-calendar-' . $connection->id . '-' . time(),
'type' => 'web_hook',
'address' => route('webhooks.google-calendar'),
'expiration' => now()->addDays(7)->timestamp * 1000,
]);
}| Header | Purpose |
|---|---|
X-Goog-Channel-ID |
Identifies the subscription channel |
X-Goog-Resource-ID |
Identifies the watched resource |
X-Goog-Resource-State |
Event type: sync, exists, update |
X-Goog-Message-Number |
Sequence number |
- Maximum lifetime: 7 days
RefreshCalendarChannelscommand renews expiring channels- Recommended: Schedule command to run hourly
Tokens are encrypted using Laravel's Crypt facade:
public function setAccessTokenAttribute($value): void
{
$this->attributes['access_token'] = $value ? Crypt::encryptString($value) : null;
}Tokens are automatically refreshed when expired:
public function getValidAccessToken(UserCalendarConnection $connection): string
{
if ($connection->isTokenExpired()) {
$tokens = $this->refreshAccessToken($connection);
return $tokens['access_token'];
}
return $connection->getDecryptedAccessToken();
}Tokens are considered expired 5 minutes before actual expiration.
Add to app/Console/Kernel.php:
$schedule->command('calendar:refresh-channels')->hourly();
$schedule->command('calendar:sync-orphaned')->everyThirtyMinutes();
$schedule->command('calendar:sync-pending')->everyFiveMinutes();The Google Calendar integration works alongside Zoom:
- CRM task can have both
include_google_meetANDinclude_zoom_meeting SyncTaskToCalendarjob also creates Zoom meetings if enabled- Zoom join URL is added to Google Calendar event description and location
- Both video conference links appear in the task and calendar event
- OAuth tokens are encrypted at rest using Laravel encryption
- API credentials stored in database per user
- Client Secret should never be exposed in frontend
- State includes CSRF token and user ID
- State is base64-encoded JSON
- Verified on callback to prevent CSRF attacks
- Webhooks validated via
channel_idandresource_id - Only known channels are processed
- Unknown channels are logged and rejected
Displays:
- Integration features (two-way sync, real-time, reminders)
- 5-step setup guide
- Links to Google Cloud Console
- Links to Calendar API documentation
- Requirements checklist
Provides:
- Connected account display with email
- Calendar selector dropdown
- Auto-sync toggle
- Task type color customization
- Manual sync buttons
- Bulk sync for existing tasks
- Push notification status and refresh
- Disconnect button
- Add new scopes in
GoogleCalendarOAuthService::SCOPES - Create API methods in
GoogleCalendarService - Update jobs to handle new functionality
- Update frontend to expose settings
- Test OAuth reconnection (scopes change requires re-auth)
- Create migration for new
crm_taskscolumns - Update
taskToEventPayload()inGoogleCalendarService - Update
eventToTaskData()for inbound sync - Update frontend task forms
| Issue | Cause | Solution |
|---|---|---|
| "Invalid state: user mismatch" | OAuth session expired | Start OAuth flow again |
| "Failed to exchange code" | Invalid credentials or redirect URI | Verify Google Cloud Console settings |
| Token refresh failing | Refresh token revoked | User must reconnect account |
| Push notifications not working | Channel expired or invalid webhook URL | Refresh channel or check SSL |
| Events not syncing | auto_sync_tasks disabled |
Enable in calendar settings |
| Conflict detected | Event edited in both systems | Resolve conflict in task view |
# Main application logs
tail -f storage/logs/laravel.log | grep -E "(Calendar|Google)"
# Specific log files
tail -f storage/logs/calendar-channels.log
tail -f storage/logs/calendar-orphaned-sync.log
tail -f storage/logs/calendar-pending-sync.log- PHP 8.2+
- Laravel 10+
- HTTPS (required for OAuth and webhooks)
- Google Account
- Google Cloud Project with Calendar API enabled
- OAuth consent screen configured
- OAuth 2.0 credentials (Web application type)
- GoogleCalendarController.php
- Webhooks/GoogleCalendarController.php
- GoogleCalendarOAuthService.php
- GoogleCalendarService.php
- UserCalendarConnection.php
- GoogleIntegration.php
- SyncTaskToCalendar.php
- ProcessCalendarWebhook.php
- Settings/Calendar/Index.vue
- Marketplace/GoogleCalendar.vue