DEV Community

Cover image for How to Connect VAPI to Google Calendar for Appointment Scheduling
CallStack Tech
CallStack Tech

Posted on • Originally published at callstack.tech

How to Connect VAPI to Google Calendar for Appointment Scheduling

How to Connect VAPI to Google Calendar for Appointment Scheduling

TL;DR

Most calendar integrations break when OAuth tokens expire mid-call or timezone mismatches corrupt availability checks. This guide shows you how to build a VAPI assistant that handles Google Calendar OAuth flows, maps tokens to session state, and queries availability without race conditions. You'll configure the assistant tools array with proper CalendarId specification, implement token refresh logic, and handle booking conflicts. Result: production-grade scheduling that doesn't double-book or crash on token expiry.

Prerequisites

API Access:

  • VAPI API key (get from dashboard.vapi.ai)
  • Google Cloud project with Calendar API enabled
  • OAuth 2.0 credentials (Client ID + Secret) from Google Cloud Console
  • Service account JSON key OR user OAuth tokens with calendar.events scope

Development Environment:

  • Node.js 18+ (for webhook server)
  • ngrok or similar tunneling tool (VAPI needs public HTTPS endpoints)
  • Environment variable manager (dotenv recommended)

Google Calendar Setup:

  • Target calendar ID (found in Calendar Settings → "Integrate calendar")
  • Verified domain ownership if using service accounts
  • Calendar sharing permissions configured (read/write access)

Technical Knowledge:

  • OAuth 2.0 token refresh flow (tokens expire every 3600s)
  • Webhook signature validation (VAPI signs all requests)
  • Function calling syntax in VAPI assistant tools array
  • Timezone handling (Calendar API uses RFC3339 format)

VAPI: Get Started with VAPI → Get VAPI

Step-by-Step Tutorial

Configuration & Setup

First, configure OAuth credentials in Google Cloud Console. Create a project, enable Calendar API, and generate OAuth 2.0 credentials. Store client_id, client_secret, and redirect_uri in environment variables—NOT hardcoded.

// OAuth token exchange - handles user authorization callback
const exchangeCodeForToken = async (authCode) => {
  try {
    const response = await fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        code: authCode,
        client_id: process.env.GOOGLE_CLIENT_ID,
        client_secret: process.env.GOOGLE_CLIENT_SECRET,
        redirect_uri: process.env.REDIRECT_URI,
        grant_type: 'authorization_code'
      })
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(`OAuth failed: ${error.error_description}`);
    }

    const tokens = await response.json();
    // Store tokens.access_token and tokens.refresh_token per user
    return tokens;
  } catch (error) {
    console.error('Token exchange error:', error);
    throw error;
  }
};
Enter fullscreen mode Exit fullscreen mode

Critical: Refresh tokens expire. Implement token refresh logic BEFORE making Calendar API calls. Most production failures happen when access tokens expire mid-conversation (401 errors).

Architecture & Flow

VAPI assistant receives scheduling request → Calls your webhook with function parameters → Your server validates OAuth token → Fetches calendar availability → Returns time slots → Assistant confirms with user → Your server creates event.

Race condition warning: If user says "book 2pm" while availability check is running, queue the booking request. Don't fire concurrent Calendar API calls—Google rate limits at 1000 req/100s per user.

Step-by-Step Implementation

Step 1: Configure VAPI assistant with function calling tool:

