DEV Community

Alex Kane
Alex Kane

Posted on

n8n for Government & Public Sector: 5 Automations That Modernize Citizen Services (Free Workflow JSON)

Public sector IT teams face a unique paradox: massive operational complexity, chronic understaffing, and zero tolerance for errors. A missed permit deadline means a constituent waits months. A late emergency alert can have serious consequences.

n8n — the open-source workflow automation platform — is increasingly adopted by government agencies and municipalities because it can be self-hosted (data never leaves your infrastructure), it's auditable (JSON-based workflows are human-readable), and it costs $0 on existing government servers.

Here are 5 automations that address real public-sector pain points, with complete workflow JSON you can import directly.


1. Citizen Inquiry Auto-Triage

The problem: City hall inboxes receive hundreds of mixed inquiries daily — permit questions, service requests, complaints, general info. Staff manually read and route each one.

The solution: Automatically classify and route emails by department.

{
  "name": "Citizen Inquiry Auto-Triage",
  "nodes": [
    {
      "parameters": {
        "operation": "getAll",
        "filters": {"readStatus": "unread"},
        "options": {"maxResults": 25}
      },
      "name": "Gmail Trigger",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "jsCode": "const email = $json;\nconst combined = ((email.subject || '') + ' ' + (email.textPlain || '')).toLowerCase();\n\nlet department = 'general';\nlet priority = 'normal';\n\nif (/permit|build|construct|zoning/.test(combined)) department = 'permits';\nelse if (/road|pothole|streetlight|sidewalk/.test(combined)) department = 'public_works';\nelse if (/water|sewage|waste|trash/.test(combined)) department = 'utilities';\nelse if (/emergency|urgent|danger|hazard/.test(combined)) { department = 'emergency'; priority = 'high'; }\nelse if (/complain|feedback|suggest/.test(combined)) department = 'constituent_services';\n\nif (/urgent|immediate|asap|critical/.test(combined)) priority = 'high';\n\nreturn { ...email, department, priority, routedAt: new Date().toISOString() };"
      },
      "name": "Classify Inquiry",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [460, 300]
    },
    {
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": "YOUR_SHEETS_ID",
        "sheetName": "Inquiries",
        "columns": {"mappingMode": "autoMapInputData"}
      },
      "name": "Log to Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [680, 300]
    },
    {
      "parameters": {
        "webhookUri": "https://hooks.slack.com/services/YOUR/WEBHOOK/HERE",
        "text": "=📧 New {{$json.priority === 'high' ? '🚨 HIGH PRIORITY ' : ''}}Inquiry → *{{$json.department.toUpperCase().replace('_',' ')}}*\nFrom: {{$json.from}}\nSubject: {{$json.subject}}\nTime: {{$json.routedAt}}"
      },
      "name": "Slack Department Alert",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 1,
      "position": [900, 300]
    }
  ],
  "connections": {
    "Gmail Trigger": {"main": [[{"node": "Classify Inquiry", "type": "main", "index": 0}]]},
    "Classify Inquiry": {"main": [[{"node": "Log to Sheets", "type": "main", "index": 0}]]},
    "Log to Sheets": {"main": [[{"node": "Slack Department Alert", "type": "main", "index": 0}]]}
  }
}
Enter fullscreen mode Exit fullscreen mode

Setup (5 min): Connect Gmail Trigger to your public inquiry inbox. Replace YOUR_SHEETS_ID and Slack webhook. Activate.

Pro tips:

  • Add AI classification (Claude Haiku or GPT-4o-mini) for nuanced routing beyond keywords
  • Add an auto-acknowledgment email: "Your inquiry has been received and routed. Expected response: 3–5 business days."
  • Route to a Jira Service Management or Freshdesk ticket instead of Slack for formal tracking

2. FOIA / Open Records Request Deadline Tracker

The problem: FOIA and open records requests have legal deadlines (often 10–20 business days). Missing them triggers mandatory fee waivers, legal complaints, and political fallout.

The solution: Automated deadline tracking with escalating alerts at 7, 3, and 1 days.

