This workflow corresponds to n8n.io template #13923 — we link there as the canonical source.
This workflow follows the Gmail → Google Calendar recipe pattern — see all workflows that pair these two integrations.
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": "c56XJZu8a8zCCEoo",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "5. Schedule Form - Google calendar",
"tags": [],
"nodes": [
{
"id": "997c252a-ea1c-455b-8afe-392225b73d70",
"name": "Code in JavaScript",
"type": "n8n-nodes-base.code",
"position": [
-880,
560
],
"parameters": {
"jsCode": "const webhookData = $input.first().json.body;\nconst startTime = webhookData.timeslot;\n\n// Parse the start time WITH timezone: \"2025-11-21T10:30:00+02:00\"\nconst match = startTime.match(/^(.+T)(\\d{2}):(\\d{2}):(\\d{2})(\\+\\d{2}:\\d{2})$/);\nif (!match) {\n throw new Error('Invalid time format');\n}\n\nconst [_, prefix, hours, minutes, seconds, timezone] = match;\nconst totalMinutes = parseInt(hours) * 60 + parseInt(minutes) + 30;\nconst newHours = Math.floor(totalMinutes / 60);\nconst newMinutes = totalMinutes % 60;\n\n// IMPORTANT: Keep the timezone offset in the endTime\nconst endTime = `${prefix}${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}:${seconds}${timezone}`;\n\nreturn [{\n json: {\n body: {\n ...webhookData,\n startTime: startTime,\n endTime: endTime\n }\n }\n}];"
},
"typeVersion": 2
},
{
"id": "a94a2cdc-0ad0-480d-8e58-5352f2416756",
"name": "Show Success Message",
"type": "n8n-nodes-base.respondToWebhook",
"position": [
0,
560
],
"parameters": {
"options": {
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "text/html"
}
]
}
},
"respondWith": "text",
"responseBody": "<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"UTF-8\">\n <style>\n body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; justify-content: center; align-items: center; margin: 0; padding: 20px; }\n .success-box { background: white; border-radius: 20px; padding: 60px 50px; text-align: center; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); max-width: 500px; animation: slideUp 0.5s ease; }\n @keyframes slideUp { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } }\n .checkmark { width: 80px; height: 80px; border-radius: 50%; background: #4CAF50; display: inline-flex; align-items: center; justify-content: center; font-size: 50px; color: white; margin-bottom: 20px; }\n h1 { color: #667eea; margin: 20px 0 10px 0; font-size: 32px; }\n p { color: #666; font-size: 16px; line-height: 1.6; margin: 10px 0; }\n .email-note { background: #f0f7ff; padding: 15px; border-radius: 10px; margin-top: 25px; border-left: 4px solid #667eea; }\n </style>\n</head>\n<body>\n <div class=\"success-box\">\n <div class=\"checkmark\">\u2713</div>\n <h1>Booking Confirmed!</h1>\n <p>Your appointment has been successfully scheduled.</p>\n <div class=\"email-note\"><p><strong>\ud83d\udce7 Check your email</strong></p><p>A confirmation has been sent to your inbox.</p></div>\n </div>\n</body>\n</html>"
},
"typeVersion": 1.1
},
{
"id": "7887c143-437b-49b1-bc52-09d0cc02e42b",
"name": "Send Confirmation Email",
"type": "n8n-nodes-base.gmail",
"position": [
-208,
560
],
"parameters": {
"sendTo": "={{ $json.email }}",
"message": "=<!DOCTYPE html>\n<html>\n<head>\n <style>\n body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; }\n .container { max-width: 600px; margin: 0 auto; background: #ffffff; }\n .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 30px; text-align: center; }\n .header h1 { margin: 0; font-size: 28px; }\n .content { padding: 40px 30px; background: #f9f9f9; }\n .details { background: white; padding: 25px; border-radius: 8px; margin: 25px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }\n .detail-row { padding: 12px 0; border-bottom: 1px solid #eee; display: flex; }\n .detail-row:last-child { border-bottom: none; }\n .label { font-weight: bold; color: #667eea; min-width: 120px; }\n .value { flex: 1; color: #333; }\n .button-container { text-align: center; margin: 30px 0; }\n .button { display: inline-block; padding: 14px 35px; background: #667eea; color: white !important; text-decoration: none; border-radius: 8px; font-weight: 600; }\n .message-box { background: #fff; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #667eea; }\n .footer { text-align: center; color: #999; font-size: 12px; padding: 30px; background: #f0f0f0; }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"header\"><h1>\u2705 Appointment Confirmed!</h1></div>\n <div class=\"content\">\n <p>Dear {{ $json.firstName }} {{ $json.lastName }},</p>\n <p>Thank you for booking an appointment!</p>\n <div class=\"details\">\n <div class=\"detail-row\"><span class=\"label\">\ud83d\udcc5 Date:</span><span class=\"value\">{{ $json.formattedDate }}</span></div>\n <div class=\"detail-row\"><span class=\"label\">\ud83d\udd50 Time:</span><span class=\"value\">{{ $json.formattedTime }}</span></div>\n <div class=\"detail-row\"><span class=\"label\">\u23f1\ufe0f Duration:</span><span class=\"value\">30 minutes</span></div>\n <div class=\"detail-row\"><span class=\"label\">\u2709\ufe0f Email:</span><span class=\"value\">{{ $json.email }}</span></div>\n </div>\n <div class=\"message-box\"><h3 style=\"margin-top:0;color:#667eea;\">Your Message:</h3><p style=\"margin:0;color:#666;\">{{ $json.message }}</p></div>\n <div class=\"button-container\"><a href=\"{{ $json.htmlLink }}\" class=\"button\">\ud83d\udcc5 Add to Google Calendar</a></div>\n <p style=\"margin-top:30px;padding:20px;background:#fff;border-radius:8px;\"><strong>Need to make changes?</strong><br>If you need to reschedule or cancel, please contact us.</p>\n </div>\n <div class=\"footer\"><p><strong>This is an automated confirmation email.</strong></p></div>\n </div>\n</body>\n</html>",
"options": {},
"subject": "=\u2705 Appointment Confirmed - {{ $json.formattedDate }}"
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
},
"typeVersion": 2.1
},
{
"id": "f296c75f-87bb-4463-8504-56534c918486",
"name": "Format Email Data",
"type": "n8n-nodes-base.code",
"position": [
-432,
560
],
"parameters": {
"jsCode": "const eventData = $input.first().json;\nconst webhookData = $('Webhook - Submit Form').first().json.body;\n\nconst startDate = new Date(eventData.start.dateTime);\nconst endDate = new Date(eventData.end.dateTime);\n\nconst formattedDate = startDate.toLocaleDateString('en-US', {\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric'\n});\n\nconst formattedTime = startDate.toLocaleTimeString('en-US', {\n hour: '2-digit',\n minute: '2-digit',\n hour12: true\n}) + ' - ' + endDate.toLocaleTimeString('en-US', {\n hour: '2-digit',\n minute: '2-digit',\n hour12: true\n});\n\nreturn [{\n json: {\n ...eventData,\n formattedDate,\n formattedTime,\n firstName: webhookData.firstName,\n lastName: webhookData.lastName,\n email: webhookData.email,\n message: webhookData.message || 'No message provided'\n }\n}];"
},
"typeVersion": 2
},
{
"id": "e0130056-a309-4a1f-bf22-7dc192bd2fce",
"name": "Create Calendar Event",
"type": "n8n-nodes-base.googleCalendar",
"position": [
-656,
560
],
"parameters": {
"end": "={{ $json.body.endTime }}",
"start": "={{ $json.body.timeslot }}",
"calendar": {
"__rl": true,
"mode": "list",
"value": "primary",
"cachedResultName": "Primary"
},
"additionalFields": {
"summary": "=Meeting with {{ $json.body.firstName }} {{ $json.body.lastName }}",
"description": "=Name: {{ $json.body.firstName }} {{ $json.body.lastName }} \nEmail: {{ $json.body.email }} \n\nMessage: {{ $json.body.message || 'No message provided' }}"
}
},
"credentials": {
"googleCalendarOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "3124fd95-0fd3-4f81-98e9-86b5fb76400c",
"name": "Webhook - Submit Form",
"type": "n8n-nodes-base.webhook",
"position": [
-1088,
560
],
"parameters": {
"path": "booking",
"options": {},
"httpMethod": "POST",
"responseMode": "responseNode"
},
"typeVersion": 1.1
},
{
"id": "6c9e11ac-bda2-4e73-abf7-4b3d43d5e802",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"position": [
-208,
336
],
"parameters": {
"options": {
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "text/html"
}
]
}
},
"respondWith": "text",
"responseBody": "={{$json.html}}"
},
"typeVersion": 1.1
},
{
"id": "b9808377-3551-4192-9124-0b7d2f452586",
"name": "Build HTML Form",
"type": "n8n-nodes-base.code",
"position": [
-416,
336
],
"parameters": {
"jsCode": "// Get all slots\nconst items = $input.all();\nconst slots = items.map(item => item.json);\n\n// FIXED: Manual time formatter (NO timezone conversion)\nfunction formatSlotTimeFromValue(value) {\n const [datePart, timePart] = value.split(\"T\"); // \"2025-11-21\", \"08:00:00\" (no timezone offset anymore)\n const [hour, minute] = timePart.split(\":\");\n\n let h = parseInt(hour, 10);\n const ampm = h >= 12 ? \"PM\" : \"AM\";\n h = h % 12 || 12;\n\n return `${h}:${minute} ${ampm}`;\n}\n\n// Group slots by date WITHOUT using new Date for timezone\nconst slotsByDate = {};\nslots.forEach(slot => {\n const [datePart] = slot.value.split(\"T\"); // \"2025-11-21\"\n \n if (!slotsByDate[datePart]) {\n // Use a fixed date string so browser cannot shift timezone\n const displayDateObj = new Date(datePart + \"T00:00:00\");\n\n slotsByDate[datePart] = {\n date: displayDateObj,\n dateKey: datePart,\n displayDate: displayDateObj.toLocaleDateString('en-US', {\n weekday: 'long',\n month: 'long',\n day: 'numeric',\n year: 'numeric'\n }),\n shortDate: displayDateObj.toLocaleDateString('en-US', {\n month: 'short',\n day: 'numeric'\n }),\n slots: []\n };\n }\n\n slotsByDate[datePart].slots.push({\n value: slot.value,\n time: formatSlotTimeFromValue(slot.value)\n });\n});\n\n// Convert to array and sort\nconst groupedSlots = Object.values(slotsByDate).sort((a, b) => a.date - b.date);\n\nconst slotsJson = JSON.stringify(groupedSlots);\n\nconst html = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Book an Appointment</title>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n \n body {\n font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;\n background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n min-height: 100vh;\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 20px;\n }\n \n .container {\n background: white;\n border-radius: 20px;\n box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);\n max-width: 700px;\n width: 100%;\n padding: 40px;\n }\n \n h1 {\n color: #667eea;\n text-align: center;\n margin-bottom: 10px;\n font-size: 32px;\n }\n \n .subtitle {\n text-align: center;\n color: #666;\n margin-bottom: 30px;\n font-size: 14px;\n }\n \n /* Date Navigator */\n .date-navigator {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 20px;\n padding: 15px;\n background: #f9f9f9;\n border-radius: 12px;\n }\n \n .nav-button {\n background: #667eea;\n color: white;\n border: none;\n width: 40px;\n height: 40px;\n border-radius: 50%;\n cursor: pointer;\n font-size: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: all 0.2s ease;\n }\n \n .nav-button:hover {\n background: #5568d3;\n transform: scale(1.1);\n }\n \n .nav-button:disabled {\n background: #ccc;\n cursor: not-allowed;\n transform: scale(1);\n }\n \n .current-date {\n font-size: 18px;\n font-weight: 600;\n color: #667eea;\n text-align: center;\n flex: 1;\n }\n \n /* Time Slots */\n .slots-container {\n min-height: 200px;\n margin-bottom: 30px;\n }\n \n .time-slots {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));\n gap: 10px;\n animation: fadeIn 0.3s ease;\n }\n \n @keyframes fadeIn {\n from { opacity: 0; transform: translateY(10px); }\n to { opacity: 1; transform: translateY(0); }\n }\n \n .time-slot {\n padding: 12px;\n border: 2px solid #e0e0e0;\n border-radius: 10px;\n background: white;\n cursor: pointer;\n text-align: center;\n transition: all 0.2s ease;\n font-size: 15px;\n font-weight: 500;\n }\n \n .time-slot:hover {\n border-color: #667eea;\n background: #f0f7ff;\n transform: translateY(-2px);\n }\n \n .time-slot.selected {\n background: #667eea;\n color: white;\n border-color: #667eea;\n }\n \n .no-slots-message {\n text-align: center;\n padding: 40px;\n color: #999;\n font-style: italic;\n }\n \n /* Selected Info */\n .selected-info {\n background: #f0f7ff;\n padding: 15px 20px;\n border-radius: 10px;\n margin-bottom: 25px;\n border-left: 4px solid #667eea;\n display: none;\n }\n \n .selected-info.visible {\n display: block;\n animation: fadeIn 0.3s ease;\n }\n \n .selected-info strong {\n color: #667eea;\n }\n \n /* Form */\n .form-divider {\n height: 2px;\n background: linear-gradient(90deg, transparent, #e0e0e0, transparent);\n margin: 30px 0;\n }\n \n .form-section-title {\n font-size: 18px;\n color: #667eea;\n margin-bottom: 20px;\n font-weight: 600;\n }\n \n .form-group {\n margin-bottom: 20px;\n }\n \n label {\n display: block;\n margin-bottom: 8px;\n color: #333;\n font-weight: 600;\n font-size: 14px;\n }\n \n input[type=\"text\"],\n input[type=\"email\"],\n textarea {\n width: 100%;\n padding: 12px 15px;\n border: 2px solid #e0e0e0;\n border-radius: 10px;\n font-size: 15px;\n transition: all 0.3s ease;\n font-family: inherit;\n }\n \n input[type=\"text\"]:focus,\n input[type=\"email\"]:focus,\n textarea:focus {\n outline: none;\n border-color: #667eea;\n box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);\n }\n \n textarea {\n resize: vertical;\n min-height: 100px;\n }\n \n .name-group {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 15px;\n }\n \n button[type=\"submit\"] {\n width: 100%;\n padding: 15px;\n background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n color: white;\n border: none;\n border-radius: 10px;\n font-size: 16px;\n font-weight: 600;\n cursor: pointer;\n transition: transform 0.2s ease, box-shadow 0.2s ease;\n margin-top: 10px;\n }\n \n button[type=\"submit\"]:hover {\n transform: translateY(-2px);\n box-shadow: 0 10px 25px rgba(102, 126, 234, 0.4);\n }\n \n button[type=\"submit\"]:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n transform: none;\n }\n \n .required {\n color: #e74c3c;\n }\n \n @media (max-width: 600px) {\n .container {\n padding: 30px 20px;\n }\n \n .time-slots {\n grid-template-columns: repeat(2, 1fr);\n }\n \n .name-group {\n grid-template-columns: 1fr;\n }\n \n h1 {\n font-size: 26px;\n }\n \n .nav-button {\n width: 35px;\n height: 35px;\n font-size: 18px;\n }\n \n .current-date {\n font-size: 16px;\n }\n }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <h1>\ud83d\udcc5 Book Your Appointment</h1>\n <p class=\"subtitle\">Select your preferred date and time</p>\n \n <form method=\"POST\" action=\"\" id=\"bookingForm\">\n <!-- Date Navigator -->\n <div class=\"date-navigator\">\n <button type=\"button\" class=\"nav-button\" id=\"prevDay\">\u2039</button>\n <div class=\"current-date\" id=\"currentDate\"></div>\n <button type=\"button\" class=\"nav-button\" id=\"nextDay\">\u203a</button>\n </div>\n \n <!-- Time Slots -->\n <div class=\"slots-container\">\n <div class=\"time-slots\" id=\"timeSlotsContainer\"></div>\n </div>\n \n <input type=\"hidden\" name=\"timeslot\" id=\"selectedSlot\" required>\n \n <!-- Selected Slot Info -->\n <div class=\"selected-info\" id=\"selectedInfo\">\n <strong>Selected time:</strong> <span id=\"selectedSlotDisplay\"></span>\n </div>\n \n <!-- Form Divider -->\n <div class=\"form-divider\"></div>\n \n <!-- User Details Form -->\n <div class=\"form-section-title\">Your Details</div>\n \n <div class=\"name-group\">\n <div class=\"form-group\">\n <label for=\"firstName\">First Name <span class=\"required\">*</span></label>\n <input type=\"text\" id=\"firstName\" name=\"firstName\" required>\n </div>\n \n <div class=\"form-group\">\n <label for=\"lastName\">Last Name <span class=\"required\">*</span></label>\n <input type=\"text\" id=\"lastName\" name=\"lastName\" required>\n </div>\n </div>\n \n <div class=\"form-group\">\n <label for=\"email\">Email Address <span class=\"required\">*</span></label>\n <input type=\"email\" id=\"email\" name=\"email\" required>\n </div>\n \n <div class=\"form-group\">\n <label for=\"message\">Message (Optional)</label>\n <textarea id=\"message\" name=\"message\" placeholder=\"Add any additional information...\"></textarea>\n </div>\n \n <button type=\"submit\" id=\"submitButton\">Confirm Booking</button>\n </form>\n </div>\n \n <script>\n const slotsByDate = ${slotsJson};\n let currentDayIndex = 0;\n let selectedSlotElement = null;\n \n const prevButton = document.getElementById('prevDay');\n const nextButton = document.getElementById('nextDay');\n const currentDateDisplay = document.getElementById('currentDate');\n const timeSlotsContainer = document.getElementById('timeSlotsContainer');\n const selectedSlotInput = document.getElementById('selectedSlot');\n const selectedSlotDisplay = document.getElementById('selectedSlotDisplay');\n const selectedInfo = document.getElementById('selectedInfo');\n const submitButton = document.getElementById('submitButton');\n \n function renderDay(index) {\n currentDayIndex = index;\n \n // Update navigation buttons\n prevButton.disabled = index === 0;\n nextButton.disabled = index === slotsByDate.length - 1;\n \n // Update date display\n const dayData = slotsByDate[index];\n currentDateDisplay.textContent = dayData.displayDate;\n \n // Clear previous slots\n timeSlotsContainer.innerHTML = '';\n \n // Render time slots\n if (dayData.slots.length > 0) {\n dayData.slots.forEach(slot => {\n const timeSlot = document.createElement('div');\n timeSlot.className = 'time-slot';\n timeSlot.textContent = slot.time;\n timeSlot.dataset.value = slot.value;\n timeSlot.dataset.label = dayData.displayDate + ' at ' + slot.time;\n \n timeSlot.addEventListener('click', function() {\n // Remove previous selection\n if (selectedSlotElement) {\n selectedSlotElement.classList.remove('selected');\n }\n \n // Select new slot\n this.classList.add('selected');\n selectedSlotElement = this;\n selectedSlotInput.value = this.dataset.value;\n selectedSlotDisplay.textContent = this.dataset.label;\n selectedInfo.classList.add('visible');\n submitButton.disabled = false;\n });\n \n timeSlotsContainer.appendChild(timeSlot);\n });\n } else {\n timeSlotsContainer.innerHTML = '<div class=\"no-slots-message\">No available slots for this day</div>';\n }\n }\n \n // Navigation\n prevButton.addEventListener('click', () => {\n if (currentDayIndex > 0) {\n renderDay(currentDayIndex - 1);\n }\n });\n \n nextButton.addEventListener('click', () => {\n if (currentDayIndex < slotsByDate.length - 1) {\n renderDay(currentDayIndex + 1);\n }\n });\n \n // Keyboard navigation\n document.addEventListener('keydown', (e) => {\n if (e.key === 'ArrowLeft' && currentDayIndex > 0) {\n renderDay(currentDayIndex - 1);\n } else if (e.key === 'ArrowRight' && currentDayIndex < slotsByDate.length - 1) {\n renderDay(currentDayIndex + 1);\n }\n });\n \n // Form validation\n document.getElementById('bookingForm').addEventListener('submit', function(e) {\n if (!selectedSlotInput.value) {\n e.preventDefault();\n alert('Please select a time slot first');\n }\n });\n \n // Initialize with first day\n if (slotsByDate.length > 0) {\n renderDay(0);\n submitButton.disabled = true;\n } else {\n currentDateDisplay.textContent = 'No available dates';\n timeSlotsContainer.innerHTML = '<div class=\"no-slots-message\">\ud83d\ude14 No available slots in the next 30 days</div>';\n prevButton.disabled = true;\n nextButton.disabled = true;\n submitButton.disabled = true;\n }\n </script>\n</body>\n</html>`;\n\nreturn [{ json: { html: html } }];"
},
"typeVersion": 2
},
{
"id": "796ed038-04a1-479e-8ac8-f162b22319ad",
"name": "Calculate Available Slots",
"type": "n8n-nodes-base.code",
"position": [
-640,
336
],
"parameters": {
"jsCode": "// PROPER TIMEZONE FIX\n// Server timezone: Berlin (CET/CEST, UTC+1)\n// Target timezone: Greece (EET/EEST, UTC+2)\n// Difference: Greece is 1 hour ahead of Berlin\n\n// Configuration\nconst workingDays = [1, 2, 3, 4, 5];\nconst startHour = 9;\nconst endHour = 14;\nconst slotDuration = 30;\n\nconst now = new Date();\nconst inputItems = $input.all();\n\n// Get existing events - only timed events within 24 hours\nconst existingEvents = [];\nif (inputItems.length > 0) {\n for (const item of inputItems) {\n const event = item.json;\n \n if (!event.start || !event.start.dateTime) {\n continue;\n }\n \n const eventStart = new Date(event.start.dateTime);\n const eventEnd = new Date(event.end.dateTime);\n const eventDurationHours = (eventEnd - eventStart) / (1000 * 60 * 60);\n \n if (eventDurationHours > 24) {\n continue;\n }\n \n const windowEnd = new Date(now);\n windowEnd.setDate(now.getDate() + 30);\n \n if (eventStart < windowEnd && eventEnd > now) {\n existingEvents.push({ start: eventStart, end: eventEnd });\n }\n }\n}\n\n// Generate slots\nconst slots = [];\n\nfor (let day = 0; day < 30; day++) {\n const currentDate = new Date(now);\n currentDate.setDate(now.getDate() + day);\n \n const year = currentDate.getFullYear();\n const month = currentDate.getMonth() + 1;\n const dayOfMonth = currentDate.getDate();\n const dayOfWeek = currentDate.getDay();\n \n if (!workingDays.includes(dayOfWeek)) continue;\n \n for (let hour = startHour; hour < endHour; hour++) {\n for (let minute = 0; minute < 60; minute += slotDuration) {\n // Create slot time with EXPLICIT Greek timezone offset\n // This tells Google Calendar: \"This is the time in Greece (UTC+2)\"\n const slotStartString = `${year}-${String(month).padStart(2, '0')}-${String(dayOfMonth).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00+02:00`;\n const slotStart = new Date(slotStartString);\n const slotEnd = new Date(slotStart.getTime() + slotDuration * 60000);\n \n if (slotStart <= now) continue;\n \n let isAvailable = true;\n for (const event of existingEvents) {\n if ((slotStart >= event.start && slotStart < event.end) ||\n (slotEnd > event.start && slotEnd <= event.end) ||\n (slotStart <= event.start && slotEnd >= event.end)) {\n isAvailable = false;\n break;\n }\n }\n \n if (isAvailable) {\n // CRITICAL: Send the FULL timestamp WITH timezone to Google Calendar\n // This ensures Google knows it's Greek time, not Berlin time\n const valueWithTimezone = slotStartString;\n \n // Format label for display\n const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];\n const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n \n const displayHour12 = hour % 12 || 12;\n const ampm = hour >= 12 ? 'PM' : 'AM';\n \n const label = `${weekdays[dayOfWeek]}, ${months[month - 1]} ${dayOfMonth}, ${year} at ${displayHour12}:${String(minute).padStart(2, '0')} ${ampm}`;\n \n slots.push({\n value: valueWithTimezone,\n label: label\n });\n }\n }\n }\n}\n\nif (slots.length === 0) {\n return [{ json: { noSlots: true, slots: [] } }];\n}\n\nreturn slots.map(slot => ({ json: slot }));"
},
"typeVersion": 2
},
{
"id": "4b727001-37aa-42df-bb4c-98b75bbe3510",
"name": "Get Calendar Events",
"type": "n8n-nodes-base.googleCalendar",
"position": [
-864,
336
],
"parameters": {
"options": {
"timeMax": "={{ $now.plus({ days: 30 }).toISO() }}",
"timeMin": "={{ $now.toISO() }}"
},
"calendar": {
"__rl": true,
"mode": "list",
"value": "primary",
"cachedResultName": "Primary"
},
"operation": "getAll",
"returnAll": true
},
"credentials": {
"googleCalendarOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "1f9bede9-b36a-4ebf-9697-12c53d5a3146",
"name": "Webhook - Show Form",
"type": "n8n-nodes-base.webhook",
"position": [
-1088,
336
],
"parameters": {
"path": "booking",
"options": {},
"responseMode": "responseNode"
},
"typeVersion": 1.1
},
{
"id": "1085bf5c-a45a-4bf9-ade3-dd76aab87cf5",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1056,
-80
],
"parameters": {
"width": 864,
"height": 352,
"content": "## Self-Hosted Booking Form with Google Calendar\n\nThis workflow has **two paths**:\n\n**Top row (GET):** Serves the booking form\nWebhook \u2192 Get Calendar Events \u2192 Calculate Available Slots \u2192 Build HTML Form \u2192 Respond\n\n**Bottom row (POST):** Processes a booking submission\nWebhook \u2192 Calculate End Time \u2192 Create Calendar Event \u2192 Format Email Data \u2192 Send Confirmation \u2192 Show Success Page\n\n### Quick Start\n1. Connect Google Calendar OAuth2\n2. Connect Gmail OAuth2\n3. Adjust availability settings in \"Calculate Available Slots\"\n4. Activate & visit: `<your-n8n-url>/webhook/booking`"
},
"typeVersion": 1
}
],
"active": true,
"settings": {
"executionOrder": "v1"
},
"versionId": "3bdcbe12-4da6-4a76-85a7-7b591c3e988a",
"connections": {
"Build HTML Form": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Format Email Data": {
"main": [
[
{
"node": "Send Confirmation Email",
"type": "main",
"index": 0
}
]
]
},
"Code in JavaScript": {
"main": [
[
{
"node": "Create Calendar Event",
"type": "main",
"index": 0
}
]
]
},
"Get Calendar Events": {
"main": [
[
{
"node": "Calculate Available Slots",
"type": "main",
"index": 0
}
]
]
},
"Webhook - Show Form": {
"main": [
[
{
"node": "Get Calendar Events",
"type": "main",
"index": 0
}
]
]
},
"Create Calendar Event": {
"main": [
[
{
"node": "Format Email Data",
"type": "main",
"index": 0
}
]
]
},
"Webhook - Submit Form": {
"main": [
[
{
"node": "Code in JavaScript",
"type": "main",
"index": 0
}
]
]
},
"Send Confirmation Email": {
"main": [
[
{
"node": "Show Success Message",
"type": "main",
"index": 0
}
]
]
},
"Calculate Available Slots": {
"main": [
[
{
"node": "Build HTML Form",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
gmailOAuth2googleCalendarOAuth2Api
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow creates a complete appointment booking system — no external scheduling tools needed. It serves a styled HTML booking form via webhook, checks your Google Calendar for availability, and lets visitors pick an open 30-minute slot. On submission, it creates a calendar…
Source: https://n8n.io/workflows/13923/ — 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.
Complete Calendly automation that handles confirmations, cancellations and reschedules in a single workflow. WHAT IT DOES:
Automate legal appointment booking end-to-end. Clients request a slot via webhook, Google Calendar checks availability, Stripe collects payment, and a confirmed calendar event is created automatically
Automate your GoHighLevel (GHL) client onboarding process from the moment a deal is marked as “Won.” This workflow seamlessly generates client folders in Google Drive, duplicates contract and kickoff
Tg Leaves Webhook. Uses notion, googleCalendar, gmail. Webhook trigger; 21 nodes.
Automate clinic appointment booking end to end. Patients submit a request via webhook, the workflow generates a unique Booking ID, routes online consultations to Google Meet and offline ones to the cl