If you're building a contract lifecycle management (CLM) platform, an eDiscovery SaaS, or a legal practice management system, your ops team faces unique challenges:
- Law firm clients requiring documented onboarding with ABA-compliant data handling
- Document processing pipelines that cannot lose a single file
- Attorney-client privilege obligations extending to your infrastructure vendors
- Regulatory timelines: bar license renewals, data retention schedules, SOC 2 evidence collection
Zapier and Make.com can handle some of this. But routing attorney-client privileged communications through a US cloud automation vendor creates legal exposure. LegalTech SaaS companies holding law firm data have confidentiality obligations under ABA Model Rule 1.6 that extend to their vendor chain.
Self-hosted n8n solves this: all 5 automations below run inside your VPC with full audit trails in Postgres.
Why LegalTech SaaS companies choose self-hosted n8n
| Requirement | Zapier / Make.com | Self-hosted n8n |
|---|---|---|
| ABA Rule 1.6 | Data flows through cloud vendor | Stays in your VPC |
| Work product doctrine | Vendor terms apply | You own the pipeline |
| SOC 2 / ISO 27001 | Additional vendor attestations | In-scope = your network |
| eDiscovery audit trail | Limited logging | Full JSON logs in Postgres |
| Custom CLM logic | Per-step pricing | Unlimited complexity |
| Cost at scale | $599-$3,499+/mo | ~$40/mo VPS |
Workflow 1: New Law Firm Client Onboarding Sequence
Law firm onboarding requires more than a typical SaaS signup: API key provisioning, sandbox setup, data processing agreements, and privilege-safe usage training.
Trigger: Google Sheets row added with new firm: name, attorney count, billing contact, CSM
Flow:
- Code node: Classify tier - BIGLAW (100+), MID_MARKET (21-100), BOUTIQUE (6-20), SOLO (1-5)
- Day 0: Gmail API credentials + sandbox URL + data processing agreement link; Slack DM to CSM
- Wait 3 days
- Day 3: Gmail check-in with matter import guide
- Wait 4 days
- Day 7: Gmail first workflow guide; Sheets log onboarding_complete = TRUE
{
"nodes": [
{ "name": "New Client Trigger", "type": "n8n-nodes-base.googleSheetsTrigger",
"parameters": { "sheetId": "YOUR_CLIENTS_SHEET_ID", "event": "rowAdded" } },
{ "name": "Classify Tier", "type": "n8n-nodes-base.code",
"parameters": { "jsCode": "const n=parseInt($json.attorney_count); const tier=n>100?'BIGLAW':n>20?'MID_MARKET':n>5?'BOUTIQUE':'SOLO'; return [{json:{...$json,tier}}];" } },
{ "name": "Day 0 Welcome", "type": "n8n-nodes-base.gmail",
"parameters": { "operation": "send", "to": "={{ $json.billing_email }}",
"subject": "Your {{ $json.firm_name }} account is ready",
"message": "API Key: {{ $json.api_key }}\nSandbox: https://sandbox.yourplatform.com/{{ $json.account_id }}" } },
{ "name": "Slack CSM", "type": "n8n-nodes-base.slack",
"parameters": { "channel": "#legaltech-csm",
"text": "New {{ $json.tier }} client: {{ $json.firm_name }} ({{ $json.attorney_count }} attorneys)" } },
{ "name": "Wait 3d", "type": "n8n-nodes-base.wait", "parameters": { "amount": 3, "unit": "days" } },
{ "name": "Day 3 Check-In", "type": "n8n-nodes-base.gmail",
"parameters": { "operation": "send", "to": "={{ $json.billing_email }}",
"subject": "Check-in: {{ $json.firm_name }} sandbox setup",
"message": "Have you imported your first matter? Book a setup call: [link]" } },
{ "name": "Wait 4d", "type": "n8n-nodes-base.wait", "parameters": { "amount": 4, "unit": "days" } },
{ "name": "Day 7 Guide", "type": "n8n-nodes-base.gmail",
"parameters": { "operation": "send", "to": "={{ $json.billing_email }}",
"subject": "Week 1: Set up your first automated workflow",
"message": "1. Upload contract -> auto-classify -> review queue\n2. Set deadline alerts\n3. Auto-generate weekly reports\n\nDocs: https://docs.yourplatform.com" } }
]
}
Workflow 2: Contract Document Processing Pipeline
Every uploaded document needs instant classification, routing, and ACK. Privileged communications get special handling that never logs to shared storage.
Trigger: Webhook POST /contract-upload with { contract_id, submitter_email, firm_id, file_name, uploaded_at }
Flow:
- Code node: Classify from filename keywords: NDA, MSA, SOW, EMPLOYMENT, COURT_FILING, PRIVILEGED
- Switch node: Route by type - PRIVILEGED to restricted Slack channel (no Sheets log); COURT_FILING to litigation-ops; others to contracts-intake
- Gmail: ACK to submitter with contract ID, type, processing time estimate
- Postgres: Insert metadata to contract_intake_log (never file content)
{
"nodes": [
{ "name": "Contract Webhook", "type": "n8n-nodes-base.webhook",
"parameters": { "path": "contract-upload", "httpMethod": "POST" } },
{ "name": "Classify", "type": "n8n-nodes-base.code",
"parameters": { "jsCode": "const fn=($json.file_name||'').toLowerCase(); const t=fn.includes('nda')?'NDA':fn.includes('msa')?'MSA':fn.includes('sow')?'SOW':fn.includes('employment')?'EMPLOYMENT':fn.includes('court')||fn.includes('motion')?'COURT_FILING':fn.includes('privilege')?'PRIVILEGED':'GENERAL'; return [{json:{...$json,contract_type:t,priority:['COURT_FILING','PRIVILEGED'].includes(t)?'HIGH':'NORMAL'}}];" } },
{ "name": "Route", "type": "n8n-nodes-base.switch",
"parameters": { "dataType": "string", "value1": "={{ $json.contract_type }}",
"rules": { "rules": [{"value2":"PRIVILEGED","output":0},{"value2":"COURT_FILING","output":1},{"value2":"NDA","output":2}] } } },
{ "name": "Slack Privileged", "type": "n8n-nodes-base.slack",
"parameters": { "channel": "#legal-privileged",
"text": "PRIVILEGED doc: {{ $json.file_name }} ({{ $json.firm_id }}) ID: {{ $json.contract_id }}" } },
{ "name": "ACK Email", "type": "n8n-nodes-base.gmail",
"parameters": { "operation": "send", "to": "={{ $json.submitter_email }}",
"subject": "Document received: {{ $json.file_name }} [{{ $json.contract_id }}]",
"message": "Received {{ $json.file_name }} ({{ $json.contract_type }}). Processing: 24 hours." } }
]
}
Workflow 3: eDiscovery Job Monitor and Failure Alert
eDiscovery processing jobs run for hours. When one stalls at 2AM, ops needs to know immediately - not when the law firm calls next morning.
Trigger: Schedule every 5 minutes
Flow:
-
HTTP Request:
GET /api/v1/ediscovery/jobs?status=running- returns{ job_id, start_time, estimated_minutes, status, matter_name, client_firm } - Code node: Calculate elapsed_min. STALLED if elapsed > estimated * 1.2. FAILED if status = error.
- Filter: STALLED or FAILED only
- $getWorkflowStaticData: Skip if job_id alerted in last 30 min (prevents alert storms)
- Slack #ediscovery-ops: Alert with job ID, matter, elapsed, client
{
"nodes": [
{ "name": "Every 5 Min", "type": "n8n-nodes-base.scheduleTrigger",
"parameters": { "rule": { "interval": [{"field":"minutes","minutesInterval":5}] } } },
{ "name": "Get Running Jobs", "type": "n8n-nodes-base.httpRequest",
"parameters": { "url": "https://api.yourplatform.internal/v1/ediscovery/jobs",
"qs": {"status":"running"} } },
{ "name": "Flag Issues", "type": "n8n-nodes-base.code",
"parameters": { "jsCode": "const now=Date.now(); return $input.all().map(i=>{const j=i.json; const e=(now-new Date(j.start_time).getTime())/60000; const t=j.status==='error'?'FAILED':e>(j.estimated_minutes||60)*1.2?'STALLED':'OK'; return {json:{...j,elapsed_min:Math.round(e),alert_type:t}}}).filter(i=>i.json.alert_type!=='OK');" } },
{ "name": "Dedup 30min", "type": "n8n-nodes-base.code",
"parameters": { "jsCode": "const s=$getWorkflowStaticData('global'); if(!s.a)s.a={}; const now=Date.now(); const r=$input.all().filter(i=>{const last=s.a[i.json.job_id]||0; if(now-last>1800000){s.a[i.json.job_id]=now;return true;} return false;}); $setWorkflowStaticData('global',s); return r;" } },
{ "name": "Slack Alert", "type": "n8n-nodes-base.slack",
"parameters": { "channel": "#ediscovery-ops",
"text": ":rotating_light: eDiscovery {{ $json.alert_type }}: Job {{ $json.job_id }} | {{ $json.client_firm }} / {{ $json.matter_name }} | {{ $json.elapsed_min }}min elapsed (est: {{ $json.estimated_minutes }}min)" } }
]
}
Workflow 4: Attorney Bar License Compliance Tracker
Bar license compliance is a core feature for legal practice management SaaS. This automates daily monitoring with tiered alerts before licenses expire.
Trigger: Daily at 8AM
Flow:
- Sheets: Load attorney roster - name, email, bar_number, state, license_expiry_date, last_alert_date, alert_tier
- Code node: days_until_expiry. EXPIRED (<=0), CRITICAL (1-14d), URGENT (15-30d), WARNING (31-60d), NOTICE (61-90d). Skip if already alerted at this tier today.
- Filter: EXPIRED, CRITICAL, URGENT only
- Gmail: Personalized email with state bar renewal link
- Slack #compliance-ops: Batch summary
- Sheets: Update last_alert_date and alert_tier
{
"nodes": [
{ "name": "Daily 8AM", "type": "n8n-nodes-base.scheduleTrigger",
"parameters": { "rule": { "interval": [{"field":"cronExpression","expression":"0 8 * * *"}] } } },
{ "name": "Load Attorneys", "type": "n8n-nodes-base.googleSheets",
"parameters": { "operation": "getAll", "sheetId": "YOUR_ATTORNEY_SHEET_ID" } },
{ "name": "Calc Tier", "type": "n8n-nodes-base.code",
"parameters": { "jsCode": "const today=new Date(); const ts=today.toISOString().split('T')[0]; return $input.all().map(item=>{const a=item.json; if(!a.license_expiry_date)return null; const d=Math.floor((new Date(a.license_expiry_date)-today)/86400000); const tier=d<=0?'EXPIRED':d<=14?'CRITICAL':d<=30?'URGENT':d<=60?'WARNING':d<=90?'NOTICE':'OK'; const skip=a.alert_tier===tier&&a.last_alert_date===ts; return {json:{...a,days_until:d,tier,skip,today:ts}};}).filter(Boolean);" } },
{ "name": "Filter Urgent", "type": "n8n-nodes-base.filter",
"parameters": { "conditions": { "conditions": [
{"value1":"={{ $json.tier }}","operation":"notEqual","value2":"OK"},
{"value1":"={{ $json.skip }}","operation":"equal","value2":false}] } } },
{ "name": "Email Attorney", "type": "n8n-nodes-base.gmail",
"parameters": { "operation": "send", "to": "={{ $json.attorney_email }}",
"subject": "Bar License [{{ $json.tier }}]: {{ $json.days_until<=0 ? 'EXPIRED' : $json.days_until+' days remaining' }}",
"message": "Dear {{ $json.name }},\n\nYour {{ $json.state }} bar license ({{ $json.bar_number }}) expires on {{ $json.license_expiry_date }} ({{ $json.days_until }} days).\n\nRenew: https://www.{{ $json.state.toLowerCase() }}bar.org/renewals" } }
]
}
Workflow 5: Weekly LegalTech Platform KPI Dashboard
One Monday morning email to the exec team: active firms, contracts processed, eDiscovery SLA hit rate - no manual Postgres queries.
Trigger: Every Monday at 8AM
Flow:
- Parallel Postgres queries: active firms (WoW), contracts processed (by type), eDiscovery SLA hit rate
- Code node: Merge results, calculate WoW%, flag anomalies
- Gmail: Color-coded HTML table to CEO, CTO, Head of CS
- Slack #exec-briefing: One-liner
{
"nodes": [
{ "name": "Monday 8AM", "type": "n8n-nodes-base.scheduleTrigger",
"parameters": { "rule": { "interval": [{"field":"cronExpression","expression":"0 8 * * 1"}] } } },
{ "name": "Query Firms", "type": "n8n-nodes-base.postgres",
"parameters": { "query": "SELECT COUNT(*) as active_firms, COUNT(*) FILTER (WHERE created_at >= NOW()-INTERVAL '7 days') as new_this_week FROM law_firm_accounts WHERE status='active'" } },
{ "name": "Query Contracts", "type": "n8n-nodes-base.postgres",
"parameters": { "query": "SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE contract_type='NDA') as ndas FROM contracts WHERE created_at >= NOW()-INTERVAL '7 days'" } },
{ "name": "Build Report", "type": "n8n-nodes-base.code",
"parameters": { "jsCode": "const f=$('Query Firms').first().json; const c=$('Query Contracts').first().json; const html=`<h2>LegalTech Platform Weekly KPIs</h2><table border='1' cellpadding='8'><tr><th>Metric</th><th>Value</th></tr><tr><td>Active Firms</td><td>${f.active_firms} (+${f.new_this_week} new)</td></tr><tr><td>Contracts This Week</td><td>${c.total} (NDAs:${c.ndas})</td></tr></table>`; return [{json:{html,slack:`KPIs: ${f.active_firms} firms, ${c.total} contracts this week.`}}];" } },
{ "name": "KPI Email", "type": "n8n-nodes-base.gmail",
"parameters": { "operation": "send", "to": "ceo@yourplatform.com",
"subject": "LegalTech Platform Weekly KPIs", "htmlBody": "={{ $json.html }}" } },
{ "name": "Slack", "type": "n8n-nodes-base.slack",
"parameters": { "channel": "#exec-briefing", "text": "={{ $json.slack }}" } }
]
}
The attorney-client privilege argument
ABA Model Rule 1.6 requires attorneys to make reasonable efforts to prevent unauthorized disclosure of information relating to representation.
When your ops tooling (Zapier/Make) routes contract metadata, eDiscovery job data, or attorney communications through a third-party cloud:
- That data is in a third party's infrastructure
- Your clients' Rule 1.6 obligations extend to your vendor chain
- A breach at the automation vendor creates exposure for every law firm you serve
Self-hosted n8n runs in your VPC. Law firm clients can cite your self-hosted n8n instance in security questionnaires from BigLaw clients.
| Risk | Zapier / Make | n8n self-hosted |
|---|---|---|
| ABA Rule 1.6 | Cloud data egress | VPC-only |
| SOC 2 scope | Third-party vendor | Your instance |
| Incident response | Vendor timeline | Your timeline |
Get the templates
All 5 workflows are available as import-ready JSON at stripeai.gumroad.com.
Browse 114 more n8n workflow guides at dev.to/flowkithq.
Top comments (0)