{
  "name": "FOIA Request Deadline Tracker",
  "nodes": [
    {
      "parameters": {
        "rule": {"interval": [{"field": "cronExpression", "expression": "0 8 * * 1-5"}]}
      },
      "name": "Weekdays 8AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "operation": "getAll",
        "documentId": "YOUR_SHEETS_ID",
        "sheetName": "FOIA_Requests"
      },
      "name": "Fetch Open Requests",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [460, 300]
    },
    {
      "parameters": {
        "jsCode": "const today = new Date();\nconst requests = $input.all().map(i => i.json);\nconst alerts = [];\n\nfor (const req of requests) {\n  if (req.status === 'COMPLETED') continue;\n  const deadline = new Date(req.deadline_date);\n  const daysLeft = Math.ceil((deadline - today) / (1000 * 60 * 60 * 24));\n  \n  let alertLevel = null;\n  if (daysLeft <= 1) alertLevel = 'DUE TODAY / OVERDUE';\n  else if (daysLeft <= 3) alertLevel = 'CRITICAL — 3 days';\n  else if (daysLeft <= 7) alertLevel = 'WARNING — 7 days';\n  \n  if (alertLevel) {\n    alerts.push({ ...req, days_left: daysLeft, alert_level: alertLevel });\n  }\n}\n\nreturn alerts.map(a => ({ json: a }));"
      },
      "name": "Check Deadlines",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [680, 300]
    },
    {
      "parameters": {
        "toEmail": "={{$json.assigned_to_email}}",
        "subject": "=⚠️ FOIA {{$json.request_id}} — {{$json.alert_level}}",
        "message": "=FOIA/Open Records Request Deadline Alert\n\nRequest ID: {{$json.request_id}}\nRequester: {{$json.requester_name}}\nDescription: {{$json.description}}\nDeadline: {{$json.deadline_date}}\nDays Remaining: {{$json.days_left}}\nStatus: {{$json.alert_level}}\n\nPlease update the tracking sheet or escalate immediately if unable to fulfill by deadline.",
        "options": {}
      },
      "name": "Alert Assigned Staff",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2,
      "position": [900, 300]
    }
  ],
  "connections": {
    "Weekdays 8AM": {"main": [[{"node": "Fetch Open Requests", "type": "main", "index": 0}]]},
    "Fetch Open Requests": {"main": [[{"node": "Check Deadlines", "type": "main", "index": 0}]]},
    "Check Deadlines": {"main": [[{"node": "Alert Assigned Staff", "type": "main", "index": 0}]]}
  }
}
Enter fullscreen mode Exit fullscreen mode

Sheets columns needed: request_id, requester_name, description, assigned_to, assigned_to_email, deadline_date, status


3. Permit & Business License Renewal Alert System

The problem: Thousands of business licenses and contractor certifications expire each year. Manual tracking leads to complaints and fines when licenses lapse unnoticed.

The solution: Automated 60/30/14/7-day renewal reminders sent directly to license holders.

{
  "name": "Permit & License Renewal Alert",
  "nodes": [
    {
      "parameters": {
        "rule": {"interval": [{"field": "cronExpression", "expression": "0 7 * * *"}]}
      },
      "name": "Daily 7AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "operation": "getAll",
        "documentId": "YOUR_SHEETS_ID",
        "sheetName": "Licenses"
      },
      "name": "Fetch Active Licenses",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [460, 300]
    },
    {
      "parameters": {
        "jsCode": "const today = new Date();\nconst licenses = $input.all().map(i => i.json);\nconst alerts = [];\n\nfor (const lic of licenses) {\n  if (lic.status !== 'ACTIVE') continue;\n  const exp = new Date(lic.expiration_date);\n  const days = Math.ceil((exp - today) / (1000 * 60 * 60 * 24));\n  \n  if ([60, 30, 14, 7].includes(days)) {\n    alerts.push({\n      ...lic,\n      days_remaining: days,\n      renewal_link: lic.renewal_url || 'https://permits.yourcity.gov/renew'\n    });\n  }\n}\n\nreturn alerts.map(a => ({ json: a }));"
      },
      "name": "Find Expiring Licenses",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [680, 300]
    },
    {
      "parameters": {
        "toEmail": "={{$json.holder_email}}",
        "subject": "=Action Required: {{$json.license_type}} Renewal — {{$json.days_remaining}} Days Remaining",
        "message": "=Dear {{$json.business_name}},\n\nYour {{$json.license_type}} (License #{{$json.license_id}}) expires on {{$json.expiration_date}}.\n\nYou have {{$json.days_remaining}} days remaining to renew.\n\nRenew online: {{$json.renewal_link}}\n\nCity of [Your City] — Department of Licensing & Inspections",
        "options": {}
      },
      "name": "Send Renewal Notice",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2,
      "position": [900, 300]
    }
  ],
  "connections": {
    "Daily 7AM": {"main": [[{"node": "Fetch Active Licenses", "type": "main", "index": 0}]]},
    "Fetch Active Licenses": {"main": [[{"node": "Find Expiring Licenses", "type": "main", "index": 0}]]},
    "Find Expiring Licenses": {"main": [[{"node": "Send Renewal Notice", "type": "main", "index": 0}]]}
  }
}
Enter fullscreen mode Exit fullscreen mode

