DEV Community

Alex Kane
Alex Kane

Posted on

n8n for WealthTech & InvestmentTech SaaS: 5 Automations That Streamline Portfolio Ops and Keep Client Data Compliant (Free Workflow JSON)

If you're building robo-advisor software, portfolio management SaaS, or a financial data API platform, you already know the operations burden: KYC onboarding queues, SEC/FINRA reporting deadlines, portfolio drift alerts, and weekly AUM dashboards — all demanding constant attention.

n8n lets you automate every one of those workflows with a self-hosted, open-source automation engine. No cloud sub-processors touching your client financial data. No $1,799/mo Zapier Enterprise bill at scale. Just clean JSON workflows running on your VPS.

Here are 5 real automations — with import-ready JSON — for WealthTech and InvestmentTech SaaS teams.


Why WealthTech Teams Choose n8n Over Zapier or Make.com

Factor n8n (self-hosted) Zapier Make.com
Client portfolio data leaves your infra No Yes Yes
SEC Reg S-P sub-processor obligation Eliminated Triggered Triggered
Cost at 500K+ tasks/month ~$20 VPS $1,799+/mo $299+/mo
Proprietary algo/model portfolio exposure Zero Stored in runs Stored in runs
FINRA/MiFID II audit trail Git-versioned JSON Zapier dashboard Make dashboard

Client AUM, trade history, and model portfolios are some of the most sensitive data in any company. Self-hosted n8n means none of it routes through a third-party cloud.


Workflow 1: Portfolio Rebalancing Drift Alert

Trigger: Every 15 minutes

What it does: Pulls all client portfolios from your custody API, calculates drift from target allocation, and fires a Slack alert + advisor email when any position drifts >5%.

{
  "name": "Portfolio Rebalancing Alert",
  "nodes": [
    {
      "id": "schedule",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 */15 * * * *" }] } },
      "position": [250, 300]
    },
    {
      "id": "http",
      "name": "Get Portfolio Positions",
      "type": "n8n-nodes-base.httpRequest",
      "parameters": {
        "url": "https://your-custody-api.com/api/v1/portfolios",
        "method": "GET",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth"
      },
      "position": [450, 300]
    },
    {
      "id": "code",
      "name": "Calculate Drift",
      "type": "n8n-nodes-base.code",
      "parameters": {
        "jsCode": "const drifted = [];\nfor (const item of $input.all()) {\n  const p = item.json;\n  for (const pos of (p.positions || [])) {\n    const drift = Math.abs(pos.current_weight - pos.target_weight);\n    if (drift > 0.05) {\n      drifted.push({ portfolio_id: p.id, client_name: p.client_name, asset: pos.symbol, current_pct: (pos.current_weight*100).toFixed(1), target_pct: (pos.target_weight*100).toFixed(1), drift_pct: (drift*100).toFixed(1) });\n    }\n  }\n}\nreturn drifted.map(d => ({ json: d }));"
      },
      "position": [650, 300]
    },
    {
      "id": "filter",
      "name": "Has Drift?",
      "type": "n8n-nodes-base.filter",
      "parameters": { "conditions": { "number": [{ "value1": "={{ $items().length }}", "operation": "larger", "value2": 0 }] } },
      "position": [850, 300]
    },
    {
      "id": "slack",
      "name": "Slack #portfolio-ops",
      "type": "n8n-nodes-base.slack",
      "parameters": {
        "resource": "message", "operation": "post",
        "channel": "#portfolio-ops",
        "text": "={{ '⚠️ Rebalancing Needed: ' + $items().length + ' positions drifted >5%\n' + $items().map(i => i.json.client_name + ' — ' + i.json.asset + ': ' + i.json.current_pct + '% (target ' + i.json.target_pct + '%)').join('\n') }}"
      },
      "position": [1050, 300]
    }
  ],
  "connections": {
    "Schedule Trigger": { "main": [[{ "node": "Get Portfolio Positions", "type": "main", "index": 0 }]] },
    "Get Portfolio Positions": { "main": [[{ "node": "Calculate Drift", "type": "main", "index": 0 }]] },
    "Calculate Drift": { "main": [[{ "node": "Has Drift?", "type": "main", "index": 0 }]] },
    "Has Drift?": { "main": [[{ "node": "Slack #portfolio-ops", "type": "main", "index": 0 }]] }
  }
}
Enter fullscreen mode Exit fullscreen mode

