How to Integrate Google Calendar API with Node.js: Building a Custom Booking System from Scratch

How to Integrate Google Calendar API with Node.js

The Problem

Building a booking system is deceptively complex. Off-the-shelf solutions like Calendly charge recurring fees and lack customization, while coding from scratch means wrestling with time zone conversions, double-booking prevention, availability checking across multiple calendars, and OAuth2 authentication flows. Google Calendar’s API is powerful but notoriously difficult to implement correctly—mishandle the token refresh logic, and your system breaks silently; forget to check for conflicts, and you’ll double-book appointments. This integration solves the core problem: creating a production-ready booking system that syncs with Google Calendar, prevents conflicts, handles authentication securely, and gives you complete control over the user experience and business logic. Get it right, and you’ll have a scalable booking infrastructure without monthly SaaS fees.

Tech Stack & Prerequisites

  • Node.js v20+ and npm/pnpm
  • TypeScript 5+ (recommended for type safety)
  • Google Cloud Project with Calendar API enabled
  • Google OAuth2 credentials (Client ID and Client Secret)
  • googleapis npm package v128+
  • Express.js v4.18+ for web server
  • dotenv for environment variables
  • date-fns or moment-timezone for time zone handling
  • PostgreSQL or MongoDB (optional, for booking persistence)
  • Basic understanding of OAuth2 flow and REST APIs

Step-by-Step Implementation

Step 1: Setup

Initialize your Node.js project:

bash
mkdir google-calendar-booking
cd google-calendar-booking
npm init -y
npm install googleapis express dotenv date-fns
npm install -D typescript @types/node @types/express tsx nodemon

Initialize TypeScript:

bash
npx tsc --init

Update tsconfig.json:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Create project structure:

bash
mkdir src
touch src/index.ts src/config.ts src/google-calendar.ts src/booking.ts src/routes.ts

Update package.json scripts:

