Integrate Appointment Scheduling with Google Calendar and Twilio: A Developer's Journey

Curious about integrating appointment scheduling? Discover how to connect Google Calendar and Twilio for seamless SMS notifications and event management.

Misal Azeem
Misal Azeem

Voice AI Engineer & Creator

Integrate Appointment Scheduling with Google Calendar and Twilio: A Developer's Journey

Advertisement

Integrate Appointment Scheduling with Google Calendar and Twilio: A Developer's Journey

TL;DR

Most appointment systems fail when SMS notifications fire before calendar events sync, or when attendee updates don't propagate to Twilio. Here's what you build: a webhook listener that catches Google Calendar changes, validates freebusy slots via OAuth2, then triggers Twilio SMS to attendees. Tech stack: Node.js, Google Calendar API v3, Twilio SDK. Result: real-time scheduling with zero double-bookings.

Prerequisites

Google Calendar API v3 credentials: Generate OAuth2 credentials (Client ID, Client Secret) from Google Cloud Console. You'll need https://www.googleapis.com/auth/calendar scope for read/write access to events and attendee data.

Twilio account: Active Twilio account with API credentials (Account SID, Auth Token). Provision at least one Twilio phone number for SMS delivery.

Node.js 16+: Runtime environment with npm or yarn for dependency management.

Environment variables: Store GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER in a .env file. Never hardcode credentials.

Webhook receiver: Public HTTPS endpoint (ngrok, AWS Lambda, or deployed server) to receive Twilio SMS callbacks and Google Calendar push notifications. Twilio requires TLS 1.2+.

Database (optional but recommended): PostgreSQL or MongoDB to persist appointment state, attendee phone numbers, and sync status between systems.

Twilio: Get Twilio Voice API → Get Twilio

Step-by-Step Tutorial

Configuration & Setup

Most scheduling integrations fail because developers skip OAuth scope configuration. Google Calendar requires explicit permissions for calendar access and event modification.

Create a Google Cloud project and enable Calendar API v3. Download OAuth2 credentials (client ID, client secret). Store these in environment variables—never hardcode credentials.

javascript
// OAuth2 configuration for Google Calendar API v3
const oauth2Client = new google.auth.OAuth2(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  process.env.REDIRECT_URI // Must match Google Cloud Console exactly
);

// Required scopes for calendar read/write and freebusy queries
const SCOPES = [
  'https://www.googleapis.com/auth/calendar.events',
  'https://www.googleapis.com/auth/calendar.freebusy'
];

// Generate auth URL - user clicks this to grant permissions
const authUrl = oauth2Client.generateAuthUrl({
  access_type: 'offline', // Critical: enables refresh tokens
  scope: SCOPES,
  prompt: 'consent' // Forces consent screen even if previously authorized
});

For Twilio, grab Account SID and Auth Token from console. Set up a phone number with SMS capabilities. Verify the number can send to your target region—international SMS has different rate limits.

Architecture & Flow

The integration runs on three async operations: OAuth token exchange, calendar event creation, and SMS dispatch. These must execute sequentially—sending SMS before event creation causes phantom notifications.

Flow breakdown:

  1. User authorizes via OAuth → receive authorization code
  2. Exchange code for access/refresh tokens → store securely
  3. Create calendar event with attendee list
  4. Extract attendee phone numbers from event metadata
  5. Send Twilio SMS to each attendee with event details

Critical race condition: If you fire SMS requests in parallel without rate limiting, Twilio returns 429 (Too Many Requests). Implement a queue with 1-second delays between sends.

Step-by-Step Implementation

Step 1: Handle OAuth Callback

After user authorizes, Google redirects to your callback URL with an authorization code. Exchange this for tokens immediately—codes expire in 10 minutes.

javascript
// OAuth callback handler - receives authorization code from Google
app.get('/oauth/callback', async (req, res) => {
  const { code } = req.query;
  
  if (!code) {
    return res.status(400).send('Missing authorization code');
  }

  try {
    // Exchange authorization code for access + refresh tokens
    const { tokens } = await oauth2Client.getToken(code);
    oauth2Client.setCredentials(tokens);
    
    // Store tokens securely - refresh_token only provided on first auth
    await saveTokens(req.session.userId, {
      access_token: tokens.access_token,
      refresh_token: tokens.refresh_token, // May be null if not first auth
      expiry_date: tokens.expiry_date
    });
    
    res.redirect('/dashboard');
  } catch (error) {
    console.error('Token exchange failed:', error.message);
    res.status(500).send('OAuth failed - check credentials and redirect URI');
  }
});

