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 →
{
"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.
googleCalendarOAuth2Api
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 →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
github code Try yourself
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
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
Rodopi Dent - Calendar Update Event. Uses googleCalendar. Webhook trigger; 5 nodes.