Compliance note: Portfolio weights and client IDs stay in your VPC. Zero data egress to Zapier/Make cloud.


Workflow 2: Client Onboarding & KYC Status Drip

Trigger: CRM webhook on new client record

What it does: Sends a welcome email with account setup instructions, DMs the CSM on Slack, then follows up at day 3 (KYC completion nudge) and day 7 (account activation confirmation) — fully automated.

{
  "name": "Client Onboarding & KYC Drip",
  "nodes": [
    { "id": "webhook", "name": "CRM Webhook", "type": "n8n-nodes-base.webhook", "parameters": { "path": "new-client", "httpMethod": "POST" }, "position": [250, 300] },
    { "id": "gmail-d0", "name": "Day 0 Welcome Email", "type": "n8n-nodes-base.gmail",
      "parameters": { "resource": "message", "operation": "send",
        "toList": "={{ $json.body.client_email }}",
        "subject": "Welcome to {{ $json.body.firm_name }} — Your Account Setup",
        "message": "Hi {{ $json.body.first_name }},\n\nYour account has been created. Complete KYC verification here: {{ $json.body.kyc_link }}\n\nYour advisor: {{ $json.body.advisor_name }} ({{ $json.body.advisor_email }})\n\nQuestions? Reply to this email." },
      "position": [450, 200] },
    { "id": "slack-csm", "name": "Slack CSM", "type": "n8n-nodes-base.slack",
      "parameters": { "resource": "message", "operation": "post", "channel": "={{ $json.body.csm_slack_id }}",
        "text": "={{ 'New client: ' + $json.body.first_name + ' ' + $json.body.last_name + ' (' + $json.body.account_type + ', $' + Number($json.body.initial_aum).toLocaleString() + ' AUM). KYC sent.' }}" },
      "position": [450, 400] },
    { "id": "wait-3d", "name": "Wait 3 Days", "type": "n8n-nodes-base.wait",
      "parameters": { "resume": "timeInterval", "unit": "days", "amount": 3 }, "position": [650, 300] },
    { "id": "gmail-d3", "name": "Day 3 KYC Nudge", "type": "n8n-nodes-base.gmail",
      "parameters": { "resource": "message", "operation": "send",
        "toList": "={{ $json.body.client_email }}",
        "subject": "Have you completed your KYC verification?",
        "message": "Hi {{ $json.body.first_name }},\n\nJust checking in — your KYC verification link is still active: {{ $json.body.kyc_link }}\n\nIt takes about 5 minutes. Once complete, your account will be fully activated and we can begin managing your portfolio.\n\nAny questions? Call us at {{ $json.body.support_phone }}." },
      "position": [850, 300] },
    { "id": "wait-4d", "name": "Wait 4 More Days", "type": "n8n-nodes-base.wait",
      "parameters": { "resume": "timeInterval", "unit": "days", "amount": 4 }, "position": [1050, 300] },
    { "id": "gmail-d7", "name": "Day 7 Account Active", "type": "n8n-nodes-base.gmail",
      "parameters": { "resource": "message", "operation": "send",
        "toList": "={{ $json.body.client_email }}",
        "subject": "Your account is ready — here's what happens next",
        "message": "Hi {{ $json.body.first_name }},\n\nWelcome aboard! Your account is fully active.\n\nNext steps:\n• Review your Investment Policy Statement (IPS)\n• Your first portfolio review is scheduled for {{ $json.body.review_date }}\n• Download our app: {{ $json.body.app_link }}\n\nLooking forward to working with you." },
      "position": [1250, 300] },
    { "id": "sheets", "name": "Log to Sheets", "type": "n8n-nodes-base.googleSheets",
      "parameters": { "operation": "append", "documentId": "YOUR_SHEET_ID", "sheetName": "onboarding_log",
        "columns": { "mappingMode": "defineBelow", "value": { "client_id": "={{ $json.body.client_id }}", "name": "={{ $json.body.first_name + ' ' + $json.body.last_name }}", "email": "={{ $json.body.client_email }}", "aum": "={{ $json.body.initial_aum }}", "kyc_sent": "={{ new Date().toISOString() }}", "status": "drip_active" } } },
      "position": [1450, 300] }
  ],
  "connections": {
    "CRM Webhook": { "main": [[{ "node": "Day 0 Welcome Email", "type": "main", "index": 0 }, { "node": "Slack CSM", "type": "main", "index": 0 }]] },
    "Day 0 Welcome Email": { "main": [[{ "node": "Wait 3 Days", "type": "main", "index": 0 }]] },
    "Wait 3 Days": { "main": [[{ "node": "Day 3 KYC Nudge", "type": "main", "index": 0 }]] },
    "Day 3 KYC Nudge": { "main": [[{ "node": "Wait 4 More Days", "type": "main", "index": 0 }]] },
    "Wait 4 More Days": { "main": [[{ "node": "Day 7 Account Active", "type": "main", "index": 0 }]] },
    "Day 7 Account Active": { "main": [[{ "node": "Log to Sheets", "type": "main", "index": 0 }]] }
  }
}
Enter fullscreen mode Exit fullscreen mode

