52 min readDeterministic automation

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)

SamsungSM-X306BQty: 4

$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

SamsungEF-GX306CBEGUSQty: 4

$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

Otterbox77-89987Qty: 2

$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

Logitech920-011434Qty: 2

$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

BuildertrendSaaS - flat monthly fee, unlimited users and projectsQty: 1

$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

PermitFlowSaaS - custom pricing based on volume

$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)

n8n GmbHOpen source (Sustainable Use License) - free for self-hosted production useQty: Unlimited executions (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

ZapierSaaS - per-seat with task limitsQty: 1

$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

DocuSignPer-seat SaaSQty: 3 users

$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

IntuitSaaS - per-company subscription

$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)

Slack Technologies / MicrosoftPer-seat SaaS

$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)

DigitalOcean2 vCPU / 4 GB RAM / 80 GB SSDQty: 1

$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.

1
Create DigitalOcean Droplet via CLI (or use web console)
2
SSH into the new droplet
3
Update system packages
4
Create project directory
5
Create docker-compose.yml
6
Install Certbot for SSL
7
Create Nginx config
8
Copy certs to nginx volume path
9
Start all services
10
Verify n8n is running
Create DigitalOcean Droplet via CLI
bash
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-auto
SSH into the new droplet
bash
ssh root@<droplet-ip>
Update system packages
bash
apt update && apt upgrade -y
Create project directory
bash
mkdir -p /opt/n8n && cd /opt/n8n
Create docker-compose.yml
bash
cat > 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:
EOF
Install Certbot and obtain SSL certificate
bash
apt install -y certbot
certbot certonly --standalone -d automation.clientdomain.com --agree-tos -m msp-admin@mspdomain.com
Create Nginx reverse proxy config
bash
cat > 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";
    }
}
EOF
Copy SSL certificates to Nginx volume path
bash
mkdir -p certs
cp /etc/letsencrypt/live/automation.clientdomain.com/fullchain.pem certs/
cp /etc/letsencrypt/live/automation.clientdomain.com/privkey.pem certs/
Start all services
bash
docker compose up -d
Verify n8n is running
bash
curl -s https://automation.clientdomain.com/healthz
Note

Replace <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)

1
Create new Zap: 'BT Phase Change → n8n Webhook'
2
Trigger: Buildertrend → 'Job Status Changed' (or 'Schedule Updated')
3
Action: Webhooks by Zapier → POST
Zapier Webhook POST payload field mapping
json
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.

Procore webhook configuration
text
URL: https://automation.clientdomain.com/webhook/procore-phase-change
Events: project.update, schedule_tasks.update
Authentication: HMAC-SHA256 with shared secret

Test the webhook endpoint in n8n

1
In n8n, create a new workflow with a Webhook node
2
Set Method: POST, Path: /bt-phase-change, Authentication: Header Auth (X-Webhook-Secret: <shared-secret>)
3
Execute the workflow in test mode and trigger the Zapier zap to verify data flows through
Note

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.

1
SSH into the n8n server
2
Create the rules directory
3
Create the jurisdiction-permit rules file
4
Mount the rules directory into the n8n container
5
Update docker-compose.yml to add volume mount (under n8n service volumes, add: - /opt/n8n/rules:/home/node/rules:ro)
6
Edit docker-compose.yml to add the volume mount
7
Restart n8n to pick up the new volume
SSH into the n8n server
bash
ssh root@<droplet-ip>
Create the rules directory
bash
mkdir -p /opt/n8n/rules
Create the jurisdiction-permit rules JSON configuration file
bash
cat > /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_EOF
Add the rules volume mount to docker-compose.yml
bash
cd /opt/n8n
sed -i '/- n8n_data:\/home\/node\/.n8n/a\      - /opt/n8n/rules:/home/node/rules:ro' docker-compose.yml
Restart n8n to pick up the new volume
bash
docker compose down && docker compose up -d
Note

Start 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.