const assistantConfig = {
  model: {
    provider: "openai",
    model: "gpt-4",
    messages: [{
      role: "system",
      content: "You schedule appointments. Ask for date, time, duration. Confirm before booking."
    }]
  },
  voice: {
    provider: "11labs",
    voiceId: "21m00Tcm4TlvDq8ikWAM"
  },
  functions: [{
    name: "check_availability",
    description: "Check calendar availability for given date range",
    parameters: {
      type: "object",
      properties: {
        calendarId: { type: "string", description: "User's calendar ID (email)" },
        timeMin: { type: "string", description: "Start time ISO 8601" },
        timeMax: { type: "string", description: "End time ISO 8601" }
      },
      required: ["calendarId", "timeMin", "timeMax"]
    }
  }, {
    name: "create_event",
    description: "Create calendar event after user confirms",
    parameters: {
      type: "object",
      properties: {
        calendarId: { type: "string" },
        summary: { type: "string", description: "Event title" },
        start: { type: "string", description: "Start time ISO 8601" },
        end: { type: "string", description: "End time ISO 8601" },
        attendees: { 
          type: "array",
          items: { type: "string" },
          description: "Attendee emails"
        }
      },
      required: ["calendarId", "summary", "start", "end"]
    }
  }]
};
Enter fullscreen mode Exit fullscreen mode

Step 2: Build webhook handler for function execution:

app.post('/webhook/vapi', async (req, res) => {
  const { message } = req.body;

  if (message.type === 'function-call') {
    const { functionCall } = message;
    const userId = message.call.metadata?.userId; // Pass during call creation

    try {
      if (functionCall.name === 'check_availability') {
        const { calendarId, timeMin, timeMax } = functionCall.parameters;
        const accessToken = await getValidToken(userId); // Handles refresh

        const response = await fetch(
          `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?` +
          `timeMin=${encodeURIComponent(timeMin)}&timeMax=${encodeURIComponent(timeMax)}&singleEvents=true`,
          {
            headers: { 'Authorization': `Bearer ${accessToken}` }
          }
        );

        if (!response.ok) throw new Error(`Calendar API error: ${response.status}`);

        const data = await response.json();
        const busySlots = data.items.map(event => ({
          start: event.start.dateTime,
          end: event.end.dateTime
        }));

        return res.json({ result: { busySlots } });
      }

      if (functionCall.name === 'create_event') {
        const { calendarId, summary, start, end, attendees } = functionCall.parameters;
        const accessToken = await getValidToken(userId);

        const eventBody = {
          summary,
          start: { dateTime: start, timeZone: 'America/New_York' },
          end: { dateTime: end, timeZone: 'America/New_York' },
          attendees: attendees?.map(email => ({ email })) || []
        };

        const response = await fetch(
          `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`,
          {
            method: 'POST',
            headers: {
              'Authorization': `Bearer ${accessToken}`,
              'Content-Type': 'application/json'
            },
            body: JSON.stringify(eventBody)
          }
        );

        if (!response.ok) {
          const error = await response.json();
          throw new Error(`Event creation failed: ${error.error.message}`);
        }

        const event = await response.json();
        return res.json({ result: { eventId: event.id, link: event.htmlLink } });
      }
    } catch (error) {
      console.error('Function execution error:', error);
      return res.json({ 
        error: { message: error.message }
      });
    }
  }

  res.json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

Step 3: Implement token refresh to prevent mid-call failures:

const getValidToken = async (userId) => {
  const stored = await db.getTokens(userId); // Your DB lookup
  const expiresAt = stored.expires_at;

  // Refresh 5 minutes before expiry
  if (Date.now() >= expiresAt - 300000) {
    const response = await fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        refresh_token: stored.refresh_token,
        client_id: process.env.GOOGLE_CLIENT_ID,
        client_secret: process.env.GOOGLE_CLIENT_SECRET,
        grant_type: 'refresh_token'
      })
    });

    const newTokens = await response.json();
    await db.updateTokens(userId, {
      access_token: newTokens.access_token,
      expires_at: Date.now() + (newTokens.expires_in * 1000)
    });

    return newTokens.access_token;
  }

  return stored.access_token;
};
Enter fullscreen mode Exit fullscreen mode

Error Handling & Edge Cases

System Diagram

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

