How to Connect VAPI to Google Calendar for Appointment Scheduling

Published: (December 12, 2025 at 07:46 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

TL;DR

Most calendar integrations break when OAuth tokens expire mid‑call or timezone mismatches corrupt availability checks. This guide shows 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’s 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 (e.g., dotenv)

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 3600 s)
  • Webhook signature validation (VAPI signs all requests)
  • Function‑calling syntax in VAPI assistant tools array
  • Timezone handling (Calendar API uses RFC 3339 format)

VAPI: Get started → Get VAPI

Step‑By‑Step Tutorial

Configuration & Setup

  1. Create OAuth credentials in Google Cloud Console: enable Calendar API, generate OAuth 2.0 client ID/secret, and set a redirect URI.
  2. Store GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and REDIRECT_URI in environment variables—do not hard‑code them.
// 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;
  }
};

Critical: Refresh tokens also expire. Implement token‑refresh logic before making any Calendar API calls. Most production failures stem from access‑token expiry mid‑conversation (401 errors).

Architecture & Flow

  1. VAPI assistant receives a scheduling request.
  2. It calls your webhook with function parameters.
  3. Your server validates (or refreshes) the OAuth token.
  4. It fetches calendar availability and returns time slots.
  5. Assistant confirms the slot with the user.
  6. Your server creates the event.

Race‑condition warning: If a user says “book 2 pm” while an availability check is still running, queue the booking request. Do not fire concurrent Calendar API calls—Google rate‑limits at 1000 req/100 s per user.

Step‑By‑Step Implementation

1. Configure VAPI assistant with function‑calling tools

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"],
      },
    },
  ],
};

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 {
      // ---------- Check Availability ----------
      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 } });
      }

      // ---------- Create Event ----------
      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 } });
    }
  }

  // Fallback for non‑function calls
  res.json({ received: true });
});

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; // Unix timestamp (seconds)

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

    if (!refreshResponse.ok) {
      const err = await refreshResponse.json();
      throw new Error(`Refresh failed: ${err.error_description}`);
    }

    const newTokens = await refreshResponse.json();
    // Update DB with new access token and expiry
    await db.updateTokens(userId, {
      access_token: newTokens.access_token,
      expires_at: Math.floor(Date.now() / 1000) + newTokens.expires_in,
    });
    return newTokens.access_token;
  }

  return stored.access_token;
};

With the above components in place, your VAPI assistant can reliably:

  • Authenticate users via Google OAuth
  • Refresh tokens automatically
  • Query calendar availability without race conditions
  • Create events safely, handling conflicts and errors

Deploy the webhook (e.g., via ngrok for local testing), register the public URL in the VAPI console, and you’re ready to offer production‑grade appointment scheduling powered by Google Calendar.

Back to Blog

Related posts

Read more »

Stop Buying Macs Just to Fix CSS

The “Hacker” Way to Debug Safari on Windows & Linux Let’s be honest: Safari is the new Internet Explorer. As web developers we work mostly with Chromium Chrome...