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”: “ 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”
}
}