Sheets columns needed: license_id, business_name, license_type, holder_email, expiration_date, renewal_url, status


4. Emergency & Public Safety Alert Escalation

The problem: When an incident is reported — road hazard, utility outage, public safety event — the right people need to know in minutes, not after a chain of phone calls.

The solution: Webhook-triggered severity classification and instant escalation.

{
  "name": "Emergency Alert Escalation",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "incident-report",
        "responseMode": "responseNode"
      },
      "name": "Incident Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "jsCode": "const report = $json.body;\nconst desc = (report.description || '').toLowerCase();\n\nlet severity = 'LOW';\nif (/injury|accident|collapse|fire|gas.?leak|flood/.test(desc)) severity = 'CRITICAL';\nelse if (/road.?closed|power.?out|water.?main|traffic.?signal/.test(desc)) severity = 'HIGH';\nelse if (/pothole|streetlight|graffiti/.test(desc)) severity = 'MEDIUM';\n\nreturn { ...report, severity, incident_id: 'INC-' + Date.now(), received_at: new Date().toISOString() };"
      },
      "name": "Classify Severity",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [460, 300]
    },
    {
      "parameters": {
        "conditions": {"conditions": [{"leftValue": "={{$json.severity}}", "rightValue": "CRITICAL", "operator": {"type": "string", "operation": "equals"}}]}
      },
      "name": "IF Critical",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [680, 300]
    },
    {
      "parameters": {
        "webhookUri": "https://hooks.slack.com/services/YOUR/EMERGENCY/WEBHOOK",
        "text": "=🚨 *CRITICAL INCIDENT* — ID: {{$json.incident_id}}\nType: {{$json.incident_type}}\nLocation: {{$json.location}}\nDetails: {{$json.description}}\n@channel — Immediate response required"
      },
      "name": "Slack Emergency Channel",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 1,
      "position": [900, 200]
    },
    {
      "parameters": {
        "webhookUri": "https://hooks.slack.com/services/YOUR/OPS/WEBHOOK",
        "text": "=⚠️ Incident [{{$json.severity}}] — ID: {{$json.incident_id}}\nLocation: {{$json.location}}\nDetails: {{$json.description}}"
      },
      "name": "Slack Ops Channel",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 1,
      "position": [900, 400]
    }
  ],
  "connections": {
    "Incident Webhook": {"main": [[{"node": "Classify Severity", "type": "main", "index": 0}]]},
    "Classify Severity": {"main": [[{"node": "IF Critical", "type": "main", "index": 0}]]},
    "IF Critical": {"main": [[{"node": "Slack Emergency Channel", "type": "main", "index": 0}], [{"node": "Slack Ops Channel", "type": "main", "index": 0}]]}
  }
}
Enter fullscreen mode Exit fullscreen mode

Add an auto-acknowledgment Respond to Webhook node (HTTP 200 with an incident ID) so the reporting person knows it was received.


5. Weekly Government Operations Dashboard

The problem: Department heads need a unified operations view, but data lives in different systems. Compiling a weekly report manually takes hours.

The solution: Automated Monday morning briefing assembled from your tracking sheets.

{
  "name": "Weekly Government Ops Dashboard",
  "nodes": [
    {
      "parameters": {
        "rule": {"interval": [{"field": "cronExpression", "expression": "0 8 * * 1"}]}
      },
      "name": "Monday 8AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "operation": "getAll",
        "documentId": "YOUR_SHEETS_ID",
        "sheetName": "Weekly_Metrics"
      },
      "name": "Fetch KPIs",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [460, 300]
    },
    {
      "parameters": {
        "jsCode": "const rows = $input.all().map(i => i.json);\nconst latest = rows[rows.length - 1];\nconst prev = rows[rows.length - 2] || {};\n\nconst pctChange = (curr, prev) => {\n  if (!prev || !curr) return 'N/A';\n  const p = ((curr - prev) / prev * 100).toFixed(1);\n  return p > 0 ? `▲${p}%` : `▼${Math.abs(p)}%`;\n};\n\nreturn [{ json: {\n  permits_issued: latest.permits_issued,\n  permits_change: pctChange(+latest.permits_issued, +prev.permits_issued),\n  service_requests: latest.service_requests,\n  requests_resolved: latest.requests_resolved,\n  avg_resolution_days: latest.avg_resolution_days,\n  foia_pending: latest.foia_pending,\n  report_week: latest.week_ending\n} }];"
      },
      "name": "Compute KPIs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [680, 300]
    },
    {
      "parameters": {
        "toEmail": "city-manager@yourcity.gov",
        "subject": "=Weekly Ops Dashboard — Week Ending {{$json.report_week}}",
        "message": "=<html><body style='font-family:sans-serif;max-width:600px'><h2>Weekly Operations Dashboard</h2><p><strong>Week Ending:</strong> {{$json.report_week}}</p><table style='width:100%;border-collapse:collapse'><tr style='background:#f0f4f8'><th style='padding:8px;text-align:left'>Metric</th><th style='padding:8px'>Value</th><th style='padding:8px'>WoW</th></tr><tr><td style='padding:8px'>Permits Issued</td><td style='padding:8px;text-align:center'>{{$json.permits_issued}}</td><td style='padding:8px;text-align:center'>{{$json.permits_change}}</td></tr><tr style='background:#f9f9f9'><td style='padding:8px'>Service Requests</td><td style='padding:8px;text-align:center'>{{$json.service_requests}}</td><td></td></tr><tr><td style='padding:8px'>Requests Resolved</td><td style='padding:8px;text-align:center'>{{$json.requests_resolved}}</td><td></td></tr><tr style='background:#f9f9f9'><td style='padding:8px'>Avg Resolution Time</td><td style='padding:8px;text-align:center'>{{$json.avg_resolution_days}} days</td><td></td></tr><tr><td style='padding:8px'>FOIA Requests Pending</td><td style='padding:8px;text-align:center'>{{$json.foia_pending}}</td><td></td></tr></table><p style='color:#666;font-size:12px'>Automated by n8n — self-hosted on government infrastructure.</p></body></html>",
        "options": {"appendAttribution": false}
      },
      "name": "Email Weekly Report",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2,
      "position": [900, 300]
    }
  ],
  "connections": {
    "Monday 8AM": {"main": [[{"node": "Fetch KPIs", "type": "main", "index": 0}]]},
    "Fetch KPIs": {"main": [[{"node": "Compute KPIs", "type": "main", "index": 0}]]},
    "Compute KPIs": {"main": [[{"node": "Email Weekly Report", "type": "main", "index": 0}]]}
  }
}
Enter fullscreen mode Exit fullscreen mode

Sheets columns: week_ending, permits_issued, service_requests, requests_resolved, avg_resolution_days, foia_pending


Why n8n for Government?

Data sovereignty is non-negotiable. Most government data policies require that citizen data, FOIA details, and permit records stay within agency infrastructure. n8n self-hosted on your servers means zero data leaves your network — unlike Zapier or Make.com.

Audit trail by default. Every n8n workflow is stored as a human-readable JSON file. Every execution logs to an internal database. Exactly what government auditors require.

No per-workflow pricing. Government agencies need hundreds of automations across departments. n8n's server-based pricing (or free self-hosted) means no per-task charges that scale with volume.


Getting Started

  1. Self-hosted: npx n8n or Docker on your government server
  2. Import any workflow: Copy JSON → n8n dashboard → Import from JSON
  3. Replace placeholders: YOUR_SHEETS_ID, Slack webhooks, email addresses
  4. Activate

FlowKit n8n Template Store

Want pre-built, tested workflows for your team? FlowKit at stripeai.gumroad.com has 10+ ready-to-import n8n templates:

  • 📋 Lead Capture to CRM ($19) — constituent inquiry routing with Google Sheets
  • 📨 Email Auto-Responder ($15) — automated acknowledgment with smart routing
  • 📅 Appointment Reminder ($15) — multi-channel reminder sequences
  • 📊 Daily Report Generator ($19) — automated KPI briefings for managers

Each includes full workflow JSON + setup guide, works on any n8n instance.


Drop a comment with your use case — happy to help customize these for your specific government workflow.

Top comments (0)