n8n Notification Automation: Multi-Channel Alert System Design
Effective notification systems are crucial for incident response, user engagement, and operational visibility. This guide covers building intelligent, multi-channel notification systems with n8n that route alerts appropriately and prevent notification fatigue.
Notification Architecture
Core Design Principles
A well-designed notification system should:
- Route intelligently - Send alerts to the right people through the right channels
- Prevent fatigue - Aggregate similar alerts and respect quiet hours
- Escalate appropriately - Ensure critical alerts reach someone
- Track delivery - Confirm notifications are received and acknowledged
Multi-Channel Router
// notification-router.js
/**
* Intelligent notification routing logic for n8n.
*/
const CHANNEL_PRIORITY = {
'critical': ['phone', 'sms', 'slack', 'email'],
'high': ['slack', 'sms', 'email'],
'medium': ['slack', 'email'],
'low': ['email'],
'info': ['email']
};
const USER_PREFERENCES = {
// Would typically come from database
'user_123': {
preferred_channels: ['slack', 'email'],
quiet_hours: { start: 22, end: 7 },
timezone: 'America/New_York',
phone: '+1234567890',
slack_id: 'U12345',
email: 'user@example.com'
}
};
/**
* Determine notification channels based on severity and user preferences
*/
function determineChannels(notification) {
const { severity, user_id, bypass_preferences } = notification;
const user = USER_PREFERENCES[user_id] || {};
// Get channels for severity level
let channels = CHANNEL_PRIORITY[severity] || CHANNEL_PRIORITY['low'];
// Check quiet hours (unless critical)
if (!bypass_preferences && severity !== 'critical') {
if (isQuietHours(user)) {
// During quiet hours, only use async channels
channels = channels.filter(c => ['email'].includes(c));
}
}
// Filter by user preferences (unless critical)
if (!bypass_preferences && severity !== 'critical' && user.preferred_channels) {
channels = channels.filter(c => user.preferred_channels.includes(c));
}
// Ensure at least one channel
if (channels.length === 0) {
channels = ['email'];
}
return channels;
}
/**
* Check if current time is within user's quiet hours
*/
function isQuietHours(user) {
if (!user.quiet_hours) return false;
const now = new Date();
// In production, convert to user's timezone
const hour = now.getHours();
const { start, end } = user.quiet_hours;
if (start > end) {
// Quiet hours span midnight
return hour >= start || hour < end;
} else {
return hour >= start && hour < end;
}
}
/**
* Format notification for specific channel
*/
function formatForChannel(notification, channel) {
const { title, message, severity, metadata, action_url } = notification;
switch (channel) {
case 'slack':
return {
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: `${getSeverityEmoji(severity)} ${title}`
}
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: message
}
},
...(metadata ? [{
type: 'context',
elements: Object.entries(metadata).map(([k, v]) => ({
type: 'mrkdwn',
text: `*${k}:* ${v}`
}))
}] : []),
...(action_url ? [{
type: 'actions',
elements: [{
type: 'button',
text: { type: 'plain_text', text: 'View Details' },
url: action_url,
style: severity === 'critical' ? 'danger' : 'primary'
}]
}] : [])
]
};
case 'email':
return {
subject: `[${severity.toUpperCase()}] ${title}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px;">
<div style="background: ${getSeverityColor(severity)}; color: white; padding: 15px;">
<h2 style="margin: 0;">${title}</h2>
</div>
<div style="padding: 20px; background: #f9f9f9;">
<p>${message}</p>
${metadata ? `
<table style="width: 100%; border-collapse: collapse;">
${Object.entries(metadata).map(([k, v]) => `
<tr>
<td style="padding: 5px; font-weight: bold;">${k}</td>
<td style="padding: 5px;">${v}</td>
</tr>
`).join('')}
</table>
` : ''}
${action_url ? `
<p style="margin-top: 20px;">
<a href="${action_url}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
View Details
</a>
</p>
` : ''}
</div>
</div>
`
};
case 'sms':
return {
body: `[${severity.toUpperCase()}] ${title}\n${message.substring(0, 140)}${action_url ? `\n${action_url}` : ''}`
};
case 'phone':
return {
twiml: `
<Response>
<Say voice="alice">
${severity} alert. ${title}. ${message}
</Say>
<Pause length="1"/>
<Say voice="alice">
Press 1 to acknowledge. Press 2 to escalate.
</Say>
<Gather numDigits="1" action="/handle-response"/>
</Response>
`
};
default:
return { title, message, severity, metadata };
}
}
function getSeverityEmoji(severity) {
const emojis = {
'critical': '🚨',
'high': '🔴',
'medium': '🟡',
'low': '🟢',
'info': 'ℹ️'
};
return emojis[severity] || '📢';
}
function getSeverityColor(severity) {
const colors = {
'critical': '#dc3545',
'high': '#fd7e14',
'medium': '#ffc107',
'low': '#28a745',
'info': '#17a2b8'
};
return colors[severity] || '#6c757d';
}
module.exports = {
determineChannels,
formatForChannel,
isQuietHours,
CHANNEL_PRIORITY
};Alert Aggregation Workflow
{
"name": "Alert Aggregation and Deduplication",
"nodes": [
{
"name": "Webhook Trigger",
"type": "n8n-nodes-base.webhook",
"parameters": {
"path": "alerts",
"httpMethod": "POST"
},
"position": [250, 300]
},
{
"name": "Normalize Alert",
"type": "n8n-nodes-base.function",
"parameters": {
"functionCode": "// Normalize incoming alert to standard format\nconst alert = $input.first().json;\n\nconst normalized = {\n id: alert.id || `alert_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,\n source: alert.source || 'unknown',\n severity: (alert.severity || alert.priority || 'medium').toLowerCase(),\n title: alert.title || alert.name || alert.summary || 'Alert',\n message: alert.message || alert.description || alert.body || '',\n timestamp: alert.timestamp || new Date().toISOString(),\n fingerprint: null,\n metadata: {\n ...alert.labels,\n ...alert.tags,\n source_id: alert.id\n }\n};\n\n// Generate fingerprint for deduplication\n// Same fingerprint = same alert type\nconst fingerprintData = [\n normalized.source,\n normalized.title,\n JSON.stringify(normalized.metadata)\n].join('|');\n\nnormalized.fingerprint = require('crypto')\n .createHash('md5')\n .update(fingerprintData)\n .digest('hex');\n\nreturn { json: normalized };"
},
"position": [450, 300]
},
{
"name": "Check Dedup Cache",
"type": "n8n-nodes-base.redis",
"parameters": {
"operation": "get",
"key": "={{ 'alert_dedup:' + $json.fingerprint }}"
},
"position": [650, 300]
},
{
"name": "Is Duplicate?",
"type": "n8n-nodes-base.if",
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.value !== null }}",
"value2": true
}
]
}
},
"position": [850, 300]
},
{
"name": "Increment Counter",
"type": "n8n-nodes-base.function",
"parameters": {
"functionCode": "// Parse existing dedup record\nconst existing = JSON.parse($('Check Dedup Cache').item.json.value || '{}');\nconst alert = $('Normalize Alert').item.json;\n\nconst updated = {\n fingerprint: alert.fingerprint,\n first_seen: existing.first_seen || alert.timestamp,\n last_seen: alert.timestamp,\n count: (existing.count || 0) + 1,\n severity: alert.severity,\n title: alert.title,\n suppressed: true\n};\n\n// Check if we should send aggregated notification\nconst shouldNotify = \n updated.count === 5 || // First threshold\n updated.count === 25 || // Second threshold\n updated.count === 100 || // Third threshold\n updated.count % 500 === 0; // Every 500 after\n\nreturn {\n json: {\n ...updated,\n should_notify: shouldNotify,\n original_alert: alert\n }\n};"
},
"position": [1050, 200]
},
{
"name": "Update Dedup Cache (Dup)",
"type": "n8n-nodes-base.redis",
"parameters": {
"operation": "set",
"key": "={{ 'alert_dedup:' + $json.fingerprint }}",
"value": "={{ JSON.stringify($json) }}",
"expire": true,
"ttl": 3600
},
"position": [1250, 200]
},
{
"name": "Should Send Aggregate?",
"type": "n8n-nodes-base.if",
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.should_notify }}",
"value2": true
}
]
}
},
"position": [1450, 200]
},
{
"name": "Create Aggregate Alert",
"type": "n8n-nodes-base.function",
"parameters": {
"functionCode": "const data = $input.first().json;\n\nreturn {\n json: {\n id: `agg_${data.fingerprint}_${Date.now()}`,\n severity: data.severity,\n title: `[AGGREGATED x${data.count}] ${data.title}`,\n message: `This alert has occurred ${data.count} times since ${data.first_seen}. Last occurrence: ${data.last_seen}`,\n metadata: {\n aggregate_count: data.count,\n first_occurrence: data.first_seen,\n last_occurrence: data.last_seen,\n fingerprint: data.fingerprint\n },\n timestamp: new Date().toISOString()\n }\n};"
},
"position": [1650, 100]
},
{
"name": "New Alert - Set Cache",
"type": "n8n-nodes-base.redis",
"parameters": {
"operation": "set",
"key": "={{ 'alert_dedup:' + $('Normalize Alert').item.json.fingerprint }}",
"value": "={{ JSON.stringify({ fingerprint: $('Normalize Alert').item.json.fingerprint, first_seen: $('Normalize Alert').item.json.timestamp, last_seen: $('Normalize Alert').item.json.timestamp, count: 1, severity: $('Normalize Alert').item.json.severity, title: $('Normalize Alert').item.json.title }) }}",
"expire": true,
"ttl": 3600
},
"position": [1050, 400]
},
{
"name": "Route to Channels",
"type": "n8n-nodes-base.function",
"parameters": {
"functionCode": "const alert = $input.first().json;\n\n// Determine channels based on severity\nconst channelMap = {\n 'critical': ['slack', 'sms', 'email', 'pagerduty'],\n 'high': ['slack', 'email', 'pagerduty'],\n 'medium': ['slack', 'email'],\n 'low': ['slack'],\n 'info': ['email']\n};\n\nconst channels = channelMap[alert.severity] || channelMap['medium'];\n\nreturn channels.map(channel => ({\n json: {\n channel,\n alert\n }\n}));"
},
"position": [1850, 300]
},
{
"name": "Channel Router",
"type": "n8n-nodes-base.switch",
"parameters": {
"dataType": "string",
"value1": "={{ $json.channel }}",
"rules": {
"rules": [
{ "value2": "slack", "output": 0 },
{ "value2": "email", "output": 1 },
{ "value2": "sms", "output": 2 },
{ "value2": "pagerduty", "output": 3 }
]
}
},
"position": [2050, 300]
},
{
"name": "Send Slack",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "#alerts",
"blocksUi": {
"blocksValues": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "={{ $json.alert.severity === 'critical' ? '🚨' : $json.alert.severity === 'high' ? '🔴' : '🟡' }} {{ $json.alert.title }}"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "={{ $json.alert.message }}"
}
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "*Source:* {{ $json.alert.source || 'Unknown' }} | *Time:* {{ $json.alert.timestamp }}"
}
]
}
]
}
},
"position": [2250, 100]
},
{
"name": "Send Email",
"type": "n8n-nodes-base.emailSend",
"parameters": {
"fromEmail": "alerts@example.com",
"toEmail": "oncall@example.com",
"subject": "=[{{ $json.alert.severity.toUpperCase() }}] {{ $json.alert.title }}",
"html": "=<h2>{{ $json.alert.title }}</h2><p>{{ $json.alert.message }}</p><p><small>Source: {{ $json.alert.source }} | Time: {{ $json.alert.timestamp }}</small></p>"
},
"position": [2250, 250]
},
{
"name": "Send SMS",
"type": "n8n-nodes-base.twilio",
"parameters": {
"operation": "send",
"from": "={{ $env.TWILIO_PHONE }}",
"to": "={{ $env.ONCALL_PHONE }}",
"message": "=[{{ $json.alert.severity.toUpperCase() }}] {{ $json.alert.title }}: {{ $json.alert.message.substring(0, 100) }}"
},
"position": [2250, 400]
},
{
"name": "Create PagerDuty Incident",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://events.pagerduty.com/v2/enqueue",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"jsonParameters": true,
"options": {},
"bodyParametersJson": "={\n \"routing_key\": \"{{ $env.PAGERDUTY_ROUTING_KEY }}\",\n \"event_action\": \"trigger\",\n \"dedup_key\": \"{{ $json.alert.id }}\",\n \"payload\": {\n \"summary\": \"{{ $json.alert.title }}\",\n \"severity\": \"{{ $json.alert.severity === 'critical' ? 'critical' : $json.alert.severity === 'high' ? 'error' : 'warning' }}\",\n \"source\": \"{{ $json.alert.source }}\",\n \"custom_details\": {{ JSON.stringify($json.alert.metadata || {}) }}\n }\n}"
},
"position": [2250, 550]
}
],
"connections": {
"Webhook Trigger": {
"main": [[{"node": "Normalize Alert", "type": "main", "index": 0}]]
},
"Normalize Alert": {
"main": [[{"node": "Check Dedup Cache", "type": "main", "index": 0}]]
},
"Check Dedup Cache": {
"main": [[{"node": "Is Duplicate?", "type": "main", "index": 0}]]
},
"Is Duplicate?": {
"main": [
[{"node": "Increment Counter", "type": "main", "index": 0}],
[{"node": "New Alert - Set Cache", "type": "main", "index": 0}]
]
},
"Increment Counter": {
"main": [[{"node": "Update Dedup Cache (Dup)", "type": "main", "index": 0}]]
},
"Update Dedup Cache (Dup)": {
"main": [[{"node": "Should Send Aggregate?", "type": "main", "index": 0}]]
},
"Should Send Aggregate?": {
"main": [
[{"node": "Create Aggregate Alert", "type": "main", "index": 0}],
[]
]
},
"Create Aggregate Alert": {
"main": [[{"node": "Route to Channels", "type": "main", "index": 0}]]
},
"New Alert - Set Cache": {
"main": [[{"node": "Route to Channels", "type": "main", "index": 0}]]
},
"Route to Channels": {
"main": [[{"node": "Channel Router", "type": "main", "index": 0}]]
},
"Channel Router": {
"main": [
[{"node": "Send Slack", "type": "main", "index": 0}],
[{"node": "Send Email", "type": "main", "index": 0}],
[{"node": "Send SMS", "type": "main", "index": 0}],
[{"node": "Create PagerDuty Incident", "type": "main", "index": 0}]
]
}
}
}Escalation Policy Workflow
// escalation-policy.js
/**
* Escalation policy implementation for n8n.
*/
const ESCALATION_POLICIES = {
'default': {
name: 'Default Escalation',
levels: [
{
level: 1,
delay_minutes: 0,
targets: [{ type: 'schedule', id: 'primary_oncall' }],
channels: ['slack', 'email']
},
{
level: 2,
delay_minutes: 15,
targets: [{ type: 'schedule', id: 'secondary_oncall' }],
channels: ['slack', 'sms', 'email']
},
{
level: 3,
delay_minutes: 30,
targets: [
{ type: 'schedule', id: 'manager_oncall' },
{ type: 'user', id: 'eng_manager' }
],
channels: ['slack', 'sms', 'phone', 'email']
}
],
repeat_interval_minutes: 60,
max_repeats: 3
},
'critical': {
name: 'Critical Escalation',
levels: [
{
level: 1,
delay_minutes: 0,
targets: [
{ type: 'schedule', id: 'primary_oncall' },
{ type: 'schedule', id: 'secondary_oncall' }
],
channels: ['slack', 'sms', 'phone']
},
{
level: 2,
delay_minutes: 5,
targets: [
{ type: 'schedule', id: 'manager_oncall' },
{ type: 'user', id: 'eng_manager' },
{ type: 'user', id: 'cto' }
],
channels: ['slack', 'sms', 'phone']
}
],
repeat_interval_minutes: 15,
max_repeats: 10
}
};
/**
* Get current on-call user from schedule
*/
async function getOnCallUser(scheduleId) {
// In production, this would query PagerDuty, OpsGenie, or internal schedule
const schedules = {
'primary_oncall': { user_id: 'user_001', name: 'Alice', phone: '+1111111111' },
'secondary_oncall': { user_id: 'user_002', name: 'Bob', phone: '+2222222222' },
'manager_oncall': { user_id: 'user_003', name: 'Carol', phone: '+3333333333' }
};
return schedules[scheduleId];
}
/**
* Determine escalation policy based on alert
*/
function getEscalationPolicy(alert) {
if (alert.severity === 'critical') {
return ESCALATION_POLICIES['critical'];
}
return ESCALATION_POLICIES['default'];
}
/**
* Create escalation state for tracking
*/
function createEscalationState(alert, policy) {
return {
alert_id: alert.id,
policy_name: policy.name,
current_level: 0,
started_at: new Date().toISOString(),
acknowledged: false,
acknowledged_by: null,
acknowledged_at: null,
resolved: false,
resolved_at: null,
escalation_history: [],
repeat_count: 0
};
}
/**
* Process escalation step
*/
async function processEscalation(state, alert, policy) {
const currentLevel = policy.levels[state.current_level];
if (!currentLevel) {
// All levels exhausted, check for repeat
if (state.repeat_count < policy.max_repeats) {
state.current_level = 0;
state.repeat_count++;
state.escalation_history.push({
action: 'repeat',
repeat_number: state.repeat_count,
timestamp: new Date().toISOString()
});
return processEscalation(state, alert, policy);
}
return { done: true, state };
}
// Resolve targets to actual users
const notifications = [];
for (const target of currentLevel.targets) {
let user;
if (target.type === 'schedule') {
user = await getOnCallUser(target.id);
} else if (target.type === 'user') {
user = { user_id: target.id }; // Would fetch from user service
}
if (user) {
for (const channel of currentLevel.channels) {
notifications.push({
user,
channel,
alert,
escalation_level: currentLevel.level
});
}
}
}
state.escalation_history.push({
action: 'notify',
level: currentLevel.level,
targets: currentLevel.targets,
channels: currentLevel.channels,
timestamp: new Date().toISOString()
});
// Schedule next escalation
const nextEscalation = {
scheduled_for: new Date(Date.now() + currentLevel.delay_minutes * 60 * 1000).toISOString(),
next_level: state.current_level + 1
};
return {
done: false,
notifications,
nextEscalation,
state
};
}
/**
* Handle acknowledgment
*/
function acknowledgeAlert(state, userId) {
state.acknowledged = true;
state.acknowledged_by = userId;
state.acknowledged_at = new Date().toISOString();
state.escalation_history.push({
action: 'acknowledged',
user_id: userId,
timestamp: state.acknowledged_at
});
return state;
}
/**
* Handle resolution
*/
function resolveAlert(state, userId) {
state.resolved = true;
state.resolved_at = new Date().toISOString();
state.escalation_history.push({
action: 'resolved',
user_id: userId,
timestamp: state.resolved_at
});
return state;
}
module.exports = {
ESCALATION_POLICIES,
getEscalationPolicy,
createEscalationState,
processEscalation,
acknowledgeAlert,
resolveAlert,
getOnCallUser
};Notification Templates
// notification-templates.js
/**
* Notification templates for different scenarios.
*/
const templates = {
// System alerts
'system.high_cpu': {
title: 'High CPU Usage Alert',
message: 'Server {{server_name}} CPU usage is at {{cpu_percent}}%',
severity: 'high',
channels: ['slack', 'email']
},
'system.disk_space': {
title: 'Low Disk Space Warning',
message: 'Server {{server_name}} disk usage is at {{disk_percent}}% on {{mount_point}}',
severity: 'medium',
channels: ['slack', 'email']
},
'system.service_down': {
title: 'Service Down',
message: 'Service {{service_name}} is not responding on {{server_name}}',
severity: 'critical',
channels: ['slack', 'sms', 'pagerduty']
},
// Application alerts
'app.error_rate': {
title: 'High Error Rate',
message: 'Application {{app_name}} error rate is {{error_rate}}% (threshold: {{threshold}}%)',
severity: 'high',
channels: ['slack', 'email']
},
'app.latency': {
title: 'High Latency Detected',
message: 'Application {{app_name}} p99 latency is {{latency_ms}}ms (threshold: {{threshold_ms}}ms)',
severity: 'medium',
channels: ['slack']
},
// Business alerts
'business.payment_failure': {
title: 'Payment Processing Failure',
message: 'Payment processing failure rate is {{failure_rate}}% in the last {{time_window}}',
severity: 'critical',
channels: ['slack', 'sms', 'email', 'pagerduty']
},
'business.signup_anomaly': {
title: 'Signup Anomaly Detected',
message: 'Unusual signup activity detected: {{signup_count}} signups in {{time_window}} ({{percent_change}}% vs average)',
severity: 'medium',
channels: ['slack', 'email']
},
// Security alerts
'security.auth_failure': {
title: 'Authentication Failure Spike',
message: 'Detected {{failure_count}} failed authentication attempts from IP {{source_ip}}',
severity: 'high',
channels: ['slack', 'email', 'security_team']
},
'security.suspicious_activity': {
title: 'Suspicious Activity Detected',
message: 'Suspicious activity detected: {{activity_description}} for user {{user_id}}',
severity: 'high',
channels: ['slack', 'email', 'security_team']
}
};
/**
* Render a template with variables
*/
function renderTemplate(templateId, variables) {
const template = templates[templateId];
if (!template) {
throw new Error(`Unknown template: ${templateId}`);
}
let title = template.title;
let message = template.message;
// Replace variables
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
title = title.replace(new RegExp(placeholder, 'g'), value);
message = message.replace(new RegExp(placeholder, 'g'), value);
}
return {
...template,
title,
message,
template_id: templateId,
rendered_at: new Date().toISOString()
};
}
/**
* Validate template variables
*/
function validateTemplateVariables(templateId, variables) {
const template = templates[templateId];
if (!template) {
return { valid: false, error: `Unknown template: ${templateId}` };
}
const requiredVars = [];
const combinedText = template.title + template.message;
const matches = combinedText.match(/\{\{(\w+)\}\}/g) || [];
for (const match of matches) {
const varName = match.replace(/[{}]/g, '');
if (!requiredVars.includes(varName)) {
requiredVars.push(varName);
}
}
const missingVars = requiredVars.filter(v => !(v in variables));
if (missingVars.length > 0) {
return {
valid: false,
error: `Missing required variables: ${missingVars.join(', ')}`
};
}
return { valid: true };
}
module.exports = {
templates,
renderTemplate,
validateTemplateVariables
};Conclusion
Building effective notification systems with n8n requires:
- Intelligent routing based on severity, user preferences, and time of day
- Alert aggregation to prevent notification fatigue from similar alerts
- Escalation policies ensuring critical alerts reach someone
- Multi-channel delivery with appropriate formatting for each channel
- Template management for consistent, informative notifications
These patterns create notification systems that are reliable, actionable, and respect users' attention while ensuring critical issues are never missed.