Customize the KYC link, account type, and advisor assignment to match your CRM schema.


Workflow 3: Regulatory Reporting Deadline Tracker

Trigger: Weekdays at 8:00 AM

What it does: Scans a Google Sheet of regulatory deadlines (SEC 13F, Form ADV, FINRA annual review, MiFID II transaction reporting, GDPR data audits), categorizes urgency, and fires Slack + email alerts by tier.

{
  "name": "Regulatory Reporting Deadline Tracker",
  "nodes": [
    { "id": "cron", "name": "Weekdays 8AM", "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 8 * * 1-5" }] } }, "position": [250, 300] },
    { "id": "sheets", "name": "Get Deadlines", "type": "n8n-nodes-base.googleSheets",
      "parameters": { "operation": "getAll", "documentId": "YOUR_SHEET_ID", "sheetName": "regulatory_deadlines",
        "filters": { "conditions": [{ "value1": "status", "operation": "equal", "value2": "active" }] } }, "position": [450, 300] },
    { "id": "code", "name": "Classify Urgency", "type": "n8n-nodes-base.code",
      "parameters": { "jsCode": "const today = new Date();\nreturn $input.all().map(item => {\n  const d = item.json;\n  const deadline = new Date(d.deadline_date);\n  const daysLeft = Math.ceil((deadline - today) / 86400000);\n  let urgency, emoji;\n  if (daysLeft < 0) { urgency = 'OVERDUE'; emoji = '🚨'; }\n  else if (daysLeft <= 7) { urgency = 'CRITICAL'; emoji = '🔴'; }\n  else if (daysLeft <= 21) { urgency = 'URGENT'; emoji = '🟠'; }\n  else if (daysLeft <= 60) { urgency = 'WARNING'; emoji = '🟡'; }\n  else { return null; }\n  return { json: { ...d, days_left: daysLeft, urgency, emoji } };\n}).filter(Boolean);" },
      "position": [650, 300] },
    { "id": "filter", "name": "Has Deadlines?", "type": "n8n-nodes-base.filter",
      "parameters": { "conditions": { "number": [{ "value1": "={{ $items().length }}", "operation": "larger", "value2": 0 }] } }, "position": [850, 300] },
    { "id": "slack", "name": "Slack #compliance-alerts", "type": "n8n-nodes-base.slack",
      "parameters": { "resource": "message", "operation": "post", "channel": "#compliance-alerts",
        "text": "={{ '📋 *Regulatory Deadline Report — ' + new Date().toISOString().slice(0,10) + '*\n' + $items().map(i => i.json.emoji + ' [' + i.json.urgency + '] ' + i.json.regulation + ' — ' + i.json.filing_type + ' (due ' + i.json.deadline_date + ', ' + i.json.days_left + ' days)\n  Owner: ' + i.json.compliance_owner).join('\n') }}" },
      "position": [1050, 200] },
    { "id": "gmail", "name": "Email Compliance Officer", "type": "n8n-nodes-base.gmail",
      "parameters": { "resource": "message", "operation": "send",
        "toList": "={{ $items()[0].json.compliance_officer_email }}",
        "subject": "={{ '[ACTION REQUIRED] ' + $items().filter(i=>i.json.urgency==='OVERDUE'||i.json.urgency==='CRITICAL').length + ' critical regulatory deadlines this week' }}",
        "message": "={{ 'Regulatory deadlines requiring immediate attention:\n\n' + $items().map(i => i.json.emoji + ' ' + i.json.urgency + '\n  Regulation: ' + i.json.regulation + '\n  Filing: ' + i.json.filing_type + '\n  Due: ' + i.json.deadline_date + ' (' + i.json.days_left + ' days)\n  Owner: ' + i.json.compliance_owner + '\n  Notes: ' + (i.json.notes||'—')).join('\n\n') }}" },
      "position": [1050, 400] }
  ],
  "connections": {
    "Weekdays 8AM": { "main": [[{ "node": "Get Deadlines", "type": "main", "index": 0 }]] },
    "Get Deadlines": { "main": [[{ "node": "Classify Urgency", "type": "main", "index": 0 }]] },
    "Classify Urgency": { "main": [[{ "node": "Has Deadlines?", "type": "main", "index": 0 }]] },
    "Has Deadlines?": { "main": [[{ "node": "Slack #compliance-alerts", "type": "main", "index": 0 }, { "node": "Email Compliance Officer", "type": "main", "index": 0 }]] }
  }
}
Enter fullscreen mode Exit fullscreen mode

