AutomationFlowsEmail & Gmail › Schedule Appointments From Booking Form with Google Calendar

Schedule Appointments From Booking Form with Google Calendar

Original n8n title: Schedule Appointments From a Booking Form with Google Calendar and Gmail

ByTheodoros Mastromanolis @teomastro on n8n.io

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…

Webhook trigger★★★★☆ complexity12 nodesGmailGoogle Calendar
Email & Gmail Trigger: Webhook Nodes: 12 Complexity: ★★★★☆ Added:

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 →

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

Pro

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 →

More Email & Gmail workflows → · Browse all categories →

Related workflows

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

Email & Gmail

Complete Calendly automation that handles confirmations, cancellations and reschedules in a single workflow. WHAT IT DOES:

Google Sheets, Google Calendar, Slack +1
Email & Gmail

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

Google Drive, Slack, Gmail +2
Email & Gmail

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

Microsoft Teams, Discord, Gmail +3
Email & Gmail

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

Google Calendar, Gmail
Email & Gmail

Scheduling. Uses googleDocs, googleCalendar, gmail, mySql. Webhook trigger; 10 nodes.

Google Docs, Google Calendar, Gmail +1