sequenceDiagram
    participant User
    participant VAPI
    participant Webhook
    participant YourServer
    User->>VAPI: Initiates call
    VAPI->>Webhook: call.started event
    Webhook->>YourServer: POST /webhook/vapi
    YourServer->>VAPI: Configure call settings
    VAPI->>User: Plays welcome message
    User->>VAPI: Provides input
    VAPI->>Webhook: input.received event
    Webhook->>YourServer: POST /webhook/vapi
    YourServer->>VAPI: Process input
    VAPI->>User: Provides response
    User->>VAPI: Ends call
    VAPI->>Webhook: call.ended event
    Webhook->>YourServer: POST /webhook/vapi
    Note over User,VAPI: Call completed successfully
    User->>VAPI: Error occurs
    VAPI->>Webhook: error.occurred event
    Webhook->>YourServer: POST /webhook/vapi
    YourServer->>VAPI: Handle error
    VAPI->>User: Error message
Enter fullscreen mode Exit fullscreen mode

Testing & Validation

Local Testing

Most webhook failures happen because developers skip local validation. Use the Vapi CLI webhook forwarder with ngrok to catch integration bugs before production.

// Test OAuth token refresh under load
const testTokenRefresh = async () => {
  const stored = await db.getToken(userId);
  const expiresAt = new Date(stored.expiresAt);

  if (expiresAt <= new Date()) {
    console.log('Token expired - testing refresh flow');
    const response = await fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: stored.refreshToken,
        client_id: process.env.GOOGLE_CLIENT_ID,
        client_secret: process.env.GOOGLE_CLIENT_SECRET
      })
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(`Token refresh failed: ${error.error}`);
    }

    const tokens = await response.json();
    console.log('Refresh successful - new expiry:', tokens.expires_in);
  }
};
Enter fullscreen mode Exit fullscreen mode

Run this every 30 minutes during testing. Google tokens expire in 3600 seconds—your code WILL break if you don't handle refresh race conditions.

Webhook Validation

Validate function call arguments match Google Calendar's exact schema. The calendarId parameter is case-sensitive and must be a valid email format.

// Validate webhook payload structure
app.post('/webhook/vapi', async (req, res) => {
  const { message } = req.body;

  if (message?.toolCalls) {
    const toolCall = message.toolCalls[0];
    const { calendarId, timeMin, timeMax } = toolCall.function.arguments;

    // Catch malformed parameters before hitting Google API
    if (!calendarId.includes('@')) {
      console.error('Invalid calendarId format:', calendarId);
      return res.json({ error: 'calendarId must be email format' });
    }

    if (new Date(timeMin) >= new Date(timeMax)) {
      console.error('Invalid time range:', { timeMin, timeMax });
      return res.json({ error: 'timeMin must be before timeMax' });
    }

    console.log('Webhook validation passed');
  }

  res.sendStatus(200);
});
Enter fullscreen mode Exit fullscreen mode

Test with curl to simulate Vapi's webhook format: curl -X POST http://localhost:3000/webhook/vapi -H "Content-Type: application/json" -d '{"message":{"toolCalls":[{"function":{"name":"checkAvailability","arguments":{"calendarId":"primary","timeMin":"2024-01-01T09:00:00Z","timeMax":"2024-01-01T17:00:00Z"}}}]}}'

Real-World Example

Barge-In Scenario

User interrupts the agent mid-sentence while it's reading available time slots. This is where most implementations break—the agent continues speaking over the user, or worse, processes the old audio buffer after the interruption.

// Production barge-in handler with buffer flush
let isProcessing = false;
let currentAudioBuffer = [];

