DEV Community

Alex Kane
Alex Kane

Posted on

n8n for Energy & Utilities: 5 Automations That Reduce Outages and Cut Ops Cost (Free Workflow JSON)

Whether you're managing a regional utility, overseeing smart grid infrastructure, or running energy operations — you're buried in manual processes that don't need to be manual.

Meter anomalies. Outage responses. Maintenance schedules. Billing exceptions. Weekly KPI reports.

Here are 5 complete n8n workflows — with import-ready JSON — built specifically for energy and utility operations teams.

All free. All self-hosted. No Zapier bill. No customer consumption data touching third-party cloud. Full NERC CIP-compatible audit trail in git.


1. Smart Meter Anomaly Alert & Demand Response Notifier

The problem: Meter reading anomalies — usage spikes, zero reads on active accounts, sudden drops — sit undetected until the next billing cycle.

What this does: Runs hourly. Pulls meter readings from Sheets or your data source. Flags anomalies: usage >150% of 7-day average, zero reads on active meters, or delta >3x previous hour. Fires Slack alert to #grid-ops with meter ID, location, reading, and flag type. Logs to audit sheet.

{
  "name": "Smart Meter Anomaly Alert",
  "nodes": [
    {
      "parameters": { "rule": { "interval": [{ "field": "hours", "hoursInterval": 1 }] } },
      "name": "Every Hour",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [250, 300]
    },
    {
      "parameters": {
        "documentId": "YOUR_SHEET_ID",
        "sheetName": "meter_readings",
        "options": {}
      },
      "name": "Get Meter Readings",
      "type": "n8n-nodes-base.googleSheets",
      "position": [450, 300]
    },
    {
      "parameters": {
        "jsCode": "const rows = $input.all();\nconst anomalies = [];\nfor (const row of rows) {\n  const d = row.json;\n  const usage = parseFloat(d.current_kwh || 0);\n  const avg = parseFloat(d.avg_7day_kwh || 1);\n  const ratio = usage / avg;\n  if (ratio > 1.5 || usage === 0) {\n    anomalies.push({ meter_id: d.meter_id, location: d.location,\n      current_kwh: usage, avg_kwh: avg, ratio: ratio.toFixed(2),\n      flag: usage === 0 ? 'ZERO_READ' : 'SPIKE' });\n  }\n}\nreturn anomalies.map(a => ({ json: a }));"
      },
      "name": "Detect Anomalies",
      "type": "n8n-nodes-base.code",
      "position": [650, 300]
    },
    {
      "parameters": {
        "channel": "#grid-ops",
        "text": "=:rotating_light: *Meter Anomaly*\n*Meter:* {{ $json.meter_id }}\n*Location:* {{ $json.location }}\n*Flag:* {{ $json.flag }}\n*Reading:* {{ $json.current_kwh }} kWh (avg: {{ $json.avg_kwh }}, ratio: {{ $json.ratio }}x)"
      },
      "name": "Slack Alert",
      "type": "n8n-nodes-base.slack",
      "position": [850, 300]
    }
  ],
  "connections": {
    "Every Hour": { "main": [[{ "node": "Get Meter Readings", "type": "main", "index": 0 }]] },
    "Get Meter Readings": { "main": [[{ "node": "Detect Anomalies", "type": "main", "index": 0 }]] },
    "Detect Anomalies": { "main": [[{ "node": "Slack Alert", "type": "main", "index": 0 }]] }
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Outage Detection & Customer Communication Pipeline

The problem: When an outage hits, ops is manually calling the call center, who is manually emailing customers, while the grid team is manually updating the status page. 20 minutes of chaos before a single customer gets notified.

What this does: Webhook receives event from your SCADA/ADMS/field reporting app. Classifies severity by affected customer count. CRITICAL: fires Slack to #outage-room, triggers batch email to affected zone, logs incident with timestamp, area, and estimated restoration time. Auto-escalates if unacknowledged after 15 min.

{
  "name": "Outage Response Pipeline",
  "nodes": [
    {
      "parameters": { "httpMethod": "POST", "path": "outage-event", "responseMode": "lastNode" },
      "name": "Outage Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [250, 300]
    },
    {
      "parameters": {
        "jsCode": "const d = $json.body || $json;\nconst customers = parseInt(d.affected_customers || 0);\nconst severity = customers > 1000 ? 'CRITICAL' : customers > 100 ? 'HIGH' : 'MEDIUM';\nreturn [{ json: { ...d, severity, ts: new Date().toISOString() } }];"
      },
      "name": "Classify Severity",
      "type": "n8n-nodes-base.code",
      "position": [450, 300]
    },
    {
      "parameters": {
        "conditions": { "string": [{ "value1": "={{ $json.severity }}", "operation": "equal", "value2": "CRITICAL" }] }
      },
      "name": "Is Critical?",
      "type": "n8n-nodes-base.if",
      "position": [650, 300]
    },
    {
      "parameters": {
        "channel": "#outage-room",
        "text": "=:red_circle: *CRITICAL OUTAGE*\n*Area:* {{ $json.area }}\n*Affected:* {{ $json.affected_customers }} customers\n*ERT:* {{ $json.estimated_restoration }}\n*Cause:* {{ $json.cause || 'Under Investigation' }}\n*ID:* {{ $json.incident_id }}"
      },
      "name": "Slack Outage Room",
      "type": "n8n-nodes-base.slack",
      "position": [900, 200]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": "YOUR_SHEET_ID",
        "sheetName": "outage_log",
        "columns": { "mappingMode": "defineBelow", "value": { "ts": "={{ $json.ts }}", "area": "={{ $json.area }}", "severity": "={{ $json.severity }}", "affected": "={{ $json.affected_customers }}", "ert": "={{ $json.estimated_restoration }}" } }
      },
      "name": "Log Incident",
      "type": "n8n-nodes-base.googleSheets",
      "position": [900, 400]
    }
  ],
  "connections": {
    "Outage Webhook": { "main": [[{ "node": "Classify Severity", "type": "main", "index": 0 }]] },
    "Classify Severity": { "main": [[{ "node": "Is Critical?", "type": "main", "index": 0 }]] },
    "Is Critical?": {
      "main": [
        [{ "node": "Slack Outage Room", "type": "main", "index": 0 }],
        [{ "node": "Log Incident", "type": "main", "index": 0 }]
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Billing Exception Handler

The problem: Billing runs complete but exceptions — zero-amount bills on active accounts, usage 3x above average, payment failures — pile up in a queue that someone reviews manually every morning.

What this does: Runs weekdays at 8AM. Reads billing run results. Flags: zero-amount bills on active accounts, bills >200% of 6-month average, payment method failures. Routes each exception to Slack #billing-ops with account ID, flag type, and amounts. Generates HTML exception summary email to billing manager.

{
  "name": "Billing Exception Handler",
  "nodes": [
    {
      "parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 8 * * 1-5" }] } },
      "name": "Weekdays 8AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [250, 300]
    },
    {
      "parameters": { "documentId": "YOUR_SHEET_ID", "sheetName": "billing_runs", "options": {} },
      "name": "Get Billing Data",
      "type": "n8n-nodes-base.googleSheets",
      "position": [450, 300]
    },
    {
      "parameters": {
        "jsCode": "const rows = $input.all();\nconst exceptions = rows\n  .filter(r => {\n    const d = r.json;\n    const amt = parseFloat(d.bill_amount || 0);\n    const avg = parseFloat(d.avg_6month || 1);\n    return d.account_status === 'active' &&\n      (amt === 0 || amt > avg * 2 || d.payment_status === 'failed');\n  })\n  .map(r => ({\n    ...r.json,\n    flag: r.json.bill_amount == 0 ? 'ZERO_BILL'\n      : parseFloat(r.json.bill_amount) > parseFloat(r.json.avg_6month || 1) * 2 ? 'SPIKE'\n      : 'PAYMENT_FAIL'\n  }));\nreturn exceptions.map(e => ({ json: e }));"
      },
      "name": "Flag Exceptions",
      "type": "n8n-nodes-base.code",
      "position": [650, 300]
    },
    {
      "parameters": {
        "channel": "#billing-ops",
        "text": "=:warning: *Billing Exception*\n*Account:* {{ $json.account_id }}\n*Flag:* {{ $json.flag }}\n*Amount:* ${{ $json.bill_amount }} (6-mo avg: ${{ $json.avg_6month }})"
      },
      "name": "Slack Alert",
      "type": "n8n-nodes-base.slack",
      "position": [850, 300]
    }
  ],
  "connections": {
    "Weekdays 8AM": { "main": [[{ "node": "Get Billing Data", "type": "main", "index": 0 }]] },
    "Get Billing Data": { "main": [[{ "node": "Flag Exceptions", "type": "main", "index": 0 }]] },
    "Flag Exceptions": { "main": [[{ "node": "Slack Alert", "type": "main", "index": 0 }]] }
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Preventive Maintenance Scheduler & Technician Notifier

The problem: Equipment maintenance schedules are in spreadsheets. Supervisors manually check them and send reminder emails. Things get missed. Assets fail.

What this does: Runs daily at 7AM. Reads your maintenance schedule. Calculates days until next maintenance for every asset. Assets due within 14 days trigger: personalized email to assigned technician (equipment name, location, maintenance type, due date), post to Slack #maintenance, log to audit sheet. Urgency tiers: CRITICAL (≤3d), URGENT (≤7d), DUE_SOON (≤14d).

{
  "name": "Preventive Maintenance Notifier",
  "nodes": [
    {
      "parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 7 * * *" }] } },
      "name": "Daily 7AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [250, 300]
    },
    {
      "parameters": { "documentId": "YOUR_SHEET_ID", "sheetName": "maintenance_schedule", "options": {} },
      "name": "Get Schedule",
      "type": "n8n-nodes-base.googleSheets",
      "position": [450, 300]
    },
    {
      "parameters": {
        "jsCode": "const today = new Date();\nreturn $input.all()\n  .map(r => {\n    const due = new Date(r.json.next_maintenance_date);\n    const daysUntil = Math.ceil((due - today) / 86400000);\n    return { json: { ...r.json, days_until: daysUntil,\n      urgency: daysUntil <= 3 ? 'CRITICAL' : daysUntil <= 7 ? 'URGENT' : 'DUE_SOON' } };\n  })\n  .filter(r => r.json.days_until >= 0 && r.json.days_until <= 14);"
      },
      "name": "Filter Due Items",
      "type": "n8n-nodes-base.code",
      "position": [650, 300]
    },
    {
      "parameters": {
        "toEmail": "={{ $json.technician_email }}",
        "subject": "=[{{ $json.urgency }}] Maintenance Due in {{ $json.days_until }}d: {{ $json.equipment_name }}",
        "emailType": "html",
        "message": "=<h2>Scheduled Maintenance Reminder</h2><p>Hi {{ $json.technician_name }},</p><p>Maintenance is due in <strong>{{ $json.days_until }} days</strong>:</p><ul><li><strong>Equipment:</strong> {{ $json.equipment_name }}</li><li><strong>Location:</strong> {{ $json.location }}</li><li><strong>Type:</strong> {{ $json.maintenance_type }}</li><li><strong>Due:</strong> {{ $json.next_maintenance_date }}</li></ul>"
      },
      "name": "Email Technician",
      "type": "n8n-nodes-base.gmail",
      "position": [850, 250]
    },
    {
      "parameters": {
        "channel": "#maintenance",
        "text": "=[{{ $json.urgency }}] {{ $json.equipment_name }} at {{ $json.location }} — maintenance due in {{ $json.days_until }} days ({{ $json.maintenance_type }}). Assigned: {{ $json.technician_name }}"
      },
      "name": "Slack Maintenance",
      "type": "n8n-nodes-base.slack",
      "position": [850, 400]
    }
  ],
  "connections": {
    "Daily 7AM": { "main": [[{ "node": "Get Schedule", "type": "main", "index": 0 }]] },
    "Get Schedule": { "main": [[{ "node": "Filter Due Items", "type": "main", "index": 0 }]] },
    "Filter Due Items": { "main": [[{ "node": "Email Technician", "type": "main", "index": 0 }], [{ "node": "Slack Maintenance", "type": "main", "index": 0 }]] }
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Weekly Grid Performance & KPI Report

The problem: The weekly ops report takes the grid manager 2+ hours every Monday — pulling from 4 spreadsheets, calculating SAIDI/CAIDI, formatting tables, pasting into a report.

What this does: Runs every Monday at 8AM. Pulls data from operational sheets. Calculates availability %, SAIDI, total outage hours, peak demand MW, and WoW changes. Builds clean HTML report table. Emails to ops director and section heads. Posts one-line Slack summary to #grid-ops.

{
  "name": "Weekly Grid Performance Report",
  "nodes": [
    {
      "parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 8 * * 1" }] } },
      "name": "Monday 8AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [250, 300]
    },
    {
      "parameters": { "documentId": "YOUR_SHEET_ID", "sheetName": "weekly_ops", "options": {} },
      "name": "Get Ops Data",
      "type": "n8n-nodes-base.googleSheets",
      "position": [450, 300]
    },
    {
      "parameters": {
        "jsCode": "const rows = $input.all().map(r => r.json);\nconst tw = rows[rows.length - 1];\nconst lw = rows[rows.length - 2] || tw;\nconst saidi = parseFloat(tw.saidi_minutes || 0);\nconst outageHrs = parseFloat(tw.total_outage_hours || 0);\nconst availability = (100 - (outageHrs / 168 * 100)).toFixed(2);\nconst peak = parseFloat(tw.peak_demand_mw || 0);\nconst saidiWoW = lw.saidi_minutes ? ((saidi - parseFloat(lw.saidi_minutes)) / parseFloat(lw.saidi_minutes) * 100).toFixed(1) : 'N/A';\nconst html = `<h2>Weekly Grid Performance Report</h2><table border='1' cellpadding='8' style='border-collapse:collapse;font-family:sans-serif'><tr style='background:#1a1a2e;color:#fff'><th>Metric</th><th>This Week</th><th>WoW</th></tr><tr><td>System Availability</td><td>${availability}%</td><td>-</td></tr><tr><td>SAIDI (minutes)</td><td>${saidi}</td><td>${saidiWoW}%</td></tr><tr><td>Total Outage Hours</td><td>${outageHrs}h</td><td>-</td></tr><tr><td>Peak Demand</td><td>${peak} MW</td><td>-</td></tr></table>`;\nreturn [{ json: { html, saidi, availability, outageHrs, peak, saidiWoW } }];"
      },
      "name": "Calculate KPIs",
      "type": "n8n-nodes-base.code",
      "position": [650, 300]
    },
    {
      "parameters": {
        "toEmail": "ops-director@utility.com",
        "subject": "=Weekly Grid Performance Report — {{ $now.format('MMM DD, YYYY') }}",
        "emailType": "html",
        "message": "={{ $json.html }}"
      },
      "name": "Email Report",
      "type": "n8n-nodes-base.gmail",
      "position": [900, 250]
    },
    {
      "parameters": {
        "channel": "#grid-ops",
        "text": "=Weekly Grid Report: Availability {{ $json.availability }}% | SAIDI {{ $json.saidi }} min ({{ $json.saidiWoW }}% WoW) | Outages {{ $json.outageHrs }}h | Peak {{ $json.peak }} MW"
      },
      "name": "Slack Summary",
      "type": "n8n-nodes-base.slack",
      "position": [900, 400]
    }
  ],
  "connections": {
    "Monday 8AM": { "main": [[{ "node": "Get Ops Data", "type": "main", "index": 0 }]] },
    "Get Ops Data": { "main": [[{ "node": "Calculate KPIs", "type": "main", "index": 0 }]] },
    "Calculate KPIs": { "main": [[{ "node": "Email Report", "type": "main", "index": 0 }], [{ "node": "Slack Summary", "type": "main", "index": 0 }]] }
  }
}
Enter fullscreen mode Exit fullscreen mode

Why n8n for Energy & Utilities?

Feature n8n Zapier Make.com
Self-hosted (data stays on-prem)
NERC CIP compliance support
SCADA/OT data integration Limited Limited
Custom code for signal processing Limited
Git-versioned workflows (audit trail)
Cost at 50k ops/month $0 (self-hosted) $49/mo $29/mo

For utility operations, self-hosted n8n isn't optional — SCADA data, grid topology, customer consumption patterns, and outage histories are critical infrastructure data that must stay inside your network. Routing them through Zapier or Make.com's cloud servers creates compliance exposure.

n8n's JSON workflows are also git-versionable — every change is auditable, which matters for NERC CIP and utility regulatory requirements.


Get ready-to-import n8n workflow templates

If you want these workflows ready to import (not manually rebuild from scratch), I've packaged them at FlowKit — n8n Automation Templates.

Individual templates: $12–$29. Full bundle (15 templates): $97.

Includes the Daily Report Generator, Appointment Reminder, Email Auto-Responder, Price Monitor, AI Customer Support Bot, Lead Capture to CRM, and 9 more — all pre-built, documented, and ready to customize.


Which of these would save your team the most time? Drop it in the comments.

Top comments (0)