AutomationFlowsGeneral › Automated Voice Appointment Booking with Vapi AI and Google Calendar

Automated Voice Appointment Booking with Vapi AI and Google Calendar

ByFrancisco Rivera @soyfrico on n8n.io

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:…

Webhook trigger★★★★☆ complexity13 nodesGoogle Calendar
General Trigger: Webhook Nodes: 13 Complexity: ★★★★☆ Added:
Automated Voice Appointment Booking with Vapi AI and Google Calendar — n8n workflow card showing Google Calendar integration

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 →

Download .json
{
  "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
          }
        ]
      ]
    }
  }
}
Pro

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 →

More General workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

General

github code Try yourself

Google Calendar
General

Need help? Want access to this workflow + many more paid workflows + live Q&A sessions with a top verified n8n creator?

Google Calendar
General

A production-ready authentication workflow implementing secure user registration, login, token verification, and refresh token mechanisms. Perfect for adding authentication to any application without

Crypto, Data Table, Execute Workflow Trigger
General

Agendamiento. Uses n8n-nodes-evolution-api, redis, dataTable, executeWorkflowTrigger. Event-driven trigger; 60 nodes.

N8N Nodes Evolution Api, Redis, Data Table +2
General

Portfolio Orchestrator. Uses httpRequest. Webhook trigger; 59 nodes.

HTTP Request