1
This workflow is built in the n8n visual editor at https://automation.clientdomain.com
2
Export the workflow as JSON and store in version control
3
The workflow JSON structure is provided in the custom_ai_components section
4
Import it via: n8n CLI or via the n8n web interface Import Workflow feature
5
After importing, configure the following credentials in n8n: Buildertrend/Procore API credentials (if using direct API), PermitFlow API key, DocuSign API credentials (OAuth2), QuickBooks Online API credentials (OAuth2), Slack Bot Token (or Microsoft Teams webhook URL), SMTP credentials for email notifications
6
To configure credentials in n8n: Navigate to Settings > Credentials > Add Credential — for each service, select the credential type and enter API keys/OAuth tokens
Note

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.

1
Sign up for PermitFlow at https://www.permitflow.com
2
Complete onboarding with PermitFlow's customer success team
3
Obtain API credentials from PermitFlow dashboard > Settings > API
4
Store the API key in n8n credentials store
Test the PermitFlow API connection from the n8n server.
bash
# 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
Note

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.

1
Create DocuSign Developer Account at https://developers.docusign.com
2
Create an Integration Key (Client ID) in the Admin Dashboard
3
Configure OAuth2 redirect URI: https://automation.clientdomain.com/rest/oauth2-credential/callback
4
Generate RSA Key Pair for JWT Grant (server-to-server auth)
5
In n8n, navigate to Settings > Credentials > Add Credential > DocuSign OAuth2 API
6
Create DocuSign envelope template for permit authorization
7
For production, switch to production DocuSign environment
DocuSign OAuth2 API credential values for n8n (sandbox/developer environment)
text
# 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
DocuSign envelope template setup for permit authorization
text
# 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 OAuth2 endpoint URIs
text
# Production DocuSign Environment:
# Auth URI: https://account.docusign.com/oauth/auth
# Token URI: https://account.docusign.com/oauth/token
Note

DocuSign 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.

1
Register a QuickBooks app at https://developer.intuit.com
2
Create an app with OAuth2 credentials
3
Set redirect URI: https://automation.clientdomain.com/rest/oauth2-credential/callback
4
Request Accounting scope
5
In n8n: Navigate to Settings > Credentials > Add Credential > QuickBooks OAuth2
6
Enter Client ID and Client Secret from Intuit Developer portal
7
Complete OAuth2 authorization flow
Test API connection
bash
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.

Note

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

1
Create Slack App at https://api.slack.com/apps
2
Add Bot Token Scopes: chat:write, channels:read, channels:join
3
Install app to workspace
4
Create Slack channels: #permits-active (all active permit notifications), #permits-urgent (rejected permits, expiring deadlines), and per-project channels: #project-{job-name} (created automatically by workflow)
5
In n8n: Add Credential > Slack API > Bot Token: xoxb-<token>

Email Setup

1
In n8n: Add Credential > SMTP
2
Host: smtp.office365.com (or smtp.gmail.com)
3
Port: 587
4
User: permits@clientdomain.com
5
Password: <app-password>
6
TLS: true

Microsoft Teams Alternative

1
If client uses Teams instead of Slack: Create Incoming Webhook in the target Teams channel
2
Copy webhook URL
3
In n8n, use HTTP Request node to POST adaptive card JSON to webhook URL

Test Notifications

Test Slack webhook connectivity
bash
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!"}'
Note

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)

1
Register MSP as Knox Reseller Partner at https://www.samsungknox.com/
2
Add device IMEIs to Knox portal
3
Configure Knox enrollment profile: Wi-Fi profile for client office network, Buildertrend app (from Google Play), Slack/Teams app, Chrome browser with PermitFlow bookmarked, Device password policy: 6-digit PIN minimum, Remote wipe enabled

Microsoft Intune (if client has M365)

