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 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
This n8n workflow is designed for businesses, consultants, and service providers who want to automate their meeting scheduling process. The workflow creates a seamless booking system that can handle m
Tired of automated booking tools cluttering your calendar with spam or overlapping meetings? SyncPoint is a “Smart Gatekeeper” that checks your availability and asks for your manual approval via email
Scheduling. Uses googleDocs, googleCalendar, gmail, mySql. Webhook trigger; 10 nodes.