RevOps and GTM teams are the engine room of a SaaS company — but most still run on manual spreadsheet exports, Slack pings, and copy-paste CRM updates. The result: slow handoffs, stale data, and reps spending 30% of their week on admin instead of selling.
This guide shows 5 production-ready n8n automations that eliminate the most painful RevOps bottlenecks — lead routing, quota tracking, pipeline forecasting, and revenue attribution — with complete import-ready workflow JSON.
Why n8n? Your pipeline data, CAC numbers, win rates, and pricing strategies are commercially sensitive. Routing them through Zapier or Make means they touch a third-party cloud server — which is a SOC 2 CC6.1 data egress finding in enterprise security reviews. Self-hosted n8n keeps everything in your VPC, git-versionable, and audit-ready.
Workflow 1: Lead Score & Auto-Route Pipeline
The problem: Inbound leads sit in a form tool or CRM for hours before a rep picks them up. No routing logic = hot leads going cold.
What this does: Scores every inbound lead by company size, industry, title, and source, then instantly routes to the right rep or queue with a Slack alert.
{
"name": "Lead Score & Auto-Route",
"nodes": [
{"id": "1", "type": "n8n-nodes-base.webhook", "name": "Inbound Lead Webhook",
"parameters": {"path": "lead-intake", "responseMode": "responseNode"},
"position": [100, 300]},
{"id": "2", "type": "n8n-nodes-base.code", "name": "Score & Tier Lead",
"parameters": {"jsCode": "const lead = $json.body;\nlet score = 0;\nif (lead.employees > 500) score += 40;\nelse if (lead.employees > 50) score += 20;\nif (['saas','fintech','healthtech'].includes(lead.industry)) score += 20;\nconst title = (lead.title || '').toLowerCase();\nif (title.includes('vp') || title.includes('director') || title.includes('head')) score += 25;\nif (title.includes('ceo') || title.includes('cto') || title.includes('coo')) score += 30;\nif (['google','linkedin','partner'].includes(lead.source)) score += 15;\nconst tier = score >= 70 ? 'ENTERPRISE' : score >= 45 ? 'MID_MARKET' : 'SMB';\nreturn [{json: {...lead, score, tier}}];"},
"position": [300, 300]},
{"id": "3", "type": "n8n-nodes-base.switch", "name": "Route by Tier",
"parameters": {"dataType": "string", "value1": "={{$json.tier}}",
"rules": [{"value2": "ENTERPRISE", "output": 0}, {"value2": "MID_MARKET", "output": 1}, {"value2": "SMB", "output": 2}]},
"position": [500, 300]},
{"id": "4", "type": "n8n-nodes-base.slack", "name": "Alert Enterprise AE",
"parameters": {"channel": "#ae-enterprise",
"text": "*🔥 ENTERPRISE LEAD (Score: {{$json.score}})*\nCompany: {{$json.company}}\nContact: {{$json.name}} — {{$json.title}}\nIndustry: {{$json.industry}} | Employees: {{$json.employees}}\nSource: {{$json.source}} | Email: {{$json.email}}"},
"position": [750, 150]},
{"id": "5", "type": "n8n-nodes-base.slack", "name": "Alert SDR Mid-Market",
"parameters": {"channel": "#sdr-mid-market",
"text": "*New MID-MARKET Lead (Score: {{$json.score}})*\n{{$json.name}} @ {{$json.company}} ({{$json.employees}} emp)\n{{$json.title}} | {{$json.email}}"},
"position": [750, 300]},
{"id": "6", "type": "n8n-nodes-base.googleSheets", "name": "Log All Leads",
"parameters": {"operation": "append", "sheetId": "YOUR_SHEET_ID",
"columns": {"mappingMode": "autoMapInputData"}},
"position": [1000, 300]}
]
}
Result: Enterprise leads hit #ae-enterprise within seconds of form submit. Mid-market goes to SDR queue. SMB goes to nurture. No leads fall through the cracks.
Workflow 2: MQL→SQL Handoff & Multi-Touch Follow-Up
The problem: Marketing hands off MQLs to sales with a spreadsheet update. Half never get followed up. The drip goes cold.
What this does: Triggers the moment a lead crosses your MQL threshold, notifies the SDR, and runs a structured 3-touch follow-up sequence with Wait nodes.
{
"name": "MQL to SQL Handoff Sequence",
"nodes": [
{"id": "1", "type": "n8n-nodes-base.googleSheetsTrigger", "name": "New MQL in Sheet",
"parameters": {"sheetId": "YOUR_MQL_SHEET", "event": "rowAdded"},
"position": [100, 300]},
{"id": "2", "type": "n8n-nodes-base.slack", "name": "Notify SDR",
"parameters": {"channel": "#sales-mql-queue",
"text": "*New MQL — Act Fast* ⚡\n{{$json.name}} @ {{$json.company}}\nScore: {{$json.score}} | Source: {{$json.source}}\nEmail: {{$json.email}}"},
"position": [300, 200]},
{"id": "3", "type": "n8n-nodes-base.gmail", "name": "Day 1 — Value Email",
"parameters": {"toList": "={{$json.email}}",
"subject": "Quick question about {{$json.company}}'s [relevant process]",
"message": "Hi {{$json.first_name}},\n\nI noticed you [specific action]. Most [role] teams we work with are dealing with [pain point] — usually costing 8-12 hours/week.\n\nWould a 15-min call this week make sense?\n\nBest, [SDR Name]"},
"position": [300, 400]},
{"id": "4", "type": "n8n-nodes-base.wait", "name": "Wait 3 Days",
"parameters": {"amount": 3, "unit": "days"},
"position": [500, 400]},
{"id": "5", "type": "n8n-nodes-base.gmail", "name": "Day 4 — Case Study",
"parameters": {"toList": "={{$json.email}}",
"subject": "How [Similar Company] saved 40% ops time",
"message": "{{$json.first_name}},\n\nSharing a quick case study — [Company] had the same challenge. Here's what changed: [link to case study].\n\nStill worth a chat?"},
"position": [700, 400]},
{"id": "6", "type": "n8n-nodes-base.wait", "name": "Wait 4 Days",
"parameters": {"amount": 4, "unit": "days"},
"position": [900, 400]},
{"id": "7", "type": "n8n-nodes-base.gmail", "name": "Day 8 — Direct Ask",
"parameters": {"toList": "={{$json.email}}",
"subject": "Last note — {{$json.company}}",
"message": "{{$json.first_name}},\n\nI don't want to keep filling your inbox. If the timing is off, happy to reconnect in Q[next quarter].\n\nIf there IS a fit, here's my calendar: [Calendly link]\n\nEither way — hope it's useful."},
"position": [1100, 400]}
]
}
Tip: Use n8n's $getWorkflowStaticData to track if a lead replied (via Gmail trigger) and stop the sequence automatically.
Workflow 3: Quota Attainment & Pipeline Forecast Digest
The problem: VP Sales manually pulls rep performance every Monday morning. Takes 90 minutes. Numbers are already stale by the time it hits inboxes.
What this does: Runs every Monday 8AM, pulls CRM data from Google Sheets, calculates per-rep attainment and pipeline gap, and emails an HTML digest.
{
"name": "Weekly Quota & Pipeline Digest",
"nodes": [
{"id": "1", "type": "n8n-nodes-base.scheduleTrigger", "name": "Monday 8AM",
"parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "0 8 * * 1"}]}},
"position": [100, 300]},
{"id": "2", "type": "n8n-nodes-base.googleSheets", "name": "Fetch CRM Data",
"parameters": {"operation": "readAllRows", "sheetId": "YOUR_CRM_SHEET"},
"position": [300, 300]},
{"id": "3", "type": "n8n-nodes-base.code", "name": "Build Rep Scorecard",
"parameters": {"jsCode": "const rows = $input.all().map(i => i.json);\nconst reps = {};\nrows.forEach(r => {\n if (!reps[r.rep]) reps[r.rep] = {quota: parseFloat(r.quota_usd)||0, closed: 0, pipeline: 0};\n if (r.stage === 'Closed Won') reps[r.rep].closed += parseFloat(r.arr_usd)||0;\n else reps[r.rep].pipeline += parseFloat(r.arr_usd)||0;\n});\nreturn Object.entries(reps).map(([rep, d]) => {\n const attainment = d.quota > 0 ? Math.round(d.closed / d.quota * 100) : 0;\n const gap = Math.max(0, d.quota - d.closed);\n const flag = attainment < 50 ? '🔴' : attainment < 80 ? '🟡' : '🟢';\n return {json: {rep, quota: d.quota, closed: d.closed, pipeline: d.pipeline, attainment, gap, flag}};\n}).sort((a,b) => a.json.attainment - b.json.attainment);"},
"position": [500, 300]},
{"id": "4", "type": "n8n-nodes-base.code", "name": "Build HTML Report",
"parameters": {"jsCode": "const reps = $input.all().map(i => i.json);\nconst rows = reps.map(r => `<tr><td>${r.flag} ${r.rep}</td><td>$${r.quota.toLocaleString()}</td><td>$${r.closed.toLocaleString()}</td><td>${r.attainment}%</td><td>$${r.gap.toLocaleString()}</td><td>$${r.pipeline.toLocaleString()}</td></tr>`).join('');\nconst html = `<h2>Weekly Quota Attainment — ${new Date().toLocaleDateString()}</h2><table border='1' cellpadding='6' style='border-collapse:collapse'><tr><th>Rep</th><th>Quota</th><th>Closed Won</th><th>Attainment</th><th>Gap to Quota</th><th>Open Pipeline</th></tr>${rows}</table>`;\nreturn [{json: {html}}];"},
"position": [700, 300]},
{"id": "5", "type": "n8n-nodes-base.gmail", "name": "Email VP Sales",
"parameters": {"toList": "vpsales@yourcompany.com",
"subject": "Weekly Quota Report — {{$now.format('MMM D')}}",
"message": "={{$json.html}}"},
"position": [900, 300]}
]
}
Bonus: Add a Slack message with a one-liner: "Team at 74% average attainment. 3 reps below 50% — see email for details."
Workflow 4: GTM Experiment & A/B Test Tracker
The problem: Growth and RevOps run 5–10 experiments per quarter. Results live in different spreadsheets. Nobody knows what's significant vs noise.
What this does: Weekly digest of all active experiments, calculates lift and a simple significance signal, flags winners.
{
"name": "GTM Experiment Tracker",
"nodes": [
{"id": "1", "type": "n8n-nodes-base.scheduleTrigger", "name": "Friday 4PM",
"parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "0 16 * * 5"}]}},
"position": [100, 300]},
{"id": "2", "type": "n8n-nodes-base.googleSheets", "name": "Fetch Experiments",
"parameters": {"operation": "readAllRows", "sheetId": "YOUR_EXPERIMENTS_SHEET"},
"position": [300, 300]},
{"id": "3", "type": "n8n-nodes-base.code", "name": "Calc Lift & Significance",
"parameters": {"jsCode": "return $input.all().map(item => {\n const e = item.json;\n if (!e.status || e.status.toLowerCase() !== 'active') return null;\n const control_cvr = parseFloat(e.control_conversions) / (parseFloat(e.control_visitors)||1);\n const variant_cvr = parseFloat(e.variant_conversions) / (parseFloat(e.variant_visitors)||1);\n const lift = control_cvr > 0 ? Math.round((variant_cvr - control_cvr) / control_cvr * 100) : 0;\n const n = (parseFloat(e.control_visitors)||0) + (parseFloat(e.variant_visitors)||0);\n const significant = n >= 1000 && Math.abs(lift) >= 10;\n const status_icon = !significant ? '⏳' : lift > 0 ? '✅ WINNER' : '❌ LOSER';\n return {json: {name: e.experiment_name, lift, n: Math.round(n), significant, status_icon, started: e.start_date}};\n}).filter(Boolean);"},
"position": [500, 300]},
{"id": "4", "type": "n8n-nodes-base.slack", "name": "Post to #growth",
"parameters": {"channel": "#growth",
"text": "*GTM Experiments — Week of {{$now.format('MMM D')}}*\n{{$input.all().map(i => `${i.json.status_icon} *${i.json.name}*: ${i.json.lift > 0 ? '+' : ''}${i.json.lift}% lift (n=${i.json.n.toLocaleString()})`).join('\\n')}}"},
"position": [750, 300]}
]
}
Workflow 5: Revenue Attribution & Funnel Conversion Report
The problem: CMO asks "which channels drive revenue?" Marketing says Google Ads. Sales says LinkedIn. Nobody has a clean multi-touch attribution number.
What this does: Monthly report combining lead source, pipeline stage, and closed-won data to produce funnel conversion rates and revenue attribution by channel.
{
"name": "Monthly Revenue Attribution Report",
"nodes": [
{"id": "1", "type": "n8n-nodes-base.scheduleTrigger", "name": "1st of Month 8AM",
"parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "0 8 1 * *"}]}},
"position": [100, 300]},
{"id": "2", "type": "n8n-nodes-base.googleSheets", "name": "Fetch Leads",
"parameters": {"operation": "readAllRows", "sheetId": "YOUR_LEADS_SHEET"},
"position": [300, 200]},
{"id": "3", "type": "n8n-nodes-base.googleSheets", "name": "Fetch Won Deals",
"parameters": {"operation": "readAllRows", "sheetId": "YOUR_DEALS_SHEET"},
"position": [300, 400]},
{"id": "4", "type": "n8n-nodes-base.merge", "name": "Merge",
"parameters": {"mode": "passThrough", "output": "both"},
"position": [500, 300]},
{"id": "5", "type": "n8n-nodes-base.code", "name": "Attribution Report",
"parameters": {"jsCode": "const allData = $input.all().map(i => i.json);\nconst leads = allData.filter(d => d.record_type === 'lead');\nconst deals = allData.filter(d => d.stage === 'Closed Won');\nconst channels = {};\nleads.forEach(l => {\n if (!channels[l.source]) channels[l.source] = {leads: 0, mqls: 0, sqls: 0, won: 0, arr: 0};\n channels[l.source].leads++;\n if (l.is_mql === 'true') channels[l.source].mqls++;\n if (l.is_sql === 'true') channels[l.source].sqls++;\n});\ndeals.forEach(d => {\n const ch = channels[d.source];\n if (ch) { ch.won++; ch.arr += parseFloat(d.arr_usd)||0; }\n});\nconst rows = Object.entries(channels).map(([src, c]) => ({\n source: src, leads: c.leads,\n lead_to_mql: c.leads > 0 ? Math.round(c.mqls/c.leads*100) + '%' : '-',\n mql_to_sql: c.mqls > 0 ? Math.round(c.sqls/c.mqls*100) + '%' : '-',\n sql_to_won: c.sqls > 0 ? Math.round(c.won/c.sqls*100) + '%' : '-',\n arr_usd: '$' + c.arr.toLocaleString()\n})).sort((a,b) => parseFloat(b.arr_usd.replace(/[$,]/g,'')) - parseFloat(a.arr_usd.replace(/[$,]/g,'')));\nconst tableRows = rows.map(r => `<tr><td>${r.source}</td><td>${r.leads}</td><td>${r.lead_to_mql}</td><td>${r.mql_to_sql}</td><td>${r.sql_to_won}</td><td>${r.arr_usd}</td></tr>`).join('');\nconst html = `<h2>Revenue Attribution — ${new Date().toLocaleDateString('en-US', {month:'long', year:'numeric'})}</h2><table border='1' cellpadding='6' style='border-collapse:collapse'><tr><th>Channel</th><th>Leads</th><th>Lead→MQL</th><th>MQL→SQL</th><th>SQL→Won</th><th>ARR Won</th></tr>${tableRows}</table>`;\nreturn [{json: {html}}];"},
"position": [700, 300]},
{"id": "6", "type": "n8n-nodes-base.gmail", "name": "Email CMO & CRO",
"parameters": {"toList": "cmo@yourcompany.com",
"ccList": "cro@yourcompany.com",
"subject": "Monthly Revenue Attribution — {{$now.minus({months:1}).format('MMMM yyyy')}}",
"message": "={{$json.html}}"},
"position": [900, 300]}
]
}
Why RevOps Teams Choose Self-Hosted n8n
| Factor | n8n (self-hosted) | Zapier | Make.com |
|---|---|---|---|
| Pipeline data egress | ❌ Never leaves VPC | ✅ Touches Zapier cloud | ✅ Touches Make cloud |
| SOC 2 data flow audit | ✅ Git-versionable JSON | ❌ Point-and-click, no audit | ❌ Scenario export only |
| Cost at 100K ops/month | $0 (self-hosted) | ~$800/month | ~$400/month |
| Custom scoring logic | ✅ Full JS/Python in Code node | ❌ No code execution | ❌ Limited to transforms |
| Multi-step sequences | ✅ Wait nodes, native | ✅ Paths add cost | ✅ Routers add operations |
The Full RevOps Automation Stack
These 5 workflows slot into a complete RevOps automation layer:
- Lead → Route (Workflow 1) handles top-of-funnel routing
- MQL → SQL drip (Workflow 2) ensures every lead gets followed up
- Quota digest (Workflow 3) keeps leadership informed weekly
- Experiment tracker (Workflow 4) drives data-driven GTM decisions
- Attribution report (Workflow 5) closes the loop on channel ROI
All 5 are in the FlowKit n8n template store at stripeai.gumroad.com — individually or as a bundle. Import-ready JSON, no setup required beyond connecting your accounts.
Using n8n for RevOps in production? Drop your setup in the comments — always curious what's working.
Top comments (0)