1
In Microsoft Endpoint Manager (endpoint.microsoft.com): navigate to Devices > Android > Enrollment > Corporate-owned, fully managed
2
Create enrollment profile for Samsung Tab Active5
3
Assign required apps: Buildertrend, Teams, Outlook, Chrome
4
Create compliance policy: encryption required, PIN required

Per-Device Setup (Manual Fallback)

1
Initial boot and sign in to Google account (permits-field@clientdomain.com)
2
Install Buildertrend from Google Play Store
3
Install Slack or Teams
4
Configure Chrome bookmarks: PermitFlow dashboard, permit status page
5
Enable Samsung Rugged features: Glove mode, wet touch mode
6
Set auto-lock to 5 minutes
7
Label each device with asset tag and assign to specific PM/super
Note

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.

1
Access n8n at https://automation.clientdomain.com
2
Import the master workflow JSON (from custom_ai_components section): Workflows > Import from File > select permit-trigger-master.json
3
Open the imported workflow and verify each node: a. Webhook node: Confirm URL path is /bt-phase-change b. Function nodes: Confirm rules file path is /home/node/rules/permit-rules.json c. PermitFlow node: Confirm credentials are connected and test d. DocuSign node: Confirm credentials and template IDs e. QuickBooks node: Confirm credentials and realm ID f. Slack node: Confirm bot token and channel IDs g. Email node: Confirm SMTP credentials
4
Run the workflow manually with test data: Use n8n's 'Test Workflow' button with sample webhook payload
5
Verify each downstream action fires correctly
6
Once validated, toggle the workflow to ACTIVE (green toggle)
7
Confirm the production webhook URL is registered in Zapier
8
Set up error handling workflow: Create a separate workflow 'Permit Automation Error Handler' that catches any execution failures and sends alerts to the MSP monitoring channel and client's #permits-urgent Slack channel
9
Enable execution logging for audit trail — In n8n Settings > Executions: Save successful executions: Yes | Save failed executions: Yes | Execution data pruning: 720 hours (30 days)
Critical

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

1
In Buildertrend, create test project: 'TEST-AutoPermit-Austin-001' — Address: 100 Test Ave, Austin, TX 78701 — Project type: New Construction
2
Advance project phase from 'Pre-Construction' to 'Permitting'
3
Verify within 60 seconds:

Test Scenario 2: Unknown Jurisdiction (Manual Review Fallback)

1
Create test project in a jurisdiction not in rules database
2
Advance to Permitting phase
3
Verify:

Test Scenario 3: Non-Permit Phase Change (No Trigger)

1
Advance a test project from 'Construction' to 'Punch List'
2
Verify:

Test Scenario 4: Error Handling

1
Temporarily invalidate PermitFlow API key
2
Trigger a permit-requiring phase change
3
Verify:
Note

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)

1
Overview of automation flow (15 min): Whiteboard walkthrough of trigger → rules → action chain
2
Live demo: Phase change triggering permit workflow (20 min): Show Buildertrend phase change; Watch notifications arrive in Slack and email; Show PermitFlow dashboard with new application; Show QuickBooks estimate entry
3
Hands-on: Each PM triggers a test workflow (20 min)
4
Edge cases and manual overrides (15 min): What happens with unknown jurisdictions; How to manually trigger a permit workflow; How to pause/skip a permit for a specific project
5
Q&A (20 min)

Session 2: Field Staff Training (30 minutes)

1
Tablet orientation (10 min): How to update project phase in Buildertrend mobile; How to check permit status
2
Notification management (10 min): Slack/Teams mobile notifications; What each notification type means
3
Q&A (10 min)

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)
Note

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
Node 2: Validate Payload — Function code
javascript
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}} equals Permitting
  • True branch: Continue to Node 4
  • False branch: Go to Node 3b (Log and Exit)

Node 3b: Log Non-Permit Phase Change

  • Type: Function
Node 3b: Log Non-Permit Phase Change — Function code
javascript
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
Node 4: Load Jurisdiction Rules — Function code
javascript
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}} equals true
  • 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