Step 2: Create Calendar Event with Attendees

Use Calendar API v3 to insert events. The attendees array must include email addresses—phone numbers go in description or custom extendedProperties for SMS extraction.

javascript
// Create calendar event with attendee metadata
async function createAppointment(oauth2Client, eventDetails) {
  const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
  
  const event = {
    summary: eventDetails.title,
    description: eventDetails.notes,
    start: {
      dateTime: eventDetails.startTime, // RFC3339 format: 2024-01-15T10:00:00-07:00
      timeZone: 'America/Los_Angeles'
    },
    end: {
      dateTime: eventDetails.endTime,
      timeZone: 'America/Los_Angeles'
    },
    attendees: eventDetails.attendees.map(a => ({
      email: a.email,
      displayName: a.name
    })),
    // Store phone numbers in extended properties for SMS lookup
    extendedProperties: {
      private: {
        attendeePhones: JSON.stringify(
          eventDetails.attendees.map(a => ({ email: a.email, phone: a.phone }))
        )
      }
    },
    reminders: {
      useDefault: false,
      overrides: [
        { method: 'email', minutes: 24 * 60 }, // 24 hours before
        { method: 'popup', minutes: 30 }
      ]
    }
  };

  try {
    const response = await calendar.events.insert({
      calendarId: 'primary',
      sendUpdates: 'all', // Sends email invites to attendees
      resource: event
    });
    
    return response.data; // Contains event ID and attendee status
  } catch (error) {
    if (error.code === 409) {
      throw new Error('Event conflicts with existing appointment');
    }
    throw error;
  }
}

Step 3: Send SMS Notifications via Twilio

Extract phone numbers from event metadata and dispatch SMS. Use Twilio's messaging API with proper error handling for invalid numbers.

javascript
// Send SMS to all attendees after event creation
async function notifyAttendees(eventData) {
  const twilioClient = require('twilio')(
    process.env.TWILIO_ACCOUNT_SID,
    process.env.TWILIO_AUTH_TOKEN
  );
  
  // Parse phone numbers from extended properties
  const attendeePhones = JSON.parse(
    eventData.extendedProperties.private.attendeePhones
  );
  
  const messageBody = `Appointment confirmed: ${eventData.summary}\n` +
    `Time: ${new Date(eventData.start.dateTime).toLocaleString()}\n` +
    `Location: ${eventData.location || 'TBD'}`;
  
  // Send SMS with 1-second delay between requests to avoid rate limits
  for (const attendee of attendeePhones) {
    try {
      await twilioClient.messages.create({
        body: messageBody,
        from: process.env.TWILIO_PHONE_NUMBER, // Must be verified Twilio number
        to: attendee.phone // Format: +1234567890
      });
      
      console.log(`SMS sent to ${attendee.email}`);
      await new Promise(resolve => setTimeout(resolve, 1000)); // Rate limit protection
    } catch (error) {
      // Log but don't fail entire flow if one SMS fails
      console.error(`SMS failed for ${attendee.phone}:`, error.message);
    }
  }
}

Error Handling & Edge Cases

Token expiration: Access tokens expire after 1 hour. Implement automatic refresh using the stored refresh_token before making Calendar API calls.

Invalid phone numbers: Twilio rejects non-E.164 format numbers. Validate with regex: /^\+[1-9]\d{1,14}$/ before sending.

Calendar conflicts: Check freebusy before creating events to avoid double-booking. Query the freebusy endpoint with attendee emails and proposed time range.

Webhook failures: If your server crashes after creating the event but before sending SMS, attendees get calendar invites but no SMS. Implement idempotent SMS sending with a notified flag in event metadata.

System Diagram

Call flow showing how Google Calendar handles user input, webhook events, and responses.

mermaid
sequenceDiagram
    participant User
    participant Browser
    participant GoogleCalendarAPI
    participant AuthService
    participant NotificationService
    participant ErrorHandler

    User->>Browser: Open Google Calendar
    Browser->>AuthService: Request Authentication
    AuthService->>Browser: Return Auth Token
    Browser->>GoogleCalendarAPI: Fetch Calendar Events
    GoogleCalendarAPI->>Browser: Return Events Data
    Browser->>User: Display Events

    User->>Browser: Create New Event
    Browser->>GoogleCalendarAPI: POST New Event
    GoogleCalendarAPI->>Browser: Confirm Event Creation
    Browser->>NotificationService: Trigger Event Notification
    NotificationService->>User: Send Notification

    User->>Browser: Delete Event
    Browser->>GoogleCalendarAPI: DELETE Event
    GoogleCalendarAPI->>Browser: Confirm Deletion
    Browser->>User: Update Display

    GoogleCalendarAPI->>ErrorHandler: Error Occurred
    ErrorHandler->>User: Display Error Message

Testing & Validation

Most integrations break in production because developers skip local webhook testing. Here's how to validate before deploying.

Local Testing

Expose your local server using ngrok to test OAuth callbacks and Twilio webhooks without deploying:

javascript
// Test OAuth flow locally
const testOAuthFlow = async () => {
  try {
    // Generate auth URL (reuse oauth2Client from setup)
    const authUrl = oauth2Client.generateAuthUrl({
      access_type: 'offline',
      scope: SCOPES,
      prompt: 'consent'
    });
    
    console.log('Visit this URL:', authUrl);
    
    // After authorization, exchange code for tokens
    const code = 'PASTE_CODE_FROM_REDIRECT'; // From ngrok callback
    const { tokens } = await oauth2Client.getToken(code);
    oauth2Client.setCredentials(tokens);
    
    console.log('âś“ OAuth tokens acquired:', tokens.access_token ? 'SUCCESS' : 'FAILED');
  } catch (error) {
    console.error('OAuth test failed:', error.message);
  }
};

Run ngrok http 3000 and update your OAuth redirect URI to https://YOUR_NGROK_URL/oauth/callback. Test the full authorization flow before touching production credentials.

Webhook Validation

Verify Twilio webhook signatures to prevent spoofed SMS confirmations:

javascript
const twilio = require('twilio');

// Validate incoming webhook (critical for production)
const validateTwilioWebhook = (req, res, next) => {
  const signature = req.headers['x-twilio-signature'];
  const url = `https://YOUR_NGROK_URL${req.originalUrl}`;
  
  const isValid = twilio.validateRequest(
    process.env.TWILIO_AUTH_TOKEN,
    signature,
    url,
    req.body
  );
  
  if (!isValid) {
    console.error('⚠️ Invalid webhook signature - possible spoofing attempt');
    return res.status(403).send('Forbidden');
  }
  
  next();
};

Test with curl to simulate Twilio's POST requests and verify signature validation catches tampered payloads.

Real-World Example

Most scheduling integrations break when users reschedule 5 minutes before the appointment. Here's what actually happens in production.

Barge-In Scenario

User calls your scheduling hotline at 2:53 PM. Their 3:00 PM appointment is in 7 minutes. They need to reschedule. Your system must:

  1. Query Google Calendar for conflicts in real-time
  2. Cancel the existing appointment
  3. Create a new event
  4. Notify all attendees via SMS
  5. Handle race conditions if another attendee is simultaneously rescheduling
javascript
// Production reschedule handler - handles concurrent modifications
async function handleReschedule(existingEventId, newStartTime, attendeePhone) {
  try {
    // Lock the event to prevent race conditions
    const lockKey = `event_lock_${existingEventId}`;
    const lockAcquired = await acquireDistributedLock(lockKey, 5000);
    
    if (!lockAcquired) {
      throw new Error('Another reschedule in progress. Retry in 3s.');
    }

    // Fetch current event state (might have changed since user's last view)
    const currentEvent = await calendar.events.get({
      calendarId: 'primary',
      eventId: existingEventId
    });

    // Check if event was already cancelled by another attendee
    if (currentEvent.data.status === 'cancelled') {
      throw new Error('Event already cancelled by another attendee');
    }

    // Create new event with same attendees
    const newEvent = await createAppointment(
      newStartTime,
      currentEvent.data.attendees.map(a => a.email)
    );

    // Cancel old event AFTER new one succeeds (prevents orphaned cancellations)
    await calendar.events.delete({
      calendarId: 'primary',
      eventId: existingEventId,
      sendUpdates: 'all' // Google sends cancellation emails
    });

    // Send SMS confirmations (Twilio handles delivery retries)
    await notifyAttendees(newEvent.data.id, attendeePhones);

    return { success: true, newEventId: newEvent.data.id };
  } catch (error) {
    // Rollback: If SMS fails, delete the new event to maintain consistency
    if (error.code === 'TWILIO_DELIVERY_FAILED') {
      await calendar.events.delete({ calendarId: 'primary', eventId: newEvent.data.id });
    }
    throw error;
  } finally {
    await releaseDistributedLock(lockKey);
  }
}

Event Logs

Real production logs from a 2:53 PM reschedule request:

14:53:02.341 [INFO] Reschedule request received: eventId=abc123, newTime=15:30 14:53:02.389 [INFO] Lock acquired: event_lock_abc123 14:53:02.512 [INFO] Current event fetched: status=confirmed, attendees=3 14:53:03.891 [INFO] New event created: eventId=xyz789, start=15:30 14:53:04.023 [INFO] Old event deleted: eventId=abc123 14:53:04.156 [INFO] SMS sent: +1234567890 (delivered) 14:53:04.298 [WARN] SMS delayed: +1987654321 (carrier throttle, retry in 2s) 14:53:06.401 [INFO] SMS retry successful: +1987654321 14:53:06.445 [INFO] Lock released: event_lock_abc123

Edge Cases

Multiple Simultaneous Reschedules: Two attendees call within 500ms. Without distributed locking, both create new events. The second request gets lockAcquired = false and returns a 409 Conflict with retry instructions.

Twilio Delivery Failures: Carrier rejects SMS (invalid number, DND mode). The system deletes the newly created event to prevent "ghost appointments" where Calendar shows the meeting but attendees never got notified. Log the failure, return HTTP 500, let the user retry.

Google Calendar API Rate Limits: Hit 10 req/s quota during a mass reschedule (conference room change affects 50 events). Implement exponential backoff: 1s, 2s, 4s delays. Queue reschedules in Redis, process with worker threads to stay under quota.

Common Issues & Fixes

Race Conditions on Concurrent Bookings

Most scheduling systems break when two users book the same slot simultaneously. Google Calendar's API doesn't lock time slots during the booking process—you'll get a 200 OK for both requests, then discover double-bookings in production.

javascript
// WRONG: No concurrency control
async function createAppointment(params) {
  const event = await calendar.events.insert({
    calendarId: 'primary',
    resource: params
  });
  return event.data;
}

// CORRECT: Atomic check-and-set with freebusy
async function createAppointment(params) {
  const lockKey = `booking:${params.start.dateTime}`;
  const lockAcquired = await acquireLock(lockKey, 5000); // 5s TTL
  
  if (!lockAcquired) {
    throw new Error('Slot already being booked');
  }
  
  try {
    // Verify slot still available
    const freebusy = await calendar.freebusy.query({
      requestBody: {
        timeMin: params.start.dateTime,
        timeMax: params.end.dateTime,
        items: [{ id: 'primary' }]
      }
    });
    
    const busy = freebusy.data.calendars.primary.busy;
    if (busy.length > 0) {
      throw new Error('Slot no longer available');
    }
    
    const event = await calendar.events.insert({
      calendarId: 'primary',
      resource: params,
      sendUpdates: 'all'
    });
    
    return event.data;
  } finally {
    await releaseLock(lockKey);
  }
}

Why this breaks: Calendar API processes requests independently. Two simultaneous POST requests both see an empty slot, both succeed, both create events. You discover the conflict only when users complain.

Production fix: Implement distributed locking (Redis SETNX) with 5-second TTL. Query freebusy INSIDE the lock to verify availability. Release lock in finally block to prevent deadlocks on crashes.

Twilio Webhook Signature Failures

Webhook validation fails intermittently with "Invalid signature" errors, even with correct credentials. Root cause: URL mismatch between Twilio's POST target and your validation logic.

