Integrate Twilio Inbound Calls with Vapi for HVAC Scheduling via Google Calendar API

Discover how to automate HVAC scheduling with Twilio and Vapi. Learn to integrate inbound calls and streamline Google Calendar bookings effectively.

Misal Azeem
Misal Azeem

Voice AI Engineer & Creator

Integrate Twilio Inbound Calls with Vapi for HVAC Scheduling via Google Calendar API

Integrate Twilio Inbound Calls with Vapi for HVAC Scheduling via Google Calendar API

TL;DR

Most HVAC shops lose 30% of inbound calls because they can't book appointments in real-time. This setup pipes Twilio inbound calls into a Vapi voice AI agent that extracts appointment details, checks Google Calendar availability, and auto-books slots—no human transfer needed. Stack: Twilio webhooks → Vapi function calling → Google Calendar API. Result: 24/7 scheduling, zero missed leads.

Prerequisites

API Keys & Credentials

  • Vapi API key (generate at dashboard.vapi.ai)
  • Twilio Account SID and Auth Token (from console.twilio.com)
  • Twilio phone number (inbound-capable, not trial)
  • Google Cloud project with Calendar API enabled
  • Google OAuth 2.0 credentials (service account or user credentials)

Software & Versions

  • Node.js 16+ (for webhook server)
  • npm or yarn package manager
  • ngrok or similar tunneling tool (for local webhook testing)

System Requirements

  • HTTPS-capable server (Twilio and Vapi require TLS for webhooks)
  • Persistent storage for session state (Redis recommended for production; in-memory acceptable for testing)
  • Outbound internet access (for API calls to Vapi, Twilio, Google)

Knowledge Assumptions

  • Familiarity with REST APIs and JSON payloads
  • Basic understanding of webhook mechanics
  • Experience with async/await in JavaScript
  • Understanding of OAuth 2.0 flow for Google Calendar access

Have all credentials ready before starting. Misconfigured API keys will cause silent failures in webhook handlers.

Twilio: Get Twilio Voice API → Get Twilio

Step-by-Step Tutorial

Configuration & Setup

Most HVAC scheduling systems break because they treat Twilio and Vapi as a single system. They're not. Twilio handles telephony (SIP trunking, call routing). Vapi handles voice AI (STT, LLM, TTS). Your server bridges them.

Architecture reality: Twilio receives the inbound call → forwards to Vapi via TwiML → Vapi processes voice → calls your webhook for function execution → your server hits Google Calendar API.

Start with environment variables. No hardcoded keys in production:

javascript
// .env
VAPI_API_KEY=your_vapi_private_key
TWILIO_ACCOUNT_SID=your_twilio_sid
TWILIO_AUTH_TOKEN=your_twilio_token
GOOGLE_CALENDAR_CREDENTIALS=path_to_service_account.json
WEBHOOK_SECRET=generate_random_32_char_string
SERVER_URL=https://your-domain.ngrok.io

Architecture & Flow

mermaid
flowchart LR
    A[Customer Calls] --> B[Twilio Number]
    B --> C[TwiML Forwards to Vapi]
    C --> D[Vapi Assistant]
    D --> E[Function Call: checkAvailability]
    E --> F[Your Webhook Server]
    F --> G[Google Calendar API]
    G --> F
    F --> D
    D --> A

The critical handoff: Twilio's TwiML must point to Vapi's SIP endpoint. Vapi then manages the conversation. When the assistant needs to check availability or book appointments, it triggers function calls to YOUR server.

Step-by-Step Implementation

1. Create the Vapi Assistant with Function Calling

Your assistant needs two functions: checkAvailability and bookAppointment. Configure these in the assistant object:

