Tools
How to Connect VAPI to Google Calendar for Appointment Scheduling
2025-12-13
0 views
admin
How to Connect VAPI to Google Calendar for Appointment Scheduling ## Prerequisites ## Step-by-Step Tutorial ## Configuration & Setup ## Architecture & Flow ## Step-by-Step Implementation ## Error Handling & Edge Cases ## System Diagram ## Testing & Validation ## Local Testing ## Webhook Validation ## Real-World Example ## Barge-In Scenario ## Event Logs ## Edge Cases ## Common Issues & Fixes ## OAuth Token Expiration Mid-Call ## Calendar ID Mismatch ## Timezone Conflicts ## Complete Working Example ## Full Server Code ## Run Instructions ## How do I handle OAuth token expiration in production? ## What's the actual latency for checking calendar availability? ## How does VAPI's function calling compare to Twilio's webhook approach? ## What happens if the user interrupts during calendar booking? ## Resources ## References 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. Development Environment: Google Calendar Setup: VAPI: Get Started with VAPI → Get VAPI 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. 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). 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 1: Configure VAPI assistant with function calling tool: Step 2: Build webhook handler for function execution: Step 3: Implement token refresh to prevent mid-call failures: Call flow showing how vapi handles user input, webhook events, and responses. Most webhook failures happen because developers skip local validation. Use the Vapi CLI webhook forwarder with ngrok to catch integration bugs before production. 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. Validate function call arguments match Google Calendar's exact schema. The calendarId parameter is case-sensitive and must be a valid email format. 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"}}}]}}' 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. 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" }) 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. 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." 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. 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. This is the production-ready server that handles OAuth, webhooks, and token refresh. Copy-paste this entire block to get started: 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. 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. 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. 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. 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. Official Documentation: Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK:
// 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 COMMAND_BLOCK:
// 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; }
}; COMMAND_BLOCK:
// 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; }
}; CODE_BLOCK:
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 CODE_BLOCK:
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"] } }]
}; CODE_BLOCK:
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"] } }]
}; COMMAND_BLOCK:
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 COMMAND_BLOCK:
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 });
}); COMMAND_BLOCK:
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 });
}); COMMAND_BLOCK:
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 COMMAND_BLOCK:
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;
}; COMMAND_BLOCK:
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;
}; CODE_BLOCK:
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 CODE_BLOCK:
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 CODE_BLOCK:
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 COMMAND_BLOCK:
// 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 COMMAND_BLOCK:
// 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); }
}; COMMAND_BLOCK:
// 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); }
}; COMMAND_BLOCK:
// 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 COMMAND_BLOCK:
// 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);
}); COMMAND_BLOCK:
// 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);
}); COMMAND_BLOCK:
// 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 COMMAND_BLOCK:
// 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);
}); COMMAND_BLOCK:
// 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);
}); COMMAND_BLOCK:
// 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 COMMAND_BLOCK:
// 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; }
} COMMAND_BLOCK:
// 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; }
} COMMAND_BLOCK:
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 COMMAND_BLOCK:
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')); COMMAND_BLOCK:
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')); CODE_BLOCK:
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 CODE_BLOCK:
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" CODE_BLOCK:
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" COMMAND_BLOCK:
npm install express node-fetch
node server.js Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
npm install express node-fetch
node server.js COMMAND_BLOCK:
npm install express node-fetch
node server.js - 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 - Node.js 18+ (for webhook server)
- ngrok or similar tunneling tool (VAPI needs public HTTPS endpoints)
- Environment variable manager (dotenv recommended) - Target calendar ID (found in Calendar Settings → "Integrate calendar")
- Verified domain ownership if using service accounts
- Calendar sharing permissions configured (read/write access) - 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 Function Calling Docs - Server-side tool implementation patterns
- Google Calendar API Reference - OAuth flows, event creation, availability queries
- Google OAuth 2.0 Guide - Token exchange, refresh logic, scope configuration - VAPI Node.js Starter - Webhook handlers, function calling setup
- Google Calendar Quickstart - OAuth implementation, token storage patterns - https://docs.vapi.ai/quickstart/phone
- https://docs.vapi.ai/quickstart/web
- https://docs.vapi.ai/workflows/quickstart
- https://docs.vapi.ai/observability/evals-quickstart
- https://docs.vapi.ai/assistants/quickstart
- https://docs.vapi.ai/assistants/structured-outputs-quickstart
- https://docs.vapi.ai/quickstart/introduction
- https://docs.vapi.ai/tools/custom-tools
- https://docs.vapi.ai/server-url/developing-locally
how-totutorialguidedev.toaimlopenaigptservernetworknodedatabase