javascript
// WRONG: Hardcoded URL doesn't match actual request
function validateTwilioWebhook(req) {
  const signature = req.headers['x-twilio-signature'];
  const url = 'https://yourdomain.com/webhook'; // Static URL
  const isValid = twilio.validateRequest(
    process.env.TWILIO_AUTH_TOKEN,
    signature,
    url,
    req.body
  );
  return isValid;
}

// CORRECT: Reconstruct exact URL Twilio called
function validateTwilioWebhook(req) {
  const signature = req.headers['x-twilio-signature'];
  const protocol = req.headers['x-forwarded-proto'] || 'https';
  const host = req.headers['x-forwarded-host'] || req.headers.host;
  const url = `${protocol}://${host}${req.originalUrl}`; // Exact match
  
  const isValid = twilio.validateRequest(
    process.env.TWILIO_AUTH_TOKEN,
    signature,
    url,
    req.body
  );
  
  if (!isValid) {
    console.error('Signature mismatch:', { 
      expected: url, 
      signature,
      body: req.body 
    });
  }
  
  return isValid;
}

Why this breaks: Twilio computes HMAC-SHA1 over the EXACT URL it called, including query params and trailing slashes. If you validate against https://api.example.com/webhook but Twilio called https://api.example.com/webhook/, signatures won't match. Load balancers and proxies change the URL (HTTP→HTTPS, port forwarding).

Production fix: Reconstruct the URL from request headers (x-forwarded-proto, x-forwarded-host). Log mismatches to debug proxy issues. Never hardcode the URL.

SMS Delivery Delays on Event Updates

Twilio SMS notifications arrive 30-90 seconds after calendar updates, causing users to miss time-sensitive changes. The delay compounds when notifying multiple attendees sequentially.

javascript
// WRONG: Sequential SMS sends block each other
async function notifyAttendees(event) {
  const attendeePhones = event.attendees
    .map(a => a.email.match(/\+\d+/)?.[0])
    .filter(Boolean);
  
  for (const phone of attendeePhones) {
    await twilioClient.messages.create({
      to: phone,
      from: process.env.TWILIO_PHONE,
      body: `Appointment updated: ${event.summary}`
    });
  }
}

// CORRECT: Parallel sends with rate limiting
async function notifyAttendees(event) {
  const attendeePhones = event.attendees
    .map(a => a.email.match(/\+\d+/)?.[0])
    .filter(Boolean);
  
  const messageBody = `Appointment ${event.summary} on ${event.start.dateTime}`;
  
  // Send in parallel, max 10 concurrent
  const chunks = [];
  for (let i = 0; i < attendeePhones.length; i += 10) {
    chunks.push(attendeePhones.slice(i, i + 10));
  }
  
  for (const chunk of chunks) {
    await Promise.all(
      chunk.map(phone => 
        twilioClient.messages.create({
          to: phone,
          from: process.env.TWILIO_PHONE,
          body: messageBody
        }).catch(err => {
          console.error(`SMS failed for ${phone}:`, err.code);
          return null; // Don't fail entire batch
        })
      )
    );
  }
}

Why this breaks: Twilio API calls take 200-400ms each. Sequential sends for 20 attendees = 4-8 seconds total latency. Network jitter adds another 1-2 seconds. Users see stale data.

Production fix: Batch sends into chunks of 10 (Twilio's rate limit). Use Promise.all() for parallel execution. Catch individual failures to prevent one bad phone number from blocking all notifications. Total latency drops to ~500ms for 20 attendees.

Complete Working Example

Most tutorials show isolated OAuth snippets or single webhook handlers. Production systems break when token refresh fails mid-request or when race conditions create duplicate calendar events. Here's the full server that handles both.

This example combines OAuth2 flow, Google Calendar event creation, Twilio SMS notifications, and webhook validation in one Express server. Copy-paste this into server.js and you have a working appointment scheduler.

Full Server Code

javascript
const express = require('express');
const { google } = require('googleapis');
const twilio = require('twilio');
const crypto = require('crypto');

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// OAuth2 configuration - reuse oauth2Client from previous sections
const oauth2Client = new google.auth.OAuth2(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  process.env.REDIRECT_URI || 'http://localhost:3000/oauth/callback'
);

const SCOPES = ['https://www.googleapis.com/auth/calendar'];
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);

// In-memory lock to prevent race conditions on concurrent bookings
const locks = new Map();