Sheet columns: regulation (SEC 13F / Form ADV / FINRA Annual / MiFID II / GDPR), filing_type, deadline_date, compliance_owner, compliance_officer_email, notes, status.


Workflow 4: Market Data Feed Health Monitor

Trigger: Every 15 minutes

What it does: Queries your data warehouse for recent price feed updates. If any symbol hasn't updated in >10 minutes (stale feed), fires a Slack alert to your data engineering team before it cascades into portfolio calculation errors.

{
  "name": "Market Data Feed Health Monitor",
  "nodes": [
    { "id": "schedule", "name": "Every 15 Min", "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "*/15 * * * *" }] } }, "position": [250, 300] },
    { "id": "postgres", "name": "Check Feed Freshness", "type": "n8n-nodes-base.postgres",
      "parameters": { "operation": "executeQuery",
        "query": "SELECT symbol, exchange, MAX(ts) as last_update, EXTRACT(EPOCH FROM (NOW() - MAX(ts)))/60 AS minutes_stale FROM market_data.price_feed GROUP BY symbol, exchange HAVING EXTRACT(EPOCH FROM (NOW() - MAX(ts)))/60 > 10 ORDER BY minutes_stale DESC LIMIT 50" },
      "position": [450, 300] },
    { "id": "code", "name": "Build Alert", "type": "n8n-nodes-base.code",
      "parameters": { "jsCode": "const stale = $input.all().map(i => i.json);\nif (!stale.length) return [{ json: { ok: true } }];\nconst prev = $getWorkflowStaticData('global');\nconst now = Date.now();\nif (prev.last_alert && (now - prev.last_alert) < 900000) return [{ json: { suppressed: true } }];\nprev.last_alert = now;\n$setWorkflowStaticData('global', prev);\nconst critical = stale.filter(s => s.minutes_stale > 30);\nreturn [{ json: { stale, critical_count: critical.length, total_count: stale.length, summary: stale.slice(0,10).map(s => s.symbol + ' (' + s.exchange + '): ' + Math.round(s.minutes_stale) + 'm stale').join('\n') } }];" },
      "position": [650, 300] },
    { "id": "if", "name": "Is Stale?", "type": "n8n-nodes-base.if",
      "parameters": { "conditions": { "number": [{ "value1": "={{ $json.total_count }}", "operation": "larger", "value2": 0 }] } }, "position": [850, 300] },
    { "id": "slack", "name": "Slack #market-data-ops", "type": "n8n-nodes-base.slack",
      "parameters": { "resource": "message", "operation": "post", "channel": "#market-data-ops",
        "text": "={{ (($json.critical_count > 0) ? '🚨 *CRITICAL: ' : '⚠️ *WARNING: ') + $json.total_count + ' stale price feeds (' + $json.critical_count + ' critical >30m)*\n' + $json.summary }}" },
      "position": [1050, 300] }
  ],
  "connections": {
    "Every 15 Min": { "main": [[{ "node": "Check Feed Freshness", "type": "main", "index": 0 }]] },
    "Check Feed Freshness": { "main": [[{ "node": "Build Alert", "type": "main", "index": 0 }]] },
    "Build Alert": { "main": [[{ "node": "Is Stale?", "type": "main", "index": 0 }]] },
    "Is Stale?": { "main": [[{ "node": "Slack #market-data-ops", "type": "main", "index": 0 }]] }
  }
}
Enter fullscreen mode Exit fullscreen mode

The $getWorkflowStaticData deduplication prevents repeat alerts every 15 minutes for the same feed outage.


Workflow 5: Weekly AUM & Client KPI Dashboard

Trigger: Every Monday at 8:00 AM

What it does: Pulls AUM, active accounts, net flows, and MRR from Postgres. Calculates week-over-week change. Sends a clean HTML dashboard email to your executive team and a one-liner to Slack.

{
  "name": "Weekly AUM & Client KPI Dashboard",
  "nodes": [
    { "id": "cron", "name": "Monday 8AM", "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 8 * * 1" }] } }, "position": [250, 300] },
    { "id": "postgres", "name": "Get KPIs", "type": "n8n-nodes-base.postgres",
      "parameters": { "operation": "executeQuery",
        "query": "SELECT\n  SUM(aum_usd) AS total_aum,\n  COUNT(DISTINCT client_id) AS active_accounts,\n  SUM(CASE WHEN account_type = 'retail' THEN 1 ELSE 0 END) AS retail_accounts,\n  SUM(CASE WHEN account_type = 'institutional' THEN 1 ELSE 0 END) AS institutional_accounts,\n  SUM(net_flow_7d) AS net_flows_7d,\n  SUM(mrr_usd) AS mrr,\n  AVG(portfolio_return_ytd) AS avg_return_ytd\nFROM portfolio_management.accounts WHERE status = 'active'" },
      "position": [450, 300] },
    { "id": "code", "name": "Build Dashboard", "type": "n8n-nodes-base.code",
      "parameters": { "jsCode": "const kpi = $input.first().json;\nconst prev = $getWorkflowStaticData('global');\nconst fmtM = v => '$' + (v/1e6).toFixed(1) + 'M';\nconst fmtPct = (curr, last) => last ? ' (' + (curr > last ? '+' : '') + ((curr-last)/last*100).toFixed(1) + '%)' : '';\nconst html = `<h2>Weekly WealthTech KPI Report — ${new Date().toISOString().slice(0,10)}</h2>\n<table border=1 cellpadding=6>\n<tr><td><b>Total AUM</b></td><td>${fmtM(kpi.total_aum)}${fmtPct(kpi.total_aum, prev.total_aum)}</td></tr>\n<tr><td><b>Active Accounts</b></td><td>${kpi.active_accounts}${fmtPct(kpi.active_accounts, prev.active_accounts)}</td></tr>\n<tr><td><b>Net Flows (7d)</b></td><td>${fmtM(kpi.net_flows_7d)}</td></tr>\n<tr><td><b>MRR</b></td><td>$${Number(kpi.mrr).toLocaleString()}${fmtPct(kpi.mrr, prev.mrr)}</td></tr>\n<tr><td><b>Avg Return YTD</b></td><td>${parseFloat(kpi.avg_return_ytd).toFixed(2)}%</td></tr>\n</table>`;\n$setWorkflowStaticData('global', { total_aum: kpi.total_aum, active_accounts: kpi.active_accounts, mrr: kpi.mrr });\nreturn [{ json: { ...kpi, html, slack_line: 'AUM: ' + fmtM(kpi.total_aum) + fmtPct(kpi.total_aum, prev.total_aum) + ' | Accounts: ' + kpi.active_accounts + ' | MRR: $' + Number(kpi.mrr).toLocaleString() } }];" },
      "position": [650, 300] },
    { "id": "gmail", "name": "Email Exec Team", "type": "n8n-nodes-base.gmail",
      "parameters": { "resource": "message", "operation": "send",
        "toList": "cio@yourfirm.com,cto@yourfirm.com,cfo@yourfirm.com",
        "subject": "={{ 'Weekly AUM Report — ' + new Date().toISOString().slice(0,10) }}",
        "message": "={{ $json.html }}",
        "options": { "appendAttribution": false } },
      "position": [850, 200] },
    { "id": "slack", "name": "Slack #exec-metrics", "type": "n8n-nodes-base.slack",
      "parameters": { "resource": "message", "operation": "post", "channel": "#exec-metrics",
        "text": "={{ '📊 Weekly KPIs: ' + $json.slack_line }}" },
      "position": [850, 400] }
  ],
  "connections": {
    "Monday 8AM": { "main": [[{ "node": "Get KPIs", "type": "main", "index": 0 }]] },
    "Get KPIs": { "main": [[{ "node": "Build Dashboard", "type": "main", "index": 0 }]] },
    "Build Dashboard": { "main": [[{ "node": "Email Exec Team", "type": "main", "index": 0 }, { "node": "Slack #exec-metrics", "type": "main", "index": 0 }]] }
  }
}
Enter fullscreen mode Exit fullscreen mode

Getting the Workflows

All 5 workflows above are available in import-ready format at stripeai.gumroad.com — including the full n8n JSON, field mapping docs, and Google Sheets templates.

The complete FlowKit Bundle includes 15 production-ready n8n workflows covering client onboarding, compliance tracking, reporting automation, lead management, and data pipeline ops.


Why Self-Hosted n8n Makes Sense for WealthTech

SEC Reg S-P requires safeguards for non-public customer financial information. Routing client AUM data, trade confirmations, or portfolio positions through Zapier's cloud creates a third-party sub-processor relationship you need to document and justify.

FINRA and MiFID II require audit trails for automated communications and order-related processes. Git-versioned n8n workflows = documentable audit trail.

Proprietary model portfolios and rebalancing algorithms are your IP. Zapier stores workflow runs — your logic and your data together in someone else's cloud.

With self-hosted n8n on a $20/mo VPS, you get 500K+ tasks/month (vs Zapier Enterprise at $1,799+) and full data sovereignty. No sub-processor. No trade secrets in a vendor's run history.


Alex Kane builds n8n automation templates for technical teams. All workflows above are production-ready and available for immediate import.

Top comments (0)