{
  "name": "Lumeor | AI lead routing (form to HubSpot owner)",
  "nodes": [
    {
      "parameters": {
        "content": "## AI lead routing\nA new inquiry arrives by webhook. The workflow matches it to existing HubSpot contacts, has Claude classify the free-text message, applies your routing table from Google Sheets in code (territory, segment, product line, weighted round-robin, daily caps), assigns the HubSpot owner and pings the rep in Slack. A second schedule escalates leads nobody has touched.\n\n**Routing logic lives in the sheet, not the prompt.** Claude only returns intent, product line, urgency and a one-line summary.\n\n**Setup**\n1. Copy the routing sheet layout from the yellow note into a Google Sheet tab named `Routing`, and add a tab named `Routing log` with the headers listed there.\n2. Credentials: Google Sheets OAuth2, HubSpot Service Key (contacts read and write), Anthropic, Slack.\n3. Replace REPLACE_WITH_ROUTING_SHEET_ID, REPLACE_WITH_DEFAULT_OWNER_ID, REPLACE_WITH_SALES_CHANNEL, REPLACE_WITH_OPS_CHANNEL and REPLACE_WITH_ALERTS_CHANNEL.\n4. Edit the product_line values in the Claude node schema to match your catalog.\n5. Point your form (website, Typeform, HubSpot form webhook) at the production webhook URL. The Normalize node maps common field names; adjust it for your form.\n\nRound-robin pointers and daily counts are kept in workflow static data, which only persists for active (production) executions.\n\nModel: claude-opus-5-5 with effort low. For high-volume, simple classification like this, claude-haiku-5-5 is a cheaper option.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/ai-lead-routing/",
        "height": 860,
        "width": 560,
        "color": 7
      },
      "id": "3024da06-8403-48dd-a490-687c6652f89b",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -460
      ]
    },
    {
      "parameters": {
        "content": "### Tab `Routing` (one row per rep per rule)\n| priority | team | territory | segment | product_line | rep_name | rep_email | hubspot_owner_id | slack_user_id | weight | daily_cap | available |\n|---|---|---|---|---|---|---|---|---|---|---|---|\n| 1 | Enterprise | * | enterprise | * | Ana | ana@co.com | 111 | U01ANA | 1 | 10 | TRUE |\n| 2 | East | NY,NJ,PA,MA | * | * | Ben | ben@co.com | 222 | U01BEN | 2 | 15 | TRUE |\n| 9 | Catch-all | * | * | * | Cy | cy@co.com | 333 | U01CY | 1 | 30 | TRUE |\n\n- **territory**: comma list of US state codes or ISO country codes, or `*`.\n- **segment**: smb (under 50 staff), mid (50 to 499), enterprise (500+) or `*`.\n- Lowest priority number that matches wins; reps in that team share leads by weight.\n\n### Tab `Routing log` headers\nreceived_at, email, company, route, team, owner_id, rep_name, rule, intent, product_line, urgency, reason",
        "height": 860,
        "width": 620,
        "color": 6
      },
      "id": "f55a56ff-432c-4176-942a-1f88569e9c02",
      "name": "Routing sheet layout",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        520,
        -460
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "lumeor-lead-intake",
        "responseMode": "onReceived",
        "options": {}
      },
      "id": "59af4e09-a488-43ad-96c9-195089b0fa92",
      "name": "New inquiry",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        300
      ],
      "webhookId": "5e9f2bf6-2817-4564-a52b-842cc5c6d936"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Maps common form field names to one shape and rejects unusable submissions.\nconst FREE_DOMAINS = ['gmail.com', 'googlemail.com', 'yahoo.com', 'outlook.com', 'hotmail.com', 'icloud.com', 'aol.com', 'proton.me', 'protonmail.com'];\nconst pick = (obj, keys) => {\n  for (const k of keys) if (obj[k] !== undefined && obj[k] !== null && String(obj[k]).trim() !== '') return String(obj[k]).trim();\n  return '';\n};\nreturn $input.all().map((item) => {\n  const b = item.json.body || item.json;\n  const email = pick(b, ['email', 'Email', 'email_address', 'work_email']).toLowerCase();\n  if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) {\n    return { json: { error: 'Inquiry has no valid email address', details: { received: JSON.stringify(b).slice(0, 400) } } };\n  }\n  const domain = email.split('@')[1];\n  const employeesRaw = pick(b, ['employees', 'company_size', 'numberofemployees', 'team_size']);\n  const employees = parseInt(employeesRaw.replace(/[^0-9]/g, ''), 10);\n  return {\n    json: {\n      email,\n      domain,\n      domain_is_free: FREE_DOMAINS.includes(domain),\n      first_name: pick(b, ['first_name', 'firstName', 'firstname']),\n      last_name: pick(b, ['last_name', 'lastName', 'lastname']),\n      company: pick(b, ['company', 'company_name', 'organization']),\n      country: pick(b, ['country', 'country_code']).toUpperCase(),\n      state: pick(b, ['state', 'region', 'state_code']).toUpperCase(),\n      employees: Number.isFinite(employees) ? employees : null,\n      message: pick(b, ['message', 'comments', 'how_can_we_help', 'notes']).slice(0, 4000),\n      source: pick(b, ['source', 'form_name']) || 'webhook',\n      received_at: new Date().toISOString(),\n    },\n  };\n});"
      },
      "id": "d10712ff-3625-41a7-a6f9-f78434f84733",
      "name": "Normalize lead",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        300
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.hubapi.com/crm/v3/objects/contacts/search",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "hubspotAppToken",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ filterGroups: [ { filters: [ { propertyName: 'email', operator: 'EQ', value: $json.email } ] } ].concat($json.domain_is_free ? [] : [ { filters: [ { propertyName: 'hs_email_domain', operator: 'EQ', value: $json.domain }, { propertyName: 'hubspot_owner_id', operator: 'HAS_PROPERTY' } ] } ]), properties: ['email', 'hubspot_owner_id', 'lifecyclestage', 'hs_email_domain'], limit: 20 }) }}",
        "options": {}
      },
      "id": "e3bfb940-51d3-42c9-ad23-4c2a71f73885",
      "name": "Find existing contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        280
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'claude-opus-5-5', max_tokens: 2000, system: \"You classify inbound website inquiries for a B2B sales team. Read the message and company details and return labels only.\\nIntent labels:\\n- new_business: wants to buy, get pricing, a demo or a quote.\\n- existing_customer_support: an existing customer with a problem or how-to question.\\n- partnership: reseller, integration or co-marketing proposal.\\n- vendor_pitch: someone selling a product or service to us.\\n- job_seeker: asking about jobs or sending a resume.\\n- spam: irrelevant, automated or abusive.\\n- unclear: not enough information to decide.\\nUse unclear rather than guessing. The summary is one plain sentence a sales rep can read in a Slack notification.\", output_config: { \"format\": { \"type\": \"json_schema\", \"schema\": { \"type\": \"object\", \"properties\": { \"intent\": { \"type\": \"string\", \"enum\": [ \"new_business\", \"existing_customer_support\", \"partnership\", \"vendor_pitch\", \"job_seeker\", \"spam\", \"unclear\" ] }, \"product_line\": { \"type\": \"string\", \"enum\": [ \"core_platform\", \"professional_services\", \"other\", \"unclear\" ], \"description\": \"Edit these values to match your catalog\" }, \"urgency\": { \"type\": \"string\", \"enum\": [ \"high\", \"normal\", \"low\" ] }, \"summary\": { \"type\": \"string\", \"description\": \"One sentence summary for the rep\" }, \"reason\": { \"type\": \"string\", \"description\": \"Short reason for the intent label\" } }, \"required\": [ \"intent\", \"product_line\", \"urgency\", \"summary\", \"reason\" ], \"additionalProperties\": false } }, \"effort\": \"low\" }, messages: [ { role: 'user', content: 'Classify this inquiry.\\n\\n' + JSON.stringify({ company: $('Normalize lead').item.json.company, employees: $('Normalize lead').item.json.employees, country: $('Normalize lead').item.json.country, state: $('Normalize lead').item.json.state, source: $('Normalize lead').item.json.source, message: $('Normalize lead').item.json.message }) } ] }) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "4d044497-83b2-4d3c-bf0e-baff1429b834",
      "name": "Classify inquiry",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        280
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "REPLACE_WITH_ROUTING_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Routing",
          "mode": "name"
        },
        "options": {}
      },
      "id": "7590d7b1-021e-47cb-92df-ec5053324d88",
      "name": "Read routing table",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        880,
        260
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Deterministic routing: existing owners first, non-sales diverted, then the sheet rules\n// with weighted round-robin and daily caps. Claude only supplies labels.\nconst DEFAULT_OWNER_ID = 'REPLACE_WITH_DEFAULT_OWNER_ID';\nconst NON_SALES = { existing_customer_support: 'Support queue', partnership: 'Partnerships', vendor_pitch: 'Vendor inbox', job_seeker: 'Recruiting' };\n\nconst lead = $('Normalize lead').first().json;\nconst res = $('Classify inquiry').first().json;\nconst search = $('Find existing contact').first().json;\nconst out = [];\n\nif (res.stop_reason === 'refusal' || res.stop_reason === 'max_tokens') {\n  return [{ json: { error: `Claude stopped with stop_reason=${res.stop_reason}`, details: { email: lead.email } } }];\n}\n// Opus 5.5 may return a thinking block first, so find the text block explicitly.\nconst block = (res.content || []).find((b) => b.type === 'text');\nif (!block) {\n  return [{ json: { error: 'Claude response had no text block', details: { email: lead.email } } }];\n}\nlet labels;\ntry {\n  labels = JSON.parse(block.text);\n} catch (e) {\n  return [{ json: { error: 'Claude classification was not valid JSON', details: { email: lead.email } } }];\n}\n\n// Validate the routing table on every load.\nconst rows = $input.all().map((i) => i.json).filter((r) => r.rep_email || r.hubspot_owner_id);\nconst bad = rows.filter((r) => !String(r.hubspot_owner_id || '').trim() || Number.isNaN(Number(r.priority)));\nif (bad.length) out.push({ json: { error: 'Routing table has rows without an owner ID or priority', details: { rows: bad.map((r) => r.row_number) } } });\n\nconst segment = lead.employees == null ? null : lead.employees >= 500 ? 'enterprise' : lead.employees >= 50 ? 'mid' : 'smb';\nconst list = (v) => String(v || '*').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);\nconst matches = (r) => {\n  const terr = list(r.territory);\n  const territoryOk = terr.includes('*') || terr.includes(lead.state) || terr.includes(lead.country);\n  const seg = String(r.segment || '*').toLowerCase();\n  const segmentOk = seg === '*' || seg === segment;\n  const prod = String(r.product_line || '*').toLowerCase();\n  const productOk = prod === '*' || prod === labels.product_line;\n  const available = String(r.available).toUpperCase() !== 'FALSE';\n  return territoryOk && segmentOk && productOk && available;\n};\n\nconst base = {\n  ...lead,\n  intent: labels.intent,\n  product_line: labels.product_line,\n  urgency: labels.urgency,\n  summary: labels.summary,\n  reason: labels.reason,\n};\n\n// 1. Spam is logged, never routed.\nif (labels.intent === 'spam') return out.concat([{ json: { ...base, route: 'spam', team: '', owner_id: '', rep_name: '', slack_user_id: '', rule: 'spam' } }]);\n\n// 2. Non-sales inquiries go to the right queue.\nif (NON_SALES[labels.intent]) {\n  return out.concat([{ json: { ...base, route: 'non_sales', team: NON_SALES[labels.intent], owner_id: '', rep_name: '', slack_user_id: '', rule: `intent:${labels.intent}` } }]);\n}\n\n// 3. Existing contact or account owner wins.\nconst contacts = search.results || [];\nconst exact = contacts.find((c) => (c.properties.email || '').toLowerCase() === lead.email && c.properties.hubspot_owner_id);\nconst sameDomain = contacts.find((c) => c.properties.hubspot_owner_id);\nconst existing = exact || (!lead.domain_is_free && sameDomain);\nif (existing) {\n  const ownerId = String(existing.properties.hubspot_owner_id);\n  const rep = rows.find((r) => String(r.hubspot_owner_id) === ownerId) || {};\n  return out.concat([{ json: { ...base, route: 'assign', team: rep.team || 'Existing owner', owner_id: ownerId, rep_name: rep.rep_name || '', slack_user_id: rep.slack_user_id || '', rule: exact ? 'existing_contact_owner' : 'existing_domain_owner' } }]);\n}\n\n// 4. Sheet rules: lowest priority number that matches, then weighted round-robin.\nconst candidates = rows.filter(matches);\nconst best = Math.min(...candidates.map((r) => Number(r.priority)));\nconst pool = candidates.filter((r) => Number(r.priority) === best);\nconst state = $getWorkflowStaticData('global');\nconst today = new Date().toISOString().slice(0, 10);\nif (state.day !== today) { state.day = today; state.counts = {}; }\nstate.counts = state.counts || {};\nstate.pointers = state.pointers || {};\n\nlet chosen = null;\nif (pool.length) {\n  const team = pool[0].team;\n  const wheel = pool.flatMap((r) => Array(Math.max(1, Number(r.weight) || 1)).fill(r));\n  let p = state.pointers[team] || 0;\n  for (let i = 0; i < wheel.length; i++) {\n    const r = wheel[(p + i) % wheel.length];\n    const cap = Number(r.daily_cap) || Infinity;\n    if ((state.counts[r.hubspot_owner_id] || 0) < cap) {\n      chosen = r;\n      state.pointers[team] = (p + i + 1) % wheel.length;\n      break;\n    }\n  }\n}\n\nif (!chosen) {\n  out.push({ json: { error: 'No routing rule matched or every rep is at their daily cap; default owner used', details: { email: lead.email, state: lead.state, country: lead.country, segment, product_line: labels.product_line } } });\n  return out.concat([{ json: { ...base, route: 'assign', team: 'Default', owner_id: DEFAULT_OWNER_ID, rep_name: '', slack_user_id: '', rule: 'default_owner' } }]);\n}\nstate.counts[chosen.hubspot_owner_id] = (state.counts[chosen.hubspot_owner_id] || 0) + 1;\nreturn out.concat([{ json: { ...base, route: 'assign', team: chosen.team, owner_id: String(chosen.hubspot_owner_id), rep_name: chosen.rep_name, slack_user_id: chosen.slack_user_id, rule: `priority:${chosen.priority} team:${chosen.team}` } }]);"
      },
      "id": "dff36dba-3992-47f6-9758-7ee104246662",
      "name": "Apply routing rules",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        280
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "REPLACE_WITH_ROUTING_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Routing log",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "received_at": "={{ $json.received_at }}",
            "email": "={{ $json.email }}",
            "company": "={{ $json.company }}",
            "route": "={{ $json.route }}",
            "team": "={{ $json.team }}",
            "owner_id": "={{ $json.owner_id }}",
            "rep_name": "={{ $json.rep_name }}",
            "rule": "={{ $json.rule }}",
            "intent": "={{ $json.intent }}",
            "product_line": "={{ $json.product_line }}",
            "urgency": "={{ $json.urgency }}",
            "reason": "={{ $json.reason }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "received_at",
              "displayName": "received_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "email",
              "displayName": "email",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "company",
              "displayName": "company",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "route",
              "displayName": "route",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "team",
              "displayName": "team",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "owner_id",
              "displayName": "owner_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "rep_name",
              "displayName": "rep_name",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "rule",
              "displayName": "rule",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "intent",
              "displayName": "intent",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "product_line",
              "displayName": "product_line",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "urgency",
              "displayName": "urgency",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "reason",
              "displayName": "reason",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "105f5505-a1b2-45fc-9e01-ab5510719a40",
      "name": "Log routing decision",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1320,
        260
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "b524f156-6459-4781-a6ed-b64624f809e6",
                    "leftValue": "={{ $('Apply routing rules').item.json.route }}",
                    "rightValue": "assign",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "assign"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "fdbc7d33-d2ad-41ed-9f5c-54a112b889da",
                    "leftValue": "={{ $('Apply routing rules').item.json.route }}",
                    "rightValue": "non_sales",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "non_sales"
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {
          "fallbackOutput": "extra",
          "renameFallbackOutput": "spam"
        }
      },
      "id": "5185c3b1-1c87-432c-9f15-09b10cd1f3e0",
      "name": "Route",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1540,
        260
      ]
    },
    {
      "parameters": {
        "authentication": "appToken",
        "resource": "contact",
        "operation": "upsert",
        "email": "={{ $('Apply routing rules').item.json.email }}",
        "additionalFields": {
          "contactOwner": "={{ $('Apply routing rules').item.json.owner_id }}",
          "firstName": "={{ $('Apply routing rules').item.json.first_name }}",
          "lastName": "={{ $('Apply routing rules').item.json.last_name }}",
          "companyName": "={{ $('Apply routing rules').item.json.company }}",
          "message": "={{ $('Apply routing rules').item.json.message }}"
        },
        "options": {
          "resolveData": false
        }
      },
      "id": "4137a058-9ed8-4ea5-93dd-0bba6ec56a4c",
      "name": "Assign owner in HubSpot",
      "type": "n8n-nodes-base.hubspot",
      "typeVersion": 2.1,
      "position": [
        1760,
        160
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "={{ $('Apply routing rules').item.json.route === 'assign' ? 'REPLACE_WITH_SALES_CHANNEL' : 'REPLACE_WITH_OPS_CHANNEL' }}",
          "mode": "name"
        },
        "text": "={{ $('Apply routing rules').item.json.route === 'assign' ? ':incoming_envelope: New ' + $('Apply routing rules').item.json.urgency + ' priority lead for ' + ($('Apply routing rules').item.json.slack_user_id ? '<@' + $('Apply routing rules').item.json.slack_user_id + '>' : 'the default owner') : ':inbox_tray: Inquiry for ' + $('Apply routing rules').item.json.team }}\n*{{ $('Apply routing rules').item.json.first_name }} {{ $('Apply routing rules').item.json.last_name }}* at *{{ $('Apply routing rules').item.json.company || $('Apply routing rules').item.json.domain }}* ({{ $('Apply routing rules').item.json.email }})\n{{ $('Apply routing rules').item.json.summary }}\n_Rule: {{ $('Apply routing rules').item.json.rule }}. Why: {{ $('Apply routing rules').item.json.reason }}_",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "0be0cb52-e646-4574-b115-54256368bbf3",
      "name": "Notify rep or queue",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1980,
        260
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_ALERTS_CHANNEL",
          "mode": "name"
        },
        "text": "=:warning: *Lead routing needs attention*\n*Reason:* {{ typeof $json.error === 'string' ? $json.error : (($json.error && $json.error.message) || 'Unknown error') }}\n*Details:* {{ JSON.stringify($json.details || $json.error || {}).slice(0, 600) }}\n*Execution:* {{ $execution.id }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "5e413eb4-eb2b-4006-8284-0de38babfa6e",
      "name": "Alert: routing needs attention",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1320,
        520
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "*/30 8-18 * * 1-5"
            }
          ]
        }
      },
      "id": "e6ceabce-2c08-4030-9461-ff0f036622cf",
      "name": "Every 30 min SLA check",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        760
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.hubapi.com/crm/v3/objects/contacts/search",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "hubspotAppToken",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ filterGroups: [ { filters: [ { propertyName: 'hubspot_owner_assigneddate', operator: 'GTE', value: String(Date.now() - 4 * 60 * 60 * 1000) }, { propertyName: 'hubspot_owner_assigneddate', operator: 'LTE', value: String(Date.now() - 60 * 60 * 1000) }, { propertyName: 'notes_last_contacted', operator: 'NOT_HAS_PROPERTY' } ] } ], properties: ['email', 'firstname', 'lastname', 'company', 'hubspot_owner_id', 'hubspot_owner_assigneddate'], limit: 100 }) }}",
        "options": {}
      },
      "id": "d2abc1b4-49ea-4305-b906-b7751b41c87c",
      "name": "Find untouched new leads",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        760
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// One alert per lead assigned 1 to 4 hours ago with no logged contact. Nothing to send = no output.\nconst results = $input.first().json.results || [];\nreturn results.map((c) => ({\n  json: {\n    error: 'Lead assigned over an hour ago with no logged contact',\n    details: {\n      email: c.properties.email,\n      company: c.properties.company,\n      owner_id: c.properties.hubspot_owner_id,\n      assigned_at: c.properties.hubspot_owner_assigneddate,\n    },\n  },\n}));"
      },
      "id": "ad47ce72-64b3-432b-b3d6-f7369d19ac8a",
      "name": "Format SLA misses",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        760
      ]
    }
  ],
  "connections": {
    "New inquiry": {
      "main": [
        [
          {
            "node": "Normalize lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize lead": {
      "main": [
        [
          {
            "node": "Find existing contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: routing needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find existing contact": {
      "main": [
        [
          {
            "node": "Classify inquiry",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify inquiry": {
      "main": [
        [
          {
            "node": "Read routing table",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: routing needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read routing table": {
      "main": [
        [
          {
            "node": "Apply routing rules",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apply routing rules": {
      "main": [
        [
          {
            "node": "Log routing decision",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: routing needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log routing decision": {
      "main": [
        [
          {
            "node": "Route",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route": {
      "main": [
        [
          {
            "node": "Assign owner in HubSpot",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Notify rep or queue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assign owner in HubSpot": {
      "main": [
        [
          {
            "node": "Notify rep or queue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every 30 min SLA check": {
      "main": [
        [
          {
            "node": "Find untouched new leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find untouched new leads": {
      "main": [
        [
          {
            "node": "Format SLA misses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format SLA misses": {
      "main": [
        [
          {
            "node": "Alert: routing needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