app.post('/webhook/vapi', async (req, res) => {
  const event = req.body;

  if (event.type === 'speech-update' && event.status === 'started') {
    // User started speaking - IMMEDIATELY cancel TTS
    if (isProcessing) {
      currentAudioBuffer = []; // Flush buffer to prevent old audio
      isProcessing = false;
    }
  }

  if (event.type === 'function-call' && event.functionCall.name === 'checkAvailability') {
    if (isProcessing) return res.json({ result: 'Processing previous request' });
    isProcessing = true;

    try {
      const { calendarId, timeMin, timeMax } = event.functionCall.parameters;
      const response = await fetch(
        `https://www.googleapis.com/calendar/v3/freeBusy`,
        {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${accessToken}`,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            timeMin,
            timeMax,
            items: [{ id: calendarId }]
          })
        }
      );

      const data = await response.json();
      const busySlots = data.calendars[calendarId].busy;

      isProcessing = false;
      return res.json({ 
        result: `Found ${busySlots.length} busy slots`,
        busySlots 
      });
    } catch (error) {
      isProcessing = false;
      return res.json({ error: 'Calendar check failed' });
    }
  }

  res.sendStatus(200);
});
Enter fullscreen mode Exit fullscreen mode

Event Logs

14:32:01.234 - function-call triggered: checkAvailability({ calendarId: "primary", timeMin: "2024-01-15T09:00:00Z" })

14:32:01.456 - TTS starts: "I found three available slots: 9 AM, 11—"

14:32:02.103 - speech-update event: { status: "started", transcript: "" } ← User interrupts

14:32:02.105 - Buffer flushed: currentAudioBuffer = []

14:32:02.890 - transcript event: { text: "Actually, I need afternoon slots" }

14:32:03.012 - New function-call: checkAvailability({ timeMin: "2024-01-15T13:00:00Z" })

Edge Cases

Multiple rapid interruptions: User says "wait" three times in 2 seconds. Without the isProcessing guard, you'd fire three concurrent Google Calendar API calls, hit rate limits (10 QPS), and return stale data. The guard ensures only the LAST request processes.

False positive VAD triggers: Background noise (door slam, cough) triggers speech-update but no actual transcript follows. Solution: Wait 300ms for transcript event before flushing buffer. If no transcript arrives, ignore the false trigger.

Token expiration mid-call: User books appointment at 14:32:01, but accessToken expired at 14:32:00. The fetch returns 401 Unauthorized. Your code MUST catch this, call getValidToken() to refresh, then retry the API call—NOT return a generic error to the user.

Common Issues & Fixes

OAuth Token Expiration Mid-Call

Most production failures happen when the access token expires during a call. Google tokens last 60 minutes, but calls can run longer. The assistant tries to create an event, gets a 401, and the user hears "I couldn't schedule that."

// Token refresh with race condition guard
async function getValidToken(userId) {
  const stored = await db.getToken(userId);
  const expiresAt = stored.expiry - 300000; // 5min buffer

  if (Date.now() < expiresAt) return stored.accessToken;

  // Prevent concurrent refreshes
  if (isProcessing[userId]) {
    await new Promise(resolve => setTimeout(resolve, 1000));
    return getValidToken(userId); // Retry after lock clears
  }

  isProcessing[userId] = true;

  try {
    const response = await fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: stored.refreshToken,
        client_id: process.env.GOOGLE_CLIENT_ID,
        client_secret: process.env.GOOGLE_CLIENT_SECRET
      })
    });

    const newTokens = await response.json();
    await db.updateToken(userId, {
      accessToken: newTokens.access_token,
      expiry: Date.now() + (newTokens.expires_in * 1000)
    });

    return newTokens.access_token;
  } finally {
    isProcessing[userId] = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Calendar ID Mismatch

The assistant receives calendarId: "primary" but your function expects the user's actual calendar ID. Google rejects the request with 404. Always map "primary" to the authenticated user's email or fetch the calendar list first.

Timezone Conflicts

User says "3pm" but doesn't specify timezone. The assistant defaults to UTC, creating events 5-8 hours off. Pass timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone in the event body and validate against the user's calendar settings before confirming.

Complete Working Example

Full Server Code

This is the production-ready server that handles OAuth, webhooks, and token refresh. Copy-paste this entire block to get started:

const express = require('express');
const fetch = require('node-fetch');
const app = express();

app.use(express.json());

// In-memory token store (use Redis in production)
const tokens = new Map();

// OAuth: Redirect user to Google
app.get('/oauth/login', (req, res) => {
  const userId = req.query.userId || 'default';
  const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
    `client_id=${process.env.GOOGLE_CLIENT_ID}&` +
    `redirect_uri=${encodeURIComponent(process.env.REDIRECT_URI)}&` +
    `response_type=code&` +
    `scope=https://www.googleapis.com/auth/calendar&` +
    `access_type=offline&` +
    `state=${userId}`;
  res.redirect(authUrl);
});

// OAuth: Exchange code for tokens
app.get('/oauth/callback', async (req, res) => {
  const { code, state: userId } = req.query;

  try {
    const response = await fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        code,
        client_id: process.env.GOOGLE_CLIENT_ID,
        client_secret: process.env.GOOGLE_CLIENT_SECRET,
        redirect_uri: process.env.REDIRECT_URI,
        grant_type: 'authorization_code'
      })
    });

    if (!response.ok) throw new Error(`OAuth failed: ${response.status}`);

    const data = await response.json();
    tokens.set(userId, {
      accessToken: data.access_token,
      refreshToken: data.refresh_token,
      expiresAt: Date.now() + (data.expires_in * 1000)
    });

    res.send('Calendar connected! Close this window.');
  } catch (error) {
    console.error('OAuth error:', error);
    res.status(500).send('Authorization failed');
  }
});

