This workflow corresponds to n8n.io template #8972 — we link there as the canonical source.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "77pJCZrTmSlJBxnh",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "AI Voice Appointment Setter (Vapi + Google Calendar)",
"tags": [],
"nodes": [
{
"id": "30a2f5b7-b9b8-477d-817c-e180192646d7",
"name": "1. CONFIGURATION (EDIT ME)",
"type": "n8n-nodes-base.set",
"notes": "This node holds all the settings for your appointment setter's availability.",
"position": [
-448,
96
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "1fec2f61-2072-4d2b-b323-b20dc8be1eee",
"name": "timeZone",
"type": "string",
"value": "America/New_York"
},
{
"id": "4262429e-b497-4b14-88a0-c1747465cb2c",
"name": "workdayStartHour",
"type": "number",
"value": 9
},
{
"id": "8ad05af7-a5ae-4838-bf60-e45b0c2021a7",
"name": "workdayEndHour",
"type": "number",
"value": 17
},
{
"id": "d618f077-a077-43ad-9bb5-55eb21445947",
"name": "meetingDurationMinutes",
"type": "number",
"value": 30
},
{
"id": "67453847-1958-45e5-aaf1-a042d9bb1c29",
"name": "bookingCadenceMinutes",
"type": "number",
"value": 30
},
{
"id": "f166930f-a05f-4382-8017-6b3c94717840",
"name": "bufferBeforeMinutes",
"type": "number",
"value": 15
},
{
"id": "dee10f61-c71e-4377-80a3-69de66c8a73a",
"name": "bufferAfterMinutes",
"type": "number",
"value": 15
}
]
}
},
"typeVersion": 3.4
},
{
"id": "774e305d-252f-4d54-a057-19aeb642e42b",
"name": "Webhook: Production URL = VAPI Server URL",
"type": "n8n-nodes-base.webhook",
"position": [
-688,
96
],
"parameters": {
"path": "AI-Appointment-Setter-Template",
"options": {},
"httpMethod": "POST",
"responseMode": "responseNode"
},
"typeVersion": 2
},
{
"id": "1f67cedc-9da7-4b7e-8286-6ee3358fd70a",
"name": "Route by Tool Name",
"type": "n8n-nodes-base.switch",
"position": [
-208,
96
],
"parameters": {
"rules": {
"values": [
{
"outputKey": "checkAvailability",
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "80297dae-adde-4206-a8c3-2b7f77c5b88c",
"operator": {
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.name }}",
"rightValue": "checkAvailability"
}
]
},
"renameOutput": true
},
{
"outputKey": "bookAppointment",
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "23430f94-763-403d-a7d6-eae05ad2b801",
"operator": {
"name": "filter.operator.equals",
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.name }}",
"rightValue": "bookAppointment"
}
]
},
"renameOutput": true
}
]
},
"options": {}
},
"typeVersion": 3.2
},
{
"id": "ac181bae-191b-42a8-aedf-5ab53c040051",
"name": "Calculate Potential Slots (do not change)",
"type": "n8n-nodes-base.code",
"position": [
208,
0
],
"parameters": {
"jsCode": "const potentialSlots = [];\n\nconst config = $('1. CONFIGURATION (EDIT ME)').item.json;\n\nconst initialISO = $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.initialSearchDateTime;\n\nconst datePart = initialISO.split('T')[0];\nconst offsetPart = initialISO.slice(-6);\nconst startHourString = String(config.workdayStartHour).padStart(2, '0');\nconst endHourString = String(config.workdayEndHour).padStart(2, '0');\n\nconst workdayStartISO = `${datePart}T${startHourString}:00:00${offsetPart}`;\nconst workdayEndISO = `${datePart}T${endHourString}:00:00${offsetPart}`;\n\nlet currentSlot = new Date(workdayStartISO);\nconst endOfWorkday = new Date(workdayEndISO);\n\nwhile (currentSlot.getTime() < endOfWorkday.getTime()) {\n const slotEnd = new Date(currentSlot.getTime() + config.bookingCadenceMinutes * 60000);\n potentialSlots.push({\n start: currentSlot.toISOString(),\n end: slotEnd.toISOString(),\n });\n currentSlot = slotEnd;\n}\n\nreturn [{\n json: {\n ...config, \n potentialSlots: potentialSlots\n }\n}];"
},
"typeVersion": 2
},
{
"id": "8e2cf90f-9f15-478b-9c4b-acae5c6ee2c9",
"name": "Filter for Available Slots (do not change)",
"type": "n8n-nodes-base.code",
"position": [
432,
0
],
"parameters": {
"jsCode": "const config = $('Calculate Potential Slots (do not change)').item.json;\nconst potentialSlots = config.potentialSlots;\nconst busyEvents = $('2. Get Calendar Events (EDIT ME)').all();\nconst toolCallId = $('Webhook: Production URL = VAPI Server URL').first().json.body.message.toolCalls[0].id;\n\nconst busyIntervals = busyEvents\n .filter(event => event.json.start && event.json.start.dateTime) \n .map(event => {\n const start = new Date(event.json.start.dateTime);\n const end = new Date(event.json.end.dateTime);\n const startWithBuffer = new Date(start.getTime() - config.bufferBeforeMinutes * 60000);\n const endWithBuffer = new Date(end.getTime() + config.bufferAfterMinutes * 60000);\n return { start: startWithBuffer, end: endWithBuffer };\n });\n\nconst availableSlots = potentialSlots.filter(slot => {\n const meetingStart = new Date(slot.start); \n const meetingEnd = new Date(meetingStart.getTime() + config.meetingDurationMinutes * 60000);\n if (meetingStart < new Date()) { return false; }\n const isOverlapping = busyIntervals.some(busy => (meetingStart < busy.end) && (meetingEnd > busy.start));\n return !isOverlapping;\n});\n\nconst formattedSlots = availableSlots.map(slot => {\n const utcDate = new Date(slot.start); \n\n const humanReadable = utcDate.toLocaleString('en-US', {\n timeZone: config.timeZone,\n weekday: 'long', month: 'long', day: 'numeric',\n hour: 'numeric', minute: '2-digit', hour12: true,\n });\n\n\n const localFormatter = new Intl.DateTimeFormat('en-US', {\n year: 'numeric', month: '2-digit', day: '2-digit',\n hour: '2-digit', minute: '2-digit', second: '2-digit',\n hourCycle: 'h23', \n timeZone: config.timeZone\n });\n\n const localParts = localFormatter.formatToParts(utcDate);\n let localYear, localMonth, localDay, localHour, localMinute, localSecond;\n\n for (const part of localParts) {\n switch (part.type) {\n case 'year': localYear = part.value; break;\n case 'month': localMonth = part.value; break;\n case 'day': localDay = part.value; break;\n case 'hour': localHour = part.value; break;\n case 'minute': localMinute = part.value; break;\n case 'second': localSecond = part.value; break;\n }\n }\n\n const fakeUtcDateFromLocal = new Date(\n `${localYear}-${localMonth}-${localDay}T${localHour}:${localMinute}:${localSecond}.000Z`\n );\n\n const offsetMillis = fakeUtcDateFromLocal.getTime() - utcDate.getTime();\n const offsetMinutes = offsetMillis / 60000; \n\n const absOffsetMinutes = Math.abs(offsetMinutes);\n const offsetHours = Math.floor(absOffsetMinutes / 60);\n const remainingOffsetMinutes = absOffsetMinutes % 60;\n\n const offsetSign = offsetMinutes < 0 ? '-' : '+';\n\n const isoOffset = `${offsetSign}${String(offsetHours).padStart(2, '0')}:${String(remainingOffsetMinutes).padStart(2, '0')}`;\n\n const isoDateTime = `${localYear}-${localMonth}-${localDay}T${localHour}:${localMinute}:${localSecond}${isoOffset}`;\n\n return { humanReadable, isoDateTime };\n});\n\nreturn [{\n json: {\n toolCallId: toolCallId,\n availableSlots: formattedSlots\n }\n}];"
},
"typeVersion": 2
},
{
"id": "d14d093c-b468-454f-85a4-815c7b99c287",
"name": "Respond with Available Times (do not change)",
"type": "n8n-nodes-base.respondToWebhook",
"position": [
656,
0
],
"parameters": {
"options": {},
"respondWith": "json",
"responseBody": "={{\n {\n \"results\": [\n {\n \"toolCallId\": $json.toolCallId,\n \"result\": JSON.stringify({ \"availableSlots\": $json.availableSlots })\n }\n ]\n }\n}}"
},
"typeVersion": 1.4
},
{
"id": "7ea10fb1-8966-4bf3-ad42-ed3b52d41c66",
"name": "I'm a note",
"type": "n8n-nodes-base.stickyNote",
"position": [
432,
192
],
"parameters": {
"color": 7,
"width": 348,
"height": 396,
"content": "This will get you going!\n\nNext level? Add:\n- SMS & Email Confirmations\n- Multilingual Support\n- In-depth Lead Qualification\n- Dynamic FAQ Answering\n- CRM Integration\n- Call Transferring\n- Rescheduling & Cancelling\n- Call Summaries & Analysis\n\nQuestions? Feel free to reach out:\nhttps://streetlamp.agency"
},
"typeVersion": 1
},
{
"id": "4f80f53f-c639-448d-ab87-5e58778efece",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-64,
-288
],
"parameters": {
"color": 7,
"height": 272,
"content": "## Edit Google Calendar nodes \n**1** Connect your Google calendar. Click: Credentials to connect with > Choose your credential OR Create new credential\n**2** Click: Calendar > From list > Choose your calendar"
},
"typeVersion": 1
},
{
"id": "c52db039-40de-4458-aaf7-d3c770db190a",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-528,
-224
],
"parameters": {
"color": 7,
"width": 256,
"height": 304,
"content": "## Edit Fields\n(Using numbers, no text)\n**1.** Enter your timezone (e.g. \"America/New_York\" for EST, without quotes)\n**2.** Enter your work start hour and your work end hour (e.g. 8 for 8AM, 17 for 5PM)\n**3.** Enter meeting duration, buffer time before and after meeting (e.g. 60 for 1 hour meeting, 15 for 15 minutes buffer)"
},
"typeVersion": 1
},
{
"id": "1f925858-22bb-4143-8a57-d37f10508858",
"name": "3. Book Appointment in Calendar (EDIT ME)",
"type": "n8n-nodes-base.googleCalendar",
"notes": "1. Select your Google Account from the 'Credential' dropdown.\n\n2. Select the **SAME** calendar you chose in the previous node to book the new appointment.",
"position": [
0,
192
],
"parameters": {
"end": "={{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.endDateTime }}",
"start": "={{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.startDateTime }}",
"calendar": {
"__rl": true,
"mode": "list"
},
"additionalFields": {
"summary": "=Roof Inspection: {{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.clientName }}",
"description": "=Job Details:\nService Type: {{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.serviceType }}\nProperty Address: {{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.propertyAddress }}\n\nClient Contact:\nName: {{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.clientName }}\nPhone: {{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.customer.number }}\n\nCall Log Id:\n{{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.call.id }}"
}
},
"retryOnFail": true,
"typeVersion": 1.3
},
{
"id": "d147ba98-9086-4ae0-b5c8-43e8cd2eb8ba",
"name": "2. Get Calendar Events (EDIT ME)",
"type": "n8n-nodes-base.googleCalendar",
"notes": "1. Select your Google Account from the 'Credential' dropdown.\n\n2. Select the calendar you want to check for availability from the 'Calendar' dropdown list.",
"position": [
0,
0
],
"parameters": {
"options": {},
"timeMax": "={{ DateTime.fromISO($('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.initialSearchDateTime).endOf('day').toISO() }}",
"timeMin": "={{ $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].function.arguments.initialSearchDateTime }}",
"calendar": {
"__rl": true,
"mode": "list"
},
"operation": "getAll",
"returnAll": true
},
"retryOnFail": true,
"typeVersion": 1.3,
"alwaysOutputData": true
},
{
"id": "4355474c-9edb-4a4f-891d-0a349c75dc43",
"name": "Booking Confirmation (do not change)",
"type": "n8n-nodes-base.respondToWebhook",
"position": [
208,
192
],
"parameters": {
"options": {},
"respondWith": "json",
"responseBody": "={{ { \"results\": [ { \"toolCallId\": $('Webhook: Production URL = VAPI Server URL').item.json.body.message.toolCalls[0].id, \"result\": \"The appointment has been successfully booked.\" } ] } }}"
},
"typeVersion": 1.4
},
{
"id": "2f332449-141c-4d6f-926f-78281aea2920",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1792,
-672
],
"parameters": {
"width": 896,
"height": 2288,
"content": "## What this template does\n\nConnect a Vapi AI voice agent to Google Calendar to capture contact details and auto-book appointments.\nThe agent asks for name, address, service type, and a preferred time. The workflow checks availability and either proposes times or books the slot\u2014no code needed.\n\n## How it works (node map)\n\n- Webhook: Production URL = VAPI Server URL \u2014 receives tool calls from Vapi and returns results.\n- **1. CONFIGURATION (EDIT ME)** \u2014 your timezone, work hours, meeting length, buffers, and cadence.\n- **Route by Tool Name** \u2014 routes Vapi tool calls:\n\t- `checkAvailability` \u2192 calendar lookup path\n\t- `bookAppointment` \u2192 create event path\n- **2. Get Calendar Events (EDIT ME)** \u2014 reads events for the requested day.\n- **Calculate Potential Slots / Filter for Available Slots** \u2014 builds conflict-free options with buffers.\n- Respond with Available Times \u2014 returns formatted slots to Vapi.\n- **3. Book Appointment in Calendar (EDIT ME)** \u2014 creates the calendar event with details.\n- **Booking Confirmation** \u2014 returns success back to Vapi.\n\n> Sticky notes in the canvas show exactly what to edit (required by n8n).\nNo API keys are hardcoded; Google uses OAuth credentials.\n\n## Requirements\n\n- n8n (Cloud or self-hosted)\n- Google account with Calendar (OAuth credential in n8n)\n- Vapi account + one Assistant\n\n## Setup (5 minutes)\n### A) Vapi \u2192 n8n connection\n\n1. Open the **Webhook** node and copy the **Production URL**.\n2. In **Vapi** \u2192 **Assistant** \u2192 **Messaging**, set **Server URL** = that Production URL.\n3. In **Server Messages**, enable **only** `toolCalls`.\n\n### B) Vapi tools (names must match exactly)\n\nCreate two **Custom Tools** in Vapi and attach them to the assistant:\n\n**Tool 1:** `checkAvailability`\n\n- **Arguments**\n\t- `initialSearchDateTime` (string, ISO-8601 with timezone offset, e.g. `2025-09-09T09:00:00-05:00`)\n\n**Tool 2:** ```bookAppointment```\n\n- **Arguments**\n\t- `startDateTime` (string, ISO-8601 with tz)\n\t- `endDateTime` (string, ISO-8601 with tz)\n\t- `clientName` (string)\n\t- `propertyAddress` (string)\n\t- `serviceType` (string)\n\n> The Switch node routes based on ```message.toolCalls[0].function.name```. If the names differ, nothing will run.\n\n### C) Configure availability\n\nOpen **1. CONFIGURATION (EDIT ME)** and set:\n\n- ```timeZone``` (e.g. ```America/New_York```)\n- ```workdayStartHour``` / ```workdayEndHour``` (24h integers)\n- ```meetingDurationMinutes``` (e.g. ```30``` or ```60```)\n- ```bufferBeforeMinutes``` / ```bufferAfterMinutes``` (e.g. ```15```)\n- ```bookingCadenceMinutes``` (e.g. ```30```)\n\n### D) Connect Google Calendar\n\n1. Open **2. Get Calendar Events (EDIT ME)** \u2192 Credentials: select/create Google Calendar OAuth.\nThen choose the calendar to check availability.\n2. Open **3. Book Appointment in Calendar (EDIT ME)** \u2192 use the same credential and same calendar to book.\n\n### E) Activate & test\n\n- Toggle the workflow **Active**.\n- Call your Vapi number (or start a session) and book a test slot.\n- Verify the event appears with description fields (client, address, service type, call id).\n\n### Customizing\n\n- Change summary/description format in **3. Book Appointment**.\n- Add SMS/Email confirmations, CRM sync, rescheduling, or analytics as follow-ups (see sticky note \u201cI\u2019m a note\u201d).\n\n### Troubleshooting\n\n- **No response back to Vapi** \u2192 confirm Vapi is set to send toolCalls only and the Server URL matches the Production URL.\n- **Switch doesn\u2019t route** \u2192 tool names must be exactly checkAvailability and bookAppointment.\n- **No times returned** \u2192 ensure timezone + work hours + cadence generate at least one future slot; confirm Google credential and calendar selection.\n- **Event not created** \u2192 use the same Google credential & calendar in both nodes; check OAuth scopes/consent.\n\n### Security & privacy\n\n- Google uses OAuth; credentials live in n8n.\n- No API keys hardcoded.\n- Webhook receives only the fields needed to check times or book."
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"callerPolicy": "workflowsFromSameOwner",
"executionOrder": "v1"
},
"versionId": "a8b22bbd-b9b6-4d43-b1c7-10d0c5b9aa79",
"connections": {
"Route by Tool Name": {
"main": [
[
{
"node": "2. Get Calendar Events (EDIT ME)",
"type": "main",
"index": 0
}
],
[
{
"node": "3. Book Appointment in Calendar (EDIT ME)",
"type": "main",
"index": 0
}
]
]
},
"1. CONFIGURATION (EDIT ME)": {
"main": [
[
{
"node": "Route by Tool Name",
"type": "main",
"index": 0
}
]
]
},
"2. Get Calendar Events (EDIT ME)": {
"main": [
[
{
"node": "Calculate Potential Slots (do not change)",
"type": "main",
"index": 0
}
]
]
},
"3. Book Appointment in Calendar (EDIT ME)": {
"main": [
[
{
"node": "Booking Confirmation (do not change)",
"type": "main",
"index": 0
}
]
]
},
"Calculate Potential Slots (do not change)": {
"main": [
[
{
"node": "Filter for Available Slots (do not change)",
"type": "main",
"index": 0
}
]
]
},
"Webhook: Production URL = VAPI Server URL": {
"main": [
[
{
"node": "1. CONFIGURATION (EDIT ME)",
"type": "main",
"index": 0
}
]
]
},
"Filter for Available Slots (do not change)": {
"main": [
[
{
"node": "Respond with Available Times (do not change)",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Connect a Vapi AI voice agent to Google Calendar to capture contact details and auto-book appointments. The agent asks for name, address, service type, and a preferred time. The workflow checks availability and either proposes times or books the slot—no code needed. Webhook:…
Source: https://n8n.io/workflows/8972/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
github code Try yourself
Need help? Want access to this workflow + many more paid workflows + live Q&A sessions with a top verified n8n creator?
A production-ready authentication workflow implementing secure user registration, login, token verification, and refresh token mechanisms. Perfect for adding authentication to any application without
Agendamiento. Uses n8n-nodes-evolution-api, redis, dataTable, executeWorkflowTrigger. Event-driven trigger; 60 nodes.
Portfolio Orchestrator. Uses httpRequest. Webhook trigger; 59 nodes.