Node 5a: Slack message template
text
🚨 *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
Node 6: Split Out Individual Permits — Function code
javascript
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)
Node 7: PermitFlow API request body
json
{
  "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}} contains owner_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
Node 10: Slack message template
text
✅ *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
Node 12: Log Completion — Function code
javascript
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
1
Send Slack message to #permits-urgent with error details
2
Send email to msp-alerts@mspdomain.com with full execution context
3
Log error with workflow_run_id for troubleshooting

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

json
{
  "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

json
{
  "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)

javascript
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

1
SSH to the n8n server
2
Edit the rules file
3
Add new jurisdiction block under jurisdictions object
4
Validate JSON
5
No restart needed — the Function node reads the file on each execution
6
Test with a synthetic webhook payload targeting the new jurisdiction
bash
ssh root@<droplet-ip>
nano /opt/n8n/rules/permit-rules.json
cat /opt/n8n/rules/permit-rules.json | python3 -m json.tool
Note

Estimated 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

Sub-workflow input payload
json
{
  "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

Node 1 function code: load contractor credentials from file
javascript
// 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

Node 2 function code: evaluate license, bond, and insurance expiry dates
javascript
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 PM
  • WARNING → Send warning, proceed with permit but flag for attention
  • CLEAR → Proceed normally

contractor-credentials.json Schema

contractor-credentials.json
json
# 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
Node 2 Function code: loads previous permit status cache, detects changes, saves updated cache, and returns changed permits
javascript
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 phase
  • rejected → Red alert to #permits-urgent + email PM
  • additional_info_required → Yellow alert + create task in Buildertrend
  • inspection_scheduled → Blue notification + calendar invite

Node 5a: Permit Approved Notification

  • Type: Slack
  • Channel: #permits-active
Node 5a Slack message template for approved permits
text
🎉 *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
Node 5b Slack message template for rejected permits
text
🚫 *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.

Note

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
Node 2: Calculate Summary Statistics
javascript
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
Node 3: Generate HTML Report
javascript
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)

1
System Overview: Architecture diagram walkthrough showing how Buildertrend → Zapier → n8n → PermitFlow → notifications chain works. Emphasize that this is rule-based automation, not magic — it follows the jurisdiction rules that were configured together.
2
Daily Operations for PMs: How project phase changes trigger permits, what notifications to expect and what they mean, how to check permit status in PermitFlow, how to handle 'manual review required' situations.
3
Daily Operations for Office Staff: How to monitor the #permits-active Slack channel, how to respond to 'additional info required' alerts, how to check QuickBooks for permit fee estimates.
4
Field Staff Tablet Usage: Buildertrend mobile phase updates, Slack mobile notifications, basic troubleshooting (no connectivity, app not loading).
5
Edge Cases and Manual Overrides: What to do when a permit should NOT be triggered (skip instructions), what to do when a new jurisdiction is needed (contact MSP), what to do when PermitFlow is down (manual fallback process).
6
Requesting Changes: How to request new jurisdictions (MSP ticket + $150–300 per jurisdiction), how to request rule changes (MSP ticket), expected turnaround times.

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.
Quarterly n8n Docker image update command
bash
cd /opt/n8n && docker compose pull && docker compose up -d

Update 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

1
Level 1 (Client Self-Service): Check Slack notifications, verify Buildertrend phase was updated correctly, check PermitFlow dashboard for status. Runbook covers common issues.
2
Level 2 (MSP Helpdesk): Client calls/emails MSP. Technician checks n8n execution logs, Zapier task history, and API connectivity. Most issues resolved here.
3
Level 3 (MSP Automation Engineer): Complex issues — workflow logic errors, API breaking changes, jurisdiction rule conflicts. Requires SSH access to n8n server.
4
Level 4 (Vendor Escalation): PermitFlow support, Buildertrend support, Zapier support for platform-specific issues. MSP manages vendor communication on client's behalf.

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?