AutomationFlowsGeneral › Rodopi Dent - Public Available Slots (calendar)

Rodopi Dent - Public Available Slots (calendar)

Rodopi Dent - Public Available Slots (Calendar). Uses googleCalendar. Webhook trigger; 9 nodes.

Webhook trigger★★★★☆ complexity9 nodesGoogle Calendar
General Trigger: Webhook Nodes: 9 Complexity: ★★★★☆ Added:

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
{
  "name": "Rodopi Dent - Public Available Slots (Calendar)",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "GET",
        "path": "public-slots",
        "responseMode": "responseNode",
        "options": {
          "allowedOrigins": "*"
        }
      },
      "id": "webhook",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Get date from query parameter (defensive \u2014 works regardless of n8n webhook version)\nconst root = $input.first().json || {};\nconst query = root.query || root.params || {};\nconst queryDate = query.date || root.date;\n\nif (!queryDate) {\n  return [{\n    json: {\n      error: true,\n      message: 'Date parameter is required',\n      debug: { keys: Object.keys(root), hasQuery: !!root.query }\n    }\n  }];\n}\n\n// Parse the requested date\nconst requestedDate = new Date(queryDate + 'T00:00:00');\nconst dayOfWeek = requestedDate.getDay();\n\n// Check if it's a working day (Monday-Friday = 1-5)\nconst workingDays = [1, 2, 3, 4, 5];\nif (!workingDays.includes(dayOfWeek)) {\n  return [{\n    json: {\n      error: false,\n      nonWorkingDay: true,\n      date: queryDate,\n      message: '\u041d\u0435\u0440\u0430\u0431\u043e\u0442\u0435\u043d \u0434\u0435\u043d'\n    }\n  }];\n}\n\n// Set time range for the day\nconst startDate = queryDate + 'T00:00:00+02:00';\nconst endDate = queryDate + 'T23:59:59+02:00';\n\nreturn [{\n  json: {\n    error: false,\n    nonWorkingDay: false,\n    date: queryDate,\n    startDate: startDate,\n    endDate: endDate\n  }\n}];"
      },
      "id": "parse-date",
      "name": "Parse Date",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.error }}",
              "value2": true
            }
          ]
        }
      },
      "id": "check-error",
      "name": "Has Error?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        440,
        0
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ success: false, error: $json.message, slots: [] }) }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "id": "respond-error",
      "name": "Respond Error",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        660,
        -100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.nonWorkingDay }}",
              "value2": true
            }
          ]
        }
      },
      "id": "check-working-day",
      "name": "Non-Working Day?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        660,
        100
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ success: true, date: $json.date, slots: [], message: '\u041d\u0435\u0440\u0430\u0431\u043e\u0442\u0435\u043d \u0434\u0435\u043d' }) }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "id": "respond-non-working",
      "name": "Respond Non-Working",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        880,
        0
      ]
    },
    {
      "parameters": {
        "operation": "getAll",
        "calendar": {
          "__rl": true,
          "mode": "id",
          "value": "rodopi.dent@gmail.com"
        },
        "returnAll": true,
        "options": {
          "timeMax": "={{ $json.endDate }}",
          "timeMin": "={{ $json.startDate }}",
          "singleEvents": true,
          "orderBy": "startTime"
        }
      },
      "id": "get-calendar",
      "name": "Get Calendar Events",
      "type": "n8n-nodes-base.googleCalendar",
      "typeVersion": 1.2,
      "position": [
        880,
        200
      ],
      "credentials": {
        "googleCalendarOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Get the requested date from previous node\nconst parsedData = $('Parse Date').first().json;\nconst queryDate = parsedData.date;\n\n// Working hours configuration (09:00-12:00 and 13:30-17:00)\nconst workingHours = {\n  morning: { start: '09:00', end: '12:00' },\n  afternoon: { start: '13:30', end: '17:00' }  // Last slot at 16:30\n};\n\n// Slot interval = 30 minutes\nconst slotInterval = 30;\n\n// Helper function to convert UTC/any timezone to Europe/Sofia\nfunction toSofiaTime(isoString) {\n  if (!isoString) return null;\n  const date = new Date(isoString);\n  const options = {\n    timeZone: 'Europe/Sofia',\n    year: 'numeric',\n    month: '2-digit',\n    day: '2-digit',\n    hour: '2-digit',\n    minute: '2-digit',\n    hour12: false\n  };\n  const formatter = new Intl.DateTimeFormat('en-CA', options);\n  const parts = formatter.formatToParts(date);\n  const getPart = (type) => parts.find(p => p.type === type)?.value || '00';\n  return {\n    date: `${getPart('year')}-${getPart('month')}-${getPart('day')}`,\n    time: `${getPart('hour')}:${getPart('minute')}`\n  };\n}\n\n// Helper functions\nfunction timeToMinutes(time) {\n  if (!time) return 0;\n  const [hours, minutes] = time.split(':').map(Number);\n  return hours * 60 + minutes;\n}\n\nfunction minutesToTime(mins) {\n  const hours = Math.floor(mins / 60);\n  const minutes = mins % 60;\n  return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;\n}\n\n// Generate all possible 30-minute slots\nconst allSlots = [];\n\n// Morning slots (09:00 - 12:00)\nlet current = timeToMinutes(workingHours.morning.start);\nconst morningEnd = timeToMinutes(workingHours.morning.end);\nwhile (current < morningEnd) {\n  allSlots.push(minutesToTime(current));\n  current += slotInterval;\n}\n\n// Afternoon slots (13:30 - 17:00, last slot 16:30)\ncurrent = timeToMinutes(workingHours.afternoon.start);\nconst afternoonEnd = timeToMinutes(workingHours.afternoon.end);\nwhile (current < afternoonEnd) {\n  allSlots.push(minutesToTime(current));\n  current += slotInterval;\n}\n\n// Get calendar events\nconst calendarEvents = $input.all();\n\n// Build list of blocked time ranges from calendar\nconst blockedRanges = [];\n\ncalendarEvents.forEach(item => {\n  const event = item.json;\n  \n  // Skip cancelled events\n  if (event.status === 'cancelled') return;\n  \n  let startTime, endTime;\n  \n  // Handle different possible structures from Google Calendar\n  let startDateTime = null;\n  let endDateTime = null;\n  \n  // Try nested structure first (event.start.dateTime)\n  if (event.start && typeof event.start === 'object') {\n    startDateTime = event.start.dateTime || event.start.date;\n    endDateTime = event.end?.dateTime || event.end?.date;\n  }\n  // Try flat structure (event.startDateTime)\n  else if (event.startDateTime) {\n    startDateTime = event.startDateTime;\n    endDateTime = event.endDateTime;\n  }\n  \n  if (!startDateTime) {\n    return; // Skip if no valid start time\n  }\n  \n  // Check if it's all-day event (date only, no time)\n  if (startDateTime.length === 10) {\n    // All-day event - block entire day\n    startTime = '00:00';\n    endTime = '23:59';\n  } else {\n    // Timed event - CONVERT TO SOFIA TIMEZONE\n    const startSofia = toSofiaTime(startDateTime);\n    const endSofia = toSofiaTime(endDateTime);\n    startTime = startSofia.time;\n    endTime = endSofia.time;\n  }\n  \n  const startMinutes = timeToMinutes(startTime);\n  const endMinutes = timeToMinutes(endTime);\n  \n  blockedRanges.push({\n    start: startMinutes,\n    end: endMinutes,\n    title: event.summary || '\u0417\u0430\u0435\u0442',\n    originalStart: startDateTime\n  });\n});\n\n// Check if current time matters (for today) - USE SOFIA TIME\nconst now = new Date();\nconst nowSofia = toSofiaTime(now.toISOString());\nconst isToday = queryDate === nowSofia.date;\nlet nowMinutes = 0;\n\nif (isToday) {\n  // Add 30 min buffer for today - can't book immediately\n  nowMinutes = timeToMinutes(nowSofia.time) + 30;\n}\n\n// Filter out blocked slots and past slots\nfunction isSlotAvailable(slotTime) {\n  const slotMinutes = timeToMinutes(slotTime);\n  \n  // If today, check if slot is in the past\n  if (isToday && slotMinutes < nowMinutes) {\n    return false;\n  }\n  \n  // Check if slot conflicts with any blocked range\n  for (const range of blockedRanges) {\n    // Conflict if slot starts during a blocked range\n    if (slotMinutes >= range.start && slotMinutes < range.end) {\n      return false;\n    }\n  }\n  \n  return true;\n}\n\nconst availableSlots = allSlots.filter(slot => isSlotAvailable(slot));\n\nreturn [{\n  json: {\n    success: true,\n    date: queryDate,\n    slots: availableSlots,\n    totalSlots: allSlots.length,\n    bookedCount: blockedRanges.length,\n    blockedRanges: blockedRanges,\n    isToday: isToday\n  }\n}];"
      },
      "id": "calculate-slots",
      "name": "Calculate Available Slots",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        200
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify($json) }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "id": "respond-slots",
      "name": "Respond with Slots",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1320,
        200
      ]
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "Parse Date",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Date": {
      "main": [
        [
          {
            "node": "Has Error?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Error?": {
      "main": [
        [
          {
            "node": "Respond Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Non-Working Day?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Non-Working Day?": {
      "main": [
        [
          {
            "node": "Respond Non-Working",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Get Calendar Events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Calendar Events": {
      "main": [
        [
          {
            "node": "Calculate Available Slots",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Available Slots": {
      "main": [
        [
          {
            "node": "Respond with Slots",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}

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

Rodopi Dent - Public Available Slots (Calendar). Uses googleCalendar. Webhook trigger; 9 nodes.

Source: https://github.com/Georgi-Piskov/RODOPI-DENT/blob/ad5b6829b5d1009f64d2cce3db8393d3c11947c2/n8n-workflows/15-public-slots.json — original creator credit. Request a take-down →

More General workflows → · Browse all categories →

Related workflows

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

General

github code Try yourself

Google Calendar
General

This workflow exposes a webhook for a Vapi or Retell voice agent to check Google Calendar availability or book an appointment, then returns a short, spoken-friendly reply for the agent to read to the

Google Calendar
General

Connect a Vapi AI voice agent to Google Calendar to capture contact details and auto-book appointments. The agent asks for name, address, service type, and a preferred time. The workflow checks availa

Google Calendar
General

n8nCal · /events. Uses googleCalendar. Webhook trigger; 9 nodes.

Google Calendar
General

Rodopi Dent - Calendar Update Event. Uses googleCalendar. Webhook trigger; 5 nodes.

Google Calendar