{
  "name": "Lumeor | Lead enrichment (HubSpot + AI website summary)",
  "nodes": [
    {
      "parameters": {
        "content": "## Lead enrichment (HubSpot + data provider + AI website summary)\n\n**What it does**\n1. Fires when a contact is created in HubSpot.\n2. Skips free and disposable email domains and contacts already enriched.\n3. Reuses a 90-day domain cache in Google Sheets before paying for a lookup.\n4. Calls one company data provider, then fetches the company homepage.\n5. Claude returns what they sell, who they sell to and a fit rating against your ICP.\n6. Writes enrichment fields with source and date. Company, website and country are filled only when blank, so rep edits are never overwritten.\n\n**Setup**\n1. Credentials: HubSpot developer app (for the trigger), HubSpot private app token (for the API calls), Header Auth for your provider, Google Sheets, Anthropic, Slack.\n2. Create contact properties lumeor_enrich_summary, lumeor_enrich_fit, lumeor_enrich_fit_reason, lumeor_enrich_industry, lumeor_enrich_employees, lumeor_enrich_source, lumeor_enriched_at.\n3. Set REPLACE_WITH_PROVIDER_COMPANY_LOOKUP_URL and adjust mapProvider() in **Build summary request**. Set REPLACE_WITH_ICP_DESCRIPTION.\n4. Create a \"Domain cache\" tab (domain, fetched_at, result_json). Replace REPLACE_WITH_SHEET_ID and REPLACE_WITH_SLACK_CHANNEL.\n\nModel: claude-opus-5-5 at low effort. claude-haiku-5-5 is a cheaper option for summarizing many websites.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/automate-lead-enrichment/",
        "height": 720,
        "width": 520
      },
      "id": "79b374f3-4e51-453b-97f6-df30a4aa5a62",
      "name": "Template notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -620,
        -260
      ]
    },
    {
      "parameters": {
        "eventsUi": {
          "eventValues": [
            {
              "name": "contact.creation"
            }
          ]
        },
        "additionalFields": {}
      },
      "id": "53ecf147-71a1-424f-b9be-102f71aeb659",
      "name": "HubSpot: contact created",
      "type": "n8n-nodes-base.hubspotTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "webhookId": "cad45810-9a97-4436-906b-e50e7419d5cd"
    },
    {
      "parameters": {
        "method": "GET",
        "url": "=https://api.hubapi.com/crm/v3/objects/contacts/{{ $json.contactId }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "hubspotAppToken",
        "sendQuery": true,
        "specifyQuery": "keypair",
        "queryParameters": {
          "parameters": [
            {
              "name": "properties",
              "value": "email,firstname,company,website,country,lumeor_enriched_at"
            }
          ]
        },
        "options": {}
      },
      "id": "c8d0cfc3-8153-4a3d-9f12-beeb92e334f9",
      "name": "HubSpot: get contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        0
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Domain cache"
        },
        "options": {}
      },
      "id": "4879a4cf-0e34-4980-86e9-35307d2b6cba",
      "name": "Read domain cache",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        440,
        0
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Normalizes the email domain, skips free and disposable domains, and reuses cached results.\nconst CACHE_DAYS = 90;\nconst FREE = ['gmail.com', 'googlemail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'live.com', 'msn.com', 'aol.com', 'icloud.com', 'me.com', 'proton.me', 'protonmail.com', 'gmx.com', 'yandex.com', 'mail.com', 'zoho.com', 'mailinator.com', 'guerrillamail.com', '10minutemail.com', 'tempmail.com'];\nconst cache = {};\nfor (const row of $input.all().map((i) => i.json)) if (row.domain) cache[String(row.domain).toLowerCase()] = row;\n\nconst out = [];\nconst contacts = $('HubSpot: get contact').all();\nfor (let i = 0; i < contacts.length; i++) {\n  const c = contacts[i].json;\n  const p = c.properties || {};\n  if (!c.id || p.lumeor_enriched_at) continue; // already enriched, or contact not found\n  const email = String(p.email || '').toLowerCase().trim();\n  const domain = email.includes('@') ? email.split('@')[1] : '';\n  if (!domain || FREE.includes(domain)) continue; // nothing to look up for personal addresses\n  const hit = cache[domain];\n  const fresh = hit && (Date.now() - Date.parse(hit.fetched_at)) / 86400000 < CACHE_DAYS;\n  let cached = null;\n  if (fresh) { try { cached = JSON.parse(hit.result_json); } catch (e) { cached = null; } }\n  if (cached && cached.cacheable === false) cached = null; // failed or thin results are retried, not reused\n  out.push({\n    json: {\n      contactId: c.id,\n      email,\n      domain,\n      current: { company: p.company || '', website: p.website || '', country: p.country || '' },\n      route: cached ? 'cached' : 'lookup',\n      result: cached,\n    },\n    pairedItem: { item: 0 },\n  });\n}\n// Build HubSpot properties for cache hits here, using the same merge rules as fresh lookups.\nfor (const o of out) {\n  if (o.json.route !== 'cached') continue;\n  const r = o.json.result;\n  const props = {\n    lumeor_enrich_summary: r.summary, lumeor_enrich_fit: r.fit, lumeor_enrich_fit_reason: r.fit_reason,\n    lumeor_enrich_industry: r.industry, lumeor_enrich_employees: r.employees, lumeor_enrich_source: r.source + ' (cache)',\n    lumeor_enriched_at: new Date().toISOString().slice(0, 10),\n  };\n  if (!o.json.current.company && r.company_name) props.company = r.company_name;\n  if (!o.json.current.website) props.website = 'https://' + o.json.domain;\n  if (!o.json.current.country && r.country) props.country = r.country;\n  o.json.hubspotProperties = props;\n}\nreturn out;\n"
      },
      "id": "5a0efc1a-82d4-46be-abb8-4e3e7b1e74ae",
      "name": "Normalize and check cache",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        0
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "5beb89f7-9653-4c2b-8779-eef5056d2224",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "lookup",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "lookup"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "5ce59cd5-831d-4fee-bdfc-f4485f7e4b0e",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "cached",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "cached"
            }
          ]
        },
        "options": {}
      },
      "id": "128ddabe-1c5f-4b03-86cb-5e7ad3dfa0af",
      "name": "Cached?",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        880,
        0
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "=REPLACE_WITH_PROVIDER_COMPANY_LOOKUP_URL",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "specifyQuery": "keypair",
        "queryParameters": {
          "parameters": [
            {
              "name": "domain",
              "value": "={{ $json.domain }}"
            }
          ]
        },
        "options": {
          "timeout": 20000
        }
      },
      "id": "7b4d487e-43ac-4269-81ab-ab2e18c40c84",
      "name": "Company data provider",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        -100
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "GET",
        "url": "=https://{{ $('Normalize and check cache').item.json.domain }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text",
              "outputPropertyName": "data"
            }
          },
          "timeout": 15000
        }
      },
      "id": "835797d3-f1ee-4676-851a-61d0355ad0f1",
      "name": "Fetch homepage",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1320,
        -100
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Maps the provider response, cleans the homepage text and asks Claude for a structured fit summary.\nconst ICP = 'REPLACE_WITH_ICP_DESCRIPTION'; // e.g. US companies with 20 to 500 employees in logistics or manufacturing that run finance on QuickBooks or NetSuite\n\n// Adjust these paths to your provider's response shape.\nfunction mapProvider(r) {\n  const d = (r && (r.data || r.company || r)) || {};\n  const pick = (...vals) => vals.find((v) => v !== undefined && v !== null && v !== '');\n  return {\n    company_name: pick(d.name, d.company_name, d.legal_name) || '',\n    industry: pick(d.industry, d.category && d.category.industry, d.sector) || '',\n    employees: String(pick(d.employee_count, d.employees, d.metrics && d.metrics.employees, d.size) || ''),\n    country: pick(d.country, d.geo && d.geo.country, d.location && d.location.country) || '',\n  };\n}\nconst clean = (html) => String(html || '')\n  .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')\n  .replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')\n  .replace(/<(nav|footer|header)[\\s\\S]*?<\\/\\1>/gi, ' ')\n  .replace(/<[^>]+>/g, ' ')\n  .replace(/&nbsp;|&#160;/g, ' ').replace(/&amp;/g, '&')\n  .replace(/\\s+/g, ' ').trim().slice(0, 15000);\n\nconst schema = {\n  type: 'object', additionalProperties: false,\n  required: ['what_they_sell', 'who_they_sell_to', 'fit', 'fit_reason'],\n  properties: {\n    what_they_sell: { type: 'string', description: 'One sentence, or unknown' },\n    who_they_sell_to: { type: 'string', description: 'One sentence, or unknown' },\n    fit: { type: 'string', enum: ['strong', 'possible', 'weak', 'unknown'] },\n    fit_reason: { type: 'string', description: 'One line citing what on the page drove the rating' },\n  },\n};\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const lead = $('Normalize and check cache').itemMatching(i).json;\n  const provider = mapProvider($('Company data provider').itemMatching(i).json);\n  const page = clean(items[i].json.data);\n  const prompt = 'Our ideal customer profile: ' + ICP + '\\n\\nCompany domain: ' + lead.domain +\n    '\\nProvider data: ' + JSON.stringify(provider) +\n    '\\n\\nHomepage text:\\n' + (page || '(page could not be fetched)') +\n    '\\n\\nDescribe the company and rate fit against the profile. If the page is thin or missing, answer unknown rather than guessing.';\n  out.push({\n    json: {\n      ...lead,\n      provider,\n      pageChars: page.length,\n      claudeRequest: {\n        model: 'claude-opus-5-5',\n        max_tokens: 1500,\n        messages: [{ role: 'user', content: prompt }],\n        output_config: { effort: 'low', format: { type: 'json_schema', schema } },\n      },\n    },\n    pairedItem: { item: i },\n  });\n}\nreturn out;\n"
      },
      "id": "e6da6c68-7ad9-4a35-a9c6-387a2850dc48",
      "name": "Build summary request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        -100
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.claudeRequest) }}",
        "options": {
          "timeout": 180000
        }
      },
      "id": "60d6a5ce-4081-4e4f-a329-a6d8785f9881",
      "name": "Claude: summarize company",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1760,
        -100
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Field-level merge rules: enrichment fields always update, primary fields only fill blanks.\n\n// Reads a Messages API response. Thinking blocks may come first, so find the text block.\nfunction readClaude(resp) {\n  if (!resp || resp.error) {\n    const msg = resp && resp.error ? (resp.error.message || JSON.stringify(resp.error)) : 'empty response';\n    return { ok: false, reason: 'api_error: ' + String(msg).slice(0, 300) };\n  }\n  if (resp.stop_reason === 'refusal') {\n    const cat = resp.stop_details && resp.stop_details.category ? ' (' + resp.stop_details.category + ')' : '';\n    return { ok: false, reason: 'model_refusal' + cat };\n  }\n  if (resp.stop_reason === 'max_tokens') return { ok: false, reason: 'max_tokens: output was cut off, raise max_tokens' };\n  const block = (resp.content || []).find((b) => b.type === 'text');\n  if (!block) return { ok: false, reason: 'no_text_block_in_response' };\n  try {\n    return { ok: true, data: JSON.parse(block.text) };\n  } catch (e) {\n    return { ok: false, reason: 'invalid_json: ' + e.message };\n  }\n}\n\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const ctx = $('Build summary request').itemMatching(i).json;\n  const r = readClaude(items[i].json);\n  const ai = r.ok ? r.data : { what_they_sell: '', who_they_sell_to: '', fit: 'unknown', fit_reason: 'AI summary failed: ' + r.reason };\n  const result = {\n    company_name: ctx.provider.company_name, industry: ctx.provider.industry, employees: ctx.provider.employees, country: ctx.provider.country,\n    summary: [ai.what_they_sell, ai.who_they_sell_to].filter((s) => s && s !== 'unknown').join(' '),\n    fit: ai.fit, fit_reason: ai.fit_reason,\n    source: 'provider + website ' + new Date().toISOString().slice(0, 10),\n    cacheable: r.ok && ctx.pageChars > 0,\n  };\n  const props = {\n    lumeor_enrich_summary: result.summary, lumeor_enrich_fit: result.fit, lumeor_enrich_fit_reason: result.fit_reason,\n    lumeor_enrich_industry: result.industry, lumeor_enrich_employees: result.employees, lumeor_enrich_source: result.source,\n    lumeor_enriched_at: new Date().toISOString().slice(0, 10),\n  };\n  if (!ctx.current.company && result.company_name) props.company = result.company_name;\n  if (!ctx.current.website) props.website = 'https://' + ctx.domain;\n  if (!ctx.current.country && result.country) props.country = result.country;\n  out.push({\n    json: {\n      contactId: ctx.contactId, domain: ctx.domain, hubspotProperties: props, result,\n    },\n    pairedItem: { item: i },\n  });\n}\nreturn out;\n"
      },
      "id": "07867870-adce-48f9-8617-8a91bcfebc66",
      "name": "Merge rules",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1980,
        -100
      ]
    },
    {
      "parameters": {
        "method": "PATCH",
        "url": "=https://api.hubapi.com/crm/v3/objects/contacts/{{ $json.contactId }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "hubspotAppToken",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ properties: $json.hubspotProperties }) }}",
        "options": {}
      },
      "id": "cbbf1638-518c-4036-9dd2-46b59bbb0bad",
      "name": "HubSpot: update contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2200,
        60
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Domain cache"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "domain": "={{ $json.domain }}",
            "fetched_at": "={{ $now.toISO() }}",
            "result_json": "={{ JSON.stringify($json.result) }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "domain",
              "displayName": "domain",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "fetched_at",
              "displayName": "fetched_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "result_json",
              "displayName": "result_json",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "a16f78d2-7868-4d3c-95db-4d664f420cc2",
      "name": "Save to domain cache",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        2200,
        -220
      ]
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#REPLACE_WITH_SLACK_CHANNEL"
        },
        "messageType": "text",
        "text": "={{ $json.slackText || (':warning: Lead enrichment error: ' + (($json.error && ($json.error.message || $json.error.description)) || JSON.stringify($json.error || $json).slice(0, 500)) + ' (execution ' + $execution.id + ')') }}",
        "otherOptions": {
          "includeLinkToWorkflow": true
        }
      },
      "id": "657b40b6-a518-471c-b968-7251438cec98",
      "name": "Post enrichment issue",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2420,
        200
      ]
    }
  ],
  "connections": {
    "HubSpot: contact created": {
      "main": [
        [
          {
            "node": "HubSpot: get contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HubSpot: get contact": {
      "main": [
        [
          {
            "node": "Read domain cache",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read domain cache": {
      "main": [
        [
          {
            "node": "Normalize and check cache",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize and check cache": {
      "main": [
        [
          {
            "node": "Cached?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cached?": {
      "main": [
        [
          {
            "node": "Company data provider",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "HubSpot: update contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Company data provider": {
      "main": [
        [
          {
            "node": "Fetch homepage",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch homepage": {
      "main": [
        [
          {
            "node": "Build summary request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build summary request": {
      "main": [
        [
          {
            "node": "Claude: summarize company",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: summarize company": {
      "main": [
        [
          {
            "node": "Merge rules",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Merge rules",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge rules": {
      "main": [
        [
          {
            "node": "HubSpot: update contact",
            "type": "main",
            "index": 0
          },
          {
            "node": "Save to domain cache",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HubSpot: update contact": {
      "main": [
        [],
        [
          {
            "node": "Post enrichment issue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
