dar API returns events based on timeMin/timeMax, overlapping polls naturally deduplicate. No external state store is required.
Implementation Steps
Step 1: Scheduler Configuration
Configure a schedule trigger to execute every 60 minutes. The cron expression 0 * * * * ensures execution at the top of each hour. For higher precision environments, */30 * * * * reduces the tolerance window requirement but increases API load.
Step 2: Calendar Data Retrieval
Use the Google Calendar getAll operation with dynamic time boundaries. The timeMin parameter uses the current execution timestamp, while timeMax adds 25 hours. This ensures the payload contains only relevant upcoming events.
Step 3: Time-Window Classification
A Code node evaluates each event's start time against the current execution time. The classifier calculates the delta in hours, applies tolerance bands, and tags events accordingly. Events outside both bands are marked for exclusion.
Step 4: Conditional Routing
An IF node filters out non-matching events. Only events tagged with 1hour or 24hour proceed to the notification dispatcher. This prevents unnecessary email API calls and keeps execution logs clean.
Step 5: Notification Dispatch
The Gmail node constructs a dynamic email payload. Subject lines and body content adapt based on the reminder type. Attendee emails are extracted from the calendar event payload, and optional fields like location and video conference links are conditionally appended.
New Code Example: Time-Window Classifier
The following TypeScript snippet demonstrates the classification logic. It is structured for clarity, type safety, and easy maintenance within n8n's Code node environment.
interface CalendarEvent {
summary: string;
start: { dateTime?: string; date?: string };
attendees?: Array<{ email: string }>;
location?: string;
hangoutLink?: string;
}
interface ReminderPayload {
type: '1hour' | '24hour' | 'none';
summary: string;
startISO: string;
attendees: string;
location: string;
meetLink: string;
}
const now = new Date();
const inputEvents = $input.all() as Array<{ json: CalendarEvent }>;
const classified: ReminderPayload[] = [];
for (const item of inputEvents) {
const evt = item.json;
const startStr = evt.start?.dateTime || evt.start?.date;
// Skip all-day events (no time component)
if (!startStr || !evt.start?.dateTime) continue;
const startTime = new Date(startStr);
const deltaMs = startTime.getTime() - now.getTime();
const deltaHours = deltaMs / (1000 * 60 * 60);
// Skip past events
if (deltaHours < 0) continue;
const payload: ReminderPayload = {
type: 'none',
summary: evt.summary,
startISO: startTime.toISOString(),
attendees: (evt.attendees || []).map(a => a.email).join(', '),
location: evt.location || 'TBD',
meetLink: evt.hangoutLink || ''
};
if (deltaHours >= 0.9 && deltaHours <= 1.1) {
payload.type = '1hour';
} else if (deltaHours >= 23.5 && deltaHours <= 24.5) {
payload.type = '24hour';
}
if (payload.type !== 'none') {
classified.push(payload);
}
}
return classified.length > 0 ? classified : [{ json: { type: 'none' } }];
This implementation explicitly handles timezone parsing via native Date, filters all-day events early, and returns a clean payload array. The tolerance bands are clearly documented, and the structure supports future extensions like SMS routing or confirmation link generation.
Pitfall Guide
1. Timezone Drift & UTC Misalignment
Explanation: Calendar APIs return timestamps in ISO 8601 format, often with timezone offsets. If the execution environment uses a different timezone than the calendar, delta calculations will be skewed, causing reminders to fire too early or too late.
Fix: Always parse timestamps using new Date(isoString) which automatically normalizes to the runtime's local timezone. For multi-region teams, store and compare all times in UTC, then format for display using a library like date-fns-tz or n8n's built-in $fromUnix() helpers.
2. Duplicate Notification Storms
Explanation: If the scheduler runs faster than the tolerance window or if calendar events are updated mid-cycle, the same event can be classified multiple times across consecutive executions.
Fix: Implement a lightweight deduplication layer. Store processed event IDs in a temporary JSON file or Redis cache with a TTL matching the reminder window. Check against this store before dispatching. Alternatively, narrow tolerance bands and ensure the scheduler interval aligns with the buffer size.
3. All-Day Event False Positives
Explanation: All-day calendar events use start.date instead of start.dateTime. The delta calculation will treat these as midnight timestamps, triggering reminders at incorrect times or spamming attendees.
Fix: Explicitly check for evt.start?.dateTime before calculating deltas. Skip processing if only start.date exists. This is handled in the classifier above but is a common oversight when adapting generic calendar parsers.
4. OAuth Token Expiration & Silent Failures
Explanation: Google Calendar and Gmail nodes rely on OAuth 2.0 tokens. Tokens expire or revoke silently, causing the workflow to fail without visible errors in the execution log.
Fix: Enable n8n's built-in credential refresh mechanism. Monitor execution logs for 401 Unauthorized responses. Implement a fallback notification (e.g., Slack webhook) that triggers when authentication fails, prompting immediate credential rotation.
5. Rate Limit Throttling on Calendar/Gmail APIs
Explanation: Google enforces strict quotas on Calendar and Gmail APIs. High-volume environments or aggressive polling intervals can trigger 429 Too Many Requests errors, halting the workflow.
Fix: Implement exponential backoff in the HTTP request layer. Use n8n's Retry On Fail settings with a maximum of 3 attempts and increasing delays. Batch calendar queries where possible, and avoid polling intervals shorter than 15 minutes unless absolutely necessary.
6. Ignoring Organizer vs. Attendee Distinction
Explanation: Calendar events often include the organizer as an attendee. Sending reminders to the organizer creates internal noise and wastes API quota.
Fix: Filter the attendees array to exclude the authenticated user's email. Store the service account or OAuth user email in an environment variable and apply a .filter(email => email !== process.env.ORGANIZER_EMAIL) step before dispatch.
7. Hardcoded Email Templates
Explanation: Embedding static HTML or plain text directly in the Gmail node makes localization, branding updates, and A/B testing difficult.
Fix: Externalize templates to a JSON file or database. Use n8n's HTTP Request node to fetch the template, then inject variables using template literals. This enables centralized template management and supports multi-language routing.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Low volume (<20 bookings/week) | Single n8n workflow with hourly polling | Minimal overhead, easy to maintain | $0 (self-hosted) |
| High volume (>100 bookings/week) | Event-driven webhooks + queue processor | Reduces API calls, scales horizontally | Infrastructure cost increases |
| Multi-region clients | UTC-normalized classifier + localized templates | Prevents timezone drift, improves UX | Template management overhead |
| SMS + Email required | Parallel dispatch nodes with fallback routing | Higher open rates for tactical reminders | Twilio/SMS provider costs |
| Enterprise compliance | On-prem n8n + internal SMTP | Data residency, audit trails, no third-party SaaS | Higher DevOps maintenance |
Configuration Template
{
"name": "Appointment Notification Engine",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 * * * *"
}
]
}
},
"id": "sched_trigger",
"name": "Hourly Poll",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [200, 300]
},
{
"parameters": {
"operation": "getAll",
"calendar": {
"__rl": true,
"value": "primary",
"mode": "name"
},
"returnAll": false,
"limit": 50,
"options": {
"timeMin": "={{ $now.toISO() }}",
"timeMax": "={{ $now.plus({ hours: 25 }).toISO() }}"
}
},
"id": "cal_fetcher",
"name": "Fetch Upcoming Bookings",
"type": "n8n-nodes-base.googleCalendar",
"typeVersion": 3,
"position": [420, 300]
},
{
"parameters": {
"jsCode": "const now = new Date();\nconst events = $input.all();\nconst results = [];\n\nfor (const item of events) {\n const e = item.json;\n const startStr = e.start?.dateTime || e.start?.date;\n if (!startStr || !e.start?.dateTime) continue;\n \n const start = new Date(startStr);\n const diffHrs = (start.getTime() - now.getTime()) / 3600000;\n if (diffHrs < 0) continue;\n\n const tag = (diffHrs >= 0.9 && diffHrs <= 1.1) ? '1hour' : \n (diffHrs >= 23.5 && diffHrs <= 24.5) ? '24hour' : 'none';\n \n if (tag !== 'none') {\n results.push({\n json: {\n type: tag,\n title: e.summary,\n startTime: start.toISOString(),\n guests: (e.attendees || []).map(a => a.email).join(', '),\n venue: e.location || 'Virtual',\n videoUrl: e.hangoutLink || ''\n }\n });\n }\n}\n\nreturn results.length ? results : [{ json: { type: 'none' } }];"
},
"id": "time_classifier",
"name": "Classify Reminder Windows",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [640, 300]
},
{
"parameters": {
"conditions": {
"string": [
{
"value1": "={{ $json.type }}",
"operation": "notEqual",
"value2": "none"
}
]
}
},
"id": "route_filter",
"name": "Route Active Reminders",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [860, 300]
},
{
"parameters": {
"sendTo": "={{ $json.guests }}",
"subject": "={{ $json.type === '1hour' ? 'Starting Soon' : 'Tomorrow' }}: {{ $json.title }}",
"message": "=Hello,\n\nYour appointment is scheduled for:\n\n• Event: {{ $json.title }}\n• Time: {{ $json.startTime }}\n• Location: {{ $json.venue }}\n{{ $json.videoUrl ? '• Link: ' + $json.videoUrl : '' }}\n\nPlease reply to confirm attendance."
},
"id": "email_dispatcher",
"name": "Dispatch Notification",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.1,
"position": [1080, 300]
}
],
"connections": {
"Hourly Poll": { "main": [[{ "node": "Fetch Upcoming Bookings", "type": "main", "index": 0 }]] },
"Fetch Upcoming Bookings": { "main": [[{ "node": "Classify Reminder Windows", "type": "main", "index": 0 }]] },
"Classify Reminder Windows": { "main": [[{ "node": "Route Active Reminders", "type": "main", "index": 0 }]] },
"Route Active Reminders": { "main": [[{ "node": "Dispatch Notification", "type": "main", "index": 0 }], []] }
},
"settings": { "executionOrder": "v1" }
}
Quick Start Guide
- Deploy n8n: Run
docker run -it --rm --name n8n -p 5678:5678 -v ~/.n8n:/home/node/.n8n n8nio/n8n or use the cloud instance.
- Import Workflow: Navigate to Workflows → Import from Clipboard → paste the JSON template above.
- Authenticate: Click the Google Calendar and Gmail nodes → Create Credential → Complete OAuth flow → Select
primary calendar.
- Validate: Manually trigger the
Hourly Poll node. Verify execution logs show correct classification and no 401/429 errors.
- Activate: Toggle the workflow to active. Monitor the first 24 hours of execution to confirm tolerance bands align with your scheduler drift.