json
{
  "scripts": {
    "dev": "nodemon --exec tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Step 2: Configuration

2.1: Set Up Google Cloud Project

  1. Go to Google Cloud Console
  2. Create a new project or select existing
  3. Enable Google Calendar API:
    • Navigate to “APIs & Services” → “Library”
    • Search for “Google Calendar API”
    • Click “Enable”
  4. Create OAuth2 credentials:
    • Go to “APIs & Services” → “Credentials”
    • Click “Create Credentials” → “OAuth client ID”
    • Choose “Web application”
    • Add authorized redirect URIs: http://localhost:3000/auth/callback
    • Save Client ID and Client Secret

2.2: Configure Environment Variables

Create .env file:

bash
# .env
PORT=3000

# Google OAuth2 credentials
GOOGLE_CLIENT_ID=your_client_id_here.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_client_secret_here
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback

# Calendar configuration
CALENDAR_ID=primary
TIMEZONE=America/New_York

# Optional: Service account for server-to-server
# GOOGLE_SERVICE_ACCOUNT_EMAIL=
# GOOGLE_PRIVATE_KEY=

Add to .gitignore:

bash
echo ".env" >> .gitignore
echo "node_modules/" >> .gitignore
echo "dist/" >> .gitignore
echo "tokens.json" >> .gitignore

Create src/config.ts:

typescript
// src/config.ts
import dotenv from 'dotenv';

dotenv.config();

export const config = {
  port: process.env.PORT || 3000,
  google: {
    clientId: process.env.GOOGLE_CLIENT_ID || '',
    clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
    redirectUri: process.env.GOOGLE_REDIRECT_URI || '',
  },
  calendar: {
    calendarId: process.env.CALENDAR_ID || 'primary',
    timeZone: process.env.TIMEZONE || 'America/New_York',
  },
} as const;

// Validate required environment variables
const requiredEnvVars = [
  'GOOGLE_CLIENT_ID',
  'GOOGLE_CLIENT_SECRET',
  'GOOGLE_REDIRECT_URI',
];

for (const envVar of requiredEnvVars) {
  if (!process.env[envVar]) {
    throw new Error(`Missing required environment variable: ${envVar}`);
  }
}

Step 3: Core Logic

3.1: Google Calendar Client

Create src/google-calendar.ts:

typescript
// src/google-calendar.ts
import { google } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';
import { config } from './config';
import fs from 'fs/promises';
import path from 'path';

export interface TimeSlot {
  start: string; // ISO 8601 format
  end: string;
}

export interface BookingRequest {
  summary: string;
  description?: string;
  attendeeEmail: string;
  startTime: string;
  endTime: string;
  timeZone?: string;
}

export class GoogleCalendarClient {
  private oauth2Client: OAuth2Client;
  private calendar;
  private tokenPath = path.join(__dirname, '../tokens.json');

  constructor() {
    this.oauth2Client = new google.auth.OAuth2(
      config.google.clientId,
      config.google.clientSecret,
      config.google.redirectUri
    );

    this.calendar = google.calendar({ version: 'v3', auth: this.oauth2Client });
  }

  /**
   * Generate authorization URL for OAuth2 flow
   */
  getAuthUrl(): string {
    const scopes = [
      'https://www.googleapis.com/auth/calendar',
      'https://www.googleapis.com/auth/calendar.events',
    ];

    return this.oauth2Client.generateAuthUrl({
      access_type: 'offline', // Important: gets refresh token
      scope: scopes,
      prompt: 'consent', // Force consent screen to get refresh token
    });
  }

  /**
   * Exchange authorization code for tokens
   */
  async getTokenFromCode(code: string): Promise<void> {
    try {
      const { tokens } = await this.oauth2Client.getToken(code);
      this.oauth2Client.setCredentials(tokens);
      
      // Save tokens to file for persistence
      await this.saveTokens(tokens);
      console.log('Tokens saved successfully');
    } catch (error) {
      console.error('Error getting token from code:', error);
      throw error;
    }
  }

  /**
   * Load tokens from file
   */
  async loadTokens(): Promise<boolean> {
    try {
      const tokensData = await fs.readFile(this.tokenPath, 'utf-8');
      const tokens = JSON.parse(tokensData);
      this.oauth2Client.setCredentials(tokens);
      
      // Set up automatic token refresh
      this.oauth2Client.on('tokens', async (newTokens) => {
        if (newTokens.refresh_token) {
          tokens.refresh_token = newTokens.refresh_token;
        }
        tokens.access_token = newTokens.access_token;
        tokens.expiry_date = newTokens.expiry_date;
        await this.saveTokens(tokens);
      });
      
      return true;
    } catch (error) {
      console.log('No saved tokens found');
      return false;
    }
  }

  /**
   * Save tokens to file
   */
  private async saveTokens(tokens: any): Promise<void> {
    await fs.writeFile(this.tokenPath, JSON.stringify(tokens, null, 2));
  }

  /**
   * Check if calendar is authenticated
   */
  isAuthenticated(): boolean {
    const credentials = this.oauth2Client.credentials;
    return !!(credentials && credentials.access_token);
  }

  /**
   * Get available time slots for a specific date
   */
  async getAvailableSlots(
    date: string, // YYYY-MM-DD format
    slotDuration: number = 30, // minutes
    workingHours: { start: string; end: string } = { start: '09:00', end: '17:00' }
  ): Promise<TimeSlot[]> {
    try {
      // Parse date and working hours
      const [startHour, startMinute] = workingHours.start.split(':').map(Number);
      const [endHour, endMinute] = workingHours.end.split(':').map(Number);

      const dayStart = new Date(`${date}T${workingHours.start}:00`);
      const dayEnd = new Date(`${date}T${workingHours.end}:00`);

      // Get busy times from calendar
      const response = await this.calendar.freebusy.query({
        requestBody: {
          timeMin: dayStart.toISOString(),
          timeMax: dayEnd.toISOString(),
          timeZone: config.calendar.timeZone,
          items: [{ id: config.calendar.calendarId }],
        },
      });

      const busySlots = response.data.calendars?.[config.calendar.calendarId]?.busy || [];

      // Generate all possible slots
      const allSlots: TimeSlot[] = [];
      let currentTime = new Date(dayStart);

      while (currentTime < dayEnd) {
        const slotEnd = new Date(currentTime.getTime() + slotDuration * 60000);
        
        if (slotEnd <= dayEnd) {
          allSlots.push({
            start: currentTime.toISOString(),
            end: slotEnd.toISOString(),
          });
        }
        
        currentTime = slotEnd;
      }

      // Filter out busy slots
      const availableSlots = allSlots.filter((slot) => {
        const slotStart = new Date(slot.start);
        const slotEnd = new Date(slot.end);

        return !busySlots.some((busy) => {
          const busyStart = new Date(busy.start!);
          const busyEnd = new Date(busy.end!);

          // Check if slot overlaps with busy time
          return slotStart < busyEnd && slotEnd > busyStart;
        });
      });

      return availableSlots;
    } catch (error) {
      console.error('Error getting available slots:', error);
      throw error;
    }
  }

  /**
   * Check if a specific time slot is available
   */
  async isSlotAvailable(startTime: string, endTime: string): Promise<boolean> {
    try {
      const response = await this.calendar.freebusy.query({
        requestBody: {
          timeMin: startTime,
          timeMax: endTime,
          timeZone: config.calendar.timeZone,
          items: [{ id: config.calendar.calendarId }],
        },
      });

      const busySlots = response.data.calendars?.[config.calendar.calendarId]?.busy || [];
      return busySlots.length === 0;
    } catch (error) {
      console.error('Error checking slot availability:', error);
      throw error;
    }
  }

  /**
   * Create a calendar event (booking)
   */
  async createBooking(booking: BookingRequest): Promise<any> {
    try {
      // First check if slot is available
      const isAvailable = await this.isSlotAvailable(
        booking.startTime,
        booking.endTime
      );

      if (!isAvailable) {
        throw new Error('Time slot is not available');
      }

      // Create event
      const event = {
        summary: booking.summary,
        description: booking.description,
        start: {
          dateTime: booking.startTime,
          timeZone: booking.timeZone || config.calendar.timeZone,
        },
        end: {
          dateTime: booking.endTime,
          timeZone: booking.timeZone || config.calendar.timeZone,
        },
        attendees: [{ email: booking.attendeeEmail }],
        reminders: {
          useDefault: false,
          overrides: [
            { method: 'email', minutes: 24 * 60 }, // 1 day before
            { method: 'popup', minutes: 30 }, // 30 minutes before
          ],
        },
        conferenceData: {
          createRequest: {
            requestId: `booking-${Date.now()}`,
            conferenceSolutionKey: { type: 'hangoutsMeet' },
          },
        },
      };

      const response = await this.calendar.events.insert({
        calendarId: config.calendar.calendarId,
        requestBody: event,
        conferenceDataVersion: 1, // Enable Google Meet
        sendUpdates: 'all', // Send email notifications
      });

      console.log('Booking created:', response.data.id);
      return response.data;
    } catch (error) {
      console.error('Error creating booking:', error);
      throw error;
    }
  }

  /**
   * Cancel a booking
   */
  async cancelBooking(eventId: string): Promise<void> {
    try {
      await this.calendar.events.delete({
        calendarId: config.calendar.calendarId,
        eventId: eventId,
        sendUpdates: 'all', // Notify attendees
      });

      console.log('Booking cancelled:', eventId);
    } catch (error) {
      console.error('Error cancelling booking:', error);
      throw error;
    }
  }

  /**
   * Update a booking
   */
  async updateBooking(
    eventId: string,
    updates: Partial<BookingRequest>
  ): Promise<any> {
    try {
      // Get existing event
      const existingEvent = await this.calendar.events.get({
        calendarId: config.calendar.calendarId,
        eventId: eventId,
      });

      // Check if new time slot is available (if time is being changed)
      if (updates.startTime && updates.endTime) {
        const isAvailable = await this.isSlotAvailable(
          updates.startTime,
          updates.endTime
        );

        if (!isAvailable) {
          throw new Error('New time slot is not available');
        }
      }

      // Prepare updated event
      const updatedEvent = {
        ...existingEvent.data,
        summary: updates.summary || existingEvent.data.summary,
        description: updates.description || existingEvent.data.description,
        start: updates.startTime
          ? {
              dateTime: updates.startTime,
              timeZone: updates.timeZone || config.calendar.timeZone,
            }
          : existingEvent.data.start,
        end: updates.endTime
          ? {
              dateTime: updates.endTime,
              timeZone: updates.timeZone || config.calendar.timeZone,
            }
          : existingEvent.data.end,
      };

      const response = await this.calendar.events.update({
        calendarId: config.calendar.calendarId,
        eventId: eventId,
        requestBody: updatedEvent,
        sendUpdates: 'all',
      });

      console.log('Booking updated:', response.data.id);
      return response.data;
    } catch (error) {
      console.error('Error updating booking:', error);
      throw error;
    }
  }

  /**
   * List upcoming bookings
   */
  async listUpcomingBookings(maxResults: number = 10): Promise<any[]> {
    try {
      const response = await this.calendar.events.list({
        calendarId: config.calendar.calendarId,
        timeMin: new Date().toISOString(),
        maxResults: maxResults,
        singleEvents: true,
        orderBy: 'startTime',
      });

      return response.data.items || [];
    } catch (error) {
      console.error('Error listing bookings:', error);
      throw error;
    }
  }
}

3.2: Booking Service Layer

Create src/booking.ts:

typescript
// src/booking.ts
import { GoogleCalendarClient, BookingRequest } from './google-calendar';
import { addMinutes, format, parseISO } from 'date-fns';

export interface AvailabilityRequest {
  date: string; // YYYY-MM-DD
  duration?: number; // minutes, default 30
}

export interface CreateBookingRequest {
  date: string; // YYYY-MM-DD
  time: string; // HH:MM
  duration: number; // minutes
  clientName: string;
  clientEmail: string;
  notes?: string;
}

export class BookingService {
  private calendarClient: GoogleCalendarClient;

  constructor() {
    this.calendarClient = new GoogleCalendarClient();
  }

  async initialize(): Promise<void> {
    const hasTokens = await this.calendarClient.loadTokens();
    
    if (!hasTokens) {
      console.log('No authentication tokens found.');
      console.log('Please visit the /auth endpoint to authenticate.');
    }
  }

  getAuthUrl(): string {
    return this.calendarClient.getAuthUrl();
  }

  async handleAuthCallback(code: string): Promise<void> {
    await this.calendarClient.getTokenFromCode(code);
  }

  isAuthenticated(): boolean {
    return this.calendarClient.isAuthenticated();
  }

  /**
   * Get available time slots for booking
   */
  async getAvailability(request: AvailabilityRequest) {
    if (!this.isAuthenticated()) {
      throw new Error('Not authenticated with Google Calendar');
    }

    const duration = request.duration || 30;
    const slots = await this.calendarClient.getAvailableSlots(
      request.date,
      duration
    );

    // Format slots for frontend
    return slots.map((slot) => ({
      start: slot.start,
      end: slot.end,
      startTime: format(parseISO(slot.start), 'HH:mm'),
      endTime: format(parseISO(slot.end), 'HH:mm'),
      available: true,
    }));
  }

  /**
   * Create a new booking
   */
  async createBooking(request: CreateBookingRequest) {
    if (!this.isAuthenticated()) {
      throw new Error('Not authenticated with Google Calendar');
    }

    // Construct ISO datetime strings
    const startDateTime = `${request.date}T${request.time}:00`;
    const startDate = parseISO(startDateTime);
    const endDate = addMinutes(startDate, request.duration);

    const bookingRequest: BookingRequest = {
      summary: `Booking with ${request.clientName}`,
      description: request.notes || `Appointment scheduled for ${request.duration} minutes`,
      attendeeEmail: request.clientEmail,
      startTime: startDate.toISOString(),
      endTime: endDate.toISOString(),
    };

    const event = await this.calendarClient.createBooking(bookingRequest);

    return {
      bookingId: event.id,
      summary: event.summary,
      startTime: event.start.dateTime,
      endTime: event.end.dateTime,
      meetLink: event.hangoutLink,
      status: event.status,
    };
  }

  /**
   * Cancel a booking
   */
  async cancelBooking(bookingId: string) {
    if (!this.isAuthenticated()) {
      throw new Error('Not authenticated with Google Calendar');
    }

    await this.calendarClient.cancelBooking(bookingId);
    
    return {
      success: true,
      message: 'Booking cancelled successfully',
    };
  }

  /**
   * Get upcoming bookings
   */
  async getUpcomingBookings(limit: number = 10) {
    if (!this.isAuthenticated()) {
      throw new Error('Not authenticated with Google Calendar');
    }

    const events = await this.calendarClient.listUpcomingBookings(limit);

    return events.map((event) => ({
      id: event.id,
      summary: event.summary,
      description: event.description,
      startTime: event.start.dateTime,
      endTime: event.end.dateTime,
      attendees: event.attendees?.map((a: any) => a.email) || [],
      meetLink: event.hangoutLink,
      status: event.status,
    }));
  }
}

3.3: Express Routes

Create src/routes.ts:

typescript
// src/routes.ts
import { Router, Request, Response } from 'express';
import { BookingService } from './booking';

const router = Router();
const bookingService = new BookingService();

// Initialize on startup
bookingService.initialize();

/**
 * GET /auth
 * Redirect user to Google OAuth consent screen
 */
router.get('/auth', (req: Request, res: Response) => {
  const authUrl = bookingService.getAuthUrl();
  res.redirect(authUrl);
});

/**
 * GET /auth/callback
 * Handle OAuth callback from Google
 */
router.get('/auth/callback', async (req: Request, res: Response) => {
  try {
    const code = req.query.code as string;

    if (!code) {
      return res.status(400).json({ error: 'No authorization code provided' });
    }

    await bookingService.handleAuthCallback(code);

    res.json({
      success: true,
      message: 'Successfully authenticated with Google Calendar',
    });
  } catch (error) {
    console.error('Auth callback error:', error);
    res.status(500).json({ error: 'Authentication failed' });
  }
});

/**
 * GET /availability
 * Get available time slots for a specific date
 * Query params: date (YYYY-MM-DD), duration (minutes, optional)
 */
router.get('/availability', async (req: Request, res: Response) => {
  try {
    const { date, duration } = req.query;

    if (!date) {
      return res.status(400).json({ error: 'Date parameter is required' });
    }

    const slots = await bookingService.getAvailability({
      date: date as string,
      duration: duration ? parseInt(duration as string) : undefined,
    });

    res.json({
      date,
      slots,
      totalAvailable: slots.length,
    });
  } catch (error: any) {
    console.error('Availability error:', error);
    res.status(500).json({ error: error.message });
  }
});

/**
 * POST /bookings
 * Create a new booking
 */
router.post('/bookings', async (req: Request, res: Response) => {
  try {
    const { date, time, duration, clientName, clientEmail, notes } = req.body;

    // Validate required fields
    if (!date || !time || !duration || !clientName || !clientEmail) {
      return res.status(400).json({
        error: 'Missing required fields: date, time, duration, clientName, clientEmail',
      });
    }

    const booking = await bookingService.createBooking({
      date,
      time,
      duration,
      clientName,
      clientEmail,
      notes,
    });

    res.status(201).json({
      success: true,
      booking,
    });
  } catch (error: any) {
    console.error('Booking creation error:', error);
    res.status(500).json({ error: error.message });
  }
});

/**
 * GET /bookings
 * List upcoming bookings
 */
router.get('/bookings', async (req: Request, res: Response) => {
  try {
    const limit = req.query.limit ? parseInt(req.query.limit as string) : 10;
    
    const bookings = await bookingService.getUpcomingBookings(limit);

    res.json({
      bookings,
      total: bookings.length,
    });
  } catch (error: any) {
    console.error('List bookings error:', error);
    res.status(500).json({ error: error.message });
  }
});

/**
 * DELETE /bookings/:id
 * Cancel a booking
 */
router.delete('/bookings/:id', async (req: Request, res: Response) => {
  try {
    const { id } = req.params;

    const result = await bookingService.cancelBooking(id);

    res.json(result);
  } catch (error: any) {
    console.error('Booking cancellation error:', error);
    res.status(500).json({ error: error.message });
  }
});

/**
 * GET /status
 * Check authentication status
 */
router.get('/status', (req: Request, res: Response) => {
  const isAuthenticated = bookingService.isAuthenticated();

  res.json({
    authenticated: isAuthenticated,
    message: isAuthenticated
      ? 'Connected to Google Calendar'
      : 'Not authenticated. Visit /auth to connect.',
  });
});

export default router;

3.4: Main Application

Create src/index.ts:

typescript
// src/index.ts
import express from 'express';
import { config } from './config';
import routes from './routes';

const app = express();

// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// CORS (configure appropriately for production)
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  next();
});

