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": "Calendar Conflict Detector (Google / Outlook)",
"nodes": [
{
"parameters": {
"content": "## Calendar Conflict Detector\n\nSchedule trigger fetches the next N days of events from Google Calendar (default) or Outlook for each calendar in `CALENDAR_IDS`, runs an interval-overlap algorithm across all pairs, and posts a Slack alert per detected conflict.\n\n**Production patterns wired:**\n- Rate limit (opt-in, `RATE_LIMIT_ENABLED=1`, per-calendar)\n- Idempotency on conflict-pair hash (opt-in, `IDEMPOTENCY_ENABLED=1`, 24h window)\n- Error branches with structured fallback per calendar\n\nNo HMAC because the trigger is internal cron.\n\nSee `README.md` for setup, env vars, and extension recipes.",
"height": 320,
"width": 400,
"color": 6
},
"id": "note-intro",
"name": "Sticky Note - Intro",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-200,
-100
]
},
{
"parameters": {
"content": "### >> SET ME <<\n\n1. Set `CALENDAR_PROVIDER=google` (default) or `outlook`.\n2. Set `CALENDAR_IDS` to a comma-separated list of calendar IDs (Google: emails / IDs, Outlook: user UPNs).\n3. Add provider credentials: `googleCalendarOAuth2Api` (scope `calendar.readonly`) OR `microsoftOutlookOAuth2Api` (scope `Calendars.Read`).\n4. Set `SLACK_OPS_WEBHOOK` for conflict alerts.\n5. Set `CONFLICT_LOOKAHEAD_DAYS` to override the 7-day default window.\n6. Self-hosted n8n: set `NODE_FUNCTION_ALLOW_BUILTIN=crypto`.\n7. Adjust schedule (default daily 06:00 UTC).",
"height": 320,
"width": 380,
"color": 5
},
"id": "note-setup",
"name": "Sticky Note - Setup",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-200,
240
]
},
{
"parameters": {
"content": "## Production Patterns\n\nThree patterns wired. Two opt-in nodes plus always-on per-calendar error branches.\n\n- **Rate limit:** `RATE_LIMIT_ENABLED=1` (60 fetches / hour / calendar)\n- **Idempotency:** `IDEMPOTENCY_ENABLED=1` (24-hour window on `sha256(eventA.id + eventB.id)`)\n- **Per-calendar error branch:** always on. Each provider fetch has `onError: continueErrorOutput`. One calendar failing does not stop overlap detection on the others.\n\nFor clustered n8n, swap the in-memory dedup for Redis SET NX EX 86400. Snippet in the node's comments.",
"height": 320,
"width": 380,
"color": 7
},
"id": "note-production-patterns",
"name": "Sticky Note - Production Patterns",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
840,
-260
]
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 6 * * *"
}
]
}
},
"id": "cal-cd-1-trigger",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
240,
60
]
},
{
"parameters": {
"jsCode": "// Read CALENDAR_IDS env (comma-separated). Emit one item per calendar.\n// Plus compute the start / end ISO window for the lookahead period.\n\nconst idsEnv = $env.CALENDAR_IDS || '';\nconst ids = idsEnv.split(',').map(s => s.trim()).filter(Boolean);\nif (ids.length === 0) {\n throw new Error('CALENDAR_IDS env var is empty.');\n}\n\nconst lookaheadDays = Math.max(1, parseInt($env.CONFLICT_LOOKAHEAD_DAYS || '7', 10));\nconst now = new Date();\nconst end = new Date(now.getTime() + lookaheadDays * 24 * 60 * 60 * 1000);\n\nreturn ids.map(id => ({\n json: {\n calendarId: id,\n timeMinIso: now.toISOString(),\n timeMaxIso: end.toISOString(),\n },\n}));"
},
"id": "cal-cd-2-list",
"name": "Read Calendar IDs",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
60
]
},
{
"parameters": {
"jsCode": "// Per-calendar-ID sliding-window rate limit, opt-in.\n// 60 fetches per hour per calendar.\n\nif ($env.RATE_LIMIT_ENABLED !== '1') {\n return $input.all();\n}\n\nconst LIMIT = 60;\nconst WINDOW_MS = 60 * 60 * 1000;\nconst MAX_KEYS = 200;\n\nconst data = $getWorkflowStaticData('global');\ndata.rateBuckets = data.rateBuckets || {};\nconst buckets = data.rateBuckets;\nconst now = Date.now();\n\nfor (const k of Object.keys(buckets)) {\n buckets[k] = (buckets[k] || []).filter(t => now - t < WINDOW_MS);\n if (buckets[k].length === 0) delete buckets[k];\n}\nif (Object.keys(buckets).length > MAX_KEYS) {\n const oldest = Object.entries(buckets).sort((a, b) => (a[1][0] || 0) - (b[1][0] || 0)).slice(0, 50);\n for (const [k] of oldest) delete buckets[k];\n}\n\nconst out = [];\nfor (const item of $input.all()) {\n const id = item.json.calendarId;\n const hits = buckets[id] || [];\n if (hits.length >= LIMIT) continue;\n buckets[id] = [...hits, now];\n out.push(item);\n}\nreturn out;"
},
"id": "cal-cd-pp-1-ratelimit",
"name": "Rate Limit (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
640,
60
]
},
{
"parameters": {
"jsCode": "// Pick provider from env. Default google.\n\nconst provider = ($env.CALENDAR_PROVIDER || 'google').toLowerCase();\nreturn $input.all().map(item => ({ json: { ...item.json, provider } }));"
},
"id": "cal-cd-3-provider",
"name": "Set Provider",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
840,
60
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": false,
"typeValidation": "loose",
"version": 2
},
"combinator": "and",
"conditions": [
{
"leftValue": "={{ $json.provider }}",
"rightValue": "google",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
},
"outputKey": "google"
},
{
"conditions": {
"options": {
"caseSensitive": false,
"typeValidation": "loose",
"version": 2
},
"combinator": "and",
"conditions": [
{
"leftValue": "={{ $json.provider }}",
"rightValue": "outlook",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
},
"outputKey": "outlook"
}
]
},
"options": {}
},
"id": "cal-cd-4-route",
"name": "Route by Provider",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
1040,
60
]
},
{
"parameters": {
"method": "GET",
"url": "={{ 'https://www.googleapis.com/calendar/v3/calendars/' + encodeURIComponent($json.calendarId) + '/events' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleCalendarOAuth2Api",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "timeMin",
"value": "={{ $json.timeMinIso }}"
},
{
"name": "timeMax",
"value": "={{ $json.timeMaxIso }}"
},
{
"name": "singleEvents",
"value": "true"
},
{
"name": "orderBy",
"value": "startTime"
},
{
"name": "maxResults",
"value": "250"
}
]
},
"options": {}
},
"id": "cal-cd-5a-google",
"name": "Google Calendar List",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1240,
-100
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"method": "GET",
"url": "={{ 'https://graph.microsoft.com/v1.0/users/' + encodeURIComponent($json.calendarId) + '/calendarView' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "microsoftOutlookOAuth2Api",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "startDateTime",
"value": "={{ $json.timeMinIso }}"
},
{
"name": "endDateTime",
"value": "={{ $json.timeMaxIso }}"
},
{
"name": "$top",
"value": "250"
}
]
},
"options": {}
},
"id": "cal-cd-5b-outlook",
"name": "Outlook Events List",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1240,
220
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "// Normalize provider-specific event shapes into a stable schema.\n// Google: items[].{id,summary,start,end,status,htmlLink}\n// Outlook: value[].{id,subject,start,end,showAs,webLink}\n//\n// Microsoft Graph calendarView default returns dateTime in UTC with `timeZone: 'UTC'`.\n// If the caller sets a `Prefer: outlook.timezone=\"...\"` header (this template does not),\n// the response can come back in a Windows timezone name like 'Eastern Standard Time'.\n// In that case our normalization keeps the value as UTC (with a warning logged) because\n// resolving Windows tz aliases requires a lookup table; false UTC interpretation is\n// safer than V8 parsing the unsuffixed string as local server time.\n\nconst tzAlreadyEncoded = (s) => /(?:Z|[+-]\\d{2}:?\\d{2})$/.test(String(s || ''));\n\nconst toUtcIsoStrict = (dateTime, timeZone) => {\n if (!dateTime) return null;\n // If the string already carries an explicit offset suffix, parse as-is\n if (tzAlreadyEncoded(dateTime)) return new Date(dateTime).toISOString();\n // Otherwise this is the calendarView default shape: ISO without suffix in UTC\n if (timeZone && timeZone !== 'UTC') {\n console.log('[calendar] WARNING: Outlook event has timeZone=' + timeZone + ' but no offset suffix, treating as UTC');\n }\n return new Date(dateTime + 'Z').toISOString();\n};\n\nconst out = [];\nfor (const item of $input.all()) {\n const j = item.json || {};\n const calendarId = (j.calendarId) || (j._calendarId) || ($('Set Provider').first() && $('Set Provider').first().json.calendarId) || 'unknown';\n const provider = j.provider || ($('Set Provider').first() && $('Set Provider').first().json.provider) || 'google';\n\n // Google envelope: { items: [...] }, Outlook envelope: { value: [...] }\n const eventArr = Array.isArray(j.items) ? j.items : (Array.isArray(j.value) ? j.value : []);\n\n for (const ev of eventArr) {\n let title = '';\n let startIso = null;\n let endIso = null;\n let url = null;\n let isAllDay = false;\n let included = true;\n\n if (provider === 'google') {\n if (String(ev.status || '') === 'cancelled') { included = false; }\n title = ev.summary || '(no title)';\n url = ev.htmlLink || null;\n const s = ev.start || {};\n const e = ev.end || {};\n isAllDay = !!s.date && !s.dateTime;\n // Google v3 events.list returns dateTime with offset suffix (`+02:00` or `Z`)\n startIso = s.dateTime ? new Date(s.dateTime).toISOString() : (s.date ? new Date(s.date + 'T00:00:00Z').toISOString() : null);\n endIso = e.dateTime ? new Date(e.dateTime).toISOString() : (e.date ? new Date(e.date + 'T23:59:59Z').toISOString() : null);\n } else {\n // outlook\n if (String(ev.showAs || '') === 'free') { included = false; }\n title = ev.subject || '(no subject)';\n url = ev.webLink || null;\n isAllDay = !!ev.isAllDay;\n const s = ev.start || {};\n const e = ev.end || {};\n startIso = toUtcIsoStrict(s.dateTime, s.timeZone);\n endIso = toUtcIsoStrict(e.dateTime, e.timeZone);\n }\n\n if (!included) continue;\n if (!startIso || !endIso) continue;\n\n out.push({ json: { calendarId, provider, eventId: ev.id || (calendarId + ':' + startIso), title, url, startIso, endIso, isAllDay } });\n }\n}\n\nreturn out;"
},
"id": "cal-cd-6-normalize",
"name": "Normalize Events",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1440,
60
]
},
{
"parameters": {
"jsCode": "// Detect overlapping events across DIFFERENT calendars.\n// Pair-wise scan, only flag when calendarId differs and time ranges intersect.\n\nconst events = $input.all().map(i => i.json);\nconst byCal = {};\nfor (const e of events) {\n byCal[e.calendarId] = byCal[e.calendarId] || [];\n byCal[e.calendarId].push(e);\n}\n\nconst toMs = iso => new Date(iso).getTime();\nconst conflicts = [];\nconst calIds = Object.keys(byCal);\n\nfor (let i = 0; i < calIds.length; i++) {\n for (let j = i + 1; j < calIds.length; j++) {\n const a = byCal[calIds[i]];\n const b = byCal[calIds[j]];\n for (const ea of a) {\n const aS = toMs(ea.startIso); const aE = toMs(ea.endIso);\n if (!Number.isFinite(aS) || !Number.isFinite(aE)) continue;\n for (const eb of b) {\n const bS = toMs(eb.startIso); const bE = toMs(eb.endIso);\n if (!Number.isFinite(bS) || !Number.isFinite(bE)) continue;\n const overlapStart = Math.max(aS, bS);\n const overlapEnd = Math.min(aE, bE);\n if (overlapStart < overlapEnd) {\n conflicts.push({\n json: {\n calendarA: ea.calendarId,\n calendarB: eb.calendarId,\n eventA: ea,\n eventB: eb,\n overlapStartIso: new Date(overlapStart).toISOString(),\n overlapEndIso: new Date(overlapEnd).toISOString(),\n overlapMinutes: Math.round((overlapEnd - overlapStart) / 60000),\n },\n });\n }\n }\n }\n }\n}\n\nreturn conflicts;"
},
"id": "cal-cd-7-overlaps",
"name": "Detect Overlaps",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1640,
60
]
},
{
"parameters": {
"jsCode": "// 24-hour idempotency window on conflict-pair hash, opt-in.\n\nconst crypto = require('crypto');\n\nif ($env.IDEMPOTENCY_ENABLED !== '1') {\n return $input.all();\n}\n\nconst WINDOW_MS = 24 * 60 * 60 * 1000;\nconst MAX_KEYS = 20000;\n\nconst data = $getWorkflowStaticData('global');\ndata.seenKeys = data.seenKeys || {};\nconst seen = data.seenKeys;\nconst now = Date.now();\n\nfor (const k of Object.keys(seen)) {\n if (now - seen[k] > WINDOW_MS) delete seen[k];\n}\nif (Object.keys(seen).length > MAX_KEYS) {\n const oldest = Object.entries(seen).sort((a, b) => a[1] - b[1]).slice(0, 2000);\n for (const [k] of oldest) delete seen[k];\n}\n\nconst out = [];\nfor (const item of $input.all()) {\n const j = item.json || {};\n const aId = j.eventA && j.eventA.eventId;\n const bId = j.eventB && j.eventB.eventId;\n if (!aId || !bId) { out.push(item); continue; }\n const sorted = [String(aId), String(bId)].sort().join(':');\n const dedupKey = crypto.createHash('sha256').update(sorted).digest('hex').slice(0, 32);\n if (seen[dedupKey]) continue;\n seen[dedupKey] = now;\n out.push(item);\n}\nreturn out;\n\n// Redis variant for clustered n8n:\n// const result = await redis.set('cf-idem:' + dedupKey, '1', 'EX', 86400, 'NX');\n// if (result === null) continue;"
},
"id": "cal-cd-pp-2-idempotency",
"name": "Idempotency Filter (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1840,
60
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SLACK_OPS_WEBHOOK }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ text: ':collision: Calendar conflict (' + ($json.overlapMinutes || 0) + ' min overlap) between ' + $json.calendarA + ' and ' + $json.calendarB + ' starting ' + $json.overlapStartIso + ' (' + ($json.eventA.title || '') + ' vs ' + ($json.eventB.title || '') + ')' }) }}",
"options": {}
},
"id": "cal-cd-8-slack",
"name": "Slack Alert per Conflict",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2040,
60
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Fallback when a calendar fetch fails.\n\nconst input = $input.first();\nconst err = (input.json && input.json.error) || input.error || {};\nconst orig = input.json || {};\n\nreturn [{\n json: {\n ok: false,\n fallback: true,\n calendarId: orig.calendarId || 'unknown',\n provider: orig.provider || 'unknown',\n error: {\n message: err.message || 'unknown error',\n name: err.name || 'CalendarFetchError',\n },\n receivedAt: new Date().toISOString(),\n },\n}];"
},
"id": "cal-cd-err-fallback",
"name": "Error Fallback",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1640,
380
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SLACK_OPS_WEBHOOK }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ text: ':warning: Calendar fetch error (' + ($json.provider || 'unknown') + ' / ' + ($json.calendarId || 'unknown') + '): ' + ($json.error.message || 'unknown') }) }}",
"options": {}
},
"id": "cal-cd-err-slack-alert",
"name": "Error Slack Alert",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1840,
380
],
"onError": "continueRegularOutput"
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "Read Calendar IDs",
"type": "main",
"index": 0
}
]
]
},
"Read Calendar IDs": {
"main": [
[
{
"node": "Rate Limit (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Rate Limit (opt-in)": {
"main": [
[
{
"node": "Set Provider",
"type": "main",
"index": 0
}
]
]
},
"Set Provider": {
"main": [
[
{
"node": "Route by Provider",
"type": "main",
"index": 0
}
]
]
},
"Route by Provider": {
"main": [
[
{
"node": "Google Calendar List",
"type": "main",
"index": 0
}
],
[
{
"node": "Outlook Events List",
"type": "main",
"index": 0
}
]
]
},
"Google Calendar List": {
"main": [
[
{
"node": "Normalize Events",
"type": "main",
"index": 0
}
],
[
{
"node": "Error Fallback",
"type": "main",
"index": 0
}
]
]
},
"Outlook Events List": {
"main": [
[
{
"node": "Normalize Events",
"type": "main",
"index": 0
}
],
[
{
"node": "Error Fallback",
"type": "main",
"index": 0
}
]
]
},
"Normalize Events": {
"main": [
[
{
"node": "Detect Overlaps",
"type": "main",
"index": 0
}
]
]
},
"Detect Overlaps": {
"main": [
[
{
"node": "Idempotency Filter (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Idempotency Filter (opt-in)": {
"main": [
[
{
"node": "Slack Alert per Conflict",
"type": "main",
"index": 0
}
]
]
},
"Error Fallback": {
"main": [
[
{
"node": "Error Slack Alert",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
How this works
Effortlessly spot and resolve calendar conflicts across Google Calendar and Outlook, saving hours of manual checking and preventing double-bookings that disrupt your day. This workflow suits busy professionals juggling multiple schedules, automatically scanning for overlaps and notifying you via email or Slack for quick action. The key step routes events by provider—Google or Outlook—before fetching and comparing appointments to detect clashes.
Use this when you manage hybrid calendars and need daily or weekly scans to maintain smooth coordination, especially in teams with mixed tools. Avoid it for single-provider setups or real-time needs, where simpler apps like Google Workspace alerts suffice. Common variations include adding Slack notifications for urgent conflicts or extending to iCal feeds for broader compatibility.
About this workflow
Calendar Conflict Detector (Google / Outlook). Uses stickyNote, scheduleTrigger, httpRequest. Scheduled trigger; 16 nodes.
Source: https://github.com/studiomeyer-io/n8n-workflows/blob/main/templates/09-calendar-conflict-detector/workflow.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.
WF-Main - XHS 主控制器. Uses scheduleTrigger, httpRequest, executeWorkflow, noOp. Scheduled trigger; 21 nodes.
Dm-Profile-Visitors. Uses httpRequest, googleSheets. Scheduled trigger; 21 nodes.
RSS to Multi-Channel Social (X / LinkedIn / Discord). Uses stickyNote, scheduleTrigger, httpRequest. Scheduled trigger; 19 nodes.
YouTube Channel to Notion. Uses stickyNote, scheduleTrigger, httpRequest, noOp. Scheduled trigger; 18 nodes.
Automate Droplet Snapshots On Digitalocean. Uses httpRequest, stickyNote. Scheduled trigger; 17 nodes.