The Problem
Prospect meetings get lost between systems. Your sales team uses a CRM to track leads, but scheduling happens in Google Calendar. When a prospect books a meeting, reps manually create a CRM activity record, copy meeting details, attach the calendar link, and hope they remembered to set a reminder. This double-entry wastes time and creates data inconsistencies—meetings scheduled but not logged in the CRM, wrong contact associations, missing follow-up tasks. Worse, prospects can’t self-book meetings that automatically sync to your CRM, forcing them to email back-and-forth for availability. You need bidirectional sync: when a prospect books a meeting via your booking page, it creates both a Google Calendar event and a CRM activity record with proper contact linking. When the meeting is updated or canceled, both systems reflect the change. However, Google Calendar API requires OAuth 2.0 flows, webhook verification, event parsing with timezone handling, and conflict detection. This tutorial provides production-ready code for a meeting booking system that syncs between Google Calendar and your CRM with proper authentication and real-time updates.
Tech Stack & Prerequisites
- Node.js v18+ with npm
- Express.js 4.18+ for backend server
- Google Cloud Project with Calendar API enabled
- Google OAuth 2.0 Credentials (Client ID, Client Secret)
- googleapis 129.0+ (Google API Node.js client)
- PostgreSQL 14+ or MongoDB for storing calendar-CRM mappings
- dotenv for environment variables
- React 18+ for booking interface (frontend)
- date-fns 3.0+ or moment-timezone for date handling
- pg 8.11+ for PostgreSQL connection
Required Google Cloud Setup:
- Google Cloud Console project created
- Calendar API enabled in API Library
- OAuth 2.0 Client ID created (Web application type)
- Authorized redirect URIs configured
- OAuth consent screen configured
Required CRM Setup:
- CRM with API access (HubSpot, Salesforce, or Pipedrive)
- API credentials for creating activities/meetings
- Contact/Lead IDs for association
Step-by-Step Implementation
Step 1: Setup
Initialize the project:
mkdir calendar-crm-integration
cd calendar-crm-integration
# Backend setup
mkdir backend
cd backend
npm init -y
npm install express googleapis pg dotenv cors body-parser date-fns
npm install --save-dev nodemon
# Frontend setup
cd ..
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm install axios date-fns react-calendar
cd ..
Create backend structure:
cd backend
mkdir src api utils db
touch src/server.js api/googleCalendar.js api/crm.js utils/dateHelpers.js
touch db/database.js db/schema.sql .env .gitignore
```
Your structure should be:
```
calendar-crm-integration/
├── backend/
│ ├── src/
│ │ └── server.js
│ ├── api/
│ │ ├── googleCalendar.js
│ │ └── crm.js
│ ├── utils/
│ │ └── dateHelpers.js
│ ├── db/
│ │ ├── database.js
│ │ └── schema.sql
│ ├── .env
│ ├── .gitignore
│ └── package.json
└── frontend/
├── src/
│ ├── App.jsx
│ └── components/
│ └── BookingForm.jsx
└── package.json
backend/db/schema.sql — Database schema for mappings:
-- Store OAuth tokens for users
CREATE TABLE IF NOT EXISTS user_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER UNIQUE NOT NULL,
email VARCHAR(255) NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT,
token_expiry TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Store calendar-CRM event mappings
CREATE TABLE IF NOT EXISTS calendar_events (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES user_tokens(user_id),
google_event_id VARCHAR(255) UNIQUE NOT NULL,
crm_activity_id VARCHAR(255),
crm_type VARCHAR(50), -- 'hubspot', 'salesforce', 'pipedrive'
contact_id VARCHAR(255),
contact_email VARCHAR(255),
event_title TEXT,
event_start TIMESTAMP NOT NULL,
event_end TIMESTAMP NOT NULL,
event_status VARCHAR(50), -- 'confirmed', 'cancelled', 'tentative'
meeting_link TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Index for faster lookups
CREATE INDEX idx_google_event_id ON calendar_events(google_event_id);
CREATE INDEX idx_crm_activity_id ON calendar_events(crm_activity_id);
CREATE INDEX idx_user_id ON calendar_events(user_id);
CREATE INDEX idx_event_start ON calendar_events(event_start);
-- Store webhook channel information
CREATE TABLE IF NOT EXISTS webhook_channels (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES user_tokens(user_id),
channel_id VARCHAR(255) UNIQUE NOT NULL,
resource_id VARCHAR(255) NOT NULL,
expiration TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Run schema:
psql -U your_username -d your_database -f db/schema.sql
backend/package.json — Add scripts:
{
"name": "calendar-crm-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js"
},
"dependencies": {
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"date-fns": "^3.0.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"googleapis": "^129.0.0",
"pg": "^8.11.3"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
backend/.gitignore:
echo "node_modules/
.env
*.log
credentials.json" > .gitignore
Step 2: Configuration
backend/.env — Store credentials securely:
# Google OAuth Configuration
GOOGLE_CLIENT_ID=your_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_client_secret
GOOGLE_REDIRECT_URI=http://localhost:5000/oauth/callback
GOOGLE_CALENDAR_WEBHOOK_URL=https://your-domain.com/webhooks/google-calendar
# CRM Configuration (HubSpot example)
HUBSPOT_API_KEY=your_hubspot_api_key
CRM_TYPE=hubspot
# PostgreSQL Configuration
PG_HOST=localhost
PG_PORT=5432
PG_DATABASE=calendar_crm
PG_USER=your_username
PG_PASSWORD=your_password
# Server Configuration
PORT=5000
NODE_ENV=development
FRONTEND_URL=http://localhost:5173
How to get Google OAuth credentials:
- Go to https://console.cloud.google.com
- Create project or select existing
- Enable Google Calendar API in API Library
- Go to Credentials → Create Credentials → OAuth 2.0 Client ID
- Application type: Web application
- Authorized redirect URIs:
http://localhost:5000/oauth/callback - Copy Client ID and Client Secret
backend/db/database.js — Database connection and operations:
import pg from 'pg';
import dotenv from 'dotenv';
dotenv.config();
const { Pool } = pg;
const pool = new Pool({
host: process.env.PG_HOST,
port: process.env.PG_PORT,
database: process.env.PG_DATABASE,
user: process.env.PG_USER,
password: process.env.PG_PASSWORD,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Test connection
export async function testConnection() {
try {
const client = await pool.connect();
console.log('✓ PostgreSQL connected');
client.release();
return true;
} catch (error) {
console.error('✗ PostgreSQL connection failed:', error.message);
return false;
}
}
// Store user OAuth tokens
export async function saveUserTokens(userId, email, tokens) {
const query = `
INSERT INTO user_tokens (user_id, email, access_token, refresh_token, token_expiry)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id)
DO UPDATE SET
access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
token_expiry = EXCLUDED.token_expiry,
updated_at = CURRENT_TIMESTAMP
RETURNING *
`;
const expiry = new Date(Date.now() + (tokens.expiry_date || 3600 * 1000));
const result = await pool.query(query, [
userId,
email,
tokens.access_token,
tokens.refresh_token,
expiry,
]);
return result.rows[0];
}
// Get user tokens
export async function getUserTokens(userId) {
const query = 'SELECT * FROM user_tokens WHERE user_id = $1';
const result = await pool.query(query, [userId]);
return result.rows[0];
}
// Store calendar-CRM event mapping
export async function saveCalendarEvent(eventData) {
const query = `
INSERT INTO calendar_events (
user_id, google_event_id, crm_activity_id, crm_type,
contact_id, contact_email, event_title, event_start,
event_end, event_status, meeting_link
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (google_event_id)
DO UPDATE SET
crm_activity_id = EXCLUDED.crm_activity_id,
event_status = EXCLUDED.event_status,
event_start = EXCLUDED.event_start,
event_end = EXCLUDED.event_end,
updated_at = CURRENT_TIMESTAMP
RETURNING *
`;
const values = [
eventData.userId,
eventData.googleEventId,
eventData.crmActivityId,
eventData.crmType,
eventData.contactId,
eventData.contactEmail,
eventData.eventTitle,
eventData.eventStart,
eventData.eventEnd,
eventData.eventStatus,
eventData.meetingLink,
];
const result = await pool.query(query, values);
return result.rows[0];
}
// Get event by Google Event ID
export async function getEventByGoogleId(googleEventId) {
const query = 'SELECT * FROM calendar_events WHERE google_event_id = $1';
const result = await pool.query(query, [googleEventId]);
return result.rows[0];
}
// Get events by user and date range
export async function getUserEvents(userId, startDate, endDate) {
const query = `
SELECT * FROM calendar_events
WHERE user_id = $1
AND event_start >= $2
AND event_start <= $3
ORDER BY event_start ASC
`;
const result = await pool.query(query, [userId, startDate, endDate]);
return result.rows;
}
// Update event status
export async function updateEventStatus(googleEventId, status) {
const query = `
UPDATE calendar_events
SET event_status = $1, updated_at = CURRENT_TIMESTAMP
WHERE google_event_id = $2
RETURNING *
`;
const result = await pool.query(query, [status, googleEventId]);
return result.rows[0];
}
export default pool;
utils/dateHelpers.js — Date/timezone utilities:
import { format, parseISO, addMinutes, isAfter, isBefore } from 'date-fns';
import { formatInTimeZone, toDate } from 'date-fns-tz';
// Convert to ISO string with timezone
export function toISOWithTimezone(date, timezone = 'UTC') {
return formatInTimeZone(date, timezone, "yyyy-MM-dd'T'HH:mm:ssXXX");
}
// Format date for display
export function formatDisplayDate(date, timezone = 'America/New_York') {
return formatInTimeZone(parseISO(date), timezone, 'MMM dd, yyyy h:mm a zzz');
}
// Check for scheduling conflicts
export function hasConflict(newEvent, existingEvents) {
const newStart = parseISO(newEvent.start);
const newEnd = parseISO(newEvent.end);
return existingEvents.some(event => {
const existingStart = parseISO(event.event_start);
const existingEnd = parseISO(event.event_end);
// Check for overlap
return (
(isAfter(newStart, existingStart) && isBefore(newStart, existingEnd)) ||
(isAfter(newEnd, existingStart) && isBefore(newEnd, existingEnd)) ||
(isBefore(newStart, existingStart) && isAfter(newEnd, existingEnd))
);
});
}
// Generate available time slots
export function generateTimeSlots(date, startHour, endHour, durationMinutes, existingEvents) {
const slots = [];
const baseDate = parseISO(date);
for (let hour = startHour; hour < endHour; hour++) {
for (let minute = 0; minute < 60; minute += durationMinutes) {
const slotStart = new Date(baseDate);
slotStart.setHours(hour, minute, 0, 0);
const slotEnd = addMinutes(slotStart, durationMinutes);
// Check if slot conflicts with existing events
const slot = {
start: slotStart.toISOString(),
end: slotEnd.toISOString(),
};
if (!hasConflict(slot, existingEvents)) {
slots.push({
start: slotStart.toISOString(),
end: slotEnd.toISOString(),
available: true,
});
}
}
}
return slots;
}
export default {
toISOWithTimezone,
formatDisplayDate,
hasConflict,
generateTimeSlots,
};
Step 3: Core Logic
api/googleCalendar.js — Google Calendar API client:
import { google } from 'googleapis';
import dotenv from 'dotenv';
import { getUserTokens, saveUserTokens } from '../db/database.js';
dotenv.config();
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI
);
// Generate OAuth URL for user authorization
export function generateAuthUrl(userId) {
const scopes = [
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/calendar.events',
];
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: scopes,
state: userId.toString(), // Pass userId in state
prompt: 'consent', // Force consent to get refresh token
});
return authUrl;
}
// Exchange authorization code for tokens
export async function getTokensFromCode(code) {
const { tokens } = await oauth2Client.getToken(code);
return tokens;
}
// Get authenticated calendar client for user
async function getCalendarClient(userId) {
const userTokens = await getUserTokens(userId);
if (!userTokens) {
throw new Error('User not authenticated with Google');
}
// Check if token is expired
const now = new Date();
const expiry = new Date(userTokens.token_expiry);
if (now >= expiry && userTokens.refresh_token) {
// Refresh token
oauth2Client.setCredentials({
refresh_token: userTokens.refresh_token,
});
const { credentials } = await oauth2Client.refreshAccessToken();
// Save new tokens
await saveUserTokens(userId, userTokens.email, credentials);
oauth2Client.setCredentials(credentials);
} else {
oauth2Client.setCredentials({
access_token: userTokens.access_token,
refresh_token: userTokens.refresh_token,
});
}
return google.calendar({ version: 'v3', auth: oauth2Client });
}
// Create calendar event
export async function createCalendarEvent(userId, eventDetails) {
try {
const calendar = await getCalendarClient(userId);
const event = {
summary: eventDetails.title,
description: eventDetails.description || '',
start: {
dateTime: eventDetails.startTime,
timeZone: eventDetails.timezone || 'UTC',
},
end: {
dateTime: eventDetails.endTime,
timeZone: eventDetails.timezone || 'UTC',
},
attendees: eventDetails.attendees || [],
conferenceData: eventDetails.createMeetLink ? {
createRequest: {
requestId: `meeting-${Date.now()}`,
conferenceSolutionKey: { type: 'hangoutsMeet' },
},
} : undefined,
reminders: {
useDefault: false,
overrides: [
{ method: 'email', minutes: 24 * 60 },
{ method: 'popup', minutes: 30 },
],
},
};
const response = await calendar.events.insert({
calendarId: 'primary',
resource: event,
conferenceDataVersion: eventDetails.createMeetLink ? 1 : 0,
sendUpdates: 'all', // Send email notifications to attendees
});
console.log(`✓ Calendar event created: ${response.data.id}`);
return {
eventId: response.data.id,
meetLink: response.data.hangoutLink || response.data.conferenceData?.entryPoints?.[0]?.uri,
htmlLink: response.data.htmlLink,
};
} catch (error) {
console.error('Error creating calendar event:', error.message);
throw error;
}
}
// Get events for a date range
export async function getEvents(userId, timeMin, timeMax) {
try {
const calendar = await getCalendarClient(userId);
const response = await calendar.events.list({
calendarId: 'primary',
timeMin: timeMin,
timeMax: timeMax,
singleEvents: true,
orderBy: 'startTime',
});
return response.data.items || [];
} catch (error) {
console.error('Error fetching events:', error.message);
throw error;
}
}
// Update calendar event
export async function updateCalendarEvent(userId, eventId, updates) {
try {
const calendar = await getCalendarClient(userId);
// First get the existing event
const existingEvent = await calendar.events.get({
calendarId: 'primary',
eventId: eventId,
});
// Merge updates
const updatedEvent = {
...existingEvent.data,
summary: updates.title || existingEvent.data.summary,
description: updates.description || existingEvent.data.description,
start: updates.startTime ? {
dateTime: updates.startTime,
timeZone: updates.timezone || 'UTC',
} : existingEvent.data.start,
end: updates.endTime ? {
dateTime: updates.endTime,
timeZone: updates.timezone || 'UTC',
} : existingEvent.data.end,
};
const response = await calendar.events.update({
calendarId: 'primary',
eventId: eventId,
resource: updatedEvent,
sendUpdates: 'all',
});
console.log(`✓ Calendar event updated: ${eventId}`);
return response.data;
} catch (error) {
console.error('Error updating event:', error.message);
throw error;
}
}
// Cancel calendar event
export async function cancelCalendarEvent(userId, eventId) {
try {
const calendar = await getCalendarClient(userId);
await calendar.events.delete({
calendarId: 'primary',
eventId: eventId,
sendUpdates: 'all',
});
console.log(`✓ Calendar event deleted: ${eventId}`);
return true;
} catch (error) {
console.error('Error deleting event:', error.message);
throw error;
}
}
// Setup webhook for calendar changes
export async function setupCalendarWebhook(userId) {
try {
const calendar = await getCalendarClient(userId);
const channelId = `calendar-${userId}-${Date.now()}`;
const expiration = Date.now() + (7 * 24 * 60 * 60 * 1000); // 7 days
const response = await calendar.events.watch({
calendarId: 'primary',
requestBody: {
id: channelId,
type: 'web_hook',
address: process.env.GOOGLE_CALENDAR_WEBHOOK_URL,
expiration: expiration.toString(),
},
});
console.log(`✓ Webhook setup for user ${userId}`);
return {
channelId: response.data.id,
resourceId: response.data.resourceId,
expiration: new Date(parseInt(response.data.expiration)),
};
} catch (error) {
console.error('Error setting up webhook:', error.message);
throw error;
}
}
export default {
generateAuthUrl,
getTokensFromCode,
createCalendarEvent,
getEvents,
updateCalendarEvent,
cancelCalendarEvent,
setupCalendarWebhook,
};
api/crm.js — CRM integration (HubSpot example):
import axios from 'axios';
import dotenv from 'dotenv';
dotenv.config();
const HUBSPOT_API_KEY = process.env.HUBSPOT_API_KEY;
const CRM_TYPE = process.env.CRM_TYPE || 'hubspot';
// Create activity/meeting in CRM
export async function createCRMActivity(activityData) {
if (CRM_TYPE === 'hubspot') {
return createHubSpotEngagement(activityData);
}
// Add other CRM types here
throw new Error(`CRM type ${CRM_TYPE} not supported`);
}
// HubSpot: Create engagement (meeting)
async function createHubSpotEngagement(data) {
try {
const engagement = {
engagement: {
active: true,
type: 'MEETING',
timestamp: new Date(data.startTime).getTime(),
},
associations: {
contactIds: [data.contactId],
},
metadata: {
title: data.title,
body: data.description || '',
startTime: new Date(data.startTime).getTime(),
endTime: new Date(data.endTime).getTime(),
meetingOutcome: null,
},
};
const response = await axios.post(
'https://api.hubapi.com/engagements/v1/engagements',
engagement,
{
params: { hapikey: HUBSPOT_API_KEY },
headers: { 'Content-Type': 'application/json' },
}
);
console.log(`✓ HubSpot engagement created: ${response.data.engagement.id}`);
return {
activityId: response.data.engagement.id.toString(),
crmType: 'hubspot',
};
} catch (error) {
console.error('Error creating HubSpot engagement:', error.response?.data || error.message);
throw error;
}
}
// Update CRM activity
export async function updateCRMActivity(activityId, updates) {
if (CRM_TYPE === 'hubspot') {
return updateHubSpotEngagement(activityId, updates);
}
throw new Error(`CRM type ${CRM_TYPE} not supported`);
}
async function updateHubSpotEngagement(engagementId, updates) {
try {
const engagement = {
engagement: {
timestamp: updates.startTime ? new Date(updates.startTime).getTime() : undefined,
},
metadata: {
title: updates.title,
body: updates.description,
startTime: updates.startTime ? new Date(updates.startTime).getTime() : undefined,
endTime: updates.endTime ? new Date(updates.endTime).getTime() : undefined,
},
};
const response = await axios.patch(
`https://api.hubapi.com/engagements/v1/engagements/${engagementId}`,
engagement,
{
params: { hapikey: HUBSPOT_API_KEY },
}
);
console.log(`✓ HubSpot engagement updated: ${engagementId}`);
return response.data;
} catch (error) {
console.error('Error updating HubSpot engagement:', error.response?.data || error.message);
throw error;
}
}
// Delete/cancel CRM activity
export async function deleteCRMActivity(activityId) {
if (CRM_TYPE === 'hubspot') {
return deleteHubSpotEngagement(activityId);
}
throw new Error(`CRM type ${CRM_TYPE} not supported`);
}
async function deleteHubSpotEngagement(engagementId) {
try {
await axios.delete(
`https://api.hubapi.com/engagements/v1/engagements/${engagementId}`,
{
params: { hapikey: HUBSPOT_API_KEY },
}
);
console.log(`✓ HubSpot engagement deleted: ${engagementId}`);
return true;
} catch (error) {
console.error('Error deleting HubSpot engagement:', error.response?.data || error.message);
throw error;
}
}
// Get contact by email
export async function getContactByEmail(email) {
if (CRM_TYPE === 'hubspot') {
return getHubSpotContactByEmail(email);
}
throw new Error(`CRM type ${CRM_TYPE} not supported`);
}
async function getHubSpotContactByEmail(email) {
try {
const response = await axios.get(
`https://api.hubapi.com/contacts/v1/contact/email/${email}/profile`,
{
params: { hapikey: HUBSPOT_API_KEY },
}
);
return {
contactId: response.data.vid.toString(),
email: response.data.properties.email.value,
firstName: response.data.properties.firstname?.value,
lastName: response.data.properties.lastname?.value,
};
} catch (error) {
if (error.response?.status === 404) {
return null; // Contact not found
}
console.error('Error fetching HubSpot contact:', error.response?.data || error.message);
throw error;
}
}
export default {
createCRMActivity,
updateCRMActivity,
deleteCRMActivity,
getContactByEmail,
};
src/server.js — Main Express server:
import express from 'express';
import cors from 'cors';
import bodyParser from 'body-parser';
import dotenv from 'dotenv';
import { testConnection, saveUserTokens, saveCalendarEvent, getEventByGoogleId, getUserEvents } from '../db/database.js';
import {
generateAuthUrl,
getTokensFromCode,
createCalendarEvent,
getEvents,
cancelCalendarEvent,
} from '../api/googleCalendar.js';
import {
createCRMActivity,
deleteCRMActivity,
getContactByEmail,
} from '../api/crm.js';
import { hasConflict, generateTimeSlots } from '../utils/dateHelpers.js';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 5000;
// Middleware
app.use(cors({ origin: process.env.FRONTEND_URL }));
app.use(bodyParser.json());
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
// OAuth: Start authorization flow
app.get('/oauth/authorize', (req, res) => {
const { userId } = req.query;
if (!userId) {
return res.status(400).json({ error: 'userId required' });
}
const authUrl = generateAuthUrl(userId);
res.json({ authUrl });
});
// OAuth: Handle callback
app.get('/oauth/callback', async (req, res) => {
const { code, state: userId } = req.query;
if (!code || !userId) {
return res.redirect(`${process.env.FRONTEND_URL}/error?message=invalid_oauth`);
}
try {
// Exchange code for tokens
const tokens = await getTokensFromCode(code);
// Get user email from token
const oauth2Client = new google.auth.OAuth2();
oauth2Client.setCredentials(tokens);
const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client });
const userInfo = await oauth2.userinfo.get();
// Save tokens
await saveUserTokens(parseInt(userId), userInfo.data.email, tokens);
res.redirect(`${process.env.FRONTEND_URL}/success`);
} catch (error) {
console.error('OAuth callback error:', error);
res.redirect(`${process.env.FRONTEND_URL}/error?message=token_exchange_failed`);
}
});
// POST /api/book-meeting - Book meeting (creates Calendar + CRM entry)
app.post('/api/book-meeting', async (req, res) => {
try {
const {
userId,
contactEmail,
title,
description,
startTime,
endTime,
timezone,
createMeetLink,
} = req.body;
console.log(`\n📅 Booking meeting for ${contactEmail}`);
// 1. Check for conflicts
const existingEvents = await getUserEvents(
userId,
new Date(startTime),
new Date(endTime)
);
if (hasConflict({ start: startTime, end: endTime }, existingEvents)) {
return res.status(409).json({
success: false,
error: 'Time slot conflicts with existing event',
});
}
// 2. Get or verify contact in CRM
let contact = await getContactByEmail(contactEmail);
if (!contact) {
return res.status(404).json({
success: false,
error: 'Contact not found in CRM',
});
}
console.log(` Contact found: ${contact.contactId}`);
// 3. Create Google Calendar event
const calendarEvent = await createCalendarEvent(userId, {
title,
description,
startTime,
endTime,
timezone,
createMeetLink,
attendees: [{ email: contactEmail }],
});
console.log(` Calendar event: ${calendarEvent.eventId}`);
// 4. Create CRM activity
const crmActivity = await createCRMActivity({
contactId: contact.contactId,
title,
description: `${description}\n\nMeeting Link: ${calendarEvent.meetLink || 'N/A'}`,
startTime,
endTime,
});
console.log(` CRM activity: ${crmActivity.activityId}`);
// 5. Save mapping to database
await saveCalendarEvent({
userId,
googleEventId: calendarEvent.eventId,
crmActivityId: crmActivity.activityId,
crmType: crmActivity.crmType,
contactId: contact.contactId,
contactEmail,
eventTitle: title,
eventStart: startTime,
eventEnd: endTime,
eventStatus: 'confirmed',
meetingLink: calendarEvent.meetLink,
});
console.log('✓ Meeting booked successfully\n');
res.json({
success: true,
data: {
calendarEventId: calendarEvent.eventId,
crmActivityId: crmActivity.activityId,
meetLink: calendarEvent.meetLink,
htmlLink: calendarEvent.htmlLink,
},
});
} catch (error) {
console.error('Error booking meeting:', error);
res.status(500).json({
success: false,
error: error.message,
});
}
});
// GET /api/available-slots - Get available time slots
app.get('/api/available-slots', async (req, res) => {
try {
const { userId, date, duration = 30 } = req.query;
if (!userId || !date) {
return res.status(400).json({ error: 'userId and date required' });
}
// Get existing events for the day
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
const existingEvents = await getUserEvents(
parseInt(userId),
startOfDay.toISOString(),
endOfDay.toISOString()
);
// Generate available slots (9 AM - 5 PM)
const slots = generateTimeSlots(
date,
9, // start hour
17, // end hour
parseInt(duration),
existingEvents
);
res.json({
success: true,
date,
slots,
});
} catch (error) {
console.error('Error getting available slots:', error);
res.status(500).json({ error: error.message });
}
});
// POST /api/cancel-meeting - Cancel meeting
app.post('/api/cancel-meeting', async (req, res) => {
try {
const { googleEventId } = req.body;
const event = await getEventByGoogleId(googleEventId);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Cancel in Google Calendar
await cancelCalendarEvent(event.user_id, googleEventId);
// Cancel in CRM
if (event.crm_activity_id) {
await deleteCRMActivity(event.crm_activity_id);
}
// Update status in database
await updateEventStatus(googleEventId, 'cancelled');
res.json({ success: true });
} catch (error) {
console.error('Error canceling meeting:', error);
res.status(500).json({ error: error.message });
}
});
// Webhook: Google Calendar changes
app.post('/webhooks/google-calendar', async (req, res) => {
try {
const channelId = req.headers['x-goog-channel-id'];
const resourceState = req.headers['x-goog-resource-state'];
console.log(`\n📥 Webhook: ${resourceState} from channel ${channelId}`);
if (resourceState === 'sync') {
// Initial sync notification
return res.status(200).send('OK');
}
// Handle event changes
// TODO: Fetch changed events and sync with CRM
res.status(200).send('OK');
} catch (error) {
console.error('Webhook error:', error);
res.status(500).send('Error');
}
});
// Start server
async function startServer() {
try {
await testConnection();
app.listen(PORT, () => {
console.log(`\n🚀 Server running on http://localhost:${PORT}`);
console.log(`\nEndpoints:`);
console.log(` GET /oauth/authorize - Start OAuth`);
console.log(` POST /api/book-meeting - Book meeting`);
console.log(` GET /api/available-slots - Get time slots`);
console.log(` POST /api/cancel-meeting - Cancel meeting\n`);
});
} catch (error) {
console.error('Failed to start:', error);
process.exit(1);
}
}
startServer();
frontend/src/components/BookingForm.jsx — Meeting booking UI:
import { useState, useEffect } from 'react';
import axios from 'axios';
import { format } from 'date-fns';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';
export default function BookingForm({ userId, salesRepEmail }) {
const [date, setDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [availableSlots, setAvailableSlots] = useState([]);
const [selectedSlot, setSelectedSlot] = useState(null);
const [formData, setFormData] = useState({
email: '',
name: '',
notes: '',
});
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
fetchAvailableSlots();
}, [date]);
async function fetchAvailableSlots() {
try {
setLoading(true);
const response = await axios.get(`${API_URL}/api/available-slots`, {
params: { userId, date, duration: 30 },
});
if (response.data.success) {
setAvailableSlots(response.data.slots);
}
} catch (err) {
setError('Failed to load available times');
} finally {
setLoading(false);
}
}
async function handleSubmit(e) {
e.preventDefault();
if (!selectedSlot) {
setError('Please select a time slot');
return;
}
try {
setLoading(true);
setError(null);
const response = await axios.post(`${API_URL}/api/book-meeting`, {
userId,
contactEmail: formData.email,
title: `Meeting with ${formData.name}`,
description: formData.notes || 'Sales meeting',
startTime: selectedSlot.start,
endTime: selectedSlot.end,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
createMeetLink: true,
});
if (response.data.success) {
setSuccess(true);
window.location.href = response.data.data.htmlLink;
}
} catch (err) {
if (err.response?.status === 404) {
setError('Email not found in our CRM. Please contact sales.');
} else if (err.response?.status === 409) {
setError('Time slot no longer available. Please select another.');
fetchAvailableSlots();
} else {
setError(err.response?.data?.error || 'Failed to book meeting');
}
} finally {
setLoading(false);
}
}
if (success) {
return (
<div className="max-w-md mx-auto p-6 bg-green-50 rounded-lg">
<h2 className="text-2xl font-bold text-green-800 mb-4">
✅ Meeting Booked!
</h2>
<p>Check your email for the calendar invite.</p>
</div>
);
}
return (
<div className="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow">
<h2 className="text-2xl font-bold mb-6">Schedule a Meeting</h2>
{error && (
<div className="mb-4 p-4 bg-red-50 text-red-700 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
{/* Contact Info */}
<div className="mb-6">
<label className="block font-medium mb-2">Your Email</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-2 border rounded"
placeholder="you@company.com"
/>
</div>
<div className="mb-6">
<label className="block font-medium mb-2">Your Name</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border rounded"
/>
</div>
{/* Date Selection */}
<div className="mb-6">
<label className="block font-medium mb-2">Select Date</label>
<input
type="date"
value={date}
min={format(new Date(), 'yyyy-MM-dd')}
onChange={(e) => {
setDate(e.target.value);
setSelectedSlot(null);
}}
className="w-full px-4 py-2 border rounded"
/>
</div>
{/* Time Slots */}
<div className="mb-6">
<label className="block font-medium mb-2">Available Times</label>
{loading ? (
<p>Loading available times...</p>
) : (
<div className="grid grid-cols-3 gap-2">
{availableSlots.map((slot, index) => (
<button
key={index}
type="button"
onClick={() => setSelectedSlot(slot)}
className={`px-4 py-2 border rounded ${
selectedSlot === slot
? 'bg-blue-600 text-white'
: 'bg-white hover:bg-gray-50'
}`}
>
{format(new Date(slot.start), 'h:mm a')}
</button>
))}
</div>
)}
</div>
{/* Notes */}
<div className="mb-6">
<label className="block font-medium mb-2">Notes (Optional)</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="w-full px-4 py-2 border rounded"
rows="3"
/>
</div>
<button
type="submit"
disabled={loading || !selectedSlot}
className="w-full py-3 bg-blue-600 text-white rounded font-medium hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Booking...' : 'Book Meeting'}
</button>
</form>
</div>
);
}
Step 4: Testing
Test 1: Start Backend
cd backend
npm run dev
Test 2: Initiate OAuth Flow
curl "http://localhost:5000/oauth/authorize?userId=1"
Expected response:
{
"authUrl": "https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar..."
}
Open the URL in browser, authorize, verify redirect to callback.
Test 3: Check Available Slots
curl "http://localhost:5000/api/available-slots?userId=1&date=2024-03-20&duration=30"
Expected response:
{
"success": true,
"date": "2024-03-20",
"slots": [
{
"start": "2024-03-20T13:00:00.000Z",
"end": "2024-03-20T13:30:00.000Z",
"available": true
},
...
]
}
Test 4: Book Meeting
curl -X POST http://localhost:5000/api/book-meeting \
-H "Content-Type: application/json" \
-d '{
"userId": 1,
"contactEmail": "prospect@example.com",
"title": "Product Demo",
"description": "Demo of product features",
"startTime": "2024-03-20T14:00:00Z",
"endTime": "2024-03-20T14:30:00Z",
"timezone": "America/New_York",
"createMeetLink": true
}'
Expected response:
{
"success": true,
"data": {
"calendarEventId": "abc123def456",
"crmActivityId": "789012",
"meetLink": "https://meet.google.com/xyz-abcd-efg",
"htmlLink": "https://calendar.google.com/event?eid=..."
}
}
Test 5: Verify in Google Calendar
- Open Google Calendar
- Check for new event on selected date/time
- Verify attendee email is included
- Confirm Google Meet link is attached
Test 6: Verify in CRM (HubSpot)
- Log into HubSpot
- Find contact by email
- Check Activities/Engagements
- Verify meeting is logged with correct time
Test 7: Check Database Mapping
psql -U your_username -d calendar_crm
SELECT * FROM calendar_events ORDER BY created_at DESC LIMIT 5;
Verify:
- ✓ Google event ID stored
- ✓ CRM activity ID stored
- ✓ Contact email linked
- ✓ Times are correct
Testing Checklist:
- ✓ OAuth flow completes
- ✓ Tokens saved to database
- ✓ Available slots return correctly
- ✓ Meeting creates in Calendar
- ✓ Meeting creates in CRM
- ✓ Database mapping saved
- ✓ Google Meet link generated
- ✓ Email notifications sent
Common Errors & Troubleshooting
Error 1: “Invalid grant” or “Token has been expired or revoked”
Problem: OAuth tokens expired or invalid, API requests fail with 401.
Solution: Implement automatic token refresh using refresh token:
// Already implemented in getCalendarClient()
if (now >= expiry && userTokens.refresh_token) {
oauth2Client.setCredentials({
refresh_token: userTokens.refresh_token,
});
const { credentials } = await oauth2Client.refreshAccessToken();
await saveUserTokens(userId, userTokens.email, credentials);
}
If refresh token missing:
User must re-authorize. Ensure OAuth flow uses prompt: 'consent' and access_type: 'offline' to always get refresh tokens:
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline', // Critical for refresh token
prompt: 'consent', // Force consent screen
scope: scopes,
});
Test token refresh:
# Check token expiry in database
SELECT token_expiry, refresh_token FROM user_tokens WHERE user_id = 1;
# If expired, next API call should auto-refresh
Error 2: “Contact not found in CRM” (404 Error)
Problem: Booking fails because email doesn’t exist in CRM.
Solution: Add contact creation flow for new prospects:
// In api/crm.js, add createContact function
export async function createContact(email, firstName, lastName) {
if (CRM_TYPE === 'hubspot') {
const contact = {
properties: {
email,
firstname: firstName,
lastname: lastName,
},
};
const response = await axios.post(
'https://api.hubapi.com/crm/v3/objects/contacts',
contact,
{
params: { hapikey: HUBSPOT_API_KEY },
}
);
return {
contactId: response.data.id,
email,
};
}
}
// In server.js, modify book-meeting endpoint
let contact = await getContactByEmail(contactEmail);
if (!contact) {
// Auto-create contact for new prospects
const [firstName, lastName] = req.body.name.split(' ');
contact = await createContact(contactEmail, firstName, lastName || '');
console.log(` Contact created: ${contact.contactId}`);
}
Alternative: Return friendly error asking user to contact sales first.
Error 3: “Insufficient Permission” or Scope Errors
Problem: Calendar API requests fail with 403 Forbidden.
Solution: Verify OAuth scopes include necessary permissions:
Required scopes:
const scopes = [
'https://www.googleapis.com/auth/calendar', // Full calendar access
'https://www.googleapis.com/auth/calendar.events', // Event management
];
Check granted scopes in database:
// When saving tokens, also log scopes
console.log('Granted scopes:', tokens.scope);
If scopes missing:
- Update scope list in
generateAuthUrl() - Force user re-authorization with
prompt: 'consent' - Delete old tokens:
DELETE FROM user_tokens WHERE user_id = 1 - User must complete OAuth flow again
Verify in Google Cloud Console:
- Go to APIs & Services → Credentials
- Check OAuth consent screen
- Ensure Calendar API is enabled in API Library
Security Checklist
Critical security practices for Google Calendar-CRM integration:
- Store OAuth tokens encrypted — Use database encryption for
access_tokenandrefresh_tokencolumns. Implement at-rest encryption:
-- PostgreSQL example with pgcrypto
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Encrypt before storing
INSERT INTO user_tokens (access_token)
VALUES (pgp_sym_encrypt('token_value', 'encryption_key'));
- Use HTTPS for OAuth redirect URIs — Google requires HTTPS in production. Update
.env:
GOOGLE_REDIRECT_URI=https://yourdomain.com/oauth/callback
- Implement CSRF protection — Verify
stateparameter matches during OAuth callback:
// Before OAuth, generate and store state
const state = crypto.randomBytes(16).toString('hex');
await redis.set(`oauth_state_${userId}`, state, 'EX', 600);
// In callback, verify
const storedState = await redis.get(`oauth_state_${userId}`);
if (state !== storedState) {
throw new Error('Invalid state parameter');
}
- Validate webhook signatures — Verify Google Calendar webhook requests:
app.post('/webhooks/google-calendar', (req, res) => {
const channelId = req.headers['x-goog-channel-id'];
const channelToken = req.headers['x-goog-channel-token'];
// Verify channel belongs to your system
const validChannel = await getWebhookChannel(channelId);
if (!validChannel || validChannel.token !== channelToken) {
return res.status(403).send('Unauthorized');
}
// Process webhook...
});
- Implement rate limiting — Protect booking endpoint from abuse:
import rateLimit from 'express-rate-limit';
const bookingLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Max 5 bookings per 15 minutes per IP
message: 'Too many booking requests',
});
app.post('/api/book-meeting', bookingLimiter, async (req, res) => {
// ...
});
- Sanitize user inputs — Validate and escape all booking form data:
function sanitizeInput(input) {
return input.trim().slice(0, 500); // Max length
}
const title = sanitizeInput(req.body.title);
const description = sanitizeInput(req.body.description);
- Restrict calendar access scope — Use minimal OAuth scopes. If only creating events, request
calendar.eventsinstead of fullcalendaraccess. - Log all booking attempts — Track suspicious activity:
await pool.query(
'INSERT INTO booking_audit_log (ip, email, success, error) VALUES ($1, $2, $3, $4)',
[req.ip, req.body.contactEmail, success, error]
);
- Implement token rotation — Refresh tokens before expiry:
// Check expiry 1 hour before actual expiration
if (expiry - Date.now() < 3600000) {
await refreshAccessToken(userId);
}
- Validate timezone inputs — Prevent timezone manipulation attacks:
const validTimezones = Intl.supportedValuesOf('timeZone');
if (!validTimezones.includes(req.body.timezone)) {
return res.status(400).json({ error: 'Invalid timezone' });
}
- Revoke access on account deletion — Clean up tokens when user deletes account:
async function revokeGoogleAccess(userId) {
const tokens = await getUserTokens(userId);
await oauth2Client.revokeToken(tokens.access_token);
await pool.query('DELETE FROM user_tokens WHERE user_id = $1', [userId]);
}
Related Resources:
- How to Integrate HubSpot OAuth – OAuth implementation patterns
- Is OAuth 2.0 Obsolete? – Understanding modern OAuth
- Connecting HubSpot to React – Similar OAuth integration guide
- Building Audit Trails and Activity Logs – Track calendar events in your system
- Custom Dashboard Using Pipedrive REST API – Another CRM API integration example

Huzaifa Asif is a dedicated software and integration specialist at TheSportsAngel, focused on making complex API and system integrations simple and actionable. With over 3+ years of hands-on experience in backend development, CRM/ERP connectivity, and third-party platform integrations, he transforms technical architecture into clear, step-by-step coding guides that both developers and non-technical users can follow.