// Routes
app.use('/', routes);

// Health check
app.get('/', (req, res) => {
  res.json({
    name: 'Google Calendar Booking API',
    version: '1.0.0',
    status: 'running',
  });
});

// Start server
app.listen(config.port, () => {
  console.log(`Server running on http://localhost:${config.port}`);
  console.log(`Authenticate at: http://localhost:${config.port}/auth`);
});

Step 4: Testing

4.1: Start the Server

Run the development server:

bash
npm run dev
```

Expected output:
```
Server running on http://localhost:3000
Authenticate at: http://localhost:3000/auth

4.2: Authenticate with Google

  1. Open browser and navigate to: http://localhost:3000/auth
  2. Sign in with your Google account
  3. Grant calendar permissions
  4. You’ll be redirected to /auth/callback with success message

4.3: Test API Endpoints with cURL or Postman

Check authentication status:

bash
curl http://localhost:3000/status

Expected response:

json
{
  "authenticated": true,
  "message": "Connected to Google Calendar"
}

Get available slots:

bash
curl "http://localhost:3000/availability?date=2026-02-25&duration=30"

Expected response:

json
{
  "date": "2026-02-25",
  "slots": [
    {
      "start": "2026-02-25T09:00:00.000Z",
      "end": "2026-02-25T09:30:00.000Z",
      "startTime": "09:00",
      "endTime": "09:30",
      "available": true
    },
    ...
  ],
  "totalAvailable": 16
}

