
Implementation Guide: Trigger permit application workflows when a job reaches the relevant phase
Step-by-step implementation guide for deploying AI to trigger permit application workflows when a job reaches the relevant phase for Construction & Contractors clients.
Hardware Procurement
Samsung Galaxy Tab Active5 (Enterprise Edition)
$600 per unit (MSP cost) / $750 suggested resale + $125 setup fee per device
Rugged field tablets for project managers and superintendents to update project phases from job sites, view permit statuses, acknowledge notifications, and upload field photos for permit applications. MIL-STD-810H rated for dust, drops, and vibration. Enterprise Edition includes Knox Mobile Enrollment for zero-touch MDM provisioning.
Samsung Galaxy Tab Active5 Protective Cover
$40 per unit (MSP cost) / $65 suggested resale
Additional protective cover with kickstand for field use; bundled with tablet deployment.
Otterbox Defender Series Case for iPad 10th Gen
$70 per unit (MSP cost) / $95 suggested resale
Alternative iPad protective case for office-based PMs who prefer iOS. Only procure if client has existing iPads; otherwise default to Samsung Galaxy Tab Active5.
Logitech Combo Touch Keyboard Case for iPad 10th Gen
$200 per unit (MSP cost) / $260 suggested resale
Keyboard case for office project coordinators who need to do extended data entry for permit applications on tablets. Only procure alongside iPad option.
Software Procurement
Buildertrend Pro
$499/month (direct); MSP resale at $599/month if managing the account
Primary construction project management platform. Serves as the source-of-truth for project phases, schedules, and job data. Phase-change events in Buildertrend trigger the entire permit automation workflow via Zapier integration or API polling. Includes scheduling, document management, client portal, and daily logs.
PermitFlow
$500–$1,500/month estimated (contact vendor for quote); referral partnership available for MSPs
AI-powered permit preparation, submission, and tracking platform. Receives automated permit initiation requests from the workflow engine, identifies jurisdiction-specific requirements, assembles application packages, and manages the submission lifecycle. Integrates with Procore Marketplace and provides API endpoints for custom integrations.
n8n (self-hosted)
$0 software license; $30–$40/month VPS hosting (Hetzner/DigitalOcean); MSP charges client $200–$500/month as managed automation service
Core workflow automation engine. Receives webhook events from Buildertrend (via Zapier webhook relay or direct API polling), evaluates jurisdiction-specific permit rules, triggers PermitFlow API calls, sends notifications, updates accounting records, and logs all actions. Self-hosted gives MSP full control, zero per-execution fees, and ability to white-label.
Zapier Team Plan
$103.50/month (Team plan, billed monthly); MSP resale at $150/month
Used as a webhook relay and lightweight integration bridge between Buildertrend (which has native Zapier triggers) and the n8n automation engine. Also serves as a fallback automation platform if client prefers a fully managed SaaS stack instead of self-hosted n8n. Provides native Buildertrend and Procore triggers without custom API code.
DocuSign Standard
$45/user/month = $135/month; MSP resale at $165/month
Electronic signature platform for permit application documents, owner authorizations, contractor affidavits, and subcontractor consent forms. Automated workflows send signing requests to appropriate parties when permit applications are assembled.
QuickBooks Online Plus
$80/month (direct); included in existing client subscription if already using QBO
Accounting platform for job costing and permit fee tracking. Automation workflows create estimates or journal entries for permit fees when a permit application is initiated, enabling accurate project-level cost tracking.
Slack Pro (or Microsoft Teams)
$8.75/user/month (Slack Pro) or included with Microsoft 365 Business Basic at $6/user/month
Real-time notification channel for permit workflow events: permit initiated, documents ready for review, permit approved, permit rejected, inspection scheduled. Channels organized per project for easy tracking.
DigitalOcean Droplet (for n8n hosting)
$28/month; MSP cost absorbed into managed service fee
Linux VPS to host the self-hosted n8n instance, PostgreSQL database for workflow state, and Nginx reverse proxy. Located in US-East or US-West region for low latency to SaaS APIs.
Prerequisites
- Client must have an active Buildertrend Pro (or higher) subscription, OR Procore subscription with API access enabled. If neither exists, budget 2–4 additional weeks and $499+/month for Buildertrend onboarding.
- Client must have a documented list of project phases currently used in their PM workflow (e.g., Lead, Pre-Construction, Permitting, Construction, Punch List, Close-Out). If no standardized phase list exists, Discovery phase must include phase taxonomy creation.
- Client must provide a list of all municipal jurisdictions they commonly work in (minimum 3–5 for initial deployment), including jurisdiction names, permit office contact information, and any known online submission portals.
- Client must have a business email system (Microsoft 365 or Google Workspace) with admin access to create service accounts, distribution lists, and app passwords.
- Client must have QuickBooks Online Plus (or higher) or Sage 100 Contractor with admin API access for job costing integration. If no accounting software is in place, budget additional time for QBO setup.
- Client must designate a Permit Coordinator or Project Manager as the internal champion who will validate business rules, approve jurisdiction-specific permit mappings, and participate in UAT testing.
- MSP must have: (1) a DigitalOcean or equivalent cloud account for n8n hosting, (2) a registered domain or subdomain for the n8n instance (e.g., automation.clientname.com), (3) an SSL certificate (Let's Encrypt is sufficient), and (4) SSH access configured for the technician.
- Client must provide copies of 3–5 recently completed permit applications per jurisdiction to serve as templates for the automated document assembly process.
- All staff who will interact with the system (office coordinators, project managers, superintendents) must have valid accounts on the PM platform with appropriate permission levels.
- Network firewall at client office must allow outbound HTTPS (port 443) to all SaaS endpoints. No inbound ports required unless self-hosting n8n with direct webhook reception (port 443 via reverse proxy).
Installation Steps
Step 1: Provision n8n Server Infrastructure
Deploy a DigitalOcean Droplet (or equivalent VPS) to host the n8n workflow automation engine. This server will be the central orchestration hub that receives phase-change events, evaluates permit rules, and triggers downstream actions. We use Docker Compose for easy deployment and updates.
doctl compute droplet create n8n-permit-auto --region nyc3 --size s-2vcpu-4gb --image docker-20-04 --ssh-keys <your-ssh-key-fingerprint> --tag-names msp-managed,construction-autossh root@<droplet-ip>apt update && apt upgrade -ymkdir -p /opt/n8n && cd /opt/n8ncat > docker-compose.yml << 'EOF'
version: '3.8'
services:
n8n:
image: n8nio/n8n:latest
restart: always
ports:
- '5678:5678'
environment:
- N8N_HOST=automation.clientdomain.com
- N8N_PORT=5678
- N8N_PROTOCOL=https
- NODE_ENV=production
- WEBHOOK_URL=https://automation.clientdomain.com/
- N8N_BASIC_AUTH_ACTIVE=true
- N8N_BASIC_AUTH_USER=msp_admin
- N8N_BASIC_AUTH_PASSWORD=<generate-strong-password>
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=<generate-db-password>
- EXECUTIONS_DATA_PRUNE=true
- EXECUTIONS_DATA_MAX_AGE=720
- GENERIC_TIMEZONE=America/New_York
volumes:
- n8n_data:/home/node/.n8n
depends_on:
- postgres
postgres:
image: postgres:15-alpine
restart: always
environment:
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=<generate-db-password>
- POSTGRES_DB=n8n
volumes:
- postgres_data:/var/lib/postgresql/data
nginx:
image: nginx:alpine
restart: always
ports:
- '80:80'
- '443:443'
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- ./certs:/etc/nginx/certs
depends_on:
- n8n
volumes:
n8n_data:
postgres_data:
EOFapt install -y certbot
certbot certonly --standalone -d automation.clientdomain.com --agree-tos -m msp-admin@mspdomain.comcat > nginx.conf << 'EOF'
server {
listen 80;
server_name automation.clientdomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
server_name automation.clientdomain.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location / {
proxy_pass http://n8n:5678;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
EOFmkdir -p certs
cp /etc/letsencrypt/live/automation.clientdomain.com/fullchain.pem certs/
cp /etc/letsencrypt/live/automation.clientdomain.com/privkey.pem certs/docker compose up -dcurl -s https://automation.clientdomain.com/healthzReplace <droplet-ip>, <generate-strong-password>, <generate-db-password>, and automation.clientdomain.com with actual values. Store all credentials in the MSP password vault (e.g., IT Glue, Hudu). Set up a cron job for Let's Encrypt auto-renewal: 0 3 1 * * certbot renew --deploy-hook 'cp /etc/letsencrypt/live/automation.clientdomain.com/*.pem /opt/n8n/certs/ && docker compose -f /opt/n8n/docker-compose.yml restart nginx'. Consider DigitalOcean automated backups ($5.60/month) for disaster recovery.
Step 2: Configure Buildertrend API Access and Zapier Webhook Relay
Set up the integration between Buildertrend and the n8n automation engine. Since Buildertrend does not offer direct webhooks but has native Zapier triggers, we use Zapier as a lightweight relay to catch phase-change events and forward them to n8n's webhook endpoint. This approach is more reliable than API polling and gives instant event detection.
In Zapier (web interface)
URL: https://automation.clientdomain.com/webhook/bt-phase-change
Payload Type: JSON
Data fields to map:
job_id: {{job_id}}
job_name: {{job_name}}
new_phase: {{new_status}}
old_phase: {{previous_status}}
project_address: {{job_address}}
city: {{job_city}}
state: {{job_state}}
zip: {{job_zip}}
project_manager: {{assigned_pm_email}}
change_timestamp: {{timestamp}}Alternatively, if using Procore instead of Buildertrend
Procore supports direct webhooks. Configure in Procore Admin > App Management > Webhooks.
URL: https://automation.clientdomain.com/webhook/procore-phase-change
Events: project.update, schedule_tasks.update
Authentication: HMAC-SHA256 with shared secretTest the webhook endpoint in n8n
The Zapier Team plan allows up to 2,000 tasks/month which is more than sufficient for phase-change events (typical contractor processes 10–50 phase changes per month). If using Procore, skip Zapier entirely and use direct webhooks — this saves $103.50/month and reduces latency. Ensure the webhook secret is stored in the MSP password vault and rotated quarterly.
Step 3: Build Jurisdiction-Permit Rules Database
Create a structured rules database that maps project types, phases, and jurisdictions to specific permit requirements. This is the 'brain' of the automation — it determines which permits to initiate for any given phase change. The database is stored as a JSON configuration file on the n8n server and loaded into workflows via the n8n Function node. This allows easy updates without modifying workflow logic.
ssh root@<droplet-ip>mkdir -p /opt/n8n/rulescat > /opt/n8n/rules/permit-rules.json << 'RULES_EOF'
{
"version": "1.0.0",
"last_updated": "2025-01-15",
"updated_by": "msp_technician",
"jurisdictions": {
"austin_tx": {
"name": "City of Austin, TX",
"portal_url": "https://abc.austintexas.gov",
"contact_phone": "512-978-4000",
"rules": [
{
"project_types": ["new_construction", "addition"],
"trigger_phase": "Permitting",
"permits_required": [
{
"permit_type": "Building Permit",
"typical_timeline_days": 30,
"estimated_fee": "$0.15 per sq ft + base fee $250",
"documents_required": ["site_plan", "architectural_drawings", "structural_calculations", "energy_compliance_form"],
"auto_submit": true
},
{
"permit_type": "Electrical Permit",
"typical_timeline_days": 14,
"estimated_fee": "$75 base + $0.05 per sq ft",
"documents_required": ["electrical_plan", "load_calculations"],
"auto_submit": true
},
{
"permit_type": "Plumbing Permit",
"typical_timeline_days": 14,
"estimated_fee": "$75 base + per fixture fee",
"documents_required": ["plumbing_plan"],
"auto_submit": true
},
{
"permit_type": "Mechanical/HVAC Permit",
"typical_timeline_days": 14,
"estimated_fee": "$75 base + per unit fee",
"documents_required": ["mechanical_plan", "manual_j_calculation"],
"auto_submit": true
}
]
},
{
"project_types": ["renovation", "remodel"],
"trigger_phase": "Permitting",
"permits_required": [
{
"permit_type": "Building Permit",
"typical_timeline_days": 21,
"estimated_fee": "Based on valuation",
"documents_required": ["scope_of_work", "architectural_drawings"],
"auto_submit": true
}
]
}
]
},
"round_rock_tx": {
"name": "City of Round Rock, TX",
"portal_url": "https://permits.roundrocktexas.gov",
"contact_phone": "512-218-5480",
"rules": [
{
"project_types": ["new_construction"],
"trigger_phase": "Permitting",
"permits_required": [
{
"permit_type": "Building Permit",
"typical_timeline_days": 25,
"estimated_fee": "$0.18 per sq ft + base fee $200",
"documents_required": ["site_plan", "architectural_drawings", "structural_calculations", "energy_code_compliance"],
"auto_submit": true
}
]
}
]
},
"_default": {
"name": "Unknown Jurisdiction - Manual Review Required",
"portal_url": null,
"contact_phone": null,
"rules": [
{
"project_types": ["all"],
"trigger_phase": "Permitting",
"permits_required": [
{
"permit_type": "MANUAL_REVIEW",
"typical_timeline_days": 0,
"estimated_fee": "Unknown",
"documents_required": [],
"auto_submit": false
}
]
}
]
}
}
}
RULES_EOFcd /opt/n8n
sed -i '/- n8n_data:\/home\/node\/.n8n/a\ - /opt/n8n/rules:/home/node/rules:ro' docker-compose.ymldocker compose down && docker compose up -dStart with the client's 3–5 most common jurisdictions. Add more over time as a managed service activity (billable at $150–$300 per jurisdiction setup). The _default jurisdiction catches any unrecognized location and routes it to manual review instead of failing silently. Have the client's Permit Coordinator review and sign off on all jurisdiction rules before go-live. Save a backup of this file in the MSP's documentation system (IT Glue / Hudu) for disaster recovery.
Step 4: Build Core n8n Permit Trigger Workflow
Create the primary n8n workflow that receives phase-change webhook events, looks up applicable permit rules, determines which permits are needed, and orchestrates the downstream actions (PermitFlow API call, notification, document assembly, accounting entry). This is the central workflow that ties everything together.
The full workflow JSON is provided in the custom_ai_components section under 'Permit Trigger Master Workflow'. After importing, test each branch individually using n8n's manual execution mode before enabling the production webhook trigger. All API credentials should be stored in n8n's built-in encrypted credential store, not in environment variables.
Step 5: Configure PermitFlow Integration
Set up the PermitFlow account and configure the API integration so that the n8n workflow can automatically create permit applications in PermitFlow when triggered. PermitFlow will handle jurisdiction-specific form completion, document packaging, and submission tracking.
# Expected response: 201 Created with permit application ID. Save the
# returned permit_id for status tracking.
curl -X POST https://api.permitflow.com/v1/permits \
-H 'Authorization: Bearer <PERMITFLOW_API_KEY>' \
-H 'Content-Type: application/json' \
-d '{
"project_name": "Test Project - Automation Validation",
"project_address": "123 Test St",
"city": "Austin",
"state": "TX",
"zip": "78701",
"permit_type": "Building Permit",
"project_type": "new_construction",
"estimated_value": 500000,
"contact_name": "Test Contact",
"contact_email": "pm@clientdomain.com",
"documents": [],
"metadata": {
"source": "n8n_automation",
"buildertrend_job_id": "TEST-001"
}
}'- If PermitFlow does not yet have a public REST API, use their Procore Marketplace integration or coordinate with their team for a custom webhook-based intake endpoint
PermitFlow is a rapidly growing startup and their API may be evolving. Schedule a technical integration call with their team during the Discovery phase. If PermitFlow API is not available or not mature enough, the fallback is to use their email intake (send structured emails to a designated PermitFlow address) or use the Procore Marketplace integration if client is on Procore. Always confirm PermitFlow's current API documentation before implementation — request access at https://www.permitflow.com/contact.
Step 6: Configure DocuSign eSignature Integration
Set up DocuSign to automatically send permit-related documents for electronic signature when the workflow determines that owner authorization, contractor affidavits, or other signed documents are required for a permit application.
# DocuSign OAuth2 Credential Fields:
# - Client ID: <integration-key>
# - Client Secret: <client-secret>
# - Auth URI: https://account-d.docusign.com/oauth/auth
# - Token URI: https://account-d.docusign.com/oauth/token
# - Scope: signature impersonation# Envelope Template Configuration:
# Template Name: 'Permit Application Authorization'
# Roles: Owner/Client (Signer 1), Contractor (Signer 2)
# Fields: Signature, Date, Project Name, Project Address, Permit Type
# Template ID will be referenced in the n8n workflow# Production DocuSign Environment:
# Auth URI: https://account.docusign.com/oauth/auth
# Token URI: https://account.docusign.com/oauth/tokenDocuSign Standard plan ($45/user/month) includes API access. For the automation use case, only 1 DocuSign user seat is needed (the service account). Additional seats are only needed if staff manually send envelopes. Create 2-3 envelope templates during implementation: (1) Owner Permit Authorization, (2) Contractor Affidavit, (3) Subcontractor Consent. Test templates with sample data before connecting to the live workflow.
Step 7: Configure QuickBooks Online Job Costing Integration
Set up the QuickBooks Online integration so that when a permit workflow is initiated, the estimated permit fees are automatically recorded against the correct job/project in QuickBooks. This keeps the accounting team informed and enables accurate job costing.
curl -X GET 'https://quickbooks.api.intuit.com/v3/company/<realm-id>/companyinfo/<realm-id>' \
-H 'Authorization: Bearer <access-token>' \
-H 'Accept: application/json'The n8n workflow will create Purchase Orders or Estimates for permit fees via POST /v3/company/<realm-id>/estimate, with line items for each permit type and estimated fee, linked to the customer/job matching the Buildertrend project.
QuickBooks Online OAuth2 tokens expire after 1 hour and refresh tokens expire after 100 days. The n8n QuickBooks node handles token refresh automatically, but set a monitoring alert if the refresh token expires (which requires manual re-authorization). Map Buildertrend job names to QuickBooks customer:job names during the Discovery phase — naming conventions must match for automated linking to work.
Step 8: Configure Notification Channels (Slack/Teams + Email)
Set up notification routing so that the right people are informed at each stage of the permit workflow: permit initiated, documents needed, documents signed, permit submitted, permit approved/rejected, inspection scheduled.
Slack Setup
Email Setup
Microsoft Teams Alternative
Test Notifications
curl -X POST https://hooks.slack.com/services/<webhook-url> \
-H 'Content-Type: application/json' \
-d '{"text": "🏗️ Permit Automation Test: System is connected and working!"}'Create a dedicated email address (permits@clientdomain.com) as a service account for permit-related communications. This provides a single point for permit offices to reply to and keeps automated messages separate from personal email. Configure email forwarding rules so that replies from permit offices are also captured in the workflow (using n8n's IMAP trigger node for inbound email monitoring).
Step 9: Configure Field Tablets and MDM
Provision and configure Samsung Galaxy Tab Active5 tablets for field staff so they can update project phases, view permit statuses, and upload field documentation from job sites. Use Samsung Knox or Microsoft Intune for mobile device management.
Samsung Knox Mobile Enrollment (if using Knox)
Microsoft Intune (if client has M365)
Per-Device Setup (Manual Fallback)
Samsung Galaxy Tab Active5 Enterprise Edition includes 4 years of OS updates and Knox support. Order from Samsung Partner portal or authorized distributor (SHI, CDW, Insight). Apply for Samsung Partner Program for MSP pricing at https://www.samsung.com/us/business/. If client has fewer than 4 field users, reduce tablet quantity accordingly. Budget $125 per device for MSP setup and enrollment time.
Step 10: Import and Activate Production Workflows
Import the complete permit automation workflow into the production n8n instance, verify all credential connections, activate the workflow, and enable the webhook endpoint for live phase-change events.
Before activating the production webhook, ensure the Zapier relay zap is paused. Activate the n8n workflow first, then unpause the Zapier zap. This prevents missed events during the switchover. Keep the test workflow data in n8n for future debugging reference. Set up a weekly automated test using n8n's Cron trigger that sends a synthetic phase-change event through the system to verify end-to-end connectivity (with a special 'test' flag that prevents real permit creation).
Step 11: Perform End-to-End Integration Testing
Execute comprehensive testing across all integrated systems to validate that phase changes in Buildertrend correctly trigger the complete permit workflow chain. Test with real project data across multiple jurisdictions.
Test Scenario 1: New Construction in Known Jurisdiction
Test Scenario 2: Unknown Jurisdiction (Manual Review Fallback)
Test Scenario 3: Non-Permit Phase Change (No Trigger)
Test Scenario 4: Error Handling
Create a testing checklist document and have both the MSP technician and the client's Permit Coordinator sign off on each test scenario. Keep test projects clearly labeled with 'TEST-' prefix so they can be easily cleaned up. Document any jurisdiction rule corrections discovered during testing and update the permit-rules.json file accordingly.
Step 12: Conduct User Training and Go-Live
Train all client staff who interact with the system: project managers, office coordinators, superintendents, and the permit coordinator. Cover daily operations, how to interpret notifications, how to handle edge cases, and how to request changes to jurisdiction rules.
Training Session Agenda (2 hours, recorded via Zoom/Teams)
Session 1: Office Staff & PM Training (90 minutes)
Session 2: Field Staff Training (30 minutes)
Deliverables to Provide After Training
- Recorded training video (uploaded to client's SharePoint/Drive)
- Quick Reference Card (PDF, 1 page, laminated for field use)
- Runbook document (detailed troubleshooting steps)
- Jurisdiction rules summary (which permits are automated where)
Schedule training 3–5 days before go-live to give staff time to practice. Provide a 'sandbox mode' period (1 week) where the automation runs but requires manual confirmation before any permit is actually submitted to PermitFlow. After the sandbox period, switch to fully automated mode. The recorded training video becomes part of the client handoff documentation.
Custom AI Components
Permit Trigger Master Workflow
Type: workflow
The core n8n workflow that receives phase-change events from the construction PM platform, evaluates jurisdiction-specific permit rules, and orchestrates all downstream actions including permit application creation, document signing, accounting entries, and team notifications. This is a deterministic rules engine, not an AI/ML model — it follows explicit if/then logic based on the jurisdiction rules database.
Implementation:
n8n Workflow Specification: Permit Trigger Master
- Workflow ID: permit-trigger-master
- Trigger: Webhook (POST /bt-phase-change)
Node 1: Webhook Receiver
- Type: Webhook
- Method: POST
- Path: /bt-phase-change
- Authentication: Header Auth — Header Name: X-Webhook-Secret, Header Value: {{$credentials.webhookSecret}}
- Response: Immediately return 200 OK
Node 2: Validate Payload
- Type: Function
const items = $input.all();
const data = items[0].json;
// Validate required fields
const requiredFields = ['job_id', 'job_name', 'new_phase', 'project_address', 'city', 'state', 'zip'];
const missingFields = requiredFields.filter(f => !data[f]);
if (missingFields.length > 0) {
throw new Error(`Missing required fields: ${missingFields.join(', ')}`);
}
// Normalize phase name (handle variations)
const phaseMap = {
'permitting': 'Permitting',
'permit': 'Permitting',
'permits': 'Permitting',
'pre-construction': 'Pre-Construction',
'preconstruction': 'Pre-Construction',
'construction': 'Construction',
'punch list': 'Punch List',
'punchlist': 'Punch List',
'close-out': 'Close-Out',
'closeout': 'Close-Out'
};
data.normalized_phase = phaseMap[data.new_phase.toLowerCase()] || data.new_phase;
data.received_at = new Date().toISOString();
data.workflow_run_id = `PR-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`;
return [{ json: data }];Node 3: Check if Phase Requires Permits
- Type: IF
- Condition:
{{$json.normalized_phase}}equalsPermitting - True branch: Continue to Node 4
- False branch: Go to Node 3b (Log and Exit)
Node 3b: Log Non-Permit Phase Change
- Type: Function
console.log(`Phase change for ${$json.job_name}: ${$json.old_phase} → ${$json.new_phase}. No permit action required.`);
return [{ json: { status: 'no_action', reason: 'Phase does not require permits', ...($input.all()[0].json) } }];Node 4: Load Jurisdiction Rules
- Type: Function
const fs = require('fs');
const data = $input.all()[0].json;
// Load rules from mounted volume
let rules;
try {
const rulesFile = fs.readFileSync('/home/node/rules/permit-rules.json', 'utf8');
rules = JSON.parse(rulesFile);
} catch (err) {
throw new Error(`Failed to load permit rules: ${err.message}`);
}
// Normalize jurisdiction key: city_state (lowercase, underscores)
const jurisdictionKey = `${data.city.toLowerCase().replace(/[^a-z0-9]/g, '_')}_${data.state.toLowerCase()}`;
// Look up jurisdiction, fall back to _default
const jurisdiction = rules.jurisdictions[jurisdictionKey] || rules.jurisdictions['_default'];
const isDefaultFallback = !rules.jurisdictions[jurisdictionKey];
// Determine project type from job metadata (default to 'new_construction' if not specified)
const projectType = data.project_type || 'new_construction';
// Find matching rules for this project type
let matchingRules = jurisdiction.rules.filter(r =>
r.project_types.includes(projectType) || r.project_types.includes('all')
);
// Filter to rules triggered by current phase
matchingRules = matchingRules.filter(r => r.trigger_phase === data.normalized_phase);
const result = {
...data,
jurisdiction_name: jurisdiction.name,
jurisdiction_key: jurisdictionKey,
jurisdiction_portal_url: jurisdiction.portal_url,
jurisdiction_contact_phone: jurisdiction.contact_phone,
is_default_fallback: isDefaultFallback,
permits_required: matchingRules.length > 0 ? matchingRules[0].permits_required : [],
total_permits: matchingRules.length > 0 ? matchingRules[0].permits_required.length : 0
};
return [{ json: result }];Node 5: Route by Permit Type
- Type: IF
- Condition:
{{$json.is_default_fallback}}equalstrue - True branch: Go to Node 5a (Manual Review Path)
- False branch: Go to Node 6 (Automated Path)
Node 5a: Manual Review Notification
- Type: Slack
- Channel: #permits-urgent
- Then: Send email to Permit Coordinator and exit
🚨 *Manual Permit Review Required*
*Project:* {{$json.job_name}}
*Address:* {{$json.project_address}}, {{$json.city}}, {{$json.state}} {{$json.zip}}
*Phase:* {{$json.normalized_phase}}
*PM:* {{$json.project_manager}}
⚠️ This jurisdiction ({{$json.city}}, {{$json.state}}) is not yet configured for automated permitting. Please review manually and contact the MSP to add this jurisdiction.
Workflow Run: {{$json.workflow_run_id}}Node 6: Split Out Individual Permits
- Type: Function
const data = $input.all()[0].json;
const permits = data.permits_required;
return permits.map(permit => ({
json: {
...data,
current_permit: permit
}
}));Node 7: Create PermitFlow Application
- Type: HTTP Request
- Method: POST
- URL: https://api.permitflow.com/v1/permits
- Authentication: Bearer Token (from credentials)
- Error handling: Continue on error (caught by Node 7a)
{
"project_name": "{{$json.job_name}}",
"project_address": "{{$json.project_address}}",
"city": "{{$json.city}}",
"state": "{{$json.state}}",
"zip": "{{$json.zip}}",
"permit_type": "{{$json.current_permit.permit_type}}",
"project_type": "{{$json.project_type}}",
"documents_required": "{{$json.current_permit.documents_required}}",
"contact_email": "{{$json.project_manager}}",
"metadata": {
"source": "n8n_automation",
"buildertrend_job_id": "{{$json.job_id}}",
"workflow_run_id": "{{$json.workflow_run_id}}"
}
}Node 7a: Handle PermitFlow Error
- Type: IF
- Condition: HTTP status code is not 200 or 201
- True branch: Log error, send alert to MSP, retry up to 3 times
- False branch: Continue to Node 8
Node 8: Send DocuSign Envelope (if authorization needed)
- Type: IF → DocuSign
- Condition:
{{$json.current_permit.documents_required}}containsowner_authorization - DocuSign Config — Template ID: {{$credentials.docusignPermitTemplateId}}
- Signer 1: Owner (from project data)
- Signer 2: Contractor (from company data)
- Custom fields: Project Name, Address, Permit Type
Node 9: Create QuickBooks Estimate Line Item
- Type: QuickBooks
- Operation: Create Estimate
- Customer: Match by job_name
- Line Item Description:
Permit Fee - {{$json.current_permit.permit_type}} - {{$json.jurisdiction_name}} - Line Item Amount:
{{$json.current_permit.estimated_fee}} - Line Item Category: Permit & Inspection Fees
Node 10: Send Slack Notification
- Type: Slack
- Channel: #permits-active
✅ *Permit Workflow Initiated*
*Project:* {{$json.job_name}}
*Permit Type:* {{$json.current_permit.permit_type}}
*Jurisdiction:* {{$json.jurisdiction_name}}
*Est. Timeline:* {{$json.current_permit.typical_timeline_days}} days
*Est. Fee:* {{$json.current_permit.estimated_fee}}
*Documents Required:* {{$json.current_permit.documents_required}}
*PM:* {{$json.project_manager}}
📋 PermitFlow Application: Created
✍️ DocuSign: {{$json.docusign_status || 'Not required'}}
💰 QuickBooks: Estimate created
Workflow Run: {{$json.workflow_run_id}}Node 11: Send Email Summary to PM
- Type: Email (SMTP)
- To: {{$json.project_manager}}
- Subject: [Permit Initiated] {{$json.job_name}} - {{$json.current_permit.permit_type}}
- Body: HTML template with all permit details, links to PermitFlow, and action items
Node 12: Log Completion
- Type: Function
const data = $input.all()[0].json;
console.log(JSON.stringify({
event: 'permit_workflow_completed',
workflow_run_id: data.workflow_run_id,
job_id: data.job_id,
permit_type: data.current_permit.permit_type,
jurisdiction: data.jurisdiction_key,
timestamp: new Date().toISOString()
}));
return $input.all();Error Handler (Global)
- Type: Error Trigger Workflow
Jurisdiction Rules Engine
Type: skill A deterministic rules evaluation engine that takes a project's location (city, state, zip), project type, and current phase as inputs, then returns the list of permits required, their estimated timelines and fees, required documents, and whether automated submission is supported for that jurisdiction. This component is the core decision-making logic of the system.
Purpose
Evaluate incoming phase-change events against a jurisdiction-specific rules database to determine which permits are needed and what actions to take.
Input Schema
{
"job_id": "string - unique identifier from PM platform",
"city": "string - project city",
"state": "string - 2-letter state code",
"zip": "string - 5-digit ZIP code",
"project_type": "string - enum: new_construction, addition, renovation, remodel, demolition, tenant_improvement, roofing, mechanical, electrical, plumbing",
"phase": "string - normalized phase name",
"estimated_value": "number - project valuation in USD (optional)",
"square_footage": "number - project square footage (optional)"
}Output Schema
{
"jurisdiction_matched": "boolean",
"jurisdiction_name": "string",
"jurisdiction_key": "string",
"requires_manual_review": "boolean",
"permits": [
{
"permit_type": "string",
"auto_submit": "boolean",
"estimated_fee": "string",
"calculated_fee": "number or null",
"typical_timeline_days": "number",
"documents_required": ["string"],
"portal_url": "string or null"
}
]
}Implementation (n8n Function Node)
const fs = require('fs');
function evaluatePermitRules(input) {
// Load rules database
const rulesFile = fs.readFileSync('/home/node/rules/permit-rules.json', 'utf8');
const rulesDb = JSON.parse(rulesFile);
// Build jurisdiction key
const cityNorm = input.city.toLowerCase().replace(/[^a-z0-9]/g, '_');
const stateNorm = input.state.toLowerCase();
const jurisdictionKey = `${cityNorm}_${stateNorm}`;
// Lookup jurisdiction
const jurisdiction = rulesDb.jurisdictions[jurisdictionKey];
const isKnown = !!jurisdiction;
const activeJurisdiction = jurisdiction || rulesDb.jurisdictions['_default'];
// Find applicable rules
const applicableRules = activeJurisdiction.rules.filter(rule => {
const typeMatch = rule.project_types.includes(input.project_type) || rule.project_types.includes('all');
const phaseMatch = rule.trigger_phase === input.phase;
return typeMatch && phaseMatch;
});
// Flatten permits from all matching rules
let permits = [];
applicableRules.forEach(rule => {
rule.permits_required.forEach(permit => {
// Calculate fee if formula is provided and inputs are available
let calculatedFee = null;
if (permit.fee_formula && input.square_footage) {
try {
// Simple fee calculation: rate * sqft + base
const formula = permit.fee_formula;
if (formula.type === 'per_sqft_plus_base') {
calculatedFee = (formula.rate_per_sqft * input.square_footage) + formula.base_fee;
} else if (formula.type === 'percentage_of_value' && input.estimated_value) {
calculatedFee = input.estimated_value * formula.percentage;
}
} catch (e) {
calculatedFee = null;
}
}
permits.push({
permit_type: permit.permit_type,
auto_submit: permit.auto_submit && isKnown,
estimated_fee: permit.estimated_fee,
calculated_fee: calculatedFee,
typical_timeline_days: permit.typical_timeline_days,
documents_required: permit.documents_required,
portal_url: activeJurisdiction.portal_url
});
});
});
return {
jurisdiction_matched: isKnown,
jurisdiction_name: activeJurisdiction.name,
jurisdiction_key: jurisdictionKey,
requires_manual_review: !isKnown || permits.some(p => p.permit_type === 'MANUAL_REVIEW'),
permits: permits.filter(p => p.permit_type !== 'MANUAL_REVIEW')
};
}
// n8n execution
const input = $input.all()[0].json;
const result = evaluatePermitRules({
job_id: input.job_id,
city: input.city,
state: input.state,
zip: input.zip,
project_type: input.project_type || 'new_construction',
phase: input.normalized_phase,
estimated_value: input.estimated_value || null,
square_footage: input.square_footage || null
});
return [{ json: { ...input, ...result } }];Adding a New Jurisdiction
jurisdictions objectssh root@<droplet-ip>
nano /opt/n8n/rules/permit-rules.json
cat /opt/n8n/rules/permit-rules.json | python3 -m json.toolEstimated MSP time per new jurisdiction: 1-2 hours (research + config + test)
License and Bond Verification Check
Type: integration A pre-permit-submission verification step that checks the contractor's license and bond status before initiating a permit application. This prevents permit rejections due to expired credentials and ensures compliance with state contractor licensing requirements. Implemented as an n8n sub-workflow that is called by the master workflow before the PermitFlow API call.
License & Bond Verification - n8n Sub-Workflow
Trigger: Called by Master Workflow (Execute Sub-Workflow node)
Input
{
"contractor_name": "string",
"license_number": "string",
"state": "string",
"bond_expiry_date": "string (ISO date)",
"insurance_expiry_date": "string (ISO date)",
"job_id": "string"
}Node 1: Load Contractor Credentials from Database
Type: Function
// Contractor credentials are stored in a JSON file
// In production, this could be a database lookup
const fs = require('fs');
const input = $input.all()[0].json;
let contractors;
try {
const file = fs.readFileSync('/home/node/rules/contractor-credentials.json', 'utf8');
contractors = JSON.parse(file);
} catch (err) {
// If no file exists, pass through with unknown status
return [{ json: { ...input, verification_status: 'UNKNOWN', message: 'Contractor credentials database not found' } }];
}
const contractor = contractors[input.license_number];
if (!contractor) {
return [{ json: { ...input, verification_status: 'NOT_FOUND', message: `License ${input.license_number} not in database` } }];
}
return [{ json: { ...input, ...contractor } }];Node 2: Check Expiry Dates
Type: Function
const data = $input.all()[0].json;
const now = new Date();
const warningDays = 30;
const issues = [];
// Check license expiry
if (data.license_expiry_date) {
const licExpiry = new Date(data.license_expiry_date);
const daysUntilLicExpiry = Math.floor((licExpiry - now) / (1000 * 60 * 60 * 24));
if (daysUntilLicExpiry < 0) {
issues.push({ type: 'CRITICAL', message: `Contractor license expired ${Math.abs(daysUntilLicExpiry)} days ago` });
} else if (daysUntilLicExpiry < warningDays) {
issues.push({ type: 'WARNING', message: `Contractor license expires in ${daysUntilLicExpiry} days` });
}
}
// Check bond expiry
if (data.bond_expiry_date) {
const bondExpiry = new Date(data.bond_expiry_date);
const daysUntilBondExpiry = Math.floor((bondExpiry - now) / (1000 * 60 * 60 * 24));
if (daysUntilBondExpiry < 0) {
issues.push({ type: 'CRITICAL', message: `Surety bond expired ${Math.abs(daysUntilBondExpiry)} days ago` });
} else if (daysUntilBondExpiry < warningDays) {
issues.push({ type: 'WARNING', message: `Surety bond expires in ${daysUntilBondExpiry} days` });
}
}
// Check insurance expiry
if (data.insurance_expiry_date) {
const insExpiry = new Date(data.insurance_expiry_date);
const daysUntilInsExpiry = Math.floor((insExpiry - now) / (1000 * 60 * 60 * 24));
if (daysUntilInsExpiry < 0) {
issues.push({ type: 'CRITICAL', message: `Insurance expired ${Math.abs(daysUntilInsExpiry)} days ago` });
} else if (daysUntilInsExpiry < warningDays) {
issues.push({ type: 'WARNING', message: `Insurance expires in ${daysUntilInsExpiry} days` });
}
}
const hasCritical = issues.some(i => i.type === 'CRITICAL');
const hasWarning = issues.some(i => i.type === 'WARNING');
return [{ json: {
...data,
verification_status: hasCritical ? 'BLOCKED' : hasWarning ? 'WARNING' : 'CLEAR',
verification_issues: issues,
can_proceed: !hasCritical
}}];Node 3: Route Based on Verification
Type: Switch
BLOCKED→ Send urgent alert, halt permit workflow, notify PMWARNING→ Send warning, proceed with permit but flag for attentionCLEAR→ Proceed normally
contractor-credentials.json Schema
# example schema with license, bond, and insurance fields
{
"TX-LIC-12345": {
"contractor_name": "ABC Construction LLC",
"license_number": "TX-LIC-12345",
"license_state": "TX",
"license_expiry_date": "2026-03-15",
"bond_number": "SB-987654",
"bond_amount": 25000,
"bond_expiry_date": "2025-12-31",
"insurance_carrier": "Hartford",
"insurance_policy": "GL-456789",
"insurance_expiry_date": "2025-09-30",
"workers_comp_policy": "WC-111222",
"workers_comp_expiry": "2025-11-15"
}
}Permit Status Polling Workflow
Type: workflow
A scheduled workflow that runs daily to check the status of all active permit applications in PermitFlow and sends notifications when permit statuses change (approved, rejected, requires additional information, inspection scheduled). This ensures the team stays informed even when not actively checking PermitFlow.
n8n Workflow: Permit Status Poller
Trigger: Cron Schedule
- Schedule: Every day at 7:00 AM and 2:00 PM (client's local timezone)
- Cron Expression:
0 7,14 * * 1-5(weekdays only)
Node 1: Fetch Active Permits from PermitFlow
- Type: HTTP Request
- Method: GET
- URL: https://api.permitflow.com/v1/permits?status=active,pending,in_review
- Auth: Bearer Token
Node 2: Load Previous Status Cache
- Type: Function
const fs = require('fs');
let previousStatuses = {};
try {
const cache = fs.readFileSync('/home/node/rules/permit-status-cache.json', 'utf8');
previousStatuses = JSON.parse(cache);
} catch (e) {
// First run, no cache exists
}
const permits = $input.all()[0].json.data || $input.all()[0].json;
const changes = [];
if (Array.isArray(permits)) {
permits.forEach(permit => {
const prevStatus = previousStatuses[permit.id];
if (prevStatus && prevStatus !== permit.status) {
changes.push({
permit_id: permit.id,
project_name: permit.project_name,
permit_type: permit.permit_type,
old_status: prevStatus,
new_status: permit.status,
jurisdiction: permit.city + ', ' + permit.state,
metadata: permit.metadata || {}
});
}
previousStatuses[permit.id] = permit.status;
});
}
// Save updated cache
fs.writeFileSync('/home/node/rules/permit-status-cache.json', JSON.stringify(previousStatuses, null, 2));
if (changes.length === 0) {
return [{ json: { no_changes: true, permits_checked: Array.isArray(permits) ? permits.length : 0 } }];
}
return changes.map(c => ({ json: c }));Node 3: Check for Changes
- Type: IF
- Condition:
{{$json.no_changes}}is not true - True (has changes): Continue to Node 4
- False (no changes): Exit silently
Node 4: Route by Status Change Type
- Type: Switch
approved→ Green notification + update Buildertrend phaserejected→ Red alert to #permits-urgent + email PMadditional_info_required→ Yellow alert + create task in Buildertrendinspection_scheduled→ Blue notification + calendar invite
Node 5a: Permit Approved Notification
- Type: Slack
- Channel: #permits-active
🎉 *Permit APPROVED!*
*Project:* {{$json.project_name}}
*Permit:* {{$json.permit_type}}
*Jurisdiction:* {{$json.jurisdiction}}
Construction can proceed for this permit scope.Node 5b: Permit Rejected Notification
- Type: Slack
- Channel: #permits-urgent
🚫 *Permit REJECTED*
*Project:* {{$json.project_name}}
*Permit:* {{$json.permit_type}}
*Jurisdiction:* {{$json.jurisdiction}}
*Previous Status:* {{$json.old_status}}
@here - Immediate action required. Check PermitFlow for rejection details.Node 6: Update Buildertrend (Optional)
Via Zapier relay or direct API, update the project's permit tracking fields.
Scheduling Note: If PermitFlow later supports webhooks for status changes, this polling workflow can be replaced with a webhook-triggered workflow for real-time updates. Check with PermitFlow quarterly for API updates.
Weekly Permit Dashboard Report
Type: workflow
A weekly automated report that summarizes all permit activity for the past 7 days and sends it to the client's management team. Includes permits initiated, permits approved, permits pending, average processing times, and upcoming deadlines. Delivered every Monday morning via email and Slack.
Implementation:
n8n Workflow: Weekly Permit Dashboard
Trigger: Cron
- Schedule: Every Monday at 6:30 AM
- Cron Expression:
30 6 * * 1
Node 1: Fetch All Permits from PermitFlow
- Type: HTTP Request
- URL: https://api.permitflow.com/v1/permits?created_after={{$today.minus(7,'days').toISO()}}
- Auth: Bearer Token
Node 2: Calculate Summary Statistics
- Type: Function
const permits = $input.all()[0].json.data || [];
const now = new Date();
const weekAgo = new Date(now - 7 * 24 * 60 * 60 * 1000);
const summary = {
report_date: now.toISOString().split('T')[0],
week_ending: now.toISOString().split('T')[0],
total_active_permits: permits.length,
initiated_this_week: permits.filter(p => new Date(p.created_at) >= weekAgo).length,
approved_this_week: permits.filter(p => p.status === 'approved' && new Date(p.updated_at) >= weekAgo).length,
rejected_this_week: permits.filter(p => p.status === 'rejected' && new Date(p.updated_at) >= weekAgo).length,
pending_review: permits.filter(p => ['pending', 'in_review', 'submitted'].includes(p.status)).length,
awaiting_info: permits.filter(p => p.status === 'additional_info_required').length,
permits_by_jurisdiction: {},
permits_by_type: {},
upcoming_deadlines: []
};
permits.forEach(p => {
const jur = `${p.city}, ${p.state}`;
summary.permits_by_jurisdiction[jur] = (summary.permits_by_jurisdiction[jur] || 0) + 1;
summary.permits_by_type[p.permit_type] = (summary.permits_by_type[p.permit_type] || 0) + 1;
// Flag permits older than expected timeline
if (p.expected_completion_date) {
const deadline = new Date(p.expected_completion_date);
const daysUntil = Math.floor((deadline - now) / (1000 * 60 * 60 * 24));
if (daysUntil <= 7 && daysUntil >= 0 && p.status !== 'approved') {
summary.upcoming_deadlines.push({
project: p.project_name,
permit_type: p.permit_type,
days_remaining: daysUntil,
jurisdiction: jur
});
}
}
});
return [{ json: summary }];Node 3: Generate HTML Report
- Type: Function
const s = $input.all()[0].json;
const html = `
<html><body style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
<h2 style='color: #2c3e50;'>📋 Weekly Permit Dashboard</h2>
<p style='color: #7f8c8d;'>Week ending ${s.week_ending}</p>
<table style='width: 100%; border-collapse: collapse; margin: 20px 0;'>
<tr style='background: #3498db; color: white;'>
<td style='padding: 10px;'>Metric</td>
<td style='padding: 10px; text-align: right;'>Count</td>
</tr>
<tr style='background: #ecf0f1;'><td style='padding: 8px;'>New Permits Initiated</td><td style='padding: 8px; text-align: right;'>${s.initiated_this_week}</td></tr>
<tr><td style='padding: 8px;'>Permits Approved</td><td style='padding: 8px; text-align: right; color: green;'>${s.approved_this_week}</td></tr>
<tr style='background: #ecf0f1;'><td style='padding: 8px;'>Permits Rejected</td><td style='padding: 8px; text-align: right; color: red;'>${s.rejected_this_week}</td></tr>
<tr><td style='padding: 8px;'>Pending Review</td><td style='padding: 8px; text-align: right;'>${s.pending_review}</td></tr>
<tr style='background: #ecf0f1;'><td style='padding: 8px;'>Awaiting Info</td><td style='padding: 8px; text-align: right; color: orange;'>${s.awaiting_info}</td></tr>
<tr style='background: #2c3e50; color: white;'><td style='padding: 10px;'><strong>Total Active</strong></td><td style='padding: 10px; text-align: right;'><strong>${s.total_active_permits}</strong></td></tr>
</table>
${s.upcoming_deadlines.length > 0 ? '<h3>⚠️ Upcoming Deadlines (Next 7 Days)</h3><ul>' + s.upcoming_deadlines.map(d => `<li><strong>${d.project}</strong> - ${d.permit_type} (${d.jurisdiction}) - ${d.days_remaining} days remaining</li>`).join('') + '</ul>' : ''}
<p style='color: #7f8c8d; font-size: 12px;'>This report is auto-generated by your permit automation system. Contact your MSP for questions.</p>
</body></html>`;
return [{ json: { ...s, html_report: html } }];Node 4: Send Email Report
- To: management@clientdomain.com, permits@clientdomain.com
- Subject: 📋 Weekly Permit Dashboard - Week of {{$json.week_ending}}
- Body: {{$json.html_report}}
Node 5: Post Slack Summary
- Channel: #permits-active
- Message: Formatted summary with key metrics
Testing & Validation
- Webhook Connectivity Test: Send a manual POST request to https://automation.clientdomain.com/webhook/bt-phase-change with a valid test payload using curl or Postman. Verify n8n receives the payload and execution appears in the execution log within 5 seconds. Expected: HTTP 200 response, execution logged.
- Zapier-to-n8n Relay Test: In Buildertrend, change a test project's status. Verify the Zapier zap fires (check Zapier task history) and the n8n webhook receives the forwarded payload. Expected: End-to-end delivery in under 60 seconds.
- Known Jurisdiction Rule Match Test: Trigger a phase change for a test project located in a jurisdiction that exists in permit-rules.json (e.g., Austin, TX). Verify the rules engine returns the correct list of permits, estimated fees, and required documents. Compare output against the rules file manually.
- Unknown Jurisdiction Fallback Test: Trigger a phase change for a test project in a city NOT in permit-rules.json. Verify the system correctly falls back to the _default jurisdiction, flags the project for manual review, sends an urgent Slack notification to #permits-urgent, and does NOT attempt to auto-submit to PermitFlow.
- Non-Permit Phase Change Test: Change a test project from 'Construction' to 'Punch List' (a phase that should NOT trigger permits). Verify the workflow receives the event, correctly determines no permits are needed, logs the event as 'no_action', and does not trigger any downstream actions.
- PermitFlow API Integration Test: Verify that the n8n workflow successfully creates a permit application in PermitFlow by checking the PermitFlow dashboard for the test application. Confirm all fields (project name, address, permit type, documents required) are correctly populated.
- DocuSign Envelope Test: Trigger a permit workflow for a project type that requires owner authorization. Verify a DocuSign envelope is sent to the designated test signer, the envelope contains correct project details and permit information, and upon signing, the workflow receives the completion callback.
- QuickBooks Estimate Creation Test: After a permit workflow fires, log into QuickBooks Online and verify a new estimate was created under the correct customer/job with the permit fee line item and correct category (Permit & Inspection Fees).
- Slack Notification Delivery Test: Verify all notification types are delivered to the correct Slack channels: (1) permit initiated → #permits-active, (2) manual review needed → #permits-urgent, (3) error occurred → #permits-urgent + MSP channel. Check message formatting and all dynamic fields are populated.
- Email Notification Test: Verify the assigned PM receives an email with the permit initiation summary. Check that the email contains: project name, permit type, jurisdiction, estimated timeline, estimated fee, documents required, and links to PermitFlow.
- Error Handling and Recovery Test: Temporarily invalidate the PermitFlow API key and trigger a permit workflow. Verify: (1) the error is caught by the workflow, (2) retry logic activates with exponential backoff (3 attempts), (3) after final retry failure, an alert is sent to both the MSP monitoring channel and #permits-urgent, (4) the workflow execution is logged as failed with full error context.
- License/Bond Verification Test: Set a test contractor's bond expiry date to yesterday in contractor-credentials.json. Trigger a permit workflow. Verify: (1) the verification sub-workflow detects the expired bond, (2) the permit workflow is BLOCKED (not submitted), (3) an urgent notification is sent with the specific issue identified.
- Daily Status Polling Test: Manually run the Permit Status Poller workflow. Verify it fetches active permits from PermitFlow, compares against the cached status, and correctly identifies any status changes. On first run, verify the cache file is created.
- Weekly Report Generation Test: Manually trigger the Weekly Permit Dashboard workflow. Verify the HTML report is generated with correct statistics, the email is delivered to the management distribution list, and the Slack summary is posted to #permits-active.
- Field Tablet End-to-End Test: Using a provisioned Samsung Galaxy Tab Active5, log into Buildertrend mobile, change a test project's phase to 'Permitting', and verify the full automation chain fires correctly. This validates the complete field-to-office-to-permit workflow.
- Load Test (Multi-Permit Scenario): Trigger a phase change for a project type that requires 4 permits simultaneously (building, electrical, plumbing, mechanical). Verify all 4 PermitFlow applications are created, all 4 Slack notifications are sent, and all 4 QuickBooks estimates are generated without errors or duplicates.
Client Handoff
Client Handoff Checklist
Training Topics Covered (2 sessions, recorded)
Documentation Delivered
- Training Video Recordings (2 sessions, uploaded to client's SharePoint/Google Drive)
- Quick Reference Card (1-page laminated PDF for field use): phase names, notification meanings, who to contact
- System Architecture Diagram (Visio/draw.io export): all components, data flows, credential locations
- Jurisdiction Rules Summary (PDF): table of all configured jurisdictions, permit types, fees, timelines
- Runbook (detailed Word/PDF document): step-by-step troubleshooting for common issues (webhook not firing, PermitFlow API error, DocuSign envelope stuck, QuickBooks sync failed)
- Escalation Contact Sheet: MSP helpdesk number, email, SLA response times, after-hours emergency contact
- Credential Inventory (stored in MSP password vault, summary provided to client admin): list of all accounts, API keys, and their purpose (NOT the actual passwords — those stay in the MSP vault)
Success Criteria Review (Sign-Off Meeting)
Maintenance
Ongoing Maintenance Responsibilities
...
MSP Monitoring Cadence
- Daily (automated): n8n health check via uptime monitor (UptimeRobot or similar) — verify https://automation.clientdomain.com/healthz returns 200. Alert MSP NOC if down for >5 minutes.
- Daily (automated): Check n8n execution log for failed executions. The Error Handler workflow sends alerts automatically, but a daily sweep catches anything missed.
- Weekly: Review n8n execution statistics — total executions, success rate, average execution time. Flag any degradation trends.
- Weekly: Verify Zapier task usage is within plan limits. Alert if approaching 80% of monthly task quota.
- Monthly: Review PermitFlow API connectivity and check for API version updates. Run a synthetic test transaction through the full workflow.
- Monthly: Verify DocuSign and QuickBooks OAuth2 tokens are valid. QuickBooks refresh tokens expire after 100 days of inactivity — set a 60-day reminder to trigger a manual refresh if no real transactions have occurred.
- Quarterly: Review jurisdiction rules with client's Permit Coordinator. Add new jurisdictions as needed ($150–300 per jurisdiction). Update fee schedules and timeline estimates based on recent experience.
- Quarterly: Update n8n Docker image to latest stable version. Test all workflows after update.
- Quarterly: Review contractor license, bond, and insurance expiry dates in contractor-credentials.json. Update with renewed credentials.
- Semi-annually: Comprehensive system review with client management — ROI assessment, permits processed, time saved, jurisdictions added, feature requests.
cd /opt/n8n && docker compose pull && docker compose up -dUpdate Schedule
- n8n updates: Quarterly, during maintenance window (Saturday 6–9 AM). Always test in a staging workflow before updating production. Pin to specific n8n version in docker-compose.yml to prevent accidental auto-updates.
- Permit rules updates: As needed (new jurisdictions, fee changes). No downtime required — file is read on each execution.
- Zapier zap updates: Only when Buildertrend changes their Zapier trigger schema (rare, ~1x/year).
- OS and Docker updates: Monthly security patches:
apt update && apt upgrade -y && docker system prune -f - SSL certificate renewal: Automated via Let's Encrypt cron job. Verify renewal is working monthly.
- DigitalOcean droplet backups: Weekly automated backups enabled ($5.60/month). Test restore annually.
SLA Considerations
- Uptime target: 99.5% for the n8n automation engine (allows ~3.6 hours downtime/month for maintenance)
- Response time (P1 — workflow completely down): 2-hour response, 4-hour resolution during business hours
- Response time (P2 — single integration broken): 4-hour response, 8-hour resolution during business hours
- Response time (P3 — new jurisdiction request): 3 business days
- Response time (P4 — feature request or enhancement): Next quarterly review
Escalation Paths
Disaster Recovery
- n8n server failure: Restore from DigitalOcean weekly backup. RTO: 1 hour. RPO: 1 week (workflow configs rarely change; rules file is also backed up in MSP documentation system).
- Data loss prevention: All permit rules, contractor credentials, and workflow exports are backed up to MSP's documentation vault (IT Glue/Hudu) after every change.
- Fallback process: If automation is completely down, client reverts to manual permit tracking using the jurisdiction rules summary PDF as a reference checklist. PermitFlow can still be used manually via its web interface.
Alternatives
Zapier-Only Stack (No Self-Hosted n8n)
Replace the self-hosted n8n automation engine with Zapier as the sole orchestration platform. All workflow logic (phase detection, rule evaluation, PermitFlow API calls, notifications) is built entirely within Zapier using multi-step Zaps, Paths, and Zapier Tables for rules storage.
Microsoft Power Automate Stack (M365 Ecosystem)
Replace both Zapier and n8n with Microsoft Power Automate as the orchestration engine. Leverage the client's existing Microsoft 365 subscription for Teams notifications, SharePoint document storage, and Dataverse/SharePoint Lists for the jurisdiction rules database. Use Power Automate Premium connectors for Procore/Buildertrend HTTP triggers.
Procore + PermitFlow Native Integration (Minimal Custom Automation)
If the client uses Procore, leverage the native PermitFlow integration available on the Procore Marketplace. This eliminates most custom automation — Procore project phase changes trigger PermitFlow directly through the marketplace integration, with minimal custom workflow needed for notifications and accounting sync only.
Buildertrend Built-In Automation (No External Platforms)
Use Buildertrend's native automated notifications, task assignments, and schedule-based triggers to create a lightweight permit workflow entirely within Buildertrend. No external automation platform or permit-specific platform is used — instead, Buildertrend To-Do's, automated emails, and schedule templates serve as the permit tracking system.
Custom Web Application (Full Build)
Build a fully custom permit management web application with a React/Next.js frontend and Node.js backend, custom database (PostgreSQL), direct API integrations with Buildertrend/Procore, PermitFlow, DocuSign, and QuickBooks. Provides a branded permit dashboard, mobile app, and complete control over every aspect of the workflow.
Want early access to the full toolkit?