Construction and AEC (Architecture, Engineering, Construction) companies operate under some of the most demanding compliance regimes in any industry: OSHA 1926 safety reporting with criminal penalties, Davis-Bacon prevailing wage with debarment risk, LEED certification deadlines, AIA contract administration, and building permit expiry timelines.
ConstructionTech SaaS vendors selling to GCs, specialty contractors, design firms, and owners face a specific problem: their customers' safety data, BIM model data, subcontractor financials, and permit conditions are commercially and legally sensitive. Routing this through Zapier or Make's multi-tenant cloud creates data egress problems that AEC firms increasingly refuse.
This article covers five production-ready n8n workflows for AEC SaaS platforms. Each includes import-ready JSON. Store link: FlowKit n8n Templates.
Why AEC SaaS Vendors Are Switching to Self-Hosted n8n
| Factor | Zapier/Make | Self-Hosted n8n |
|---|---|---|
| OSHA audit trail | 30-day task log | Permanent Postgres log |
| BIM model data | Multi-tenant cloud | Stays in your VPC |
| Davis-Bacon payroll data | Third-party servers | Air-gapped option |
| OSHA 8-hour reporting | Webhook latency risk | Real-time trigger |
| 1B sensor events/mo | $1M+ Zapier | ~$400 VPS |
| Git-versioned workflows | Not possible | JSON = version control |
The core compliance argument: OSHA 29 CFR 1904.39 requires fatality reports within 8 hours and inpatient hospitalization reports within 24 hours. If Zapier's webhook queue delays your safety alert pipeline, you miss the window — $15,625 per violation, potential criminal referral for willful violations. On self-hosted n8n, the trigger fires in under 1 second.
Workflow 1: New AEC Customer Regulatory Onboarding Drip
AEC customers have wildly different compliance profiles: a federal GC needs Davis-Bacon, OSHA 1926, FFATA reporting, and LEED tracking. A commercial design firm needs AIA contract admin and building permit tracking. A specialty contractor needs OSHA and prevailing wage. One onboarding email doesn't fit all — this workflow classifies customers and personalizes every message.
{
"nodes": [
{
"id": "1",
"name": "Customer Signed Up",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
240,
300
],
"parameters": {
"path": "aec-onboard",
"responseMode": "responseNode",
"options": {}
}
},
{
"id": "2",
"name": "Classify Tier and Compliance Flags",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
460,
300
],
"parameters": {
"jsCode": "const c = $json; const arr = c.annual_revenue_usd || 0; let tier; if (arr >= 1e9) tier = 'ENTERPRISE_GC'; else if (arr >= 1e8) tier = 'MID_MARKET_GC'; else if (arr >= 1e7) tier = 'SPECIALTY_CONTRACTOR'; else tier = 'DESIGN_FIRM'; const flags = []; if (c.employees >= 10) flags.push('OSHA_1926_APPLICABLE'); if (c.federal_contracts) flags.push('DAVIS_BACON_REQUIRED'); if (c.leed_projects) flags.push('LEED_TRACKING'); if (c.bim_required) flags.push('BIM_REQUIRED'); if (c.aia_contracts) flags.push('AIA_CONTRACT_ADMIN'); if (c.federal_contracts) flags.push('FFATA_REPORTING_REQUIRED'); if (c.eu_projects) flags.push('GDPR_APPLICABLE'); return [{ json: { ...c, tier, compliance_flags: flags } }];"
}
},
{
"id": "3",
"name": "Day 0 Welcome Email",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2,
"position": [
680,
300
],
"parameters": {
"operation": "send",
"toList": "={{ $json.email }}",
"subject": "Welcome to {{ $json.platform_name }} \u2014 Your AEC Compliance Onboarding",
"message": "Hi {{ $json.first_name }},\\n\\nWelcome aboard. Your account is configured for {{ $json.tier }}.\\n\\n{{ $json.compliance_flags.includes('OSHA_1926_APPLICABLE') ? 'OSHA note: your OSHA 300 log automation is active. Fatality/hospitalization reports triggered automatically within 8/24h windows.' : '' }}\\n{{ $json.compliance_flags.includes('DAVIS_BACON_REQUIRED') ? 'Davis-Bacon: weekly certified payroll report (WH-347) reminders are set. Non-compliance carries contract debarment risk.' : '' }}\\n{{ $json.compliance_flags.includes('LEED_TRACKING') ? 'LEED: certification deadline tracker is live. Credit-at-risk alerts configured for your project portfolio.' : '' }}\\n\\nBest,\\nThe {{ $json.platform_name }} Team"
}
},
{
"id": "4",
"name": "Wait 3 Days",
"type": "n8n-nodes-base.wait",
"typeVersion": 1,
"position": [
900,
300
],
"parameters": {
"amount": 3,
"unit": "days"
}
},
{
"id": "5",
"name": "Day 3 Compliance Checklist",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2,
"position": [
1120,
300
],
"parameters": {
"operation": "send",
"toList": "={{ $json.email }}",
"subject": "Day 3 \u2014 Your AEC Compliance Checklist",
"message": "Hi {{ $json.first_name }},\\n\\nDay 3 checklist:\\n\\n{{ $json.compliance_flags.includes('OSHA_1926_APPLICABLE') ? '\u2713 OSHA 300 log: auto-generated. 300A summary posts Feb 1 - Apr 30. Check OSHA 1904 exemptions for your NAICS.' : '' }}\\n{{ $json.compliance_flags.includes('AIA_CONTRACT_ADMIN') ? '\u2713 AIA G704: substantial completion certificate tracker active. Punch list deadlines logged.' : '' }}\\n{{ $json.compliance_flags.includes('LEED_TRACKING') ? '\u2713 LEED credit checklist: 8 credits flagged at risk. Review materials documentation.' : '' }}\\n{{ $json.compliance_flags.includes('DAVIS_BACON_REQUIRED') ? '\u2713 Davis-Bacon: weekly payroll reports due each Thursday. First report due this week.' : '' }}\\n\\nReply with questions.\\nThe {{ $json.platform_name }} Team"
}
},
{
"id": "6",
"name": "Wait 4 Days",
"type": "n8n-nodes-base.wait",
"typeVersion": 1,
"position": [
1340,
300
],
"parameters": {
"amount": 4,
"unit": "days"
}
},
{
"id": "7",
"name": "Day 7 Platform Health Check",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2,
"position": [
1560,
300
],
"parameters": {
"operation": "send",
"toList": "={{ $json.email }}",
"subject": "Week 1 Complete \u2014 Your AEC Compliance Dashboard is Live",
"message": "Hi {{ $json.first_name }},\\n\\nWeek 1 complete. Audit trail active, safety incident pipeline live, LEED deadline tracker running.\\n\\n{{ $json.compliance_flags.includes('BIM_REQUIRED') ? 'BIM note: model coordination clash detection alerts configured. Critical clashes route to Slack #bim-ops within 5 minutes.' : '' }}\\n{{ $json.compliance_flags.includes('FFATA_REPORTING_REQUIRED') ? 'FFATA/2 CFR 200: sub-award reporting reminders set. First report due 30 days after award.' : '' }}\\n\\nBook your 30-min integration review: [Calendly link]\\n\\nBest,\\nThe {{ $json.platform_name }} Team"
}
}
],
"connections": {
"Customer Signed Up": {
"main": [
[
{
"node": "Classify Tier and Compliance Flags",
"type": "main",
"index": 0
}
]
]
},
"Classify Tier and Compliance Flags": {
"main": [
[
{
"node": "Day 0 Welcome Email",
"type": "main",
"index": 0
}
]
]
},
"Day 0 Welcome Email": {
"main": [
[
{
"node": "Wait 3 Days",
"type": "main",
"index": 0
}
]
]
},
"Wait 3 Days": {
"main": [
[
{
"node": "Day 3 Compliance Checklist",
"type": "main",
"index": 0
}
]
]
},
"Day 3 Compliance Checklist": {
"main": [
[
{
"node": "Wait 4 Days",
"type": "main",
"index": 0
}
]
]
},
"Wait 4 Days": {
"main": [
[
{
"node": "Day 7 Platform Health Check",
"type": "main",
"index": 0
}
]
]
}
}
}
Key logic: The Code node classifies customers into four tiers (ENTERPRISE_GC / MID_MARKET_GC / SPECIALTY_CONTRACTOR / DESIGN_FIRM) and builds a compliance_flags array from customer data (federal contracts, LEED projects, BIM requirements, AIA contract use). Every email in the Day0/3/7 drip only surfaces the sections relevant to that customer's flags. OSHA note goes only to firms with ≥10 employees. Davis-Bacon reminders only to federal contract holders.
Workflow 2: BIM/Construction Data Pipeline Health Monitor
Modern AEC SaaS integrates with BIM authoring tools (Revit, ArchiCAD), construction management platforms (Procore, Autodesk Build), and field data apps. When these integrations go down, safety incident data may not transmit, coordination clashes may not surface, and AIA G704 closeout documentation may fall behind. This workflow polls all customer API endpoints every 5 minutes and alerts when any are degraded.
{
"nodes": [
{
"id": "1",
"name": "Every 5 Minutes",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
240,
300
],
"parameters": {
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 5
}
]
}
}
},
{
"id": "2",
"name": "Fetch BIM API Endpoints",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
460,
300
],
"parameters": {
"operation": "executeQuery",
"query": "SELECT id, account_name, endpoint_url, endpoint_type FROM bim_endpoints WHERE is_active = true"
}
},
{
"id": "3",
"name": "Split Endpoints",
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
680,
300
],
"parameters": {
"batchSize": 1,
"options": {}
}
},
{
"id": "4",
"name": "Ping Endpoint",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
900,
300
],
"parameters": {
"url": "={{ $json.endpoint_url }}",
"method": "GET",
"options": {
"timeout": 10000,
"response": {
"response": {
"responseFormat": "text"
}
},
"redirect": {
"redirect": {
"followRedirects": true
}
}
}
}
},
{
"id": "5",
"name": "Evaluate Status",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1120,
300
],
"parameters": {
"jsCode": "const prev = $getWorkflowStaticData('global'); const key = $('Fetch BIM API Endpoints').first().json.id; const now = Date.now(); let status = 'OK'; const resp = $('Ping Endpoint').first(); if (!resp || resp.json.$response?.statusCode >= 400) { status = 'DOWN'; } else { const ms = resp.json.$response?.headers?.['x-response-time'] || 0; if (ms > 8000) status = 'DEGRADED'; } const lastAlert = prev[key] || 0; const shouldAlert = status !== 'OK' && (now - lastAlert) > 30 * 60 * 1000; if (shouldAlert) prev[key] = now; $setWorkflowStaticData('global', prev); return [{ json: { ...($('Fetch BIM API Endpoints').first().json), status, should_alert: shouldAlert } }];"
}
},
{
"id": "6",
"name": "Alert Needed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1340,
300
],
"parameters": {
"conditions": {
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.should_alert }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true"
}
}
]
}
}
},
{
"id": "7",
"name": "Slack BIM Ops Alert",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [
1560,
200
],
"parameters": {
"operation": "message",
"channel": "#bim-ops",
"text": "={{ `${$json.status==='DOWN'?'BIM API DOWN':'BIM API DEGRADED'}\\nAccount: ${$json.account_name}\\nEndpoint: ${$json.endpoint_type} | ${$json.endpoint_url}\\n${$json.status==='DOWN'?'OSHA safety incident reporting may be impaired. AIA G704 closeout data at risk. Investigating.':'Response time degraded >8s. BIM coordination clash detection delayed. Monitoring.'}` }}"
}
},
{
"id": "8",
"name": "Log to Postgres",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1780,
200
],
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO bim_api_incidents (account_id, account_name, endpoint_type, status, detected_at) VALUES ('{{ $json.id }}', '{{ $json.account_name }}', '{{ $json.endpoint_type }}', '{{ $json.status }}', NOW()) ON CONFLICT (account_id, endpoint_type) DO UPDATE SET status=EXCLUDED.status, detected_at=NOW()"
}
}
],
"connections": {
"Every 5 Minutes": {
"main": [
[
{
"node": "Fetch BIM API Endpoints",
"type": "main",
"index": 0
}
]
]
},
"Fetch BIM API Endpoints": {
"main": [
[
{
"node": "Split Endpoints",
"type": "main",
"index": 0
}
]
]
},
"Split Endpoints": {
"main": [
[
{
"node": "Ping Endpoint",
"type": "main",
"index": 0
}
]
]
},
"Ping Endpoint": {
"main": [
[
{
"node": "Evaluate Status",
"type": "main",
"index": 0
}
]
]
},
"Evaluate Status": {
"main": [
[
{
"node": "Alert Needed?",
"type": "main",
"index": 0
}
]
]
},
"Alert Needed?": {
"main": [
[
{
"node": "Slack BIM Ops Alert",
"type": "main",
"index": 0
}
],
[]
]
},
"Slack BIM Ops Alert": {
"main": [
[
{
"node": "Log to Postgres",
"type": "main",
"index": 0
}
]
]
}
}
}
Key logic: Uses $getWorkflowStaticData to implement 30-minute deduplication per endpoint — so if an endpoint is down for 2 hours, you get one alert, not 24. DOWN status flags an OSHA safety data integrity concern in the Slack message; DEGRADED flags BIM coordination clash detection delay. Incidents logged to Postgres for SOC 2 audit trail.
Workflow 3: OSHA/LEED/AIA/Davis-Bacon Compliance Deadline Tracker
AEC compliance has some of the most date-sensitive deadlines across any industry. OSHA 300A summary must be posted February 1 and remain through April 30 — missing it is a $15,625 citation. Davis-Bacon WH-347 certified payroll reports are due every week on federal projects — non-compliance triggers DOL investigation and debarment. LEED certification submissions have hard GBCI deadlines. This workflow runs every weekday at 7 AM and surfaces anything coming due within 30 days.
{
"nodes": [
{
"id": "1",
"name": "Weekdays 7 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
240,
300
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 7 * * 1-5"
}
]
}
}
},
{
"id": "2",
"name": "Read Compliance Calendar",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
460,
300
],
"parameters": {
"operation": "readRows",
"documentId": {
"__rl": true,
"value": "YOUR_SHEET_ID",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "AEC_Deadlines",
"mode": "name"
}
}
},
{
"id": "3",
"name": "Calculate Urgency",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
300
],
"parameters": {
"jsCode": "const actionMap = { OSHA_300_LOG_ANNUAL: 'Post OSHA 300 log by Feb 1. OSHA 29 CFR 1904.32. $15,625/violation.', OSHA_300A_SUMMARY: 'OSHA 300A summary posting Feb 1 - Apr 30. \u00a71904.32(b)(6).', OSHA_INCIDENT_8HR: 'OSHA fatality/inpatient report \u2014 8h window. 29 CFR 1904.39. $15,625/violation.', DAVIS_BACON_WEEKLY_PAYROLL: 'Davis-Bacon WH-347 weekly certified payroll. 40 U.S.C. \u00a73142. Debarment risk.', LEED_CERTIFICATION_DEADLINE: 'LEED v4 certification submission. GBCI documentation deadline. Up to 80 credits at stake.', AIA_CLOSEOUT_PUNCH_LIST: 'AIA A201-2017 \u00a79.8.2 punch list close. Certificate of Substantial Completion triggers warranty start.', BUILDING_PERMIT_EXPIRY: 'Local AHJ building permit expiry. Stop-work order risk. Re-application 30-90 day delay.', FFATA_REPORTING: 'FFATA/2 CFR 200 sub-award reporting. 30 days after award. SAM.gov entry required.', PREVAILING_WAGE_AUDIT: 'Davis-Bacon prevailing wage audit. DOL WHD investigation. Back-pay + debarment.', LEED_CREDIT_AT_RISK: 'LEED credit documentation gap. Certification at risk. Project team action required.' }; const today = new Date(); const results = []; for (const item of $input.all().map(i => i.json)) { if (!item.deadline_date || !item.account_name) continue; const dl = new Date(item.deadline_date); const d = Math.floor((dl - today) / 86400000); if (d > 30) continue; const urgency = d <= 0 ? 'OVERDUE' : d <= 7 ? 'CRITICAL' : d <= 14 ? 'URGENT' : 'WARNING'; const action = actionMap[item.deadline_type] || 'Review compliance calendar.'; results.push({ json: { ...item, days_left: d, urgency, action_note: action } }); } return results;"
}
},
{
"id": "4",
"name": "Has Deadlines?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
900,
300
],
"parameters": {
"conditions": {
"conditions": [
{
"id": "c1",
"leftValue": "={{ $input.all().length }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
]
}
}
},
{
"id": "5",
"name": "Route by Urgency",
"type": "n8n-nodes-base.switch",
"typeVersion": 3,
"position": [
1120,
200
],
"parameters": {
"mode": "rules",
"rules": {
"values": [
{
"outputKey": "critical",
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.urgency }}",
"rightValue": "OVERDUE",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
}
},
{
"outputKey": "critical",
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.urgency }}",
"rightValue": "CRITICAL",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
}
},
{
"outputKey": "warn",
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.urgency }}",
"rightValue": "URGENT",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
}
}
]
}
}
},
{
"id": "6",
"name": "Slack Critical",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [
1340,
120
],
"parameters": {
"operation": "message",
"channel": "#compliance-critical",
"text": "={{ `@here ${$json.urgency} \u2014 ${$json.deadline_type}\\nAccount: ${$json.account_name} | Owner: ${$json.owner_email}\\nDue: ${$json.deadline_date} (${$json.days_left <= 0 ? 'OVERDUE by ' + Math.abs($json.days_left) + 'd' : $json.days_left + 'd remaining'})\\n${$json.action_note}` }}"
}
},
{
"id": "7",
"name": "Gmail Owner Alert",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2,
"position": [
1340,
260
],
"parameters": {
"operation": "send",
"toList": "={{ $json.owner_email }}",
"subject": "={{ `${$json.urgency}: ${$json.deadline_type} \u2014 ${$json.account_name}` }}",
"message": "={{ `${$json.urgency}: ${$json.deadline_type}\\n\\nAccount: ${$json.account_name}\\nDue: ${$json.deadline_date} (${$json.days_left} days)\\n\\n${$json.action_note}` }}"
}
}
],
"connections": {
"Weekdays 7 AM": {
"main": [
[
{
"node": "Read Compliance Calendar",
"type": "main",
"index": 0
}
]
]
},
"Read Compliance Calendar": {
"main": [
[
{
"node": "Calculate Urgency",
"type": "main",
"index": 0
}
]
]
},
"Calculate Urgency": {
"main": [
[
{
"node": "Has Deadlines?",
"type": "main",
"index": 0
}
]
]
},
"Has Deadlines?": {
"main": [
[
{
"node": "Route by Urgency",
"type": "main",
"index": 0
}
],
[]
]
},
"Route by Urgency": {
"critical": [
[
{
"node": "Slack Critical",
"type": "main",
"index": 0
}
]
],
"warn": [
[
{
"node": "Gmail Owner Alert",
"type": "main",
"index": 0
}
]
]
}
}
}
Key deadline types tracked: OSHA_300_LOG_ANNUAL (Feb 1 posting), OSHA_300A_SUMMARY (Feb 1–Apr 30), OSHA_INCIDENT_8HR (fatality — 8h window), DAVIS_BACON_WEEKLY_PAYROLL (every Thursday on federal projects), LEED_CERTIFICATION_DEADLINE (GBCI submission), AIA_CLOSEOUT_PUNCH_LIST (§9.8.2 punch list close), BUILDING_PERMIT_EXPIRY (local AHJ), FFATA_REPORTING (30 days post-award), PREVAILING_WAGE_AUDIT (DOL WHD), LEED_CREDIT_AT_RISK (documentation gap).
The action map includes the regulatory citation and consequence for each deadline type — so the alert Slack message tells the recipient exactly what to do and what's at stake, not just 'deadline coming.'
Workflow 4: Construction Safety Incident & Regulatory Alert Pipeline
OSHA's construction safety standard (29 CFR 1926) governs over 10 million construction workers. When a fatality or inpatient hospitalization occurs on a project, your AEC SaaS platform needs to surface the reporting deadline immediately — before the project team is overwhelmed with the incident response. This webhook pipeline triggers on safety events and routes them by severity.
{
"nodes": [
{
"id": "1",
"name": "Safety Incident Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
240,
300
],
"parameters": {
"path": "aec-safety-incident",
"responseMode": "responseNode",
"options": {}
}
},
{
"id": "2",
"name": "Dedup Check",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
460,
300
],
"parameters": {
"operation": "executeQuery",
"query": "SELECT id FROM aec_incidents WHERE incident_type='{{ $json.incident_type }}' AND project_id='{{ $json.project_id }}' AND created_at > NOW()-INTERVAL '30 minutes'"
}
},
{
"id": "3",
"name": "New Incident?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
680,
300
],
"parameters": {
"conditions": {
"conditions": [
{
"id": "c1",
"leftValue": "={{ $('Dedup Check').all().length }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "equals"
}
}
]
}
}
},
{
"id": "4",
"name": "Log to Postgres",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
900,
200
],
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO aec_incidents (account_id, project_id, project_name, incident_type, severity, description, created_at) VALUES ('{{ $json.account_id }}', '{{ $json.project_id }}', '{{ $json.project_name }}', '{{ $json.incident_type }}', '{{ $json.severity }}', '{{ $json.description }}', NOW())"
}
},
{
"id": "5",
"name": "Route by Incident Type",
"type": "n8n-nodes-base.switch",
"typeVersion": 3,
"position": [
1120,
200
],
"parameters": {
"mode": "rules",
"rules": {
"values": [
{
"outputKey": "osha_fatality",
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.incident_type }}",
"rightValue": "OSHA_REPORTABLE_FATALITY",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
}
},
{
"outputKey": "osha_inpatient",
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.incident_type }}",
"rightValue": "OSHA_REPORTABLE_INPATIENT",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
}
},
{
"outputKey": "safety_risk",
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.incident_type }}",
"rightValue": "OSHA_NEAR_MISS",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
}
},
{
"outputKey": "leed_risk",
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.incident_type }}",
"rightValue": "LEED_CREDIT_AT_RISK",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
}
}
]
}
}
},
{
"id": "6",
"name": "Alert Fatality",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [
1340,
60
],
"parameters": {
"operation": "message",
"channel": "#safety-emergency",
"text": "={{ `<!channel> OSHA REPORTABLE FATALITY \u2014 8-HOUR REPORTING WINDOW\\nProject: ${$json.project_name} | Account: ${$json.account_name}\\nIncident: ${$json.description}\\nOSHA 29 CFR 1904.39: report to OSHA within 8 hours. 1-800-321-OSHA.\\nState plan states may have shorter windows. $15,625/violation.` }}"
}
},
{
"id": "7",
"name": "Alert Inpatient",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [
1340,
180
],
"parameters": {
"operation": "message",
"channel": "#safety-emergency",
"text": "={{ `<!channel> OSHA INPATIENT HOSPITALIZATION \u2014 24-HOUR WINDOW\\nProject: ${$json.project_name} | Account: ${$json.account_name}\\nIncident: ${$json.description}\\nOSHA 29 CFR 1904.39: report within 24 hours. 1-800-321-OSHA. $15,625/violation.` }}"
}
},
{
"id": "8",
"name": "Alert Near Miss",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [
1340,
300
],
"parameters": {
"operation": "message",
"channel": "#safety-ops",
"text": "={{ `OSHA NEAR MISS \u2014 Document and investigate\\nProject: ${$json.project_name}\\nDescription: ${$json.description}\\nOSHA 1926.20(a): employer must initiate and maintain programs to provide for frequent and regular inspections. Near miss logged to OSHA 300 audit trail.` }}"
}
},
{
"id": "9",
"name": "Alert LEED Risk",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [
1340,
420
],
"parameters": {
"operation": "message",
"channel": "#leed-ops",
"text": "={{ `LEED CREDIT AT RISK\\nProject: ${$json.project_name}\\nCredit: ${$json.leed_credit}\\nIssue: ${$json.description}\\nAction required before GBCI submission deadline.` }}"
}
}
],
"connections": {
"Safety Incident Webhook": {
"main": [
[
{
"node": "Dedup Check",
"type": "main",
"index": 0
}
]
]
},
"Dedup Check": {
"main": [
[
{
"node": "New Incident?",
"type": "main",
"index": 0
}
]
]
},
"New Incident?": {
"main": [
[
{
"node": "Log to Postgres",
"type": "main",
"index": 0
}
],
[]
]
},
"Log to Postgres": {
"main": [
[
{
"node": "Route by Incident Type",
"type": "main",
"index": 0
}
]
]
},
"Route by Incident Type": {
"osha_fatality": [
[
{
"node": "Alert Fatality",
"type": "main",
"index": 0
}
]
],
"osha_inpatient": [
[
{
"node": "Alert Inpatient",
"type": "main",
"index": 0
}
]
],
"safety_risk": [
[
{
"node": "Alert Near Miss",
"type": "main",
"index": 0
}
]
],
"leed_risk": [
[
{
"node": "Alert LEED Risk",
"type": "main",
"index": 0
}
]
]
}
}
}
Incident types handled:
-
OSHA_REPORTABLE_FATALITY: 8-hour reporting window. Alert fires to#safety-emergencywith<!channel>and the 1-800-321-OSHA number. -
OSHA_REPORTABLE_INPATIENT: 24-hour window. Same channel, same urgency. -
OSHA_NEAR_MISS: Routes to#safety-opsfor documentation. OSHA 1926.20(a) requires frequent inspections — near-miss records support this. -
LEED_CREDIT_AT_RISK: Routes to#leed-opsfor the project team.
All incidents log to Postgres with ON CONFLICT DO NOTHING dedup. 30-minute dedup window prevents duplicate alerts on the same incident. Webhook responds 200 immediately — no blocking.
Workflow 5: Weekly AEC Platform KPI Dashboard
Your Monday morning report needs to tell you three things: ARR health, safety incident trend, and compliance risk. This workflow queries two Postgres views in parallel, merges them, and builds a color-coded HTML email. If there are OSHA fatalities or overdue compliance deadlines, they appear in the email subject line so you know before you open it.
{
"nodes": [
{
"id": "1",
"name": "Monday 7 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
240,
300
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 7 * * 1"
}
]
}
}
},
{
"id": "2",
"name": "Fetch Platform Metrics",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
460,
200
],
"parameters": {
"operation": "executeQuery",
"query": "SELECT COUNT(*) as total_accounts, COUNT(*) FILTER (WHERE arr_usd > 0) as paying_accounts, SUM(arr_usd) as total_arr, AVG(arr_usd) FILTER (WHERE arr_usd > 0) as avg_arr, COUNT(*) FILTER (WHERE created_at > NOW()-INTERVAL '7 days') as new_signups_7d FROM aec_accounts WHERE is_active = true"
}
},
{
"id": "3",
"name": "Fetch Compliance Metrics",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
460,
420
],
"parameters": {
"operation": "executeQuery",
"query": "SELECT COUNT(*) FILTER (WHERE incident_type LIKE 'OSHA%' AND created_at > NOW()-INTERVAL '7 days') as osha_incidents_7d, COUNT(*) FILTER (WHERE incident_type = 'OSHA_REPORTABLE_FATALITY' AND created_at > NOW()-INTERVAL '30 days') as fatalities_30d, COUNT(*) FILTER (WHERE deadline_type IS NOT NULL AND status = 'OVERDUE') as overdue_deadlines, COUNT(*) FILTER (WHERE incident_type = 'LEED_CREDIT_AT_RISK' AND created_at > NOW()-INTERVAL '30 days') as leed_risks_30d FROM aec_incidents WHERE created_at > NOW()-INTERVAL '30 days'"
}
},
{
"id": "4",
"name": "Merge Metrics",
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
680,
300
],
"parameters": {
"mode": "combine",
"combineBy": "combineAll",
"options": {}
}
},
{
"id": "5",
"name": "Build KPI Report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
900,
300
],
"parameters": {
"jsCode": "const prev = $getWorkflowStaticData('global'); const m = { ...($input.first().json), ...($input.last().json) }; const prevArr = prev.last_arr || 0; const arrWoW = prevArr > 0 ? (((m.total_arr - prevArr) / prevArr) * 100).toFixed(1) : 'N/A'; prev.last_arr = m.total_arr; $setWorkflowStaticData('global', prev); const hasOshaFatality = (m.fatalities_30d || 0) > 0; const hasOverdueDeadlines = (m.overdue_deadlines || 0) > 0; const subject = [hasOshaFatality ? `[OSHA FATALITY: ${m.fatalities_30d}]` : '', hasOverdueDeadlines ? `[OVERDUE: ${m.overdue_deadlines}]` : '', 'Weekly AEC Platform KPI'].filter(Boolean).join(' | '); const html = ['<h2>AEC Platform Weekly KPI</h2>', '<table border=\\'1\\' cellpadding=\\'6\\' style=\\'border-collapse:collapse;font-family:monospace;\\'>', '<tr><th>Metric</th><th>Value</th><th>WoW</th></tr>', `<tr><td>Total Accounts</td><td>${m.total_accounts||0}</td><td>\u2014</td></tr>`, `<tr><td>Paying Accounts</td><td>${m.paying_accounts||0}</td><td>\u2014</td></tr>`, `<tr><td>Total ARR</td><td>$${Number(m.total_arr||0).toLocaleString()}</td><td>${arrWoW}%</td></tr>`, `<tr><td>New Signups (7d)</td><td>${m.new_signups_7d||0}</td><td>\u2014</td></tr>`, '<tr><td colspan=\\'3\\' style=\\'background:#f5f5f5;font-weight:bold;\\'>Safety & Compliance</td></tr>', `<tr style=\\'background:${(m.osha_incidents_7d||0)>0?'#ffe0e0':'#e8f5e9'};\\'><td>OSHA Incidents (7d)</td><td>${m.osha_incidents_7d||0}</td><td>\u2014</td></tr>`, `<tr style=\\'background:${(m.fatalities_30d||0)>0?'#ffcccc':'#e8f5e9'};\\'><td>Fatalities (30d)</td><td>${m.fatalities_30d||0}</td><td>\u2014</td></tr>`, `<tr style=\\'background:${(m.overdue_deadlines||0)>0?'#fff3e0':'#e8f5e9'};\\'><td>Overdue Deadlines</td><td>${m.overdue_deadlines||0}</td><td>\u2014</td></tr>`, `<tr style=\\'background:${(m.leed_risks_30d||0)>0?'#fff9c4':'#e8f5e9'};\\'><td>LEED Credit Risks (30d)</td><td>${m.leed_risks_30d||0}</td><td>\u2014</td></tr>`, '</table>'].join(''); return [{ json: { subject, html_body: html, ...m } }];"
}
},
{
"id": "6",
"name": "Email CEO",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2,
"position": [
1120,
300
],
"parameters": {
"operation": "send",
"toList": "ceo@yourcompany.com",
"ccList": "safetydir@yourcompany.com",
"subject": "={{ $json.subject }}",
"message": "={{ $json.html_body }}",
"options": {
"bodyContentType": "html"
}
}
}
],
"connections": {
"Monday 7 AM": {
"main": [
[
{
"node": "Fetch Platform Metrics",
"type": "main",
"index": 0
},
{
"node": "Fetch Compliance Metrics",
"type": "main",
"index": 0
}
]
]
},
"Fetch Platform Metrics": {
"main": [
[
{
"node": "Merge Metrics",
"type": "main",
"index": 0
}
]
]
},
"Fetch Compliance Metrics": {
"main": [
[
{
"node": "Merge Metrics",
"type": "main",
"index": 1
}
]
]
},
"Merge Metrics": {
"main": [
[
{
"node": "Build KPI Report",
"type": "main",
"index": 0
}
]
]
},
"Build KPI Report": {
"main": [
[
{
"node": "Email CEO",
"type": "main",
"index": 0
}
]
]
}
}
}
Subject line logic: If fatalities_30d > 0, the subject includes [OSHA FATALITY: N]. If overdue_deadlines > 0, it includes [OVERDUE: N]. This means your CEO reads critical compliance news from the subject line — they don't need to open the email to know it's urgent. Safety Director is CC'd automatically.
Implementation Notes
Self-hosting angle for AEC SaaS: Construction site safety data (injury records, near-miss reports), subcontractor financials (Davis-Bacon payroll rates, bid pricing), BIM model data (IP-sensitive design files), and permit conditions (commercially sensitive competitive intelligence) are all categories that AEC general counsel increasingly objects to routing through third-party automation clouds. n8n on a private VPC eliminates the sub-processor disclosure problem entirely.
OSHA 8-hour window: In production, wire the Safety Incident webhook directly to your field reporting app (Procore, PlanGrid, or a custom form). When a supervisor marks an incident as OSHA_REPORTABLE_FATALITY in the field, the webhook fires, n8n processes in under 1 second, and the Slack alert goes out with the 8-hour clock timestamp. Zapier's webhook processing queue can add minutes of latency — unacceptable for OSHA reporting.
Davis-Bacon: Weekly certified payroll (WH-347) is due each Thursday for federal projects. Wire the deadline tracker to your project list — when a new federal project is added to Sheets, it auto-creates a recurring weekly deadline row.
Get the Complete Workflow Pack
All 5 workflows above are available as import-ready JSON files, plus 10 more automations covering additional AEC and SaaS use cases:
👉 FlowKit n8n Templates on Gumroad
Individual templates from $12. Full bundle $97.
Have questions about a specific AEC compliance workflow? Drop a comment below.
Top comments (0)