Create a booking:

bash
curl -X POST http://localhost:3000/bookings \
  -H "Content-Type: application/json" \
  -d '{
    "date": "2026-02-25",
    "time": "10:00",
    "duration": 30,
    "clientName": "John Doe",
    "clientEmail": "john@example.com",
    "notes": "Initial consultation"
  }'

Expected response:

json
{
  "success": true,
  "booking": {
    "bookingId": "abc123xyz",
    "summary": "Booking with John Doe",
    "startTime": "2026-02-25T10:00:00.000Z",
    "endTime": "2026-02-25T10:30:00.000Z",
    "meetLink": "https://meet.google.com/xxx-yyyy-zzz",
    "status": "confirmed"
  }
}

List upcoming bookings:

bash
curl http://localhost:3000/bookings

Cancel a booking:

bash
curl -X DELETE http://localhost:3000/bookings/abc123xyz

4.4: Test in Google Calendar

  1. Open Google Calendar web interface
  2. Verify the created event appears
  3. Check that attendee email received invitation
  4. Verify Google Meet link is included

4.5: Create Test Script

Create src/test.ts:

typescript
// src/test.ts
import axios from 'axios';

const API_BASE = 'http://localhost:3000';

async function runTests() {
  console.log('Starting API tests...\n');

  try {
    // Test 1: Check status
    console.log('Test 1: Check authentication status');
    const statusRes = await axios.get(`${API_BASE}/status`);
    console.log('Status:', statusRes.data);
    console.log('✓ Passed\n');

    // Test 2: Get availability
    console.log('Test 2: Get availability for tomorrow');
    const tomorrow = new Date();
    tomorrow.setDate(tomorrow.getDate() + 1);
    const dateStr = tomorrow.toISOString().split('T')[0];
    
    const availabilityRes = await axios.get(
      `${API_BASE}/availability?date=${dateStr}&duration=30`
    );
    console.log(`Available slots: ${availabilityRes.data.totalAvailable}`);
    console.log('✓ Passed\n');

    // Test 3: Create booking (only if slots available)
    if (availabilityRes.data.totalAvailable > 0) {
      console.log('Test 3: Create test booking');
      const firstSlot = availabilityRes.data.slots[0];
      
      const bookingRes = await axios.post(`${API_BASE}/bookings`, {
        date: dateStr,
        time: firstSlot.startTime,
        duration: 30,
        clientName: 'Test User',
        clientEmail: 'test@example.com',
        notes: 'Automated test booking',
      });
      
      console.log('Booking created:', bookingRes.data.booking.bookingId);
      console.log('✓ Passed\n');

      // Test 4: List bookings
      console.log('Test 4: List upcoming bookings');
      const listRes = await axios.get(`${API_BASE}/bookings`);
      console.log(`Total bookings: ${listRes.data.total}`);
      console.log('✓ Passed\n');

      // Test 5: Cancel booking
      console.log('Test 5: Cancel test booking');
      await axios.delete(
        `${API_BASE}/bookings/${bookingRes.data.booking.bookingId}`
      );
      console.log('✓ Passed\n');
    }

    console.log('All tests passed! ✓');
  } catch (error: any) {
    console.error('Test failed:', error.response?.data || error.message);
  }
}

