VAPI를 Google Calendar에 연결하여 약속 일정 관리하기

발행: (2025년 12월 13일 오전 09:46 GMT+9)
8 min read
원문: Dev.to

Source: Dev.to

TL;DR

대부분의 캘린더 통합은 OAuth 토큰이 통화 중에 만료되거나 시간대 불일치로 가용성 검사가 손상될 때 중단됩니다. 이 가이드는 Google Calendar OAuth 흐름을 처리하고, 토큰을 세션 상태에 매핑하며, 레이스 컨디션 없이 가용성을 조회하는 VAPI 어시스턴트를 구축하는 방법을 보여줍니다. 어시스턴트의 tools 배열에 올바른 calendarId 지정, 토큰 갱신 로직 구현, 예약 충돌 처리 등을 설정합니다. 결과: 중복 예약이나 토큰 만료 시 크래시가 발생하지 않는 프로덕션 수준의 스케줄링.

Prerequisites

API Access

  • VAPI API 키 ( dashboard.vapi.ai 에서 얻음)
  • Calendar API가 활성화된 Google Cloud 프로젝트
  • Google Cloud Console에서 발급받은 OAuth 2.0 자격 증명 (클라이언트 ID + 시크릿)
  • Service‑account JSON 키 또는 calendar.events 범위가 포함된 사용자 OAuth 토큰

Development Environment

  • Node.js 18+ (웹훅 서버용)
  • ngrok 등 공개 HTTPS 엔드포인트를 제공하는 터널링 도구 (VAPI는 공개 HTTPS 엔드포인트가 필요)
  • 환경 변수 관리 도구 (예: dotenv)

Google Calendar Setup

  • 대상 캘린더 ID (캘린더 설정 → “Integrate calendar”에서 확인)
  • 서비스 계정을 사용할 경우 도메인 소유권 검증
  • 캘린더 공유 권한 설정 (읽기/쓰기 접근)

Technical Knowledge

  • OAuth 2.0 토큰 갱신 흐름 (토큰은 매 3600 초마다 만료)
  • 웹훅 서명 검증 (VAPI는 모든 요청에 서명)
  • VAPI 어시스턴트 tools 배열에서 함수 호출 구문
  • 시간대 처리 (Calendar API는 RFC 3339 형식 사용)

VAPI: 시작하기 → 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 어시스턴트가 스케줄링 요청을 받음.
  2. 어시스턴트가 함수 파라미터와 함께 웹훅을 호출함.
  3. 서버가 OAuth 토큰을 검증(또는 갱신)함.
  4. 캘린더 가용성을 조회하고 시간 슬롯을 반환함.
  5. 어시스턴트가 사용자에게 슬롯을 확인함.
  6. 서버가 이벤트를 생성함.

Race‑condition warning: 사용자가 “오후 2시 예약해줘”라고 말하는 동안 가용성 검사가 아직 진행 중이라면, 예약 요청을 큐에 넣으세요. 동시에 Calendar API 호출을 발생시키지 마세요—Google은 사용자당 1000 req/100 s로 제한합니다.

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;
};

위 구성 요소들을 모두 갖추면 VAPI 어시스턴트는 다음을 안정적으로 수행할 수 있습니다.

  • Google OAuth를 통한 사용자 인증
  • 토큰 자동 갱신
  • 레이스 컨디션 없이 캘린더 가용성 조회
  • 충돌 및 오류를 처리하면서 안전하게 이벤트 생성

웹훅을 배포하고(예: 로컬 테스트를 위해 ngrok 사용) 공개 URL을 VAPI 콘솔에 등록하면, Google Calendar 기반의 프로덕션 급 약속 스케줄링을 제공할 준비가 완료됩니다.

Back to Blog

관련 글

더 보기 »

celery-plus 🥬 — Node.js용 현대적인 Celery

왜 확인해 보세요? - 🚀 기존 Python Celery 워커와 함께 작동합니다 - 📘 TypeScript로 작성되었으며 전체 타입을 제공합니다 - 🔄 RabbitMQ AMQP와 Redis를 지원합니다 - ⚡ Async/a...