The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "Form to CRM Lead Router (Pipedrive / HubSpot / Salesforce)",
"nodes": [
{
"parameters": {
"content": "## Form to CRM Lead Router\n\nForm webhook lands here, BANT scoring decides hot / warm / cold, multi-CRM Switch routes to Pipedrive (default), HubSpot, or Salesforce.\n\n**Production patterns wired:**\n- HMAC verify (opt-in, `LEAD_FORM_SIGNING_SECRET`)\n- Rate limit (opt-in, `RATE_LIMIT_ENABLED=1`)\n- Idempotency on submission ID (opt-in, `IDEMPOTENCY_ENABLED=1`)\n- Error branches with structured fallback\n\nSee `README.md` for setup, env vars, and extension recipes.",
"height": 300,
"width": 400,
"color": 6
},
"id": "note-intro",
"name": "Sticky Note - Intro",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-200,
-100
]
},
{
"parameters": {
"content": "### >> SET ME <<\n\n1. Configure your form provider (Webflow / Tally / Typeform / custom HTML form) to POST to this webhook URL.\n2. Optional: enable HMAC by setting `LEAD_FORM_SIGNING_SECRET` env var and configuring the same secret in your form provider.\n3. Set `CRM_TARGET=pipedrive` (or `hubspot` or `salesforce`) in your n8n env.\n4. Set CRM stage IDs: `CRM_PIPELINE_HOT_ID`, `CRM_PIPELINE_WARM_ID`, `CRM_PIPELINE_COLD_ID`.\n5. Configure Slack webhook URL in `SLACK_OPS_WEBHOOK` for ops notifications.",
"height": 280,
"width": 380,
"color": 5
},
"id": "note-setup",
"name": "Sticky Note - Setup",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-200,
220
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "lead-form",
"responseMode": "responseNode",
"options": {
"rawBody": true
}
},
"id": "form-1-trigger",
"name": "Form Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
240,
60
]
},
{
"parameters": {
"jsCode": "// HMAC-SHA256 signature verification, opt-in.\n// Enable by setting LEAD_FORM_SIGNING_SECRET in your n8n env\n// AND configuring the same secret in your form provider.\n// Pass-through when unset.\n\nconst secret = $env.LEAD_FORM_SIGNING_SECRET;\nconst integrityCheckEnabled = $env.WEBHOOK_INTEGRITY_CHECK_ENABLED === '1';\n\nif (!secret || !integrityCheckEnabled) {\n // Disabled by design, let the request through.\n return [$input.first()];\n}\n\nconst crypto = require('crypto');\nconst item = $input.first();\nconst rawBody = item.json.rawBody || (typeof item.json.body === 'string' ? item.json.body : JSON.stringify(item.json.body || {}));\nconst providedSig = item.json.headers && (item.json.headers['x-form-signature'] || item.json.headers['X-Form-Signature']);\n\nif (!providedSig) {\n throw new Error('UNAUTHORIZED: missing x-form-signature header');\n}\n\nconst expected = crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex');\n\n// Length-guard before constant-time compare. Without this, a 1-char\n// signature throws RangeError = free DoS on the verifier.\nif (providedSig.length !== expected.length) {\n throw new Error('UNAUTHORIZED: signature length mismatch');\n}\n\nconst expectedBuf = Buffer.from(expected, 'utf8');\nconst providedBuf = Buffer.from(providedSig, 'utf8');\n\nif (!crypto.timingSafeEqual(expectedBuf, providedBuf)) {\n throw new Error('UNAUTHORIZED: invalid signature');\n}\n\nreturn [$input.first()];"
},
"id": "form-pp-1-verify",
"name": "Verify Webhook (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
60
]
},
{
"parameters": {
"jsCode": "// Per-IP sliding-window rate limit, opt-in.\n// Enable by setting RATE_LIMIT_ENABLED=1 in your n8n env.\n// 60 requests per 5 minutes per IP. Adjust below.\n//\n// For real production loads put rate limiting on a reverse proxy\n// (Nginx limit_req_zone, Cloudflare WAF, Traefik). This node is\n// defense-in-depth, not the primary control.\n//\n// $getWorkflowStaticData is per-instance and not cluster-aware.\n// Multi-worker n8n: replace with Redis INCR + EXPIRE (snippet below).\n\nif ($env.RATE_LIMIT_ENABLED !== '1') {\n return [$input.first()];\n}\n\nconst LIMIT = 60;\nconst WINDOW_MS = 5 * 60 * 1000;\nconst MAX_KEYS = 5000;\n\nconst item = $input.first();\nconst ip = (item.json.headers && (item.json.headers['x-forwarded-for'] || item.json.headers['x-real-ip'])) || 'unknown';\nconst key = String(ip).split(',')[0].trim();\n\nconst data = $getWorkflowStaticData('global');\ndata.rateBuckets = data.rateBuckets || {};\nconst buckets = data.rateBuckets;\nconst now = Date.now();\n\n// Evict expired entries\nfor (const k of Object.keys(buckets)) {\n buckets[k] = (buckets[k] || []).filter(t => now - t < WINDOW_MS);\n if (buckets[k].length === 0) delete buckets[k];\n}\n// Cap map size\nif (Object.keys(buckets).length > MAX_KEYS) {\n const oldest = Object.entries(buckets).sort((a,b) => (a[1][0]||0) - (b[1][0]||0)).slice(0, 100);\n for (const [k] of oldest) delete buckets[k];\n}\n\nconst hits = buckets[key] || [];\nif (hits.length >= LIMIT) {\n throw new Error('RATE_LIMIT_EXCEEDED: ' + LIMIT + ' requests per ' + Math.round(WINDOW_MS / 60000) + ' minutes for ' + key);\n}\nbuckets[key] = [...hits, now];\n\nreturn [$input.first()];\n\n// Redis variant for clustered n8n:\n// const Redis = require('ioredis');\n// const redis = new Redis(process.env.REDIS_URL);\n// const cnt = await redis.incr('rl:' + key);\n// if (cnt === 1) await redis.expire('rl:' + key, 300);\n// await redis.quit();\n// if (cnt > LIMIT) throw new Error('RATE_LIMIT_EXCEEDED');"
},
"id": "form-pp-2-ratelimit",
"name": "Rate Limit (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
640,
60
]
},
{
"parameters": {
"jsCode": "// 5-minute idempotency window on form submission ID, opt-in.\n// Enable by setting IDEMPOTENCY_ENABLED=1.\n// Form providers retry on 5xx. Without dedup we create the lead twice in CRM.\n//\n// Replace the in-memory block with Redis SET NX EX 300 for clustered n8n.\n\nif ($env.IDEMPOTENCY_ENABLED !== '1') {\n return [$input.first()];\n}\n\nconst WINDOW_MS = 5 * 60 * 1000;\nconst MAX_KEYS = 10000;\n\nconst item = $input.first();\nconst body = item.json.body || {};\n// Idempotency key: provider-supplied submission ID, fallback to email + minute-bucketed timestamp\nconst submissionId = body.submission_id || body.id;\nconst email = (body.email || '').toLowerCase();\nconst minuteBucket = Math.floor(Date.now() / 60000);\nconst idempotencyKey = submissionId || (email && (email + ':' + minuteBucket));\n\nif (!idempotencyKey) {\n // No deduplicable key, let it through\n return [$input.first()];\n}\n\nconst data = $getWorkflowStaticData('global');\ndata.seenKeys = data.seenKeys || {};\nconst seen = data.seenKeys;\nconst now = Date.now();\n\n// Evict expired\nfor (const k of Object.keys(seen)) {\n if (now - seen[k] > WINDOW_MS) delete seen[k];\n}\n// Cap size\nif (Object.keys(seen).length > MAX_KEYS) {\n const oldest = Object.entries(seen).sort((a,b) => a[1] - b[1]).slice(0, 1000);\n for (const [k] of oldest) delete seen[k];\n}\n\nif (seen[idempotencyKey]) {\n // Duplicate detected. Emit a sentinel item that the\n // 'Skip If Duplicate' IF node routes to 'Respond Duplicate'\n // (200 OK + { deduped: true }). Without that 200 the source\n // provider would hold the HTTP connection until n8n's webhook\n // timeout (default 30s) and mark delivery failed.\n return [{ json: { skipped: true, reason: 'duplicate', dedupKey: String(idempotencyKey) } }];\n}\nseen[idempotencyKey] = now;\n\nreturn [$input.first()];\n\n// Redis variant:\n// const result = await redis.set('idem:' + idempotencyKey, '1', 'EX', 300, 'NX');\n// if (result === null) return [];"
},
"id": "form-pp-3-idempotency",
"name": "Idempotency Check (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
840,
60
]
},
{
"parameters": {
"jsCode": "// Normalize the form payload into a stable schema regardless of provider\n// (Webflow / Tally / Typeform / custom HTML). Map common field names.\n\nconst body = ($input.first().json.body || {});\n\nconst pick = (...keys) => {\n for (const k of keys) {\n if (body[k] != null && String(body[k]).trim() !== '') return String(body[k]).trim();\n }\n return null;\n};\n\nconst submissionId = pick('submission_id', 'id', 'submissionId') || ('lead-' + Date.now());\nconst email = (pick('email', 'email_address', 'EmailAddress') || '').toLowerCase();\nconst name = pick('name', 'full_name', 'fullName', 'first_name') || 'Unknown';\nconst company = pick('company', 'organization', 'company_name', 'org') || null;\nconst phone = pick('phone', 'phoneNumber', 'phone_number') || null;\n\n// BANT-aligned input fields. Form-provider-specific names mapped here.\nconst budget = pick('budget', 'budget_range', 'monthly_budget') || null;\nconst authority = pick('authority', 'role', 'job_title', 'title') || null;\nconst need = pick('need', 'pain_point', 'problem', 'use_case') || null;\nconst timeline = pick('timeline', 'when', 'urgency', 'start_date') || null;\nconst intent = pick('intent', 'reason', 'message', 'notes') || null;\nconst source = pick('source', 'utm_source', 'referrer') || 'direct';\n\nreturn [{\n json: {\n submissionId,\n email,\n name,\n company,\n phone,\n bant: { budget, authority, need, timeline, intent },\n source,\n receivedAt: new Date().toISOString(),\n },\n}];"
},
"id": "form-1-normalize",
"name": "Normalize Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1040,
60
]
},
{
"parameters": {
"jsCode": "// BANT scoring: 0-100 score from Budget / Authority / Need / Timeline + Intent.\n// Each filled-out BANT field is worth 20 points. Intent is worth 20.\n// Override the rubric below for your sales motion.\n\nconst input = $input.first().json;\nconst { bant } = input;\n\nlet score = 0;\nconst reasons = [];\n\n// Budget signal\nif (bant.budget) {\n const b = String(bant.budget).toLowerCase();\n if (b.includes('10k') || b.includes('25k') || b.includes('50k') || b.includes('100k') || b.includes('enterprise')) {\n score += 25;\n reasons.push('high-budget');\n } else if (b.includes('5k') || b.includes('1k') || b.includes('mid')) {\n score += 15;\n reasons.push('mid-budget');\n } else {\n score += 8;\n reasons.push('budget-disclosed');\n }\n}\n\n// Authority signal\nif (bant.authority) {\n const a = String(bant.authority).toLowerCase();\n if (a.includes('ceo') || a.includes('founder') || a.includes('vp') || a.includes('director') || a.includes('head')) {\n score += 25;\n reasons.push('decision-maker');\n } else if (a.includes('manager') || a.includes('lead')) {\n score += 15;\n reasons.push('influencer');\n } else {\n score += 8;\n reasons.push('individual-contributor');\n }\n}\n\n// Need signal (free-text length proxy)\nif (bant.need) {\n const len = String(bant.need).length;\n if (len > 100) { score += 20; reasons.push('detailed-need'); }\n else if (len > 30) { score += 12; reasons.push('clear-need'); }\n else { score += 6; reasons.push('vague-need'); }\n}\n\n// Timeline signal\nif (bant.timeline) {\n const t = String(bant.timeline).toLowerCase();\n if (t.includes('immediate') || t.includes('now') || t.includes('this month') || t.includes('week')) {\n score += 20;\n reasons.push('urgent-timeline');\n } else if (t.includes('quarter') || t.includes('month')) {\n score += 12;\n reasons.push('mid-timeline');\n } else {\n score += 6;\n reasons.push('long-timeline');\n }\n}\n\n// Intent signal\nif (bant.intent && String(bant.intent).length > 30) {\n score += 10;\n reasons.push('expressed-intent');\n}\n\nlet temperature = 'cold';\nif (score >= 70) temperature = 'hot';\nelse if (score >= 40) temperature = 'warm';\n\nreturn [{\n json: {\n ...input,\n score,\n temperature,\n scoringReasons: reasons,\n },\n}];"
},
"id": "form-2-score",
"name": "BANT Score",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1240,
60
]
},
{
"parameters": {
"jsCode": "// Pick CRM target from env. Default pipedrive.\n// Pick stage ID from temperature.\n\nconst input = $input.first().json;\nconst crmTarget = ($env.CRM_TARGET || 'pipedrive').toLowerCase();\n\nconst stageMap = {\n hot: $env.CRM_PIPELINE_HOT_ID || '1',\n warm: $env.CRM_PIPELINE_WARM_ID || '2',\n cold: $env.CRM_PIPELINE_COLD_ID || '3',\n};\n\nconst stageId = stageMap[input.temperature] || stageMap.cold;\n\nreturn [{\n json: {\n ...input,\n crmTarget,\n stageId,\n },\n}];"
},
"id": "form-3-set-routing",
"name": "Set CRM Target",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1440,
60
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": false,
"typeValidation": "loose",
"version": 2
},
"combinator": "and",
"conditions": [
{
"leftValue": "={{ $json.crmTarget }}",
"rightValue": "pipedrive",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
},
"outputKey": "pipedrive"
},
{
"conditions": {
"options": {
"caseSensitive": false,
"typeValidation": "loose",
"version": 2
},
"combinator": "and",
"conditions": [
{
"leftValue": "={{ $json.crmTarget }}",
"rightValue": "hubspot",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
},
"outputKey": "hubspot"
},
{
"conditions": {
"options": {
"caseSensitive": false,
"typeValidation": "loose",
"version": 2
},
"combinator": "and",
"conditions": [
{
"leftValue": "={{ $json.crmTarget }}",
"rightValue": "salesforce",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
},
"outputKey": "salesforce"
}
]
},
"options": {
"fallbackOutput": "extra"
}
},
"id": "form-4-route",
"name": "Route by CRM",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
1640,
60
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.pipedrive.com/v1/deals",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "api_token",
"value": "={{ $credentials.pipedriveApi.apiToken }}"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "title",
"value": "={{ $json.name }} ({{ $json.company || $json.email }}) - {{ $json.temperature }}"
},
{
"name": "value",
"value": "0"
},
{
"name": "currency",
"value": "EUR"
},
{
"name": "stage_id",
"value": "={{ $json.stageId }}"
},
{
"name": "person_id",
"value": ""
}
]
},
"options": {}
},
"id": "form-5a-pipedrive",
"name": "Pipedrive Create Deal",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1840,
-100
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"method": "POST",
"url": "https://api.hubapi.com/crm/v3/objects/deals",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer {{ $credentials.hubspotApi.accessToken }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ properties: { dealname: $json.name + ' (' + ($json.company || $json.email) + ') - ' + $json.temperature, amount: '0', dealstage: $json.stageId } }) }}",
"options": {}
},
"id": "form-5b-hubspot",
"name": "HubSpot Create Deal",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1840,
60
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"method": "POST",
"url": "https://your-instance.my.salesforce.com/services/data/v60.0/sobjects/Opportunity",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer {{ $credentials.salesforceApi.accessToken }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ Name: $json.name + ' (' + ($json.company || $json.email) + ') - ' + $json.temperature, StageName: $json.stageId, Amount: 0, CloseDate: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10) }) }}",
"options": {}
},
"id": "form-5c-salesforce",
"name": "Salesforce Create Deal",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1840,
220
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "// Normalize CRM response shape so downstream nodes do not care which CRM\n// fired. Pull deal_id and deal_url where possible.\n\nconst input = $input.first();\nconst body = input.json || {};\nconst incoming = body;\n\n// Pipedrive returns { success: true, data: { id, ... } }\n// HubSpot returns { id, properties, ... }\n// Salesforce returns { id, success, errors, ... }\nlet dealId = null;\nlet dealUrl = null;\n\nif (incoming.data && incoming.data.id) {\n dealId = incoming.data.id;\n} else if (incoming.id) {\n dealId = incoming.id;\n}\n\nreturn [{\n json: {\n ...incoming,\n dealId,\n dealUrl,\n crmSuccess: !!dealId,\n },\n}];"
},
"id": "form-6-normalize-crm",
"name": "Normalize CRM Output",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2040,
60
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SLACK_OPS_WEBHOOK }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ text: 'New ' + ($json.temperature || 'unknown').toUpperCase() + ' lead from ' + ($json.email || 'unknown') + ' via ' + ($json.source || 'unknown') + ' (score ' + ($json.score || 0) + ')' }) }}",
"options": {}
},
"id": "form-7-slack",
"name": "Slack Ops Notification",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2240,
60
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ok: true, dealId: $json.dealId, temperature: $json.temperature, score: $json.score }) }}",
"options": {
"responseCode": 200
}
},
"id": "form-8-respond",
"name": "Respond to Form",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2440,
60
]
},
{
"parameters": {
"jsCode": "// Fallback for downstream API failures (Pipedrive / HubSpot / Salesforce / Slack).\n// Builds a structured error log and produces a graceful response so the form\n// submitter sees a 200 instead of a hung request.\n\nconst input = $input.first();\nconst err = (input.json && input.json.error) || input.error || {};\nconst original = ($('Set CRM Target').first() && $('Set CRM Target').first().json) || {};\n\nreturn [{\n json: {\n ok: false,\n fallback: true,\n submissionId: original.submissionId || 'unknown',\n crmTarget: original.crmTarget || 'unknown',\n stage: original.temperature || 'unknown',\n error: {\n message: err.message || 'unknown error',\n name: err.name || 'CrmError',\n },\n receivedAt: original.receivedAt || new Date().toISOString(),\n },\n}];"
},
"id": "form-err-fallback",
"name": "Error Fallback",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2240,
380
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SLACK_OPS_WEBHOOK }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ text: ':warning: Lead-router error ' + ($json.error.name || 'CrmError') + ': ' + ($json.error.message || '') + ' (submission ' + ($json.submissionId || 'unknown') + ', target ' + ($json.crmTarget || 'unknown') + ')' }) }}",
"options": {}
},
"id": "form-err-slack-alert",
"name": "Error Slack Alert",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2440,
380
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ok: false, message: 'Lead recorded, downstream sync deferred' }) }}",
"options": {
"responseCode": 200
}
},
"id": "form-err-respond",
"name": "Error Respond to Form",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2640,
380
]
},
{
"parameters": {
"content": "## Production Patterns\n\nFour patterns wired as opt-in nodes. Default-off via env vars so import boots clean.\n\n- **HMAC verify:** `LEAD_FORM_SIGNING_SECRET` + `WEBHOOK_INTEGRITY_CHECK_ENABLED=1`\n- **Rate limit:** `RATE_LIMIT_ENABLED=1` (60 req / 5 min / IP)\n- **Idempotency:** `IDEMPOTENCY_ENABLED=1` (5-min window on submission ID)\n- **Error branch:** always on. Every HTTP node has `onError: continueErrorOutput` wired to the Error Fallback Code node.\n\n- **Respond-duplicate gateway:** when the Idempotency Check detects a duplicate it emits a `{ skipped: true }` sentinel that the `Skip If Duplicate` IF node routes to the dedicated `Respond Duplicate` `respondToWebhook` node (200 OK + `{ ok: true, deduped: true }`). This avoids the 30s connection-hang that would otherwise occur on `responseMode: responseNode` if the duplicate path returned `[]` and never reached a respond node.\n\nFor clustered n8n, swap the in-memory dedup for Redis SET NX EX 300. Snippet in each opt-in node's comments.",
"height": 320,
"width": 380,
"color": 7
},
"id": "note-production-patterns",
"name": "Sticky Note - Production Patterns",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
840,
-260
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "cond-01-form-to-crm-lead-router-skipped",
"leftValue": "={{ $json.skipped }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "01-for-if-skip-dup",
"name": "Skip If Duplicate",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1060,
60
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ok: true, deduped: true, reason: \"duplicate\" }) }}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "X-Dedup",
"value": "1"
}
]
}
}
},
"id": "01-for-respond-duplicate",
"name": "Respond Duplicate",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1280,
-120
]
}
],
"connections": {
"Form Webhook": {
"main": [
[
{
"node": "Verify Webhook (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Verify Webhook (opt-in)": {
"main": [
[
{
"node": "Rate Limit (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Rate Limit (opt-in)": {
"main": [
[
{
"node": "Idempotency Check (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Idempotency Check (opt-in)": {
"main": [
[
{
"node": "Skip If Duplicate",
"type": "main",
"index": 0
}
]
]
},
"Normalize Payload": {
"main": [
[
{
"node": "BANT Score",
"type": "main",
"index": 0
}
]
]
},
"BANT Score": {
"main": [
[
{
"node": "Set CRM Target",
"type": "main",
"index": 0
}
]
]
},
"Set CRM Target": {
"main": [
[
{
"node": "Route by CRM",
"type": "main",
"index": 0
}
]
]
},
"Route by CRM": {
"main": [
[
{
"node": "Pipedrive Create Deal",
"type": "main",
"index": 0
}
],
[
{
"node": "HubSpot Create Deal",
"type": "main",
"index": 0
}
],
[
{
"node": "Salesforce Create Deal",
"type": "main",
"index": 0
}
],
[
{
"node": "Pipedrive Create Deal",
"type": "main",
"index": 0
}
]
]
},
"Pipedrive Create Deal": {
"main": [
[
{
"node": "Normalize CRM Output",
"type": "main",
"index": 0
}
],
[
{
"node": "Error Fallback",
"type": "main",
"index": 0
}
]
]
},
"HubSpot Create Deal": {
"main": [
[
{
"node": "Normalize CRM Output",
"type": "main",
"index": 0
}
],
[
{
"node": "Error Fallback",
"type": "main",
"index": 0
}
]
]
},
"Salesforce Create Deal": {
"main": [
[
{
"node": "Normalize CRM Output",
"type": "main",
"index": 0
}
],
[
{
"node": "Error Fallback",
"type": "main",
"index": 0
}
]
]
},
"Normalize CRM Output": {
"main": [
[
{
"node": "Slack Ops Notification",
"type": "main",
"index": 0
}
]
]
},
"Slack Ops Notification": {
"main": [
[
{
"node": "Respond to Form",
"type": "main",
"index": 0
}
],
[
{
"node": "Error Fallback",
"type": "main",
"index": 0
}
]
]
},
"Error Fallback": {
"main": [
[
{
"node": "Error Slack Alert",
"type": "main",
"index": 0
}
]
]
},
"Error Slack Alert": {
"main": [
[
{
"node": "Error Respond to Form",
"type": "main",
"index": 0
}
]
]
},
"Skip If Duplicate": {
"main": [
[
{
"node": "Respond Duplicate",
"type": "main",
"index": 0
}
],
[
{
"node": "Normalize Payload",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
How this works
Streamline your lead capture by automatically routing form submissions from your website or app directly into your preferred CRM, ensuring no valuable prospect slips through the cracks. This workflow is ideal for marketing teams or sales operations managers handling high volumes of inquiries across tools like Pipedrive, HubSpot, or Salesforce, saving hours of manual data entry and reducing errors. The key step involves normalising the incoming form data and applying a BANT scoring mechanism to qualify leads before seamless integration with your CRM of choice.
Use this workflow when you need robust handling of form data with optional features like rate limiting and idempotency checks to prevent duplicates during peak traffic. Avoid it for simple, low-volume forms where basic built-in CRM tools suffice, or if your setup requires complex AI-driven qualification beyond basic scoring. Common variations include customising the BANT criteria for specific industries or adding email notifications for high-scoring leads.
About this workflow
Form to CRM Lead Router (Pipedrive / HubSpot / Salesforce). Uses stickyNote, httpRequest, respondToWebhook. Webhook trigger; 22 nodes.
Source: https://github.com/studiomeyer-io/n8n-workflows/blob/main/templates/01-form-to-crm-lead-router/workflow.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Portfolio Orchestrator. Uses httpRequest. Webhook trigger; 59 nodes.
jump-section: Comment Fix Pipeline. Uses httpRequest. Webhook trigger; 24 nodes.
GitHub Issues Router (Linear / Jira / ClickUp). Uses stickyNote, httpRequest, respondToWebhook. Webhook trigger; 23 nodes.
Calendly to CRM Sync (Pipedrive / HubSpot / Salesforce). Uses stickyNote, httpRequest, respondToWebhook. Webhook trigger; 22 nodes.
Reputation Engine — Technical SEO Implementer. Uses httpRequest. Webhook trigger; 22 nodes.