Double booking google calendar events

Hi guys, I’ve got an issue with my n8n instance and my VAPI AI double booking appointments via Google Calendar.

Here is my workflow:
{
“nodes”: [
{
“parameters”: {
“assignments”: {
“assignments”: [
{
“id”: “ccabe9f4-7911-4488-a75b-7c5779fb2014”,
“name”: “timeZone”,
“type”: “string”,
“value”: “=America/New_York”
},
{
“id”: “b802d976-78f5-4c00-8764-f8c49eaded29”,
“name”: “endtime”,
“type”: “string”,
“value”: “={{ $json.body.message.toolCalls[0].function.arguments.endtime }}”
},
{
“id”: “02d58122-6a0f-4bdb-9914-6f50d2af6df4”,
“name”: “starttime”,
“type”: “string”,
“value”: “={{ $json.body.message.toolCalls[0].function.arguments.starttime }}”
},
{
“id”: “c1249493-a1d7-4a91-9468-9e5c49430d2e”,
“name”: “body.message.toolCalls[0].id”,
“type”: “string”,
“value”: “={{ $json.body.message.toolCalls[0].id }}”
},
{
“id”: “4bc7c88e-63b6-4a3e-8f27-81ea294a0f44”,
“name”: “number”,
“value”: “={{ $json.body.message.toolCalls[0].function.arguments.number }}”,
“type”: “string”
}
]
},
“options”: {}
},
“id”: “36d0b0d4-b454-4a9b-8168-bcc7942a7cc7”,
“name”: “Input Arguments”,
“type”: “n8n-nodes-base.set”,
“position”: [
2688,
640
],
“typeVersion”: 3.3
},
{
“parameters”: {
“operation”: “concatenateItems”,
“aggregate”: “aggregateAllItemData”,
“destinationFieldName”: “response”,
“include”: “allFieldsExcept”,
“fieldsToExclude”: “sort”,
“options”: {}
},
“id”: “2d8485ad-9007-4664-9182-7eda25fc96ee”,
“name”: “Format response”,
“type”: “n8n-nodes-base.itemLists”,
“position”: [
4176,
752
],
“typeVersion”: 3
},
{
“parameters”: {
“operation”: “sort”,
“sortFieldsUi”: {
“sortField”: [
{
“fieldName”: “sort”
}
]
},
“options”: {}
},
“id”: “b23c75e0-3697-4137-a595-cf26fedaa898”,
“name”: “Sort”,
“type”: “n8n-nodes-base.itemLists”,
“position”: [
3936,
752
],
“typeVersion”: 3
},
{
“parameters”: {
“jsCode”: “// Input data\nconst inputData = $input.all()[0].json.response;\n\n// Define workday hours in EST\nconst WORKDAY_START = "10:00:00 EST";\nconst WORKDAY_END = "17:00:00 EST";\nconst SLOT_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds\n\n// Helper to parse EST datetime strings\nconst parseEST = (datetime) => {\n const parsedDate = new Date(datetime.replace("EST", "-05:00"));\n return isNaN(parsedDate) ? null : parsedDate;\n};\n\n// Function to generate 30-minute start times\nconst generateStartTimes = (start, end) => {\n const startTimes = ;\n let current = new Date(start);\n\n while (current < end) {\n startTimes.push(\n current.toLocaleTimeString(‘en-US’, {\n timeZone: ‘EST’,\n hour: ‘2-digit’,\n minute: ‘2-digit’,\n })\n );\n current = new Date(current.getTime() + SLOT_DURATION);\n }\n\n return startTimes;\n};\n\n// Function to find wide open ranges\nconst findWideOpenRanges = (startTimes) => {\n if (startTimes.length < 3) return ; // Not enough slots for a wide open range\n\n const ranges = ;\n let rangeStart = null;\n let consecutiveCount = 0;\n\n for (let i = 0; i < startTimes.length - 1; i++) {\n const currentTime = parseEST(2000-01-01 ${startTimes[i]} EST);\n const nextTime = parseEST(2000-01-01 ${startTimes[i + 1]} EST);\n const diff = nextTime - currentTime;\n\n if (diff === SLOT_DURATION) {\n consecutiveCount += 1;\n if (rangeStart === null) rangeStart = startTimes[i];\n } else {\n if (consecutiveCount >= 2) {\n ranges.push(${rangeStart} to ${startTimes[i]});\n }\n rangeStart = null;\n consecutiveCount = 0;\n }\n }\n\n // Handle the final range\n if (consecutiveCount >= 2) {\n ranges.push(${rangeStart} to ${startTimes[startTimes.length - 1]});\n }\n\n return ranges;\n};\n\n// Group meetings by date, ignoring invalid dates\nconst meetingsByDate = inputData.reduce((acc, meeting) => {\n const start = parseEST(meeting.start);\n const end = parseEST(meeting.end);\n\n if (!start || !end) {\n return acc; // Ignore invalid dates\n }\n\n const dateKey = start.toISOString().split(‘T’)[0];\n\n if (!acc[dateKey]) {\n acc[dateKey] = ;\n }\n\n acc[dateKey].push({ start, end });\n return acc;\n}, {});\n\n// Generate availability\nconst availability = Object.keys(meetingsByDate)\n .filter((date) => {\n // Exclude Saturdays (6) and Sundays (0)\n const dayOfWeek = new Date(date).getUTCDay();\n return dayOfWeek !== 0 && dayOfWeek !== 6;\n })\n .map((date) => {\n const workdayStart = parseEST(${date} ${WORKDAY_START});\n const workdayEnd = parseEST(${date} ${WORKDAY_END});\n\n const dayMeetings = meetingsByDate[date].sort((a, b) => a.start - b.start);\n\n let availableStartTimes = ;\n let lastEnd = workdayStart;\n\n for (const meeting of dayMeetings) {\n if (meeting.start > lastEnd) {\n availableStartTimes = availableStartTimes.concat(generateStartTimes(lastEnd, meeting.start));\n }\n lastEnd = meeting.end > lastEnd ? meeting.end : lastEnd;\n }\n\n if (lastEnd < workdayEnd) {\n availableStartTimes = availableStartTimes.concat(generateStartTimes(lastEnd, workdayEnd));\n }\n\n const wideOpenRanges = findWideOpenRanges(availableStartTimes);\n\n return {\n date: new Date(date).toLocaleDateString(‘en-US’, {\n weekday: ‘long’,\n year: ‘numeric’,\n month: ‘long’,\n day: ‘numeric’,\n }),\n availableStartTimes,\n wideOpenRanges,\n };\n });\n\n// Format output as plaintext\nconst availableTimes = availability\n .map(({ date, availableStartTimes, wideOpenRanges }) => {\n const times = availableStartTimes.map((time) => - ${time}).join(‘\n’);\n const ranges = wideOpenRanges.length\n ? Wide Open Ranges:\\n${wideOpenRanges.map((range) => - ${range}).join('\\n')}\n : "Wide Open Ranges: None";\n\n return ### ${date}\\nAvailable Start Times:\\n${times}\\n\\n${ranges};\n })\n .join(‘\n\n’);\n\n// Set the output\nreturn {\n json: {\n availableTimes,\n },\n};\n”
},
“id”: “660e3d2f-a424-4e76-8c13-5b62b9f22202”,
“name”: “Available Start Times & Ranges”,
“type”: “n8n-nodes-base.code”,
“position”: [
4416,
752
],
“typeVersion”: 2
},
{
“parameters”: {
“mode”: “runOnceForEachItem”,
“jsCode”: “const flattenSlots = (data) => {\n // If data is missing or empty, return an empty array of slots\n if (!data) {\n return { slots: };\n }\n\n // data is an object whose keys are dates\n // each date key has an array of slot objects\n // we just need to flatten them all into one array\n const flattened = Object.values(data).flat(); // merges all arrays from each date key\n\n // Return a new object with a single ‘slots’ array\n return { slots: flattened };\n};\n\n// Then assign the flattened slots back to $input.item.json.data\n$input.item.json.data = flattenSlots($input.item.json.data);\nreturn $input.item;\n”
},
“id”: “f3110658-2f90-4b19-9874-7d6c4e108895”,
“name”: “Flatten Slots”,
“type”: “n8n-nodes-base.code”,
“position”: [
4640,
752
],
“typeVersion”: 2
},
{
“parameters”: {
“mode”: “runOnceForEachItem”,
“jsCode”: “function formatTimeSlot(dateString) {\n // Format options for date/time with America/Chicago timezone\n const options = {\n timeZone: ‘America/Chicago’,\n weekday: ‘long’,\n month: ‘long’,\n day: ‘numeric’,\n hour: ‘numeric’,\n minute: ‘numeric’,\n hour12: true\n };\n\n // Create a formatter with timezone support\n const dateFormatter = new Intl.DateTimeFormat(‘en-US’, options);\n \n // Format the date/time string\n return dateFormatter.format(new Date(dateString));\n}\n\n// Process each slot and add formatted time strings to the result\nconst slots = $input.item.json.data.slots;\nconst formattedSlots = slots.map(slot => formatTimeSlot(slot.start));\n\n// Attach formatted results to the output\n$input.item.json.data.slots = formattedSlots;\n\nreturn $input.item;\n”
},
“id”: “5065439e-34e3-4eaf-8226-8ba7393a5cf3”,
“name”: “Enrich Date”,
“type”: “n8n-nodes-base.code”,
“position”: [
4848,
752
],
“typeVersion”: 2
},
{
“parameters”: {
“assignments”: {
“assignments”: [
{
“id”: “5cb05b10-e916-459e-84a2-9c314a859a07”,
“name”: “results[0].toolCallId”,
“type”: “string”,
“value”: “={{ $(‘Input Arguments’).item.json.body.message.toolCalls[0].id }}”
},
{
“id”: “552246f9-7afd-404e-9fb3-cb38c7447359”,
“name”: “results[0].result”,
“type”: “string”,
“value”: “={{ $json.availableTimes }}”
}
]
},
“options”: {}
},
“id”: “d8ed3a92-697b-4718-b65f-5276c9a9bfaf”,
“name”: “Build Response Payload”,
“type”: “n8n-nodes-base.set”,
“position”: [
5072,
752
],
“typeVersion”: 3.4
},
{
“parameters”: {
“content”: “# Get Slots”,
“height”: 80,
“width”: 190,
“color”: 4
},
“id”: “d6cbad26-d974-4a11-b0fd-2a35bb555378”,
“name”: “Sticky Note”,
“type”: “n8n-nodes-base.stickyNote”,
“position”: [
2432,
528
],
“typeVersion”: 1
},
{
“parameters”: {
“content”: “## Check Availability\n”,
“height”: 80,
“width”: 230
},
“id”: “bcccc8cb-2e9d-4f8b-9964-e4d656e794ed”,
“name”: “Sticky Note1”,
“type”: “n8n-nodes-base.stickyNote”,
“position”: [
2912,
832
],
“typeVersion”: 1
},
{
“parameters”: {
“content”: “## If time available Respond\n”,
“height”: 80,
“width”: 310
},
“id”: “30b34e37-ee7a-434c-ab4d-445df994459a”,
“name”: “Sticky Note2”,
“type”: “n8n-nodes-base.stickyNote”,
“position”: [
3488,
432
],
“typeVersion”: 1
},
{
“parameters”: {
“content”: “## Get All Events\n”,
“height”: 80,
“width”: 190
},
“id”: “725a9b59-ea66-4326-a410-93a723157ced”,
“name”: “Sticky Note3”,
“type”: “n8n-nodes-base.stickyNote”,
“position”: [
3456,
928
],
“typeVersion”: 1
},
{
“parameters”: {
“content”: “## Get Available Slots\n\nFormat the slots and Enrich the date and timings\n”,
“height”: 100,
“width”: 350
},
“id”: “1f2bf4a3-8aeb-4a56-8bff-0bb370e12718”,
“name”: “Sticky Note4”,
“type”: “n8n-nodes-base.stickyNote”,
“position”: [
4320,
928
],
“typeVersion”: 1
},
{
“parameters”: {
“content”: “## Respond to Vapi”,
“height”: 80,
“width”: 230
},
“id”: “5909d88f-b9c6-4e62-b1e3-bdc1d05ad7aa”,
“name”: “Sticky Note5”,
“type”: “n8n-nodes-base.stickyNote”,
“position”: [
5456,
912
],
“typeVersion”: 1
},
{
“parameters”: {
“httpMethod”: “POST”,
“path”: “getslots”,
“responseMode”: “responseNode”,
“options”: {}
},
“id”: “e5622e9e-9b0a-43b2-ab80-e3e33a4b0409”,
“name”: “Getslot_tool”,
“type”: “n8n-nodes-base.webhook”,
“position”: [
2432,
640
],
“webhookId”: “42afdbc1-afd0-4d65-a713-cf7a59062d6c”,
“typeVersion”: 2
},
{
“parameters”: {
“resource”: “calendar”,
“calendar”: {
“__rl”: true,
“value”: “[email protected]”,
“mode”: “list”,
“cachedResultName”: “[email protected]
},
“timeMin”: “={{ $json.starttime.toDateTime() }}”,
“timeMax”: “={{ $json.endtime.toDateTime() || $now.plus(1, ‘hour’).toISO() }}”,
“options”: {
“timezone”: {
“__rl”: true,
“value”: “America/New_York”,
“mode”: “list”,
“cachedResultName”: “America/New_York”
}
}
},
“id”: “e15781cf-5405-4f60-aa6d-ba19d1b7dabc”,
“name”: “Check Availability”,
“type”: “n8n-nodes-base.googleCalendar”,
“position”: [
2976,
640
],
“typeVersion”: 1.3,
“credentials”: {
“googleCalendarOAuth2Api”: {
“id”: “lm70E16y5yVOeOKM”,
“name”: “Google Calendar account”
}
}
},
{
“parameters”: {
“respondWith”: “json”,
“responseBody”: “={\n "results":[\n {\n "toolCallId":"{{ $(‘Getslot_tool’).first().json.body.message.toolCalls[0].id }}",\n "result":"available:{{ $json.available }}"\n }\n ]\n}”,
“options”: {}
},
“id”: “1e064283-2964-4eba-a893-e4270157c603”,
“name”: “Response”,
“type”: “n8n-nodes-base.respondToWebhook”,
“position”: [
3712,
544
],
“typeVersion”: 1.1
},
{
“parameters”: {
“conditions”: {
“options”: {
“version”: 2,
“leftValue”: “”,
“caseSensitive”: true,
“typeValidation”: “strict”
},
“combinator”: “and”,
“conditions”: [
{
“id”: “4a8741a2-a903-4fb7-b0a3-5c74c7eea6ca”,
“operator”: {
“type”: “boolean”,
“operation”: “true”,
“singleValue”: true
},
“leftValue”: “={{ $json.available }}”,
“rightValue”: “=”
}
]
},
“options”: {}
},
“id”: “498401cb-00e5-4fdd-b6a9-dd3e91376993”,
“name”: “Check if time is available or not”,
“type”: “n8n-nodes-base.if”,
“position”: [
3200,
640
],
“typeVersion”: 2.2
},
{
“parameters”: {
“assignments”: {
“assignments”: [
{
“id”: “f582d965-af15-4ecf-8a8c-d8bf6c0d15c1”,
“name”: “body.message.toolCalls[0].id”,
“type”: “string”,
“value”: “={{ $(‘Input Arguments’).item.json.body.message.toolCalls[0].id }}”
},
{
“id”: “834ee925-5c8d-4e46-aeee-f399dc1ff40c”,
“name”: “available”,
“type”: “boolean”,
“value”: “={{ $json.available }}”
}
]
},
“options”: {}
},
“id”: “96e43c15-a332-4acf-af04-80dd989d5660”,
“name”: “Time available (true) & Call_id”,
“type”: “n8n-nodes-base.set”,
“position”: [
3488,
544
],
“typeVersion”: 3.4
},
{
“parameters”: {
“operation”: “getAll”,
“calendar”: {
“__rl”: true,
“value”: “[email protected]”,
“mode”: “list”,
“cachedResultName”: “[email protected]
},
“returnAll”: true,
“options”: {
“timeMin”: “={{ $now.toISO() }}”,
“timeMax”: “={{ $now.plus(1, ‘week’).toISO() }}”,
“singleEvents”: true,
“orderBy”: “startTime”
}
},
“id”: “fb7ad8c6-9f78-4518-b955-60f3f7088cb9”,
“name”: “Get All Calendar Events”,
“type”: “n8n-nodes-base.googleCalendar”,
“position”: [
3488,
752
],
“typeVersion”: 1,
“alwaysOutputData”: true,
“credentials”: {
“googleCalendarOAuth2Api”: {
“id”: “lm70E16y5yVOeOKM”,
“name”: “Google Calendar account”
}
}
},
{
“parameters”: {
“assignments”: {
“assignments”: [
{
“id”: “1045b97f-c76f-450e-8f57-008602000848”,
“name”: “start”,
“type”: “string”,
“value”: “={{ DateTime.fromISO($json.start.dateTime).toLocaleString(DateTime.DATE_HUGE) }}, {{ DateTime.fromISO($json.start.dateTime).toLocaleString(DateTime.TIME_24_WITH_SHORT_OFFSET) }}”
},
{
“id”: “457e3a2b-d33e-4a65-b2da-d19ad9d754ac”,
“name”: “end”,
“type”: “string”,
“value”: “={{ DateTime.fromISO($json.end.dateTime).toLocaleString(DateTime.DATE_HUGE) }}, {{ DateTime.fromISO($json.end.dateTime).toLocaleString(DateTime.TIME_24_WITH_SHORT_OFFSET) }}”
},
{
“id”: “b6802452-557e-4568-af14-4574e8ecc013”,
“name”: “name”,
“type”: “string”,
“value”: “={{ $json.summary }}”
},
{
“id”: “799b656f-68b6-467c-88a1-217ff7c7801b”,
“name”: “sort”,
“type”: “string”,
“value”: “={{ $json.start.dateTime }}”
}
]
},
“options”: {
“ignoreConversionErrors”: true
}
},
“id”: “390599ee-ddeb-4628-af0b-36fbdd357cee”,
“name”: “Extract start, end and name”,
“type”: “n8n-nodes-base.set”,
“position”: [
3712,
752
],
“typeVersion”: 3.4
},
{
“parameters”: {
“jsCode”: “// Get the input data for the first item\nconst inputData = $input.first().json;\nconsole.log("Input Data:", inputData); // Log input for debugging\n\n// Access the message string from the correct path within the input structure.\n// The input comes from the "Build Response Payload" node, which structures data under ‘results’.\n// Use optional chaining (?.) for safety in case the structure is not as expected.\nlet message = inputData.results?.[0]?.result;\n\n// Check if the message was found and is a string\nif (typeof message !== ‘string’) {\n console.error("Could not find the message string at inputData.results[0].result or it’s not a string. Input:", inputData);\n // Return an object with an empty message or an error indicator\n return { message: "" }; // Or potentially throw an error: throw new Error("Input message not found or not a string");\n}\n\n// Start cleaning the message string\n\n// 1. Replace the literal string "\\n" (backslash followed by n) with a space.\n// This handles the newline representation seen in the input screenshot.\nlet cleanedMessage = message.replace(/\\n/g, ’ ');\n\n// 2. Remove spaces immediately surrounding colons (e.g., "Times : " becomes "Times:").\ncleanedMessage = cleanedMessage.replace(/\s*:\s*/g, ‘:’);\n\n// 3. Replace sequences of multiple whitespace characters (including spaces from replaced \n)\n// with a single space. Then, trim any leading or trailing whitespace from the result.\ncleanedMessage = cleanedMessage.replace(/\s+/g, ’ ').trim();\n\n// Create the final output JSON object containing the cleaned message.\nconst output = {\n message: cleanedMessage\n};\n\n// Return the output object. This will be the output of the Code node.\nreturn output;”
},
“id”: “2c9a73da-37b7-4abd-af5e-695036cd2c2b”,
“name”: “Convert into Json format for Vapi”,
“type”: “n8n-nodes-base.code”,
“position”: [
5296,
752
],
“typeVersion”: 2
},
{
“parameters”: {
“respondWith”: “json”,
“responseBody”: “={\n "results":[\n {\n "toolCallId":"{{ $(‘Getslot_tool’).first().json.body.message.toolCalls[0].id }}",\n "result":"The original time is not available, here are available slots:{{ $json.message }}"\n }\n ]\n}”,
“options”: {}
},
“id”: “e00cf72a-af6a-441b-9b76-81bd8096d3df”,
“name”: “Response to Vapi”,
“type”: “n8n-nodes-base.respondToWebhook”,
“position”: [
5536,
752
],
“typeVersion”: 1.1
},
{
“parameters”: {
“content”: “:index_pointing_up: Set up Getslot tool and Webhook in Vapi\n”,
“height”: 80,
“width”: 191,
“color”: 7
},
“id”: “44596616-27ba-47c0-8a6d-cf50f86a136e”,
“name”: “Sticky Note15”,
“type”: “n8n-nodes-base.stickyNote”,
“position”: [
2448,
800
],
“typeVersion”: 1
}
],
“connections”: {
“Input Arguments”: {
“main”: [
[
{
“node”: “Check Availability”,
“type”: “main”,
“index”: 0
}
]
]
},
“Format response”: {
“main”: [
[
{
“node”: “Available Start Times & Ranges”,
“type”: “main”,
“index”: 0
}
]
]
},
“Sort”: {
“main”: [
[
{
“node”: “Format response”,
“type”: “main”,
“index”: 0
}
]
]
},
“Available Start Times & Ranges”: {
“main”: [
[
{
“node”: “Flatten Slots”,
“type”: “main”,
“index”: 0
}
]
]
},
“Flatten Slots”: {
“main”: [
[
{
“node”: “Enrich Date”,
“type”: “main”,
“index”: 0
}
]
]
},
“Enrich Date”: {
“main”: [
[
{
“node”: “Build Response Payload”,
“type”: “main”,
“index”: 0
}
]
]
},
“Build Response Payload”: {
“main”: [
[
{
“node”: “Convert into Json format for Vapi”,
“type”: “main”,
“index”: 0
}
]
]
},
“Getslot_tool”: {
“main”: [
[
{
“node”: “Input Arguments”,
“type”: “main”,
“index”: 0
}
]
]
},
“Check Availability”: {
“main”: [
[
{
“node”: “Check if time is available or not”,
“type”: “main”,
“index”: 0
}
]
]
},
“Check if time is available or not”: {
“main”: [
[
{
“node”: “Time available (true) & Call_id”,
“type”: “main”,
“index”: 0
}
],
[
{
“node”: “Get All Calendar Events”,
“type”: “main”,
“index”: 0
}
]
]
},
“Time available (true) & Call_id”: {
“main”: [
[
{
“node”: “Response”,
“type”: “main”,
“index”: 0
}
]
]
},
“Get All Calendar Events”: {
“main”: [
[
{
“node”: “Extract start, end and name”,
“type”: “main”,
“index”: 0
}
]
]
},
“Extract start, end and name”: {
“main”: [
[
{
“node”: “Sort”,
“type”: “main”,
“index”: 0
}
]
]
},
“Convert into Json format for Vapi”: {
“main”: [
[
{
“node”: “Response to Vapi”,
“type”: “main”,
“index”: 0
}
]
]
}
},
“pinData”: {},
“meta”: {
“templateId”: “3427”,
“instanceId”: “6be5fb7933e6afcecec9195c3fb4e265c761c06517652b8e0ee4e543acc2738b”
}
}

Hi @Doctor_Strangelove
Based on your n8n workflow and the problem description, the issue of VAPI AI double-booking appointments in Google Calendar likely stems from the conditional logic or the way the “Check Availability” node is being used.

The workflow seems to be set up to first “Check Availability” and then, if the time is not available, it goes down a separate path to “Get All Calendar Events” and respond with available slots. However, the core problem of double-booking typically happens because the AI makes the decision to book before the first “Check Availability” has had a chance to run or if the availability check is not sufficiently robust.

Recommended Steps to Fix the Double-Booking Issue

Enforce a stricter, sequential logic:

  1. Atomic Check and Book Logic: The most robust solution is to combine the “check” and “create” logic into a single atomic action within your n8n workflow. The AI should not have separate tools for checking and creating. Instead, it should have a single tool (e.g., scheduleAppointment) that takes the start and end times as arguments.
  2. Modify your n8n workflow:
  • Keep your “Get All Calendar Events” node.
  • Add a new “Google Calendar” node with the “Create” operation.
  • Before this “Create” node, use an “IF” node to check if the proposed time slot from VAPI overlaps with any existing events.
  • Only if there is no overlap should the workflow proceed to the “Create” node.

Very cool. Thanks a lot I’ll give it a shot and let you know when I get it to work :slight_smile: