DEV Community

Alex Kane
Alex Kane

Posted on

n8n for ConstructionTech SaaS: 5 Automations That Scale Project Ops and Keep Construction Data Compliant (Free Workflow JSON)

If you sell construction project management SaaS, BIM software, field operations tools, or subcontractor management platforms, you already know the problem: your clients run massive projects with thousands of events — RFIs, punch lists, equipment pings, safety reports, lien notices — and the moment you wire any of that through a commercial iPaaS like Zapier, you inherit a compliance exposure that your clients' legal teams will flag in the next audit.

This article covers 5 production-ready n8n workflows built specifically for ConstructionTech SaaS vendors — not general contractors, but the software companies selling to them. Each workflow includes full import-ready JSON.

All workflows self-host inside your client's VPC. No OSHA worker health data routes through commercial servers. No Davis-Bacon payroll data leaves the perimeter. No BIM model IP touches a third-party log.


1. New GC / Owner Client Onboarding Drip

Trigger: a new row lands in your CRM Google Sheet (or replace with a Postgres webhook). The workflow classifies the account tier and fires a calibrated Day 0 / Day 3 / Day 7 email sequence.

{
  "name": "ConstructionTech Client Onboarding Drip",
  "nodes": [
    {
      "name": "Watch New Accounts",
      "type": "n8n-nodes-base.googleSheetsTrigger",
      "parameters": {
        "documentId": "{{ $env.GC_ACCOUNTS_SHEET_ID }}",
        "sheetName": "Accounts",
        "event": "rowAdded"
      }
    },
    {
      "name": "Classify Tier",
      "type": "n8n-nodes-base.code",
      "parameters": {
        "jsCode": "const row = $input.first().json;\nconst seats = parseInt(row.seats) || 0;\nconst projects = parseInt(row.active_projects) || 0;\nlet tier, day0Subject, day0Body, csmChannel;\nif (seats >= 200 || projects >= 50) {\n  tier = 'ENTERPRISE_GC';\n  day0Subject = 'Your dedicated ConstructionOS onboarding team is ready';\n  day0Body = 'Hi ' + row.first_name + ',\\n\\nYour enterprise workspace is live. Your dedicated CSM '+ row.csm_name +' will reach out within 1 business hour.\\n\\nDay 1 checklist: https://docs.yourplatform.com/enterprise-start';\n  csmChannel = '#cs-enterprise';\n} else if (seats >= 20 || projects >= 10) {\n  tier = 'MID_SIZE_CONTRACTOR';\n  day0Subject = 'Get your first project live in 30 minutes';\n  day0Body = 'Hi ' + row.first_name + ',\\n\\nWelcome to ConstructionOS. Here\'s your quick-start guide: https://docs.yourplatform.com/mid-start';\n  csmChannel = '#cs-growth';\n} else if (row.account_type === 'SPECIALTY_SUB') {\n  tier = 'SPECIALTY_SUB';\n  day0Subject = 'Your subcontractor workspace is ready';\n  day0Body = 'Hi ' + row.first_name + ', start with punch lists: https://docs.yourplatform.com/sub-start';\n  csmChannel = '#cs-smb';\n} else {\n  tier = 'OWNER_REP';\n  day0Subject = 'Owner dashboard ready — track every project in one view';\n  day0Body = 'Hi ' + row.first_name + ', your owner portal is live: https://docs.yourplatform.com/owner-start';\n  csmChannel = '#cs-smb';\n}\nreturn [{ json: { ...row, tier, day0Subject, day0Body, csmChannel } }];"
      }
    },
    {
      "name": "Send Day 0 Email",
      "type": "n8n-nodes-base.gmail",
      "parameters": {
        "toEmail": "={{ $json.email }}",
        "subject": "={{ $json.day0Subject }}",
        "message": "={{ $json.day0Body }}"
      }
    },
    {
      "name": "Notify CSM",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "={{ $json.csmChannel }}",
        "text": "New {{ $json.tier }} account: {{ $json.company_name }} ({{ $json.seats }} seats, {{ $json.active_projects }} active projects). Owner: {{ $json.first_name }} {{ $json.last_name }}"
      }
    },
    {
      "name": "Log to Audit Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "parameters": {
        "operation": "append",
        "documentId": "{{ $env.ONBOARDING_AUDIT_SHEET_ID }}",
        "sheetName": "SOC2_AuditLog",
        "columns": { "mappingMode": "autoMapInputData" }
      }
    },
    {
      "name": "Wait 3 Days",
      "type": "n8n-nodes-base.wait",
      "parameters": { "unit": "days", "amount": 3 }
    },
    {
      "name": "Send Day 3 Email",
      "type": "n8n-nodes-base.gmail",
      "parameters": {
        "toEmail": "={{ $json.email }}",
        "subject": "Quick check-in — how is your first project going?",
        "message": "Hi {{ $json.first_name }},\n\nDay 3 tip: connect your Procore/Autodesk integration to sync RFIs automatically.\n\nNeed help? Book a 15-min call: https://cal.yourplatform.com/onboarding"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Day 0 fires immediately with tier-specific messaging. Day 3 checks in with the top integration tip for that tier. Day 7 (not shown) delivers a case study matching their vertical (commercial GC, residential, heavy civil, specialty sub).

The Google Sheets audit log satisfies SOC 2 CC6.1 (access provisioning evidence) and GDPR Art. 30 (records of processing). Zapier's 30-day log retention fails both.


2. Construction Site & Equipment Telematics Health Monitor

Construction equipment generates continuous telematics: GPS position, engine hours, fuel, idle time. If your platform's telematics API goes silent, site managers lose visibility — and your SLA clock starts running.

{
  "name": "Equipment Telematics Health Monitor",
  "nodes": [
    {
      "name": "Every 3 Minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": { "rule": { "interval": [{ "field": "minutes", "minutesInterval": 3 }] } }
    },
    {
      "name": "Load Endpoints",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT endpoint_id, url, site_name, equipment_type, sla_minutes FROM telematics_endpoints WHERE active = true ORDER BY endpoint_id"
      }
    },
    {
      "name": "Ping Each Endpoint",
      "type": "n8n-nodes-base.httpRequest",
      "parameters": {
        "url": "={{ $json.url }}",
        "timeout": 8000
      }
    },
    {
      "name": "Classify Status",
      "type": "n8n-nodes-base.code",
      "parameters": {
        "jsCode": "const SUPPRESS_MINUTES = 30;\nconst state = $getWorkflowStaticData('global');\nconst results = [];\nfor (const item of $input.all()) {\n  const ep = item.json;\n  const now = Date.now();\n  const lastTs = ep.last_data_ts ? new Date(ep.last_data_ts).getTime() : 0;\n  const staleMs = now - lastTs;\n  let status = 'OK';\n  if (ep.error || ep.httpCode >= 500) status = 'DOWN';\n  else if (staleMs > ep.sla_minutes * 60000) status = 'STALE_DATA';\n  else if (ep.httpCode >= 400) status = 'DEGRADED';\n  if (status !== 'OK') {\n    const key = 'suppress_' + ep.endpoint_id;\n    const lastAlert = state[key] || 0;\n    if (now - lastAlert < SUPPRESS_MINUTES * 60000) continue;\n    state[key] = now;\n    results.push({ json: { ...ep, status } });\n  }\n}\n$setWorkflowStaticData('global', state);\nreturn results;"
      }
    },
    {
      "name": "Alert Site Ops",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "#site-ops",
        "text": ":rotating_light: {{ $json.status }}: {{ $json.equipment_type }} at {{ $json.site_name }}\nEndpoint: {{ $json.url }}\nSLA: {{ $json.sla_minutes }}min"
      }
    },
    {
      "name": "Log Incident",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO telematics_incidents (endpoint_id, status, detected_at) VALUES ('{{ $json.endpoint_id }}', '{{ $json.status }}', NOW()) ON CONFLICT DO NOTHING"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

$getWorkflowStaticData suppresses repeat alerts for 30 minutes per endpoint. The Postgres incident log is SOC 2 evidence (availability monitoring). Equipment health data, GPS coordinates, and job-site sensor readings are commercially sensitive — they reveal your client's project velocity, equipment utilisation, and cost structure. None of it should route through Zapier's shared infrastructure.


3. OSHA & Lien Law Compliance Deadline Tracker

ConstructionTech SaaS vendors whose platforms manage safety or payment workflows carry their clients' hardest-deadline obligations: OSHA 300 Log postings, mechanic's lien preliminary notice windows, Davis-Bacon certified payroll submissions.

These are jurisdictional hard deadlines — a missed preliminary lien notice in California (20-day window) or Texas (served before first furnishing for some tiers) kills a subcontractor's entire lien rights. No grace period. No cure.

{
  "name": "OSHA & Lien Law Compliance Tracker",
  "nodes": [
    {
      "name": "Weekdays 8 AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 8 * * 1-5" }] } }
    },
    {
      "name": "Load Compliance Calendar",
      "type": "n8n-nodes-base.googleSheets",
      "parameters": {
        "operation": "readRows",
        "documentId": "{{ $env.COMPLIANCE_SHEET_ID }}",
        "sheetName": "DeadlineTracker"
      }
    },
    {
      "name": "Classify Urgency",
      "type": "n8n-nodes-base.code",
      "parameters": {
        "jsCode": "const today = new Date(); const results = [];\nconst actionMap = {\n  OSHA_300_LOG_ANNUAL: 'Post OSHA 300A Summary by Feb 1 — 29 CFR 1904.32',\n  OSHA_300_RECORDKEEPING: 'Update OSHA 300 Log within 7 days of recordable incident — 29 CFR 1904.29',\n  MECHANIC_LIEN_PRELIMINARY: 'Serve Preliminary Lien Notice — deadline is jurisdictional (CA:20d, TX:first furnishing, FL:45d)',\n  MECHANIC_LIEN_CLAIM: 'File Mechanic\'s Lien Claim — state-specific deadline from last furnishing',\n  BOND_CLAIM_NOTICE: 'Serve Bond Claim Notice on public project — state Miller Act equivalent',\n  RETENTION_RELEASE: 'Process retention holdback release — check contract and state statute',\n  DAVIS_BACON_CERTIFIED_PAYROLL: 'Submit Davis-Bacon Certified Payroll (WH-347) to contracting agency — weekly',\n  AIA_G704_SUBSTANTIAL: 'Issue AIA G704 Certificate of Substantial Completion',\n  SOC2_EVIDENCE: 'Collect SOC 2 Type II control evidence for period end',\n  GDPR_ART_30: 'Update GDPR Art. 30 Records of Processing Activities'\n};\nfor (const row of $input.all()) {\n  const d = row.json; const due = new Date(d.due_date);\n  const daysLeft = Math.ceil((due - today) / 86400000);\n  let tier;\n  if (daysLeft < 0) tier = 'OVERDUE';\n  else if (daysLeft <= 1) tier = 'CRITICAL';\n  else if (daysLeft <= 3) tier = 'URGENT';\n  else if (daysLeft <= 7) tier = 'WARNING';\n  else if (daysLeft <= 14) tier = 'NOTICE';\n  else continue;\n  results.push({ json: { ...d, tier, daysLeft, action: actionMap[d.obligation_type] || d.obligation_type } });\n}\nreturn results;"
      }
    },
    {
      "name": "Route by Tier",
      "type": "n8n-nodes-base.switch",
      "parameters": {
        "rules": {
          "values": [
            { "conditions": { "options": { "leftValue": "={{ $json.tier }}", "operation": "equals", "rightValue": "OVERDUE" } }, "outputIndex": 0 },
            { "conditions": { "options": { "leftValue": "={{ $json.tier }}", "operation": "equals", "rightValue": "CRITICAL" } }, "outputIndex": 1 },
            { "conditions": { "options": { "leftValue": "={{ $json.tier }}", "operation": "equals", "rightValue": "URGENT" } }, "outputIndex": 2 }
          ]
        }
      }
    },
    {
      "name": "Slack Compliance Ops",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "#compliance-ops",
        "text": ":fire: {{ $json.tier }} ({{ $json.daysLeft }}d): {{ $json.obligation_type }} — {{ $json.client_name }}\n{{ $json.action }}\nOwner: {{ $json.owner_email }}"
      }
    },
    {
      "name": "Email Owner",
      "type": "n8n-nodes-base.gmail",
      "parameters": {
        "toEmail": "={{ $json.owner_email }}",
        "subject": "ACTION: {{ $json.tier }} compliance deadline — {{ $json.obligation_type }} ({{ $json.daysLeft }} days)",
        "message": "={{ $json.action }}\n\nClient: {{ $json.client_name }}\nDue: {{ $json.due_date }}\nNotes: {{ $json.notes }}"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The actionMap encodes the statutory citation for each obligation type. OVERDUE and CRITICAL items fire to both Slack and Gmail simultaneously via parallel branches after the Switch node. A dedup check on the Sheets log prevents the same deadline triggering twice in one day.

Why this can't route through Zapier: Davis-Bacon certified payroll (WH-347) contains worker names, SSNs, and wage rates — federal contractor personally identifiable information. Routing that through Zapier's shared US servers is not an unauthorized disclosure per se, but it expands the attack surface your clients will flag in every FedCon compliance review.


4. Safety Incident & Near-Miss Alert Pipeline

OSHA 29 CFR Part 1904.39 requires employers to report fatalities within 8 hours and in-patient hospitalizations within 24 hours. If your platform receives safety incident webhooks from job sites, you need a hard-branching pipeline — not a general-purpose SaaS workflow that might queue behind other tasks.

{
  "name": "Safety Incident Alert Pipeline",
  "nodes": [
    {
      "name": "Safety Incident Webhook",
      "type": "n8n-nodes-base.webhook",
      "parameters": {
        "path": "safety-incident",
        "responseMode": "responseNode",
        "httpMethod": "POST"
      }
    },
    {
      "name": "Classify Severity",
      "type": "n8n-nodes-base.code",
      "parameters": {
        "jsCode": "const inc = $input.first().json.body;\nconst outcomeMap = {\n  fatality: { tier: 'OSHA_FATALITY', slaHours: 8, channel: '#safety-emergency', mention: '@channel', osha: '29 CFR 1904.39(a)(1) — report within 8h' },\n  hospitalization: { tier: 'OSHA_HOSPITALIZATION', slaHours: 24, channel: '#safety-emergency', mention: '@here', osha: '29 CFR 1904.39(a)(2) — report within 24h' },\n  lost_time: { tier: 'OSHA_RECORDABLE', slaHours: 168, channel: '#safety-ops', mention: '', osha: '29 CFR 1904.29 — log within 7 days' },\n  near_miss: { tier: 'NEAR_MISS', slaHours: 48, channel: '#safety-ops', mention: '', osha: 'OSHA recommended reporting — internal only' },\n  property_damage: { tier: 'PROPERTY_DAMAGE', slaHours: 72, channel: '#safety-ops', mention: '', osha: 'Internal incident report required' }\n};\nconst meta = outcomeMap[inc.outcome_type] || outcomeMap.property_damage;\nconst reportDeadline = new Date(Date.now() + meta.slaHours * 3600000).toISOString();\nreturn [{ json: { ...inc, ...meta, reportDeadline, receivedAt: new Date().toISOString() } }];"
      }
    },
    {
      "name": "Slack Emergency Alert",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "={{ $json.channel }}",
        "text": ":construction: {{ $json.mention }} {{ $json.tier }}: {{ $json.incident_description }}\nSite: {{ $json.site_name }} | Worker: {{ $json.worker_id }}\nOSHA SLA: {{ $json.osha }}\nReport deadline: {{ $json.reportDeadline }}"
      }
    },
    {
      "name": "Log to OSHA 300 Staging",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO osha_300_staging (incident_id, site_id, worker_id, outcome_type, tier, incident_ts, report_deadline, raw_payload) VALUES ('{{ $json.incident_id }}', '{{ $json.site_id }}', '{{ $json.worker_id }}', '{{ $json.outcome_type }}', '{{ $json.tier }}', '{{ $json.incident_ts }}', '{{ $json.reportDeadline }}', '{{ JSON.stringify($json) }}') ON CONFLICT (incident_id) DO NOTHING"
      }
    },
    {
      "name": "Respond 200",
      "type": "n8n-nodes-base.respondToWebhook",
      "parameters": { "respondWith": "json", "responseBody": "{ \"status\": \"received\", \"tier\": \"{{ $json.tier }}\", \"reportDeadline\": \"{{ $json.reportDeadline }}\" }" }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The pipeline returns the OSHA reporting deadline in the webhook ACK — the calling mobile app can display it to the site supervisor immediately. Worker injury data (names, worker IDs, outcome types) maps directly to OSHA 300 Log fields — this is personally identifiable health information that belongs inside your client's infrastructure, not queued in Zapier's shared task log.


5. Weekly ConstructionTech Platform KPI Dashboard

Your exec team needs to see platform health, account risk, and retention signals every Monday morning — before the week's customer calls begin.

{
  "name": "Weekly ConstructionTech KPI Dashboard",
  "nodes": [
    {
      "name": "Monday 8 AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 8 * * 1" }] } }
    },
    {
      "name": "Fetch Platform Metrics",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT week_start, mau, new_accounts, churned_accounts, active_projects, rfis_processed, punch_items_closed, avg_response_ms FROM platform_metrics WHERE week_start >= NOW() - INTERVAL '2 weeks' ORDER BY week_start DESC LIMIT 2"
      }
    },
    {
      "name": "Fetch Account Health",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT COUNT(*) FILTER (WHERE health_score < 40) AS red_accounts, COUNT(*) FILTER (WHERE health_score BETWEEN 40 AND 70) AS amber_accounts, COUNT(*) FILTER (WHERE last_active < NOW() - INTERVAL '21 days') AS dormant_accounts, SUM(arr_usd) FILTER (WHERE health_score < 40) AS red_arr FROM account_health WHERE tier != 'CHURNED'"
      }
    },
    {
      "name": "Build Dashboard",
      "type": "n8n-nodes-base.code",
      "parameters": {
        "jsCode": "const state = $getWorkflowStaticData('global');\nconst [curr, prev] = $node['Fetch Platform Metrics'].json;\nconst health = $node['Fetch Account Health'].json;\nconst wow = (curr, prev) => prev > 0 ? (((curr - prev) / prev) * 100).toFixed(1) + '%' : 'N/A';\nconst mauWoW = wow(curr.mau, prev.mau);\nstate.prev_mau = curr.mau; $setWorkflowStaticData('global', state);\nconst html = `<h2>ConstructionOS Weekly KPI — ${curr.week_start}</h2>\n<table border='1' cellpadding='6'><tr><th>Metric</th><th>This Week</th><th>WoW</th></tr>\n<tr><td>MAU</td><td>${curr.mau}</td><td>${mauWoW}</td></tr>\n<tr><td>New Accounts</td><td>${curr.new_accounts}</td><td>-</td></tr>\n<tr><td>Churned</td><td>${curr.churned_accounts}</td><td>-</td></tr>\n<tr><td>Active Projects</td><td>${curr.active_projects}</td><td>-</td></tr>\n<tr><td>RFIs Processed</td><td>${curr.rfis_processed}</td><td>-</td></tr>\n<tr><td>Punch Items Closed</td><td>${curr.punch_items_closed}</td><td>-</td></tr>\n<tr><td>Avg Response (ms)</td><td>${curr.avg_response_ms}</td><td>-</td></tr>\n</table>\n<h3>Account Health</h3>\n<p><b>Red (<40):</b> ${health.red_accounts} accounts | ARR at risk: $${health.red_arr}</p>\n<p><b>Amber (40-70):</b> ${health.amber_accounts} accounts</p>\n<p><b>Dormant (21d+):</b> ${health.dormant_accounts} accounts</p>`;\nreturn [{ json: { html, mauWoW, curr, health } }];"
      }
    },
    {
      "name": "Email CEO",
      "type": "n8n-nodes-base.gmail",
      "parameters": {
        "toEmail": "{{ $env.CEO_EMAIL }}",
        "ccEmail": "{{ $env.CTO_EMAIL }},{{ $env.VP_CS_EMAIL }},{{ $env.COO_EMAIL }}",
        "subject": "ConstructionOS Weekly KPI — MAU {{ $json.curr.mau }} ({{ $json.mauWoW }} WoW)",
        "message": "={{ $json.html }}",
        "options": { "appendAttribution": false }
      }
    },
    {
      "name": "Slack Exec Summary",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "channel": "#exec-kpis",
        "text": "Weekly KPI: MAU {{ $json.curr.mau }} ({{ $json.mauWoW }}) | New {{ $json.curr.new_accounts }} | Churned {{ $json.curr.churned_accounts }} | Red accounts: {{ $json.health.red_accounts }} | Red ARR: ${{ $json.health.red_arr }}"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The parallel Postgres branches run simultaneously (connect both to the Merge node). $getWorkflowStaticData stores last week's MAU so the WoW delta survives across runs without a database write.


Why Self-Hosted n8n vs Zapier for ConstructionTech

n8n (self-hosted) Zapier
OSHA 300 worker health data in boundary ✅ VPC only ❌ Routes through Zapier US servers
Davis-Bacon payroll data (SSN, wages) ✅ Never leaves perimeter ❌ Exposed in task log
BIM model IP and project financials ✅ Client VPC ❌ Third-party infrastructure
Mechanic's lien hard-deadline logic ✅ State-specific code branches ❌ No jurisdictional branching
OSHA reporting SLA (8h/24h) ✅ Instant hard branch ❌ Depends on queue depth
SOC 2 audit log retention ✅ Permanent Postgres ❌ 30-day limit
Cost at 5M events/month ✅ ~$60/mo VPS ❌ $5,000+/mo Zapier
Air-gapped / offline site deployment ✅ Runs on edge hardware ❌ Requires internet

Get the Full Workflow Pack

These 5 workflows are part of the FlowKit n8n Automation Template Pack — 14 production-ready workflows covering onboarding, monitoring, compliance tracking, alert pipelines, and KPI dashboards.

👉 Get the full pack at stripeai.gumroad.com

Individual templates start at $12. The full bundle is $97 — less than one hour of consultant time, and the workflows run forever.


All JSON is import-ready. In n8n: Settings → Import workflow → paste the JSON. Replace env var placeholders with your values.

Top comments (0)