// Token refresh logic
async function getValidToken(userId) {
  const stored = tokens.get(userId);
  if (!stored) throw new Error('User not authenticated');

  // Token still valid
  if (stored.expiresAt > Date.now() + 60000) {
    return stored.accessToken;
  }

  // Refresh expired token
  const response = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      refresh_token: stored.refreshToken,
      client_id: process.env.GOOGLE_CLIENT_ID,
      client_secret: process.env.GOOGLE_CLIENT_SECRET,
      grant_type: 'refresh_token'
    })
  });

  if (!response.ok) throw new Error('Token refresh failed');

  const data = await response.json();
  stored.accessToken = data.access_token;
  stored.expiresAt = Date.now() + (data.expires_in * 1000);
  tokens.set(userId, stored);

  return stored.accessToken;
}

// Webhook: Handle VAPI function calls
let isProcessing = false;

app.post('/webhook', async (req, res) => {
  const { message } = req.body;

  // Race condition guard
  if (isProcessing) {
    return res.json({ error: 'Request already processing' });
  }

  if (message?.type !== 'function-call') {
    return res.json({ result: 'Not a function call' });
  }

  isProcessing = true;
  const toolCall = message.functionCall;
  const userId = message.call?.metadata?.userId || 'default';

  try {
    const accessToken = await getValidToken(userId);

    if (toolCall.name === 'checkAvailability') {
      const { calendarId, timeMin, timeMax } = toolCall.parameters;

      const response = await fetch(
        `https://www.googleapis.com/calendar/v3/freeBusy`,
        {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${accessToken}`,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            timeMin,
            timeMax,
            items: [{ id: calendarId }]
          })
        }
      );

      if (!response.ok) throw new Error(`Calendar API error: ${response.status}`);

      const data = await response.json();
      const busySlots = data.calendars[calendarId]?.busy || [];

      isProcessing = false;
      return res.json({ 
        result: busySlots.length === 0 ? 'Available' : `Busy: ${busySlots.length} slots` 
      });
    }

    if (toolCall.name === 'createEvent') {
      const { calendarId, summary, start, end, attendees } = toolCall.parameters;

      const eventBody = {
        summary,
        start: { dateTime: start, timeZone: 'America/New_York' },
        end: { dateTime: end, timeZone: 'America/New_York' },
        attendees: attendees?.map(email => ({ email })) || []
      };

      const response = await fetch(
        `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events`,
        {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${accessToken}`,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(eventBody)
        }
      );

      if (!response.ok) throw new Error(`Event creation failed: ${response.status}`);

      const event = await response.json();

      isProcessing = false;
      return res.json({ result: `Event created: ${event.htmlLink}` });
    }

    isProcessing = false;
    res.json({ error: 'Unknown function' });

  } catch (error) {
    console.error('Webhook error:', error);
    isProcessing = false;
    res.status(500).json({ error: error.message });
  }
});