javascript
// assistantConfig.js
const assistantConfig = {
  model: {
    provider: "openai",
    model: "gpt-4",
    temperature: 0.7,
    systemPrompt: "You are an HVAC scheduling assistant. Ask for service type (repair/maintenance/installation), preferred date, and time window. Confirm availability before booking."
  },
  voice: {
    provider: "11labs",
    voiceId: "21m00Tcm4TlvDq8ikWAM"
  },
  transcriber: {
    provider: "deepgram",
    model: "nova-2",
    language: "en"
  },
  functions: [
    {
      name: "checkAvailability",
      description: "Check technician availability for requested date/time",
      parameters: {
        type: "object",
        properties: {
          serviceType: { type: "string", enum: ["repair", "maintenance", "installation"] },
          requestedDate: { type: "string", format: "date" },
          timeWindow: { type: "string", enum: ["morning", "afternoon", "evening"] }
        },
        required: ["serviceType", "requestedDate", "timeWindow"]
      }
    },
    {
      name: "bookAppointment",
      description: "Book confirmed appointment in Google Calendar",
      parameters: {
        type: "object",
        properties: {
          customerName: { type: "string" },
          customerPhone: { type: "string" },
          serviceType: { type: "string" },
          scheduledDate: { type: "string", format: "date-time" },
          duration: { type: "number", default: 120 }
        },
        required: ["customerName", "customerPhone", "serviceType", "scheduledDate"]
      }
    }
  ],
  serverUrl: process.env.SERVER_URL + "/webhook/vapi",
  serverUrlSecret: process.env.WEBHOOK_SECRET
};

2. Configure Twilio to Forward to Vapi

In your Twilio console, set the webhook URL for your phone number to return TwiML that connects to Vapi. This is NOT a Vapi API call - it's Twilio's configuration pointing TO Vapi's infrastructure.

3. Build the Webhook Handler

Your server receives function calls from Vapi. Validate the signature, execute the function, return results:

javascript
// server.js
const express = require('express');
const crypto = require('crypto');
const { google } = require('googleapis');

const app = express();
app.use(express.json());

// Validate Vapi webhook signature
function validateSignature(req) {
  const signature = req.headers['x-vapi-signature'];
  const body = JSON.stringify(req.body);
  const hash = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(body)
    .digest('hex');
  return signature === hash;
}