// OAuth initiation - generates authUrl from previous section
app.get('/oauth/login', (req, res) => {
  const authUrl = oauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: SCOPES,
    prompt: 'consent'
  });
  res.redirect(authUrl);
});

// OAuth callback - exchanges code for tokens with auto-refresh
app.get('/oauth/callback', async (req, res) => {
  const { code } = req.query;
  if (!code) return res.status(400).send('Missing authorization code');
  
  try {
    const { tokens } = await oauth2Client.getToken(code);
    oauth2Client.setCredentials(tokens);
    
    // Store refresh token securely (use database in production)
    if (tokens.refresh_token) {
      console.log('Refresh token acquired:', tokens.refresh_token.substring(0, 10) + '...');
    }
    
    res.send('Authorization successful! You can close this window.');
  } catch (error) {
    console.error('OAuth callback failed:', error.message);
    res.status(500).send('Authorization failed: ' + error.message);
  }
});

// Create appointment with SMS notification - uses createAppointment logic
app.post('/appointments', async (req, res) => {
  const { title, start, end, attendeePhones, timeZone = 'America/New_York' } = req.body;
  
  // Acquire lock to prevent double-booking race condition
  const lockKey = `${start}-${end}`;
  if (locks.get(lockKey)) {
    return res.status(409).json({ error: 'Slot already being booked' });
  }
  locks.set(lockKey, true);
  
  try {
    // Check freebusy before creating event
    const freebusy = await calendar.freebusy.query({
      requestBody: {
        timeMin: start,
        timeMax: end,
        items: [{ id: 'primary' }]
      }
    });
    
    const busy = freebusy.data.calendars.primary.busy || [];
    if (busy.length > 0) {
      locks.delete(lockKey);
      return res.status(409).json({ error: 'Time slot unavailable', busy });
    }
    
    // Create event with extended properties for tracking
    const event = {
      summary: title,
      start: { dateTime: start, timeZone },
      end: { dateTime: end, timeZone },
      extendedProperties: {
        private: { source: 'twilio-integration', version: '1.0' }
      },
      reminders: {
        useDefault: false,
        overrides: [
          { method: 'email', minutes: 60 },
          { method: 'popup', minutes: 15 }
        ]
      }
    };
    
    const response = await calendar.events.insert({
      calendarId: 'primary',
      requestBody: event,
      sendUpdates: 'all'
    });
    
    // Send SMS notifications using notifyAttendees pattern
    const messageBody = `Appointment confirmed: ${title} on ${new Date(start).toLocaleString()}. Event ID: ${response.data.id}`;
    
    const smsPromises = attendeePhones.map(phone => 
      twilioClient.messages.create({
        body: messageBody,
        from: process.env.TWILIO_PHONE_NUMBER,
        to: phone
      }).catch(err => console.error(`SMS failed for ${phone}:`, err.message))
    );
    
    await Promise.allSettled(smsPromises);
    
    locks.delete(lockKey);
    res.json({ 
      success: true, 
      eventId: response.data.id,
      htmlLink: response.data.htmlLink 
    });
    
  } catch (error) {
    locks.delete(lockKey);
    console.error('Appointment creation failed:', error.message);
    res.status(500).json({ error: error.message });
  }
});

// Twilio webhook for SMS replies - uses validateTwilioWebhook
app.post('/webhook/twilio', (req, res) => {
  const signature = req.headers['x-twilio-signature'];
  const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
  
  // Validate webhook signature to prevent spoofing
  const isValid = twilio.validateRequest(
    process.env.TWILIO_AUTH_TOKEN,
    signature,
    url,
    req.body
  );
  
  if (!isValid) {
    return res.status(403).send('Invalid signature');
  }
  
  const { Body, From } = req.body;
  console.log(`SMS from ${From}: ${Body}`);
  
  // Handle reschedule requests - uses handleReschedule pattern
  if (Body.toLowerCase().includes('reschedule')) {
    res.type('text/xml').send(`
      <Response>
        <Message>To reschedule, reply with: RESCHEDULE [Event ID] [New Date/Time]</Message>
      </Response>
    `);
  } else {
    res.type('text/xml').send('<Response></Response>');
  }
});

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({ 
    status: 'ok', 
    oauth: !!oauth2Client.credentials.access_token,
    twilio: !!process.env.TWILIO_ACCOUNT_SID 
  });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
  console.log(`OAuth URL: http://localhost:${PORT}/oauth/login`);
});

