{ "name": "Book confirmed appointments in Google Calendar", "nodes": [ { "name": "Setup Instructions", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 0, -510 ], "parameters": { "content": "## Book confirmed appointments in Google Calendar\n\nValidate a confirmed booking request, check calendar availability and create a private event only when the interval is free.\n\nServices: n8n and Google Calendar.\n\nSetup:\n1. Choose a Webhook Header Auth credential and set a unique path.\n2. Choose a Google Calendar OAuth credential on every Google Calendar node and replace YOUR_CALENDAR_ID with the same selected calendar.\n3. Use ISO timestamps with an explicit offset. Test only on a dedicated test calendar before activation.\n4. Use the same request_id for repeated attempts at the same booking. It must be 16 to 64 lowercase characters from a to v or digits.\n5. Provide start, end, summary, request_id and confirmed true. The event is created without guests or notification recipients.\n6. Keep all calendar writers serialized if overlapping reservations are unacceptable. Test busy, invalid, successful and ambiguous provider cases before activation.\n\nKeep this workflow inactive until configured and tested.", "width": 720, "height": 440 }, "id": "template-node-1" }, { "name": "Receive Request", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [ 0, 0 ], "parameters": { "httpMethod": "POST", "path": "template-calendar-booking", "authentication": "headerAuth", "responseMode": "responseNode", "options": {} }, "webhookId": "template-calendar-booking-entry", "id": "template-node-2" }, { "name": "Validate Request", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 240, 0 ], "parameters": { "jsCode": "const b = $input.first().json.body || {};\nconst errors = [];\nconst parseInstant = value => {\n if (typeof value !== 'string') return null;\n const match = value.match(/^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,3}))?(Z|[+-]\\d{2}:\\d{2})$/);\n if (!match) return null;\n const [year,month,day,hour,minute,second] = match.slice(1,7).map(Number);\n const leapYear = year%4===0 && (year%100!==0 || year%400===0);\n const days = [31,leapYear ? 29 : 28,31,30,31,30,31,31,30,31,30,31];\n if (month<1 || month>12 || day<1 || day>days[month-1] || hour>23 || minute>59 || second>59) return null;\n const offset = match[8];\n if (offset!=='Z' && (Number(offset.slice(1,3))>23 || Number(offset.slice(4,6))>59)) return null;\n const parsed = new Date(value);\n return Number.isFinite(parsed.getTime()) ? parsed : null;\n};\nconst start = parseInstant(b.start);\nconst end = parseInstant(b.end);\nif (!start || !end) errors.push('start and end must be real RFC3339 dates with seconds and an explicit offset');\nelse {\n if (start <= new Date()) errors.push('start must be in the future');\n if (end <= start || end-start > 24*60*60*1000) errors.push('The interval must be positive and at most one day');\n}\nif (b.confirmed !== true) errors.push('confirmed must be true');\nif (typeof b.summary !== 'string' || !b.summary.trim() || b.summary.length > 160) errors.push('summary is required and must be at most 160 characters');\nif (typeof b.request_id !== 'string' || !/^[a-v0-9]{16,64}$/.test(b.request_id)) errors.push('request_id must be 16 to 64 lowercase base32hex characters');\nreturn [{ json: { valid: errors.length===0, errors, request: { start: errors.length ? null : start.toISOString(), end: errors.length ? null : end.toISOString(), summary: typeof b.summary === 'string' ? b.summary.trim() : '', request_id: b.request_id || '' } } }];" }, "id": "template-node-3" }, { "name": "Request Is Valid", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 480, 0 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "condition-valid", "leftValue": "={{ $json.valid }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "id": "template-node-4" }, { "name": "Check Calendar", "type": "n8n-nodes-base.googleCalendar", "typeVersion": 1.3, "position": [ 720, 0 ], "parameters": { "resource": "calendar", "operation": "availability", "calendar": { "__rl": true, "value": "YOUR_CALENDAR_ID", "mode": "id" }, "timeMin": "={{ $('Validate Request').first().json.request.start }}", "timeMax": "={{ $('Validate Request').first().json.request.end }}", "options": { "outputFormat": "availability" } }, "onError": "continueErrorOutput", "id": "template-node-5" }, { "name": "Interval Is Available", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 1200, 0 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "condition-valid", "leftValue": "={{ $json.available === true }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "id": "template-node-6" }, { "name": "Create Appointment", "type": "n8n-nodes-base.googleCalendar", "typeVersion": 1.3, "position": [ 1440, 0 ], "parameters": { "resource": "event", "operation": "create", "calendar": { "__rl": true, "value": "YOUR_CALENDAR_ID", "mode": "id" }, "start": "={{ $('Validate Request').first().json.request.start }}", "end": "={{ $('Validate Request').first().json.request.end }}", "useDefaultReminders": false, "additionalFields": { "id": "={{ $('Validate Request').first().json.request.request_id }}", "summary": "={{ $('Validate Request').first().json.request.summary }}", "visibility": "private", "showMeAs": "opaque" }, "remindersUi": {} }, "onError": "continueErrorOutput", "id": "template-node-7" }, { "name": "Confirm Created Event", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1680, 0 ], "parameters": { "jsCode": "const requested = $('Validate Request').first().json.request;\nconst receiptStart = $json.start && $json.start.dateTime;\nconst receiptEnd = $json.end && $json.end.dateTime;\nif (typeof $json.id !== 'string' || $json.id !== requested.request_id || $json.status !== 'confirmed') throw new Error('Appointment creation was not confirmed');\nif (typeof receiptStart !== 'string' || typeof receiptEnd !== 'string' || new Date(receiptStart).getTime() !== new Date(requested.start).getTime() || new Date(receiptEnd).getTime() !== new Date(requested.end).getTime()) throw new Error('Appointment time was not confirmed');\nreturn [{ json: { status: 'booked', appointment_id: $json.id, start: requested.start, end: requested.end } }];" }, "id": "template-node-8", "onError": "continueErrorOutput" }, { "name": "Return Booking", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.4, "position": [ 1920, 0 ], "parameters": { "respondWith": "json", "responseBody": "={{ $json }}", "options": { "responseCode": 201 } }, "id": "template-node-9" }, { "name": "Return Invalid", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.4, "position": [ 720, 260 ], "parameters": { "respondWith": "json", "responseBody": "={{ { status: 'invalid_request', errors: $json.errors } }}", "options": { "responseCode": 400 } }, "id": "template-node-10" }, { "name": "Return Provider Error", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.4, "position": [ 960, 260 ], "parameters": { "respondWith": "json", "responseBody": "={{ { status: 'unavailable', message: 'The calendar could not be checked. No availability claim was made.' } }}", "options": { "responseCode": 502 } }, "id": "template-node-11" }, { "name": "Return Busy", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.4, "position": [ 1440, 260 ], "parameters": { "respondWith": "json", "responseBody": "={{ { status: 'busy', request_id: $('Validate Request').first().json.request.request_id, message: 'The interval is busy. No new event was created in this attempt. If this is a repeat request, look up its request_id in the calendar to determine the previous result.' } }}", "options": { "responseCode": 409 } }, "id": "template-node-12" }, { "name": "Return Unconfirmed", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.4, "position": [ 1680, 260 ], "parameters": { "respondWith": "json", "responseBody": "={{ { status: 'unconfirmed', request_id: $('Validate Request').first().json.request.request_id, message: 'Creation was not confirmed. Look up this request in the calendar before retrying.' } }}", "options": { "responseCode": 502 } }, "id": "template-node-13" }, { "name": "Validate Calendar Result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 960, 0 ], "parameters": { "jsCode": "if (typeof $json.available !== 'boolean') throw new Error('Availability was not confirmed');\nreturn [{ json: { available: $json.available } }];" }, "onError": "continueErrorOutput", "id": "template-node-14" } ], "connections": { "Receive Request": { "main": [ [ { "node": "Validate Request", "type": "main", "index": 0 } ] ] }, "Validate Request": { "main": [ [ { "node": "Request Is Valid", "type": "main", "index": 0 } ] ] }, "Request Is Valid": { "main": [ [ { "node": "Check Calendar", "type": "main", "index": 0 } ], [ { "node": "Return Invalid", "type": "main", "index": 0 } ] ] }, "Check Calendar": { "main": [ [ { "node": "Validate Calendar Result", "type": "main", "index": 0 } ], [ { "node": "Return Provider Error", "type": "main", "index": 0 } ] ] }, "Interval Is Available": { "main": [ [ { "node": "Create Appointment", "type": "main", "index": 0 } ], [ { "node": "Return Busy", "type": "main", "index": 0 } ] ] }, "Create Appointment": { "main": [ [ { "node": "Confirm Created Event", "type": "main", "index": 0 } ], [ { "node": "Return Unconfirmed", "type": "main", "index": 0 } ] ] }, "Confirm Created Event": { "main": [ [ { "node": "Return Booking", "type": "main", "index": 0 } ], [ { "node": "Return Unconfirmed", "type": "main", "index": 0 } ] ] }, "Validate Calendar Result": { "main": [ [ { "node": "Interval Is Available", "type": "main", "index": 0 } ], [ { "node": "Return Provider Error", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1", "timezone": "UTC", "saveDataSuccessExecution": "none", "saveDataErrorExecution": "none", "saveManualExecutions": false }, "active": false }