app.listen(3000, () => console.log('Server running on port 3000'));
Enter fullscreen mode Exit fullscreen mode

Run Instructions

Environment setup:

export GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
export GOOGLE_CLIENT_SECRET="your-client-secret"
export REDIRECT_URI="http://localhost:3000/oauth/callback"
Enter fullscreen mode Exit fullscreen mode

Start server:

npm install express node-fetch
node server.js
Enter fullscreen mode Exit fullscreen mode

Authorize user: Navigate to http://localhost:3000/oauth/login?userId=user123. After OAuth completes, tokens are stored in memory. In production, replace tokens Map with Redis and set TTL to match token expiry.

Test webhook locally: Use ngrok to expose port 3000, then configure your VAPI assistant's serverUrl to point to https://your-ngrok-url.ngrok.io/webhook. The race condition guard (isProcessing) prevents duplicate Calendar API calls when VAPI retries on network jitter.

FAQ

How do I handle OAuth token expiration in production?

Google Calendar access tokens expire after 3600 seconds. Store the refresh_token from the initial OAuth exchange in a secure database (encrypted at rest). Before each API call, check if expiresAt < Date.now(). If expired, POST to https://oauth2.googleapis.com/token with grant_type=refresh_token to get a new accessToken. This will bite you: Refresh tokens can be revoked if the user changes their Google password or revokes app access. Implement a fallback that re-prompts the user for OAuth consent when refresh fails with a 400 error.

What's the actual latency for checking calendar availability?

Google Calendar API typically responds in 150-300ms for availability checks (timeMin/timeMax queries). Add 50-100ms for VAPI function call overhead. Real-world problem: If you query multiple calendars or long date ranges, latency spikes to 800ms+. Optimize by limiting timeMax to 30 days and caching busy slots for 5 minutes using Redis. For sub-200ms responses, pre-fetch availability during the conversation's idle moments.

How does VAPI's function calling compare to Twilio's webhook approach?

VAPI uses structured function definitions in the assistantConfig.functions array with JSON Schema validation. Twilio requires you to parse raw webhook payloads and manually validate parameters. Why this breaks in production: Twilio webhooks don't enforce parameter types—you'll get strings when you expect integers. VAPI's schema validation catches this before your server code runs. Trade-off: VAPI adds 30-50ms for schema validation, but eliminates 90% of parameter-related bugs.

What happens if the user interrupts during calendar booking?

Set isProcessing = true when the function call starts. If VAPI sends a new transcript event while isProcessing === true, cancel the in-flight Google Calendar API request using AbortController. Flush currentAudioBuffer to prevent the assistant from finishing the old confirmation message. What beginners miss: Without cancellation, you'll create duplicate calendar events because the original request completes even after interruption.

Resources

Official Documentation:

GitHub Examples:

References

  1. https://docs.vapi.ai/quickstart/phone
  2. https://docs.vapi.ai/quickstart/web
  3. https://docs.vapi.ai/workflows/quickstart
  4. https://docs.vapi.ai/observability/evals-quickstart
  5. https://docs.vapi.ai/assistants/quickstart
  6. https://docs.vapi.ai/assistants/structured-outputs-quickstart
  7. https://docs.vapi.ai/quickstart/introduction
  8. https://docs.vapi.ai/tools/custom-tools
  9. https://docs.vapi.ai/server-url/developing-locally

Top comments (0)