You can wire Vapi's speech-to-intent engine to ElevenLabs' neural text-to-speech, then glue the two together with an n8n workflow that talks to Google Calendar and Twilio. The result is a fully-automated "voice AI for appointment booking" that can take calls, create calendar events, confirm via SMS, and call the client back with a reminder generated on the fly.
| Tool | Plan / Price* | Role |
|---|---|---|
| Vapi | Free tier (up to 5,000 minutes/month) - check the pricing page | Speech recognition, intent extraction, phone number provisioning |
| ElevenLabs | Free tier (10,000 characters/month) - check the pricing page | High-quality voice synthesis for confirmations & reminders |
| n8n | Self-hosted Docker (free) or Cloud starter $20 / month | Orchestrates API calls between Vapi, ElevenLabs, Google Calendar, Twilio, OpenAI |
| Google Cloud - Calendar API | Free tier (up to 1 million calls/month) - check pricing | Stores appointment slots, provides event IDs and reminders |
| Twilio | Pay-as-you-go (≈ $0.0085 per outbound call, $0.0075 per SMS) | PSTN/SMS gateway for confirmations and reminder calls |
| OpenAI (optional) | Free trial $18, then $0.002 per 1 k tokens | Fallback NLU when Vapi intent confidence is low |
| Calendly (optional) | Free plan (basic scheduling) | Alternative booking UI if you prefer a web link over Google Calendar |
*Prices are accurate as of August 2026; always verify on the provider's pricing page.
Estimated build time: 6-8 hours (account setup ≈ 1 h, n8n workflow ≈ 3 h, testing ≈ 2 h).
Below is a concrete, copy-paste-ready path from "zero" to a production-ready voice AI for appointment booking.
| Intent | Sample utterances | Slots (variables) |
|---|---|---|
book_appointment |
"I'd like to book a meeting", "Schedule a 30-minute call next Thursday at 2 pm" | date ,time ,duration ,name ,phone |
reschedule_appointment |
"Can we move my appointment to Friday?", "Change my booking" | event_id ,new_date ,new_time |
https://YOUR_N8N_DOMAIN/webhook/vapi-book).
Tip: Vapi returns a JSON payload with the extracted slots and a confidence score. Keep the confidence > 0.8 for direct processing; otherwise forward to OpenAI.
EXAMPLE_VOICE_ID). https://YOUR_N8N_DOMAIN/ as an authorized redirect URI. credentials.json and store it in the n8n data folder (/root/.n8n/).
Why OAuth? The Calendar API requires a refresh token for long-running workflows; n8n can store it automatically.
docker run -d \
--name n8n \
-p 5678:5678 \
-v ~/.n8n:/root/.n8n \
n8nio/n8n:latest
http://localhost:5678 and set a strong | Credential | Service | Fields |
|---|---|---|
| Vapi API | Vapi | apiKey |
| ElevenLabs API | ElevenLabs | apiKey |
| Google Calendar OAuth2 | Google Calendar | upload credentials.json , authorize the account that owns the target calendar |
| Twilio | Twilio | Account SID ,Auth Token ,From number (a purchased Twilio number) |
The workflow consists of three logical parts:
What this does: Accepts the JSON Vapi sends after intent extraction.
{
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "vapi-book",
"responseMode": "onReceived"
},
"name": "Vapi Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [
250,
300
]
}
]
}
The incoming payload looks like:
{
"intent": "book_appointment",
"confidence": 0.93,
"slots": {
"date": "2026-09-12",
"time": "14:00",
"duration": "30",
"name": "Jane Doe",
"phone": "+15551234567"
}
}
Add a Function node after the webhook to merge date and time into an ISO-8601 string and compute the end time.
// Input: $json.slots
const { date, time, duration } = $json.slots;
const start = new Date(`${date}T${time}:00Z`);
const end = new Date(start.getTime() + Number(duration) * 60000);
return {
json: {
...$json.slots,
start_iso: start.toISOString(),
end_iso: end.toISOString()
}
};
Add a Google Calendar node (Operation: Create). Map fields:
| Calendar field | Source |
|---|---|
summary |
{{ $json.name }} - Consultation |
description |
"Booked via voice AI." |
start.dateTime |
{{ $json.start_iso }} |
end.dateTime |
{{ $json.end_iso }} |
attendees[0].email |
(optional - if you collect email) |
reminders.useDefault |
false |
reminders.overrides[0].method |
popup |
reminders.overrides[0].minutes |
10 |
The node returns an id (Google eventId) we will need for reminders.
Add a Twilio node (Operation: Send SMS).
{{ $json.phone }}
Hi {{ $json.name }}, your appointment is confirmed for {{ $json.date }} at {{ $json.time }} ({{ $json.duration }} min). Reply STOP to cancel.
https://api.elevenlabs.io/v1/text-to-speech/{{ $env.ELEVENLABS_VOICE_ID }}) xi-api-key: {{ $env.ELEVENLABS_API_KEY }}
{
"text": "Your appointment is booked for {{ $json.date }} at {{ $json.time }}. We will call you a day before as a reminder.",
"voice_settings": {
"stability": 0.75,
"similarity_boost": 0.85
}
}
binary (audio/mpeg). The node outputs an MP3 buffer; pipe it straight to Twilio Make Call node.
Add a Twilio node (Operation: Make Call).
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Play>{{ $node["ElevenLabs TTS"].json["data"] }}</Play>
</Response>
Why Twiml? Twilio expects a URL or inline XML. n8n can host a temporary endpoint that returns the MP3 as a URL; the above inline <Play> works when you expose the binary data via n8n's Webhook response.
Two approaches:
email or popup 24 h before the event. Below is the Cron approach (runs daily at 08:00 UTC).
{
"nodes": [
{
"parameters": {
"cronExpression": "0 8 * * *"
},
"name": "Daily Reminder Trigger",
"type": "n8n-nodes-base.cron",
"typeVersion": 1,
"position": [100, 100]
},
{
"parameters": {
"operation": "Search",
"calendarId": "primary",
"timeMin": "{{$moment().add(1, 'day').startOf('day').toISOString()}}",
"timeMax": "{{$moment().add(1, 'day').endOf('day').toISOString()}}",
"maxResults": 50
},
"name": "Find Tomorrow Events",
"type": "n8n-nodes-base.googleCalendar",
"typeVersion": 1,
"position": [300, 100]
},
{
"parameters": {
"functionCode": "return items.map(item => {\n const ev = item.json;\n return {\n json: {\n phone: ev.attendees?.[0]?.email || ev.description.match(/Phone:\\s*(\\+\\d+)/i)?.[1] || null,\n name: ev.summary.split(' - ')[0],\n date: ev.start.dateTime.split('T')[0],\n time: ev.start.dateTime.split('T')[1].substring(0,5)\n }\n };\n});"
},
"name": "Extract Info",
"type": "n8n-nodes-base.function",
"typeVersion": 1,
"position": [500, 100]
},
{
"parameters": {
"url": "https://api.elevenlabs.io/v1/text-to-speech/{{ $env.ELEVENLABS_VOICE_ID }}",
"options": {
"bodyContentType": "json",
"jsonParameters": true,
"json": {
"text": "Hello {{ $json.name }}, this is a reminder that you have an appointment tomorrow at {{ $json.time }}.",
"voice_settings": {
"stability": 0.75,
"similarity_boost": 0.85
}
},
"headers": {
"xi-api-key": "={{ $env.ELEVENLABS_API_KEY }}"
}
},
"responseFormat": "binary"
},
"name": "ElevenLabs Reminder TTS",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 1,
"position": [700, 100]
},
{
"parameters": {
"to": "={{ $json.phone }}",
"from": "{{ $env.TWILIO_NUMBER }}",
"twiml": "<?xml version='1.0' encoding='UTF-8'?><Response><Play>{{ $node[\"ElevenLabs Reminder TTS\"].json[\"data\"] }}</Play></Response>"
},
"name": "Call Reminder",
"type": "n8n-nodes-base.twilio",
"typeVersion": 1,
"position": [900, 100]
}
],
"connections": {
"Daily Reminder Trigger": {
"main": [
[
{
"node": "Find Tomorrow Events",
"type": "main",
"index": 0
}
]
]
},
"Find Tomorrow Events": {
"main": [
[
{
"node": "Extract Info",
"type": "main",
"index": 0
}
]
]
},
"Extract Info": {
"main": [
[
{
"node": "ElevenLabs Reminder TTS",
"type": "main",
"index": 0
}
]
]
},
"ElevenLabs Reminder TTS": {
"main": [
[
{
"node": "Call Reminder",
"type": "main",
"index": 0
}
]
]
}
}
}
Key point: The workflow pulls the phone number from the event's description; you can also store it in a custom extendedProperties field during the booking step.
Return to the Vapi console, edit the agent's Webhook URL, and paste the publicly reachable n8n endpoint (e.g., https://YOUR_N8N_DOMAIN/webhook/vapi-book). Enable "Send webhook on every intent".
book_appointment, forwards the payload to n8n. If anything fails, check the Execution Log in n8n - each node's output is stored for debugging.
| Failure mode | Symptom | Fix / Mitigation |
|---|---|---|
| Vapi minute quota | Calls drop after a few hundred minutes in a month. | Monitor usage in the Vapi dashboard; upgrade to a paid plan before hitting the free 5,000-minute ceiling. |
| ElevenLabs character limit | TTS API returns 429 Too Many Requests . | Cache repeated confirmation sentences; combine multiple sentences into a single request; purchase additional quota if needed. |
| Google OAuth token expiry | "Invalid Credentials" error from Calendar node after ~1 hour. | n8n's OAuth2 credential automatically refreshes if the refresh token is saved; ensure you selected**"Access type: offline"** when creating the client ID. |
| Twilio cost surprise | Unexpected $ per-call charges after a volume spike. | Set a daily spend limit in the Twilio console; log each call in n8n and the workflow if cost exceeds a threshold. |
| Timezone drift | Appointments end up an hour early/late. | Store and convert all dates in UTC ( Z suffix). Use themoment-timezone library in Function nodes if you need a specific zone (e.g.,America/New_York ). |
| Webhook not reachable | n8n returns 404 or "Connection refused". | Expose n8n behind a trusted domain with TLS (Let's Encrypt). Use a tunneling service (ngrok) only for dev; never in production. |
| Edge-case speech | Caller says "next Monday" but Vapi extracts a wrong date. | Add a fallback to OpenAI: if confidence < 0.8 , forward the raw transcript togpt-4o-mini with a prompt to parse a date. |
Bottom line: The most common production blocker is auth token expiry. Keep the Google Calendar OAuth credentials refreshed automatically and store them in n8n's credential manager - that alone eliminates 80 % of runtime errors.
For a deeper technical reference, see n8n's documentation.
Yes. Instead of the Google Calendar node, use an HTTP Request node that calls Calendly's Create Scheduled Event endpoint (https://api.calendly.com/scheduled_events). You'll need a Calendly API key (found in Integrations → API). The rest of the workflow (SMS, TTS) stays unchanged.
Vapi returns the spoken time in the caller's local context, but it does not tag a zone. You must ask the caller for their city or use the phone number's country code to infer a default offset. In a Function node you can apply moment.tz(dateTime, "America/Los_Angeles") before sending the ISO string to Google Calendar.
The Vapi webhook fires as soon as intent extraction completes, regardless of call state. The workflow continues in n8n, creates the event, and sends an SMS. If you need a "call-back-only" flow, set a flag in the intent payload and branch to a no-action path that waits for the user to call back.
No. If you prefer a managed service, n8n Cloud starts at $20 / month and includes automatic SSL, scaling, and built-in credential storage. The self-hosted Docker image is free but you must manage updates and security patches yourself.
Add a Header Authentication node right after the webhook that checks for a custom X-Secret header (store the secret as an environment variable). Reject any request that doesn't match with a 401 response.
If you're hunting for more monetizable ideas, check out AI automations you can sell. And when you're ready to dive deeper, the free guide walks you through scaling voice AI agents from a single prototype to a multi-tenant SaaS.
Happy building - the world needs a voice AI for appointment booking that actually works, not just hype.