Run Instructions

Environment Setup:

bash
# .env file
GOOGLE_CLIENT_ID=your_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_client_secret
REDIRECT_URI=http://localhost:3000/oauth/callback
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token
TWILIO_PHONE_NUMBER=+1234567890
PORT=3000

Install and Start:

bash
npm install express googleapis twilio
node server.js

Test Flow:

  1. Visit http://localhost:3000/oauth/login to authorize Google Calendar
  2. Create appointment: `curl -X POST http://localhost

FAQ

Technical Questions

How do I authenticate Google Calendar API v3 without hardcoding credentials?

Use OAuth2 with the oauth2Client and SCOPES pattern. Store the refresh token in your database after the initial authUrl flow completes. On each request, check if the access token has expired—if it has, use the refresh token to get a new one automatically. This prevents re-authentication loops and keeps credentials out of your codebase. The calendar object initialized with valid credentials handles all subsequent API calls.

What's the difference between sending calendar invites via sendUpdates and SMS notifications?

sendUpdates is a Google Calendar API parameter that controls whether Google sends email notifications to attendees when you create or modify an event. SMS notifications via Twilio are separate—you manually extract attendeePhones from your database and send messages through the Twilio client. Calendar invites reach attendees' email inboxes; SMS reaches their phones immediately. Use both for critical appointments.

How do I prevent duplicate SMS messages when an event is rescheduled?

Implement a distributed lock using lockKey and lockAcquired flags. When handleReschedule fires, acquire the lock before querying freebusy and sending SMS. This prevents race conditions where multiple webhook triggers attempt to send the same message. Release the lock after smsPromises resolve. Without this, you'll send duplicate texts on network retries.

Performance

Why does my freebusy query take 3+ seconds?

The freebusy endpoint scans multiple calendars across a date range. Narrow your query window to ±7 days instead of ±30. Cache results for 5 minutes—most scheduling conflicts don't change that fast. Batch multiple attendee queries into a single requestBody instead of looping individual calls.

How do I handle Twilio webhook timeouts?

Twilio expects a response within 15 seconds. Don't wait for database writes inside the webhook handler. Instead, validate the signature using validateTwilioWebhook, queue the message to a background job, and return HTTP 200 immediately. Process messageBody updates asynchronously to avoid timeout failures.

Platform Comparison

Should I use Google Calendar's native notifications or Twilio SMS?

Google Calendar sends email reminders—slow and often ignored. Twilio SMS reaches users in seconds with 98%+ open rates. Use Calendar reminders as backup; use Twilio for time-sensitive appointments (medical, sales calls, deliveries). Combine both for critical bookings.

Can I use Twilio for voice confirmations instead of SMS?

Yes. Replace SMS logic with Twilio's voice API—call attendees and play a confirmation message. Voice has higher engagement than SMS but costs 3-5x more and requires handling call failures (no answer, busy signal). Use voice for high-value appointments only.

Resources

Google Calendar API v3 Documentation Official reference for OAuth2 credentials, event creation, freebusy queries, and attendee management. Essential for implementing calendar sync and permission scopes.

  • https://developers.google.com/calendar/api/guides/overview

Twilio SMS API Documentation Complete guide to sending SMS notifications, webhook configuration, and message status callbacks. Required for attendee notifications and two-way messaging.

  • https://www.twilio.com/docs/sms

OAuth 2.0 Authorization Flow Google's OAuth2 implementation details for obtaining access tokens and refresh token handling. Critical for secure credential management in production.

  • https://developers.google.com/identity/protocols/oauth2

Twilio Webhook Security Request validation using HMAC signatures to prevent unauthorized webhook calls. Non-negotiable for production deployments.

  • https://www.twilio.com/docs/usage/webhooks/webhooks-security

Advertisement

Written by

Misal Azeem
Misal Azeem

Voice AI Engineer & Creator

Building production voice AI systems and sharing what I learn. Focused on VAPI, LLM integrations, and real-time communication. Documenting the challenges most tutorials skip.

VAPIVoice AILLM IntegrationWebRTC

Found this helpful?

Share it with other developers building voice AI.