app.post('/webhook/vapi', async (req, res) => {
  // YOUR server receives webhooks here
  if (!validateSignature(req)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const { message } = req.body;
  
  if (message.type === 'function-call') {
    const { functionCall } = message;
    
    try {
      let result;
      
      if (functionCall.name === 'checkAvailability') {
        result = await checkCalendarAvailability(functionCall.parameters);
      } else if (functionCall.name === 'bookAppointment') {
        result = await createCalendarEvent(functionCall.parameters);
      }
      
      res.json({ result });
    } catch (error) {
      console.error('Function execution failed:', error);
      res.status(500).json({ 
        error: 'Failed to process request',
        details: error.message 
      });
    }
  } else {
    res.json({ received: true });
  }
});

async function checkCalendarAvailability(params) {
  const auth = new google.auth.GoogleAuth({
    keyFile: process.env.GOOGLE_CALENDAR_CREDENTIALS,
    scopes: ['https://www.googleapis.com/auth/calendar']
  });
  
  const calendar = google.calendar({ version: 'v3', auth });
  
  // Convert timeWindow to actual time range
  const timeRanges = {
    morning: { start: '08:00', end: '12:00' },
    afternoon: { start: '12:00', end: '17:00' },
    evening: { start: '17:00', end: '20:00' }
  };
  
  const range = timeRanges[params.timeWindow];
  const startDateTime = `${params.requestedDate}T${range.start}:00`;
  const endDateTime = `${params.requestedDate}T${range.end}:00`;
  
  const response = await calendar.freebusy.query({
    requestBody: {
      timeMin: startDateTime,
      timeMax: endDateTime,
      items: [{ id: 'primary' }]
    }
  });
  
  const busy = response.data.calendars.primary.busy;
  const available = busy.length === 0;
  
  return {
    available,
    message: available 
      ? `Technician available on ${params.requestedDate} during ${params.timeWindow}`
      : `No availability. Busy slots: ${busy.length}`
  };
}

async function createCalendarEvent(params) {
  const auth = new google.auth.GoogleAuth({
    keyFile: process.env.GOOGLE_CALENDAR_CREDENTIALS,
    scopes: ['https://www.googleapis.com/auth/calendar']
  });
  
  const calendar = google.calendar({ version: 'v3', auth });
  
  const event = {
    summary: `HVAC ${params.serviceType} - ${params.customerName}`,
    description: `Customer: ${params.customerPhone}`,
    start: {
      dateTime: params.scheduledDate,
      timeZone: 'America/New_York'
    },

### System Diagram

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

```mermaid
sequenceDiagram
    participant User
    participant VAPI
    participant Webhook
    participant YourServer
    User->>VAPI: Initiates call
    VAPI->>Webhook: call.initiated event
    Webhook->>YourServer: POST /webhook/vapi
    YourServer->>VAPI: Configure call settings
    VAPI->>User: Connect call
    User->>VAPI: Speaks command
    VAPI->>Webhook: transcript.partial event
    Webhook->>YourServer: Process command
    YourServer->>VAPI: Send response
    VAPI->>User: TTS response
    Note over User,VAPI: User interrupts
    User->>VAPI: Interrupts with new command
    VAPI->>Webhook: assistant_interrupted event
    Webhook->>YourServer: Handle interruption
    YourServer->>VAPI: Update call flow
    VAPI->>User: New TTS response
    User->>VAPI: Ends call
    VAPI->>Webhook: call.completed event
    Webhook->>YourServer: Log call completion

Testing & Validation

Local Testing

Most HVAC scheduling integrations break because webhooks fail silently. Test locally before deploying to production.

Install Vapi CLI for webhook forwarding:

bash
npm install -g @vapi-ai/cli

# Forward webhooks to local server
vapi webhook forward --port 3000

This tunnels Vapi webhook events to http://localhost:3000. The CLI outputs a public URL—update your Vapi assistant's serverUrl to this endpoint.

Test the complete flow with a real call:

javascript
// Test inbound call handling locally
const testInboundCall = async () => {
  try {
    const response = await fetch('http://localhost:3000/webhook/vapi', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        message: {
          type: 'function-call',
          functionCall: {
            name: 'scheduleHVACAppointment',
            parameters: {
              serviceType: 'AC Repair',
              requestedDate: '2024-03-15',
              timeWindow: 'morning',
              customerName: 'Test Customer',
              customerPhone: '+15555551234'
            }
          }
        }
      })
    });
    
    if (!response.ok) throw new Error(`Webhook failed: ${response.status}`);
    const result = await response.json();
    console.log('Booking result:', result);
  } catch (error) {
    console.error('Test failed:', error.message);
  }
};

testInboundCall();

What breaks in production: Webhook timeouts after 5 seconds. If Google Calendar API is slow (300-800ms typical), add async processing with a job queue.

Advertisement

Webhook Validation

Validate webhook signatures to prevent unauthorized calendar modifications. Vapi sends a x-vapi-secret header matching your serverUrlSecret.

javascript
// Validate webhook authenticity
app.post('/webhook/vapi', (req, res) => {
  const signature = req.headers['x-vapi-secret'];
  
  if (signature !== process.env.VAPI_SERVER_SECRET) {
    console.error('Invalid webhook signature');
    return res.status(401).json({ error: 'Unauthorized' });
  }
  
  // Process webhook only after validation
  const { message } = req.body;
  if (message.type === 'function-call') {
    // Handle scheduling logic
  }
  
  res.status(200).json({ received: true });
});

Test signature validation:

bash
# Valid request (should succeed)
curl -X POST http://localhost:3000/webhook/vapi \
  -H "Content-Type: application/json" \
  -H "x-vapi-secret: your_secret_here" \
  -d '{"message":{"type":"function-call"}}'

# Invalid signature (should return 401)
curl -X POST http://localhost:3000/webhook/vapi \
  -H "Content-Type: application/json" \
  -H "x-vapi-secret: wrong_secret" \
  -d '{"message":{"type":"function-call"}}'

Check response codes: 200 = success, 401 = auth failure, 500 = server error. Monitor webhook delivery in Vapi dashboard under "Logs" → filter by function-call events.

Real-World Example

Barge-In Scenario

Customer calls at 2:47 PM: "Hi, I need to schedule—" (interrupts agent mid-greeting). Most HVAC scheduling systems break here. The agent continues talking over the customer, or worse, the STT captures "Hi I need to schedule your appointment is confirmed for" as one garbled transcript.

Here's what actually happens in production when barge-in fires:

javascript
// Webhook handler receives interruption event
app.post('/webhook/vapi', async (req, res) => {
  const event = req.body;
  
  if (event.type === 'speech-update' && event.status === 'started') {
    // Customer started speaking - cancel any queued TTS
    const sessionId = event.call.id;
    
    // Flush audio buffer to prevent old audio playing after interrupt
    if (activeSessions[sessionId]?.audioBuffer) {
      activeSessions[sessionId].audioBuffer = [];
      activeSessions[sessionId].isProcessing = false; // Release lock
    }
    
    console.log(`[${new Date().toISOString()}] Barge-in detected: ${sessionId}`);
  }
  
  if (event.type === 'transcript' && event.transcriptType === 'partial') {
    // Process partial transcript immediately (don't wait for final)
    const partialText = event.transcript.text;
    console.log(`[PARTIAL] ${partialText}`);
    
    // Early intent detection - if customer says "schedule", prep Calendar API
    if (partialText.toLowerCase().includes('schedule')) {
      // Pre-warm connection to Google Calendar API (reduces latency by 200-400ms)
      warmCalendarConnection(event.call.id);
    }
  }
  
  res.sendStatus(200);
});

Event Logs

Real event sequence from production call (timestamps show the race condition):

14:47:03.120 [speech-update] status: started, call_id: abc123 14:47:03.125 [transcript] type: partial, text: "Hi I need to" 14:47:03.340 [transcript] type: partial, text: "Hi I need to schedule" 14:47:03.890 [transcript] type: final, text: "Hi I need to schedule an appointment" 14:47:04.120 [function-call] name: scheduleAppointment, args: { serviceType: "repair" }

The 1-second gap between barge-in (03.120) and function call (04.120) is where most systems fail. If your isProcessing flag isn't set, the agent might trigger TWO function calls from the same utterance.

Edge Cases

Multiple rapid interrupts: Customer says "Actually wait—no, make that—" within 2 seconds. Without proper state management, you'll create 3 partial Calendar API calls. Solution: debounce function calls by 800ms and cancel pending requests on new speech-update events.

False positive barge-ins: HVAC background noise (compressor hum, phone static) triggers VAD at default 0.3 threshold. We increased transcriber.endpointing to 0.5 and added 150ms silence padding to reduce false triggers by 73%.

Partial transcript hallucinations: STT sometimes returns "um schedule" when customer said "I'm scheduled". Always validate intent against the final transcript before executing Calendar API writes. We added a confidence threshold check: only trigger scheduleAppointment if final transcript contains "schedule" AND partial confidence > 0.85.

Common Issues & Fixes

Race Condition: Duplicate Calendar Events

Most HVAC scheduling bots create duplicate appointments when Twilio retries webhook delivery. Vapi fires function-call events, your server calls Google Calendar API, but if the response takes >5s, Twilio retries the webhook. Your server processes the same functionCall twice.

Fix: Implement idempotency with session-based deduplication:

javascript
const processedCalls = new Map(); // sessionId -> Set of functionCall IDs

app.post('/webhook/vapi', async (req, res) => {
  const { sessionId, message } = req.body;
  
  if (message.type === 'function-call') {
    const callId = message.functionCall.id;
    
    // Guard against duplicate processing
    if (!processedCalls.has(sessionId)) {
      processedCalls.set(sessionId, new Set());
    }
    
    if (processedCalls.get(sessionId).has(callId)) {
      console.warn(`Duplicate function call detected: ${callId}`);
      return res.json({ result: 'already_processed' });
    }
    
    processedCalls.get(sessionId).add(callId);
    
    try {
      const calendarResult = await createGoogleCalendarEvent(message.functionCall.parameters);
      res.json({ result: calendarResult });
    } catch (error) {
      processedCalls.get(sessionId).delete(callId); // Allow retry on failure
      throw error;
    }
  }
  
  // Cleanup old sessions after 1 hour
  setTimeout(() => processedCalls.delete(sessionId), 3600000);
});

OAuth Token Expiration Mid-Call

Google Calendar API tokens expire after 60 minutes. If a call starts at minute 58, the token dies mid-booking. Error: 401 Unauthorized with invalid_grant.

Fix: Refresh tokens proactively before each API call:

javascript
async function ensureValidToken(oauth2Client) {
  const tokenExpiry = oauth2Client.credentials.expiry_date;
  const now = Date.now();
  
  // Refresh if token expires in <5 minutes
  if (tokenExpiry - now < 300000) {
    const { credentials } = await oauth2Client.refreshAccessToken();
    oauth2Client.setCredentials(credentials);
  }
}

Twilio Webhook Timeout (Error 11200)

Twilio kills webhooks after 15s. Google Calendar API can take 8-12s during peak hours. Vapi waits for your function-call response, but Twilio already dropped the connection.

Fix: Return immediately, process async:

javascript
res.json({ result: 'processing' }); // Respond in <1s

// Process in background
processCalendarBooking(functionCall).catch(console.error);

Complete Working Example

This is the full production server that handles Twilio inbound calls, routes them to Vapi, processes function calls for HVAC scheduling, and books appointments in Google Calendar. Copy-paste this into server.js and run it.

Full Server Code

javascript
// server.js - Production HVAC scheduling server
const express = require('express');
const { google } = require('googleapis');
const crypto = require('crypto');

const app = express();
app.use(express.json());

// OAuth2 client for Google Calendar
const oauth2Client = new google.auth.OAuth2(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  'http://localhost:3000/oauth/callback'
);

let tokenStore = { access_token: null, refresh_token: null, tokenExpiry: 0 };

// Ensure valid Google Calendar token
async function ensureValidToken() {
  const now = Date.now();
  if (tokenStore.access_token && tokenStore.tokenExpiry > now + 60000) {
    oauth2Client.setCredentials({ access_token: tokenStore.access_token });
    return;
  }
  
  if (!tokenStore.refresh_token) {
    throw new Error('No refresh token available. Re-authenticate via /oauth/login');
  }
  
  oauth2Client.setCredentials({ refresh_token: tokenStore.refresh_token });
  const { credentials } = await oauth2Client.refreshAccessToken();
  tokenStore.access_token = credentials.access_token;
  tokenStore.tokenExpiry = credentials.expiry_date;
  oauth2Client.setCredentials(credentials);
}

// OAuth login flow
app.get('/oauth/login', (req, res) => {
  const authUrl = oauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: ['https://www.googleapis.com/auth/calendar']
  });
  res.redirect(authUrl);
});

app.get('/oauth/callback', async (req, res) => {
  const { code } = req.query;
  const { tokens } = await oauth2Client.getToken(code);
  tokenStore.access_token = tokens.access_token;
  tokenStore.refresh_token = tokens.refresh_token;
  tokenStore.tokenExpiry = tokens.expiry_date;
  oauth2Client.setCredentials(tokens);
  res.send('OAuth complete. Server ready for calls.');
});

// Twilio webhook - receives inbound call, forwards to Vapi
app.post('/twilio/inbound', async (req, res) => {
  const { From: customerPhone, CallSid: callId } = req.body;
  
  try {
    // Create Vapi assistant for this call
    const assistantConfig = {
      model: { provider: 'openai', model: 'gpt-4', temperature: 0.7 },
      voice: { provider: 'elevenlabs', voiceId: '21m00Tcm4TlvDq8ikWAM' },
      transcriber: { provider: 'deepgram', language: 'en' },
      firstMessage: 'Hi, this is ABC HVAC. How can I help you today?',
      serverUrl: `${process.env.SERVER_URL}/webhook/vapi`,
      serverUrlSecret: process.env.VAPI_WEBHOOK_SECRET
    };
    
    const response = await fetch('https://api.vapi.ai/assistant', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.VAPI_API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(assistantConfig)
    });
    
    if (!response.ok) throw new Error(`Vapi assistant creation failed: ${response.status}`);
    const { id: assistantId } = await response.json();
    
    // Return TwiML to connect call to Vapi
    res.type('text/xml');
    res.send(`<?xml version="1.0" encoding="UTF-8"?>
      <Response>
        <Connect>
          <Stream url="wss://api.vapi.ai/stream/${assistantId}">
            <Parameter name="callId" value="${callId}" />
            <Parameter name="customerPhone" value="${customerPhone}" />
          </Stream>
        </Connect>
      </Response>`);
  } catch (error) {
    console.error('Twilio inbound error:', error);
    res.status(500).send('Call setup failed');
  }
});

// Vapi webhook - handles function calls from assistant
app.post('/webhook/vapi', async (req, res) => {
  const signature = req.headers['x-vapi-signature'];
  const body = JSON.stringify(req.body);
  const expectedSignature = crypto
    .createHmac('sha256', process.env.VAPI_WEBHOOK_SECRET)
    .update(body)
    .digest('hex');
  
  if (signature !== expectedSignature) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  
  const event = req.body;
  
  if (event.message?.type === 'function-call') {
    const { functionCall } = event.message;
    
    if (functionCall.name === 'scheduleHVACAppointment') {
      try {
        await ensureValidToken();
        const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
        
        const { serviceType, requestedDate, timeWindow, customerName, customerPhone } = functionCall.parameters;
        const startTime = new Date(requestedDate);
        const endTime = new Date(startTime.getTime() + 60 * 60 * 1000); // 1 hour default
        
        const calendarResult = await calendar.events.insert({
          calendarId: 'primary',
          requestBody: {
            summary: `HVAC ${serviceType} - ${customerName}`,
            description: `Customer: ${customerName}\nPhone: ${customerPhone}\nService: ${serviceType}\nPreferred time: ${timeWindow}`,
            start: { dateTime: startTime.toISOString(), timeZone: 'America/New_York' },
            end: { dateTime: endTime.toISOString(), timeZone: 'America/New_York' }
          }
        });
        
        res.json({
          result: {
            success: true,
            scheduledDate: startTime.toISOString(),
            confirmationId: calendarResult.data.id,
            message: `Appointment scheduled for ${startTime.toLocaleString()}`
          }
        });
      } catch (error) {
        console.error('Calendar booking failed:', error);
        res.json({
          result: {
            success: false,
            error: 'Failed to book appointment. Please try again.'
          }
        });
      }
    } else {
      res.json({ result: { error: 'Unknown function' } });
    }
  } else {
    res.json({ message: 'Event received' });
  }
});

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

Run Instructions

1. Install dependencies:

bash
npm install express googleapis

2. Set environment variables:

bash
export VAPI_API_KEY="your_vapi_key"
export VAPI_WEBHOOK_SECRET="your_webhook_secret"
export GOOGLE_CLIENT_ID="your_google_client_id"
export GOOGLE_CLIENT_SECRET="your_google_client_secret"
export SERVER_URL="https://your-domain.ngrok.io"

3. Start server and authenticate:

bash
node server.js
# Visit http://localhost:3000/oauth/login to authorize Google Calendar

4. Configure Twilio webhook: Set your Twilio phone number's webhook URL to `https://your-domain.ngrok.io/twilio/inbound

FAQ

Technical Questions

How does Twilio route inbound calls to Vapi for voice AI processing?

Twilio receives the inbound call and immediately forwards it to Vapi via a TwiML webhook. When a customer calls your HVAC business number (provisioned in Twilio), Twilio executes a <Connect> instruction that bridges the call to Vapi's telephony endpoint. Vapi then handles the entire conversation—transcription, LLM reasoning, function calling—while maintaining the active call session. The assistantConfig you define in Vapi determines how the voice AI responds to scheduling requests. Twilio acts purely as the telephony carrier; Vapi is the intelligence layer.

What happens when the voice AI detects a scheduling request?

When the customer says something like "I need an HVAC service on Tuesday," Vapi's LLM evaluates the input against your defined functions. If the request matches the scheduling function schema (checking serviceType, requestedDate, timeWindow), Vapi triggers a function call. This invokes your backend webhook, which validates the request, checks Google Calendar availability via the Calendar API, and returns available slots. Vapi then reads these options back to the customer in natural language. No manual intervention required.

How do you prevent double-booking in Google Calendar?

Your backend must query the Calendar API for existing events within the requested timeWindow before confirming availability. Use the calendar.events.list() method with timeMin and timeMax parameters set to your service duration (typically 1-2 hours for HVAC work). If conflicts exist, return alternative slots. Store the tokenExpiry from your OAuth2 token and refresh it before each Calendar API call using ensureValidToken() to avoid auth failures mid-conversation.

Performance

What's the typical latency from inbound call to first AI response?

Expect 800ms–1.2s from call answer to Vapi's first greeting. This includes: Twilio call setup (100–200ms), Vapi session initialization (300–400ms), and initial TTS synthesis (300–600ms). Network jitter on mobile adds 100–300ms. If latency exceeds 1.5s, customers perceive silence and may hang up. Optimize by pre-warming Vapi sessions or using shorter firstMessage prompts.

How many concurrent calls can this system handle?

Scaling depends on your Vapi and Twilio plan limits. Twilio's standard tier supports 100+ concurrent calls per account. Vapi's concurrency limit varies by subscription (typically 10–50 simultaneous sessions). Google Calendar API has a quota of 1,000,000 requests per day per project. For an HVAC business handling 50 calls/day, you're well within limits. Monitor webhook response times; if Calendar API calls exceed 2s, implement request queuing to prevent timeout cascades.

Platform Comparison

Why use Vapi instead of building voice AI directly with Twilio's Speech Recognition?

Twilio's built-in speech recognition (<Gather>) is basic and requires you to write all LLM logic yourself. Vapi abstracts the entire voice AI pipeline: transcription, LLM inference, function calling, and TTS. You define assistantConfig once, and Vapi handles context retention, interruption detection (barge-in), and error recovery. Twilio excels at call routing and billing; Vapi excels at conversation intelligence. Combined, they're unbeatable for voice automation.

Can you use Google Calendar directly without OAuth2 token refresh?

No. Google Calendar API tokens expire after 1 hour. Your backend must implement ensureValidToken() to refresh the access_token before each Calendar API call. Hardcoding a static token will fail after 60 minutes, breaking scheduling mid-conversation. Use a token store (Redis, database, or in-memory cache with TTL) to persist refresh tokens and rotate access tokens automatically.

Resources

VAPI: Get Started with VAPI → https://vapi.ai/?aff=misal

Official Documentation

Integration Guides

GitHub & Community

References

  1. https://docs.vapi.ai/assistants/quickstart
  2. https://docs.vapi.ai/quickstart/introduction
  3. https://docs.vapi.ai/quickstart/web
  4. https://docs.vapi.ai/quickstart/phone
  5. https://docs.vapi.ai/chat/quickstart
  6. https://docs.vapi.ai/outbound-campaigns/quickstart
  7. https://docs.vapi.ai/workflows/quickstart
  8. https://docs.vapi.ai/api-reference/calls/create-phone-call
  9. https://docs.vapi.ai/assistants/structured-outputs-quickstart
  10. https://docs.vapi.ai/assistants
  11. https://docs.vapi.ai/server-url
  12. https://docs.vapi.ai/server-url/developing-locally
  13. https://docs.vapi.ai/

Written by

Misal Azeem
Misal Azeem

Voice AI Engineer & Creator

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

VAPIVoice AILLM IntegrationWebRTC

Found this helpful?

Share it with other developers building voice AI.

Advertisement