This workflow follows the HTTP Request → OpenAI recipe pattern — see all workflows that pair these two integrations.
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": "Lead-Qualifier with BANT+I and Pipedrive (Multi-Provider)",
"nodes": [
{
"parameters": {
"content": "## AI Lead-Qualifier with BANT+I and Pipedrive Bot\n\n**Stack:** Lead Form (or WhatsApp / web chat) \u2192 StudioMeyer Memory entity lookup \u2192 Claude or OpenAI with lead dossier \u2192 reply \u2192 persist outcome.\n\n**Why this beats a stateless bot:** Returning leads don't have to re-explain who they are. The bot greets them by name, references past leads, and the agent that takes over has full history one click away.\n\n**To swap Lead Form for WhatsApp:** replace the trigger node with a WhatsApp Trigger and the reply node with WhatsApp Send Message. The middle stays identical.\n\n**Production patterns ship in this workflow.json** as opt-in Code nodes. See the orange Sticky Notes below for the four env vars that toggle them on.\n\nREQUIRED env vars before activation: PIPEDRIVE_COMPANY_DOMAIN, PIPEDRIVE_API_TOKEN, PIPEDRIVE_STAGE_HOT, PIPEDRIVE_STAGE_WARM, PIPEDRIVE_STAGE_COLD. The Pipedrive: Create Deal node will hit https://.pipedrive.com/... and return HTTP 400 if PIPEDRIVE_COMPANY_DOMAIN is unset.",
"height": 380,
"width": 480,
"color": 6
},
"id": "note-intro",
"name": "Sticky Note - Intro",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-260,
-60
]
},
{
"parameters": {
"content": ">> SET ME <<\n\n**Lead Form Bot credential** required.\n\n1. Talk to your form provider (Typeform / Tally / Webflow / custom), create a bot, get the token.\n2. n8n Credentials \u2192 New \u2192 Lead Form \u2192 paste token.\n3. After activation, message your bot once and check this node's incoming-data view.\n\n**Webhook security:** to harden the trigger, expand `additionalFields` and set `secretToken` to a strong random string. Then re-register the webhook so Lead Form sends `X-Lead Form-Bot-Api-Secret-Token` on every request. The trigger validates it automatically.",
"height": 320,
"width": 320,
"color": 5
},
"id": "note-telegram",
"name": "Sticky Note - Telegram",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
240,
-60
]
},
{
"parameters": {
"content": "## Production patterns (opt-in)\n\nThree Code nodes below are off by default. Toggle each with an n8n env var:\n\n- `IDEMPOTENCY_ENABLED=1` deduplicates Lead Form retries on the same `email + first-100-chars-of-message` (5-min window).\n- `RATE_LIMIT_ENABLED=1` caps each chat at 60 requests / 5 min.\n- `WEBHOOK_INTEGRITY_CHECK_ENABLED=1` rejects malformed payloads (no `message`, no `from`).\n\nEach node returns pass-through when its env var is unset, so the default import boots clean. Production deployments enable all three plus the Lead Form Trigger `secretToken`.\n\nFor clustered n8n deployments, swap the in-memory `$getWorkflowStaticData` blocks for Redis (`SET NX EX 300` for idempotency, `INCR + EXPIRE` for rate limit). Single-instance n8n is fine with the default.",
"height": 360,
"width": 540,
"color": 7
},
"id": "note-production-patterns",
"name": "Sticky Note - Production Patterns",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
700,
-120
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "lead-form",
"responseMode": "responseNode",
"options": {
"rawBody": true
}
},
"id": "lead-1-trigger",
"name": "Form Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
240,
320
]
},
{
"parameters": {
"jsCode": "// Webhook integrity check (opt-in via WEBHOOK_INTEGRITY_CHECK_ENABLED=1).\n// Telegram Trigger handles HMAC via its secretToken option (set on the\n// trigger node itself). This Code node is the second defense layer:\n// reject malformed payloads that lack the fields downstream nodes expect.\n//\n// To enable: set the n8n env var WEBHOOK_INTEGRITY_CHECK_ENABLED to '1'.\n// To disable: leave the env var unset (default). The node passes through.\n\nconst enabled = $env.WEBHOOK_INTEGRITY_CHECK_ENABLED === '1';\nif (!enabled) {\n return [{ json: $input.first().json }];\n}\n\nconst item = $input.first().json;\nconst message = item?.message ?? item;\n\nif (!message || typeof message !== 'object') {\n throw new Error('Webhook integrity check failed: no message object');\n}\nif (typeof message.from?.id !== 'number' && typeof message.from?.id !== 'string') {\n throw new Error('Webhook integrity check failed: missing message.from.id');\n}\nif (typeof message.chat?.id !== 'number' && typeof message.chat?.id !== 'string') {\n throw new Error('Webhook integrity check failed: missing message.chat.id');\n}\nif (typeof message.text !== 'string' && typeof message.caption !== 'string') {\n throw new Error('Webhook integrity check failed: no text or caption');\n}\n\nreturn [{ json: item }];"
},
"id": "support-pp-1-verify",
"name": "Verify Webhook (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
460,
320
]
},
{
"parameters": {
"jsCode": "// Rate limit (opt-in via RATE_LIMIT_ENABLED=1).\n// Per-chat-id 60 requests in a 5-minute window. Tracked in workflow\n// static data (per-instance). For clustered n8n deployments or higher\n// throughput, use Nginx limit_req_zone or Cloudflare WAF instead.\n//\n// To enable: set the n8n env var RATE_LIMIT_ENABLED to '1'.\n// To disable: leave the env var unset (default). The node passes through.\n//\n// Concurrency note: $getWorkflowStaticData is not atomic. Under heavy\n// burst load the count can over-shoot the limit by a few percent. For\n// hard limits use a reverse proxy or Redis INCR + EXPIRE.\n\nconst enabled = $env.RATE_LIMIT_ENABLED === '1';\nif (!enabled) {\n return [{ json: $input.first().json }];\n}\n\nconst item = $input.first().json;\nconst message = item?.message ?? item;\nconst chatId = message?.chat?.id ?? message?.from?.id ?? 'unknown';\nconst bucketKey = `chat:${chatId}`;\n\nconst data = $getWorkflowStaticData('global');\nconst buckets = data.rateBuckets ?? {};\nconst now = Date.now();\nconst WINDOW_MS = 5 * 60 * 1000;\nconst LIMIT = 60;\nconst MAX_BUCKETS = 5000;\n\nconst bucket = buckets[bucketKey] ?? { count: 0, windowStart: now };\nif (now - bucket.windowStart > WINDOW_MS) {\n bucket.count = 0;\n bucket.windowStart = now;\n}\nbucket.count++;\nbuckets[bucketKey] = bucket;\n\n// Bound the map: evict expired entries when full\nif (Object.keys(buckets).length > MAX_BUCKETS) {\n const cutoff = now - WINDOW_MS;\n for (const k of Object.keys(buckets)) {\n if (buckets[k].windowStart < cutoff) delete buckets[k];\n }\n}\ndata.rateBuckets = buckets;\n\nif (bucket.count > LIMIT) {\n throw new Error(`Rate limit exceeded for ${bucketKey}: ${bucket.count} requests in 5 min window`);\n}\n\nreturn [{ json: item }];"
},
"id": "support-pp-2-ratelimit",
"name": "Rate Limit (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
320
]
},
{
"parameters": {
"jsCode": "// Idempotency check (opt-in via IDEMPOTENCY_ENABLED=1).\n// Telegram retries on 5xx. Without dedup, the workflow fires twice and\n// writes Memory twice. This node holds a 5-minute in-memory window of\n// seen update_ids and short-circuits duplicates.\n//\n// To enable: set the n8n env var IDEMPOTENCY_ENABLED to '1'.\n// To disable: leave the env var unset (default). The node passes through.\n//\n// Concurrency note: $getWorkflowStaticData is not atomic and not cluster-\n// aware. Two simultaneous fires of the same update_id may both pass.\n// For production scale, swap the staticData block for Redis SET NX EX 300.\n\nconst enabled = $env.IDEMPOTENCY_ENABLED === '1';\nif (!enabled) {\n return [{ json: $input.first().json }];\n}\n\nconst item = $input.first().json;\nconst message = item?.message ?? item;\nconst updateId = item?.update_id ?? message?.update_id;\nconst messageId = message?.message_id;\nconst idempotencyKey = updateId ? `tg-update:${updateId}` : (messageId ? `tg-msg:${messageId}` : null);\n\nif (!idempotencyKey) {\n // No dedup key available, pass through\n return [{ json: item }];\n}\n\nconst data = $getWorkflowStaticData('global');\nconst seen = data.seenKeys ?? {};\nconst now = Date.now();\nconst WINDOW_MS = 5 * 60 * 1000;\n\n// Purge expired entries\nfor (const k of Object.keys(seen)) {\n if (now - seen[k] > WINDOW_MS) 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;\ndata.seenKeys = seen;\n\nreturn [{ json: item }];"
},
"id": "support-pp-3-idempotency",
"name": "Idempotency Check (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
900,
320
]
},
{
"parameters": {
"jsCode": "// Extract lead identity + form fields from the incoming webhook payload.\n// Form context: lead fills out a form on your landing page with email, name,\n// company, and a free-text message about their problem. We extract email as\n// the primary key (more stable than name) and pass the rest forward as\n// observations to be stored on the lead entity.\n\nconst body = $input.first().json;\n\nconst email = (body?.email ?? body?.email_address ?? '').trim().toLowerCase();\nconst name = (body?.name ?? body?.full_name ?? '').trim();\nconst company = (body?.company ?? body?.organization ?? '').trim();\nconst phone = (body?.phone ?? body?.tel ?? '').trim();\nconst message = (body?.message ?? body?.notes ?? body?.question ?? '').trim();\nconst source = body?.source ?? body?.utm_source ?? body?.referrer ?? 'unknown';\n\nif (!email || !email.includes('@')) {\n throw new Error('Missing or invalid email in form payload (required field: email).');\n}\n\nif (!message) {\n throw new Error('Missing message text in form payload (required field: message).');\n}\n\nreturn [{\n json: {\n customerKey: email, // reuse downstream \"customerKey\" naming for entity-lookup parity with T02\n customerLabel: name || company || email,\n email,\n name,\n company,\n phone,\n messageText: message,\n source,\n receivedAt: new Date().toISOString(),\n },\n}];"
},
"id": "lead-2-extract",
"name": "Extract Lead",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1120,
320
]
},
{
"parameters": {
"resource": "entity",
"operation": "search",
"query": "={{ $json.customerKey }}",
"entityType": "customer",
"limit": 1
},
"id": "lead-3-lookup",
"name": "Memory: Lookup Lead",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
1340,
320
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "cond-known",
"leftValue": "={{ ($json.entities ?? $json.results ?? []).length }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "lead-3b-known",
"name": "Returning Lead?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1560,
320
]
},
{
"parameters": {
"resource": "entity",
"operation": "open",
"entityRef": "={{ $('Extract Lead').item.json.customerKey }}"
},
"id": "lead-3c-dossier",
"name": "Memory: Lead Dossier",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
1800,
200
]
},
{
"parameters": {
"resource": "entity",
"operation": "create",
"name": "={{ $('Extract Lead').item.json.customerKey }}",
"entityType": "customer",
"project": "support-bot",
"observations": "=First contact via form on {{ $('Extract Lead').item.json.receivedAt }}. Message: {{ $('Extract Lead').item.json.messageText.slice(0, 200) }}"
},
"id": "lead-3d-create",
"name": "Memory: Create Lead",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
1800,
460
]
},
{
"parameters": {
"content": "## Why entity.open here\n\nFor returning leads we use `entity.open` (not `memory.search`) because we want the **complete dossier**: entity-type, first-seen date, every observation, every relation. That's exactly what an agent needs to feel like a colleague who reviewed the lead's file before saying hello.\n\nFor recency-weighted search across **all** memory (e.g. when the lead asks about a product topic, not their own history), use the Memory: Search operation instead.",
"height": 240,
"width": 380,
"color": 7
},
"id": "note-entity-open",
"name": "Sticky Note - entity.open",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1800,
-120
]
},
{
"parameters": {
"jsCode": "// Build a system + user prompt for the LLM that performs BANT+I classification.\n// BANT = Budget, Authority, Need, Timeline. The \"I\" extension is Intent\n// (buying-signal strength: 1-5).\n//\n// We ask the LLM to return STRICT JSON with five integer scores 1-5 and a\n// brief justification per dimension. Downstream Pipedrive node uses the\n// total score + bucketed-state to decide which Pipedrive Pipeline to assign.\n\nconst customerKey = $('Extract Lead').item.json.customerKey;\nconst customerLabel = $('Extract Lead').item.json.customerLabel;\nconst messageText = $('Extract Lead').item.json.messageText;\nconst company = $('Extract Lead').item.json.company;\n\nconst dossierRaw = $input.first().json;\nconst dossier = dossierRaw?.data ?? dossierRaw;\nconst observations = dossier?.observations ?? [];\nconst priorBant = observations\n .slice(-5)\n .map(o => `- ${o?.created_at?.slice(0, 10) ?? '?'}: ${(o?.content ?? '').slice(0, 200)}`)\n .join('\\n');\n\nconst systemPrompt = `You are a B2B sales-qualification assistant. You evaluate inbound leads on the BANT+I framework and return STRICT JSON only.\n\nBANT+I dimensions (each 1-5):\n- B Budget: 1=no budget signal, 5=explicit budget mentioned\n- A Authority: 1=junior IC, 5=decision-maker (C-level, founder, head-of)\n- N Need: 1=vague exploration, 5=specific painful problem with impact mentioned\n- T Timeline: 1=no urgency, 5=signed RFP or \"this quarter\"\n- I Intent: 1=research-mode browsing, 5=buying-signal explicit (\"looking for vendors\")\n\nReturn ONLY a JSON object of this exact shape, no markdown, no preamble:\n{\n \"scores\": { \"B\": 1-5, \"A\": 1-5, \"N\": 1-5, \"T\": 1-5, \"I\": 1-5 },\n \"total\": <sum of scores>,\n \"qualifiedAs\": \"hot\" | \"warm\" | \"cold\",\n \"summary\": \"one sentence in English with the why behind the bucketing\",\n \"nextAction\": \"one sentence with what sales rep should do next\"\n}\n\nBucketing rule: total >= 20 = hot, 15-19 = warm, < 15 = cold.\n\nLead label: ${customerLabel}\nCompany: ${company || '(not provided)'}\nLead message: ${messageText}\n\nPrior BANT observations on this lead (most-recent last):\n${priorBant || '(no prior interactions on file, this is a first-time lead)'}`;\n\nreturn [{\n json: {\n systemPrompt,\n messageText,\n customerKey,\n customerLabel,\n },\n}];"
},
"id": "lead-4-prompt",
"name": "Build LLM Prompt",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2040,
320
]
},
{
"parameters": {
"mode": "manual",
"duplicateItem": false,
"assignments": {
"assignments": [
{
"id": "set-provider",
"name": "provider",
"value": "openai",
"type": "string"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "lead-5-set-provider",
"name": "Set Provider",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
2260,
320
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "rule-openai",
"leftValue": "={{ $json.provider }}",
"rightValue": "openai",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "openai"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "rule-anthropic",
"leftValue": "={{ $json.provider }}",
"rightValue": "anthropic",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "anthropic"
}
]
},
"options": {
"fallbackOutput": "extra",
"renameFallbackOutput": "fallback"
}
},
"id": "lead-5b-route",
"name": "Route by Provider",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
2480,
320
]
},
{
"parameters": {
"resource": "text",
"operation": "message",
"modelId": {
"__rl": true,
"value": "gpt-5-mini",
"mode": "list",
"cachedResultName": "gpt-5-mini"
},
"messages": {
"values": [
{
"content": "={{ $json.systemPrompt }}",
"role": "system"
},
{
"content": "={{ $json.messageText ?? $json.transcript ?? $json.payload }}",
"role": "user"
}
]
},
"jsonOutput": false,
"options": {
"maxTokens": 400,
"temperature": 0.5
}
},
"id": "lead-6-openai",
"name": "OpenAI Reply",
"type": "n8n-nodes-base.openAi",
"typeVersion": 1.7,
"onError": "continueErrorOutput",
"position": [
2700,
200
]
},
{
"parameters": {
"resource": "text",
"operation": "message",
"modelId": {
"__rl": true,
"value": "claude-haiku-4-5",
"mode": "list",
"cachedResultName": "claude-haiku-4-5"
},
"messages": {
"values": [
{
"content": "={{ $json.systemPrompt }}",
"role": "system"
},
{
"content": "={{ $json.messageText }}",
"role": "user"
}
]
},
"options": {
"maxTokens": 400,
"temperature": 0.5
}
},
"id": "lead-6-anthropic",
"name": "Anthropic Reply",
"type": "@n8n/n8n-nodes-langchain.anthropic",
"typeVersion": 1,
"onError": "continueErrorOutput",
"position": [
2700,
440
]
},
{
"parameters": {
"content": "## Error branch (always on)\n\nThe two LLM Reply nodes have `On Error: Continue (Error Output)` enabled. The red error pin lands at **LLM Fallback Reply**, which builds a graceful lead message and feeds two destinations:\n\n1. **Lead Form Reply** so the lead gets an answer instead of silence.\n2. **Memory: Learn Error** with `category: mistake, tags: [llm-error, <provider>]` so you spot patterns in your knowledge graph.\n\nNo env var, this branch is always wired. Without it, an OpenAI rate-limit or Anthropic 5xx leaves the lead hanging.\n\nThe error syntax is `{{ $json.error.message }}`, not `$error.message` (which does not exist in n8n) and not `$json.execution.error.message` (which is for separate Error Trigger Workflows, not inline error pins).",
"height": 360,
"width": 460,
"color": 7
},
"id": "note-error-branch",
"name": "Sticky Note - Error Branch",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2960,
-100
]
},
{
"parameters": {
"jsCode": "// LLM Fallback Reply: fires when OpenAI/Anthropic Reply errors,\n// or when Route by Provider receives an unknown provider value.\n//\n// Two arrival paths:\n// 1. LLM error - input has $json.error\n// 2. Router fallback - input has the original prompt object including\n// systemPrompt with the full BANT-context (prior lead history).\n// We must NOT JSON.stringify the whole input.\n\nconst errorRaw = $input.first().json;\nconst provider = $('Set Provider').item.json.provider ?? 'unknown';\n\nconst isLlmError = !!(errorRaw?.error || errorRaw?.message);\nlet errorMessage;\nif (isLlmError) {\n errorMessage = errorRaw?.error?.message\n ?? errorRaw?.error?.name\n ?? errorRaw?.message\n ?? 'Unknown LLM error';\n} else {\n errorMessage = `Unknown provider value: ${provider}. Set \"provider\" to \"openai\" or \"anthropic\" in the Set Provider node.`;\n}\n\nconst customerKey = $('Extract Lead').item.json.customerKey;\nconst customerLabel = $('Extract Lead').item.json.customerLabel;\nconst messageText = $('Extract Lead').item.json.messageText;\n\nreturn [{\n json: {\n scores: { B: 1, A: 1, N: 1, T: 1, I: 1 },\n total: 5,\n qualifiedAs: 'cold',\n summary: 'Qualifier unavailable, lead defaulted to cold for human review.',\n nextAction: 'Manual review required: BANT classifier failed.',\n provider,\n isFallback: true,\n isRouterFallback: !isLlmError,\n errorMessage: String(errorMessage),\n customerKey,\n customerLabel,\n messageText,\n replyText: 'Qualifier unavailable, lead defaulted to cold.',\n },\n}];"
},
"id": "lead-7-fallback",
"name": "LLM Fallback Reply",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2960,
580
]
},
{
"parameters": {
"jsCode": "// Parse the strict-JSON BANT+I response from either OpenAI or Anthropic\n// and lift the structured fields up so downstream nodes can pivot on them.\n//\n// OpenAI shape: $json.choices[0].message.content (string with JSON inside)\n// Anthropic shape: $json.content[0].text (string with JSON inside)\n//\n// LLMs sometimes wrap JSON in fenced code blocks despite instructions. We\n// strip ``` and ```json fences before parsing, then fall back to raw.\n\nconst raw = $input.first().json;\nlet text = '';\n\nif (raw?.choices?.[0]?.message?.content) {\n text = raw.choices[0].message.content;\n} else if (Array.isArray(raw?.content) && raw.content[0]?.text) {\n text = raw.content[0].text;\n} else if (raw?.message?.content) {\n text = raw.message.content;\n} else if (raw?.text) {\n text = raw.text;\n}\n\ntext = String(text).trim();\ntext = text.replace(/^```(?:json)?\\s*/, '').replace(/\\s*```$/, '');\n\nlet parsed;\ntry {\n parsed = JSON.parse(text);\n} catch (e) {\n // LLM returned non-JSON: degrade gracefully, qualify as cold for safety\n parsed = {\n scores: { B: 1, A: 1, N: 1, T: 1, I: 1 },\n total: 5,\n qualifiedAs: 'cold',\n summary: 'LLM did not return valid JSON, lead defaulted to cold for safety. Raw output: ' + text.slice(0, 120),\n nextAction: 'Manual review required: classifier output was unparseable.',\n };\n}\n\nconst total = Number(parsed?.total ?? 0);\nconst qualifiedAs = parsed?.qualifiedAs ?? (total >= 20 ? 'hot' : total >= 15 ? 'warm' : 'cold');\n\nreturn [{\n json: {\n scores: parsed?.scores ?? {},\n total,\n qualifiedAs,\n summary: parsed?.summary ?? '',\n nextAction: parsed?.nextAction ?? '',\n provider: $('Set Provider').item.json.provider,\n replyText: parsed?.summary ?? 'Lead qualified.',\n },\n}];"
},
"id": "lead-8-normalize",
"name": "Normalize LLM Output",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2960,
320
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ status: 'received', leadEmail: $('Extract Lead').item.json.email, qualifiedAs: $json.qualifiedAs ?? 'pending', message: 'Thanks, we will be in touch within one business day.' }) }}",
"options": {}
},
"id": "lead-9-respond",
"name": "Form Acknowledge",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
3200,
320
],
"credentials": {}
},
{
"parameters": {
"resource": "entity",
"operation": "observe",
"entityRef": "={{ $('Extract Lead').item.json.customerKey }}",
"observations": "=Ticket on {{ $('Extract Lead').item.json.receivedAt }}, Customer: {{ $('Extract Lead').item.json.messageText.slice(0, 200) }} | Bot reply: {{ ($json.replyText ?? '').slice(0, 200) }}"
},
"id": "lead-9b-observe",
"name": "Memory: Observe BANT",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
3440,
220
]
},
{
"parameters": {
"resource": "memory",
"operation": "learn",
"content": "=Support interaction with {{ $('Extract Lead').item.json.customerLabel }} ({{ $('Extract Lead').item.json.customerKey }}): \"{{ $('Extract Lead').item.json.messageText.slice(0, 150) }}\" \u2192 Bot resolved with: \"{{ ($json.replyText ?? '').slice(0, 150) }}\"",
"category": "insight",
"project": "support-bot",
"tags": "=support, customer-{{ $('Extract Lead').item.json.customerKey }}",
"confidence": 0.7
},
"id": "lead-9c-learn",
"name": "Memory: Learn Lead-Insight",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
3440,
380
]
},
{
"parameters": {
"resource": "memory",
"operation": "learn",
"content": "=LLM error in support bot ({{ $json.provider }}): {{ $json.errorMessage }} | Customer: {{ $json.customerLabel }} ({{ $json.customerKey }}) | Question: \"{{ ($json.messageText ?? '').slice(0, 150) }}\"",
"category": "mistake",
"project": "support-bot",
"tags": "=llm-error, {{ $json.provider }}, support-bot",
"confidence": 0.6
},
"id": "lead-9d-error-learn",
"name": "Memory: Learn Error",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
3440,
580
]
},
{
"parameters": {
"method": "POST",
"url": "=https://{{ $env.PIPEDRIVE_COMPANY_DOMAIN }}.pipedrive.com/api/v1/deals?api_token={{ $env.PIPEDRIVE_API_TOKEN }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ title: ($('Extract Lead').item.json.company || $('Extract Lead').item.json.email) + ' - ' + $('Normalize LLM Output').item.json.qualifiedAs.toUpperCase(), value: 0, currency: 'EUR', stage_id: ($('Normalize LLM Output').item.json.qualifiedAs === 'hot' ? Number($env.PIPEDRIVE_STAGE_HOT || 1) : $('Normalize LLM Output').item.json.qualifiedAs === 'warm' ? Number($env.PIPEDRIVE_STAGE_WARM || 2) : Number($env.PIPEDRIVE_STAGE_COLD || 3)), person_id: null, person_name: $('Extract Lead').item.json.name, person_email: [{ value: $('Extract Lead').item.json.email, primary: true }], expected_close_date: null, label: $('Normalize LLM Output').item.json.qualifiedAs, custom_fields: { bant_total: $('Normalize LLM Output').item.json.total, bant_summary: $('Normalize LLM Output').item.json.summary, bant_next_action: $('Normalize LLM Output').item.json.nextAction } }) }}",
"options": {
"redirect": {
"redirect": {}
}
}
},
"id": "lead-9e-pipedrive",
"name": "Pipedrive: Create Deal",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
3140,
100
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "cond-06-lead-qualifier-pipedrive-skipped",
"leftValue": "={{ $json.skipped }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "06-lea-if-skip-dup",
"name": "Skip If Duplicate",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1120,
320
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ok: true, deduped: true, reason: \"duplicate\" }) }}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "X-Dedup",
"value": "1"
}
]
}
}
},
"id": "06-lea-respond-duplicate",
"name": "Respond Duplicate",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1340,
140
]
}
],
"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
}
]
]
},
"Extract Lead": {
"main": [
[
{
"node": "Memory: Lookup Lead",
"type": "main",
"index": 0
}
]
]
},
"Memory: Lookup Lead": {
"main": [
[
{
"node": "Returning Lead?",
"type": "main",
"index": 0
}
]
]
},
"Returning Lead?": {
"main": [
[
{
"node": "Memory: Lead Dossier",
"type": "main",
"index": 0
}
],
[
{
"node": "Memory: Create Lead",
"type": "main",
"index": 0
}
]
]
},
"Memory: Lead Dossier": {
"main": [
[
{
"node": "Build LLM Prompt",
"type": "main",
"index": 0
}
]
]
},
"Memory: Create Lead": {
"main": [
[
{
"node": "Build LLM Prompt",
"type": "main",
"index": 0
}
]
]
},
"Build LLM Prompt": {
"main": [
[
{
"node": "Set Provider",
"type": "main",
"index": 0
}
]
]
},
"Set Provider": {
"main": [
[
{
"node": "Route by Provider",
"type": "main",
"index": 0
}
]
]
},
"Route by Provider": {
"main": [
[
{
"node": "OpenAI Reply",
"type": "main",
"index": 0
}
],
[
{
"node": "Anthropic Reply",
"type": "main",
"index": 0
}
],
[
{
"node": "LLM Fallback Reply",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Reply": {
"main": [
[
{
"node": "Normalize LLM Output",
"type": "main",
"index": 0
}
],
[
{
"node": "LLM Fallback Reply",
"type": "main",
"index": 0
}
]
]
},
"Anthropic Reply": {
"main": [
[
{
"node": "Normalize LLM Output",
"type": "main",
"index": 0
}
],
[
{
"node": "LLM Fallback Reply",
"type": "main",
"index": 0
}
]
]
},
"Normalize LLM Output": {
"main": [
[
{
"node": "Form Acknowledge",
"type": "main",
"index": 0
},
{
"node": "Memory: Observe BANT",
"type": "main",
"index": 0
},
{
"node": "Memory: Learn Lead-Insight",
"type": "main",
"index": 0
}
]
]
},
"LLM Fallback Reply": {
"main": [
[
{
"node": "Form Acknowledge",
"type": "main",
"index": 0
},
{
"node": "Memory: Learn Error",
"type": "main",
"index": 0
}
]
]
},
"Memory: Observe BANT": {
"main": [
[
{
"node": "Pipedrive: Create Deal",
"type": "main",
"index": 0
}
]
]
},
"Skip If Duplicate": {
"main": [
[
{
"node": "Respond Duplicate",
"type": "main",
"index": 0
}
],
[
{
"node": "Extract Lead",
"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 qualification process by automatically assessing incoming leads against BANT criteria—budget, authority, need, and timeline—plus an interest factor, ensuring only high-potential prospects reach your sales team and saving hours of manual review. This workflow is ideal for sales managers and marketers handling high volumes of leads from web forms or chatbots, integrating seamlessly with Pipedrive to update deals directly. The key step involves AI-driven analysis using OpenAI or Anthropic models to evaluate lead details extracted from the webhook trigger, scoring them before routing qualified ones into your CRM.
Use this workflow when you receive leads via webhooks from multiple sources and need consistent, scalable qualification without human intervention, particularly for B2B sales pipelines. Avoid it for low-volume or highly custom qualification logic that requires bespoke rules beyond BANT+I. Common variations include adapting the AI prompts for specific industries or adding integrations like email notifications for borderline leads.
About this workflow
Lead-Qualifier with BANT+I and Pipedrive (Multi-Provider). Uses stickyNote, n8n-nodes-studiomeyer-memory, openAi, anthropic. Webhook trigger; 28 nodes.
Source: https://github.com/studiomeyer-io/n8n-templates/blob/main/templates/06-lead-qualifier-pipedrive/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.
Meeting-Bot Cross-Meeting Continuity (Multi-Provider). Uses stickyNote, n8n-nodes-studiomeyer-memory, openAi, anthropic. Webhook trigger; 28 nodes.
This workflow bridges the gap between raw product data and revenue sales tools. It automates the entire Product Qualified Lead (PQL) lifecycle—from real-time intent routing to churn prevention—reducin
How it works Runs on schedule (Monday-Friday at 9 AM) to automate lead generation Searches for companies on Google Maps by location and category Extracts owner information from company websites and im
Tourist-Bot Repeat-Visitor (Multi-Provider). Uses stickyNote, n8n-nodes-studiomeyer-memory, openAi, anthropic. Webhook trigger; 27 nodes.
This powerful n8n automation workflow is designed to execute advanced B2B lead enrichment and hyper-personalization for cold email outreach. By orchestrating a complex chain of data scraping, AI analy