runTests();

Run tests: npx tsx src/test.ts

Common Errors & Troubleshooting

1. Error: “invalid_grant” or “Token has been expired or revoked”

Cause: Refresh token expired, was revoked by user, or never obtained because access_type: 'offline' wasn’t set during initial OAuth flow.

Fix: Delete tokens.json and re-authenticate with prompt: 'consent':

typescript
// In getAuthUrl()
return this.oauth2Client.generateAuthUrl({
  access_type: 'offline', // Critical: must be 'offline'
  scope: scopes,
  prompt: 'consent', // Force consent screen every time
});

Delete tokens and re-auth:

bash
rm tokens.json
# Visit /auth endpoint again

2. Error: “Calendar API has not been used in project before or it is disabled”

Cause: Google Calendar API is not enabled in your Google Cloud project.

Fix: Enable the API manually:

  1. Go to Google Cloud Console
  2. Select your project
  3. Navigate to “APIs & Services” → “Library”
  4. Search “Google Calendar API”
  5. Click “Enable”

Or enable via command line:

bash
gcloud services enable calendar-json.googleapis.com --project=YOUR_PROJECT_ID

3. Error: Time slots showing as unavailable when calendar is empty

Cause: Time zone mismatch between your code and Google Calendar. The working hours you define don’t align with the calendar’s time zone.

Fix: Always use consistent time zones and ISO 8601 format:

typescript
// Ensure consistent timezone handling
import { toZonedTime, format } from 'date-fns-tz';

const timeZone = 'America/New_York';

// When creating date from string
const localDate = toZonedTime(`${date}T${time}:00`, timeZone);
const isoString = format(localDate, "yyyy-MM-dd'T'HH:mm:ssXXX", { timeZone });

// Always pass timeZone to Calendar API
const event = {
  start: {
    dateTime: isoString,
    timeZone: timeZone, // Explicit timezone
  },
  // ...
};

Security Checklist

  • Never commit tokens.json or .env files to version control – add them to .gitignore
  • Use HTTPS only in production – HTTP exposes OAuth tokens and API keys
  • Implement rate limiting on booking endpoints to prevent abuse and calendar spam
  • Validate all user inputs before passing to Calendar API (email format, date ranges, duration limits)
  • Set up OAuth consent screen properly in Google Cloud Console with accurate app information
  • Restrict API key scope to only necessary permissions (use least privilege principle)
  • Implement CORS properly – don’t use * in production, whitelist specific domains
  • Add authentication to your API endpoints so only authorized users can create/cancel bookings
  • Log all booking actions for audit trail and debugging
  • Handle token refresh automatically using the tokens event listener to prevent service interruptions
  • Set webhook verification if using Google Calendar push notifications
  • Monitor API usage in Google Cloud Console to detect anomalies
  • Implement booking limits per user/email to prevent spam
  • Validate time zones to prevent booking manipulation across different zones
  • Add CAPTCHA or similar to public booking forms to prevent bot abuse

Need Expert Help?

Building production-ready integrations requires careful attention to edge cases, security, and scalability. If you need assistance with custom booking systems, calendar integrations, or SaaS development, schedule a consultation with our team. We specialize in turning complex API integrations into robust, maintainable solutions.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top