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 Intake Normalizer & Deduplicator",
"active": false,
"settings": {
"executionOrder": "v1",
"saveDataErrorExecution": "all",
"saveDataSuccessExecution": "all",
"saveManualExecutions": true
},
"nodes": [
{
"name": "Lead Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
-1120,
300
],
"parameters": {
"httpMethod": "POST",
"path": "lead-intake",
"responseMode": "responseNode",
"options": {}
},
"id": "ed6c30c1-bc38-4d3c-8fb7-1a730cc49dbe"
},
{
"name": "Normalize & Validate Lead",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-900,
300
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "/**\n * NODE: \"Normalize & Validate Lead\" (Code node, Run Once for All Items)\n * ---------------------------------------------------------------------\n * Takes whatever a lead form / ad platform / partner actually posted and turns it\n * into one predictable contact record, or refuses it with a reason code.\n *\n * Handles, deliberately:\n * - Four different payload envelopes (raw form post, {data:{}}, Facebook Lead Ads\n * field_data[], Typeform form_response.answers[]).\n * - 130+ field-name aliases (\"First Name\", firstName, fname, given_name, ...).\n * - Phone numbers in any human format, normalised to E.164 with per-country\n * length validation. Junk numbers (0000000000, 1234567890) are rejected, not\n * silently written to the CRM where they burn an SMS credit and a sender score.\n * - Emails with whitespace, mailto:, <angle brackets>, and the typo domains\n * that account for most bounced confirmations.\n * - Names in one field, two fields, \"Last, First\", ALL CAPS, with titles,\n * suffixes and particles (van, de, bin, al-).\n * - Consent / TCPA fields, UTM attribution, timestamps in three formats.\n * - An idempotencyKey derived from the submission's own content, so a payload\n * redelivered an hour or a week later keys the same, plus in-execution\n * replay detection for the case where a batch arrives as one call.\n *\n * Output: one item per input item, always. Never throws on bad data \u2014 a bad lead\n * comes out with validation.status = \"rejected\" and a machine-readable issue list,\n * so the workflow can route it to a review queue instead of losing it.\n *\n * Local test: node ../test/run.js\n */\n\n// ---------------------------------------------------------------------------\n// CONFIG \u2014 the only part you normally edit per client.\n// ---------------------------------------------------------------------------\nconst CONFIG = {\n // Used when a phone number has no country code. Set to the country the client\n // actually sells in. Getting this wrong is the #1 cause of un-textable lists.\n defaultCountry: 'US',\n\n // Below this score a lead still gets created but is tagged for manual review.\n reviewScoreThreshold: 40,\n\n // Accept a lead with no phone as long as the email is good (and vice versa).\n // Set to false if the client's whole follow-up is SMS.\n allowEmailOnly: true,\n allowPhoneOnly: true,\n\n // Free-text fields get truncated to these lengths before they hit the CRM,\n // which rejects the whole record on overflow rather than trimming.\n maxNoteLength: 2000,\n maxFieldLength: 255,\n\n // How a timestamp with no timezone in it (\"August 14, 2026 3:15 PM\") is read.\n // 0 = UTC. Set to the client's UTC offset in minutes if their forms post local\n // wall-clock time (Mal\u00e9 = 300, New York in summer = -240). Left at 0 the value\n // is at least the same on every machine, which is the part that matters.\n naiveTimestampOffsetMinutes: 0,\n\n // Role addresses rarely belong to a buyer. Flagged, not rejected.\n roleLocalParts: [\n 'info', 'admin', 'support', 'sales', 'contact', 'hello', 'office',\n 'billing', 'noreply', 'no-reply', 'help', 'team', 'enquiries', 'webmaster',\n ],\n\n disposableDomains: [\n 'mailinator.com', 'guerrillamail.com', '10minutemail.com', 'tempmail.com',\n 'yopmail.com', 'trashmail.com', 'sharklasers.com', 'getnada.com',\n 'throwawaymail.com', 'temp-mail.org', 'maildrop.cc', 'dispostable.com',\n ],\n\n // domain typo -> intended domain\n domainCorrections: {\n 'gmial.com': 'gmail.com', 'gmai.com': 'gmail.com', 'gmail.co': 'gmail.com',\n 'gmail.con': 'gmail.com', 'gmail.cm': 'gmail.com', 'gnail.com': 'gmail.com',\n 'gmaill.com': 'gmail.com', 'gmail.comm': 'gmail.com',\n 'yaho.com': 'yahoo.com', 'yahooo.com': 'yahoo.com', 'yahoo.co': 'yahoo.com',\n 'hotmial.com': 'hotmail.com', 'hotmai.com': 'hotmail.com', 'hotmail.co': 'hotmail.com',\n 'outlok.com': 'outlook.com', 'outllook.com': 'outlook.com',\n 'iclould.com': 'icloud.com', 'icloud.co': 'icloud.com',\n },\n\n // Words in a free-text message that mean \"this person is ready to buy\".\n intentKeywords: [\n 'quote', 'pricing', 'price', 'cost', 'book', 'booking', 'appointment',\n 'asap', 'urgent', 'today', 'tomorrow', 'this week', 'ready', 'buy',\n 'purchase', 'install', 'schedule', 'call me', 'estimate',\n ],\n};\n\n// ---------------------------------------------------------------------------\n// Country rules for phone normalisation.\n// cc international dialling code, no '+'\n// nsnLengths valid national significant number lengths (after trunk strip)\n// trunk national trunk prefix stripped before prepending cc\n// Add a row per country the client sells into. This is a deliberately small,\n// auditable table rather than a 2MB metadata library \u2014 an agency owner can read\n// it and an n8n Code node can hold it.\n// ---------------------------------------------------------------------------\nconst COUNTRY_RULES = {\n US: { cc: '1', nsnLengths: [10], trunk: '1' },\n CA: { cc: '1', nsnLengths: [10], trunk: '1' },\n GB: { cc: '44', nsnLengths: [10, 9], trunk: '0' },\n IE: { cc: '353', nsnLengths: [9, 8], trunk: '0' },\n AU: { cc: '61', nsnLengths: [9], trunk: '0' },\n NZ: { cc: '64', nsnLengths: [9, 8], trunk: '0' },\n IN: { cc: '91', nsnLengths: [10], trunk: '0' },\n AE: { cc: '971', nsnLengths: [9], trunk: '0' },\n SA: { cc: '966', nsnLengths: [9], trunk: '0' },\n SG: { cc: '65', nsnLengths: [8], trunk: '' },\n ZA: { cc: '27', nsnLengths: [9], trunk: '0' },\n DE: { cc: '49', nsnLengths: [10, 11], trunk: '0' },\n PH: { cc: '63', nsnLengths: [10], trunk: '0' },\n MV: { cc: '960', nsnLengths: [7], trunk: '' },\n};\n\n// Longest-prefix lookup so we can recognise a country from an E.164 number.\nconst CC_TO_COUNTRY = Object.entries(COUNTRY_RULES)\n .sort((a, b) => b[1].cc.length - a[1].cc.length)\n .map(([iso, rule]) => ({ iso, cc: rule.cc }));\n\n// ---------------------------------------------------------------------------\n// Field aliases. Keys are canonicalised (lowercase, non-alphanumerics stripped)\n// before lookup, so \"First Name\", \"first-name\" and \"FIRSTNAME\" all collapse to\n// \"firstname\" and only need listing once.\n// ---------------------------------------------------------------------------\nconst ALIASES = {\n firstName: ['firstname', 'fname', 'givenname', 'first', 'forename', 'nombre', 'contactfirstname'],\n lastName: ['lastname', 'lname', 'surname', 'familyname', 'last', 'apellido', 'contactlastname'],\n fullName: ['fullname', 'name', 'yourname', 'contactname', 'customername', 'leadname', 'fullnames'],\n email: ['email', 'emailaddress', 'email1', 'youremail', 'emailid', 'mail', 'contactemail', 'workemail'],\n phone: ['phone', 'phonenumber', 'mobile', 'mobilenumber', 'cell', 'cellphone', 'tel', 'telephone',\n 'contactnumber', 'contactphone', 'whatsapp', 'phone1', 'yourphone'],\n company: ['company', 'companyname', 'business', 'businessname', 'organisation', 'organization', 'org'],\n message: ['message', 'comments', 'comment', 'notes', 'note', 'enquiry', 'inquiry', 'description',\n 'howcanwehelp', 'details', 'projectdetails', 'question'],\n postalCode: ['postalcode', 'zip', 'zipcode', 'postcode', 'pincode'],\n city: ['city', 'town', 'suburb', 'locality'],\n state: ['state', 'province', 'region', 'county'],\n country: ['country', 'countrycode', 'nation'],\n address: ['address', 'address1', 'streetaddress', 'street', 'addressline1'],\n source: ['source', 'leadsource', 'utmsource', 'referrer', 'referer', 'origin', 'channel'],\n consent: ['consent', 'optin', 'optedin', 'smsconsent', 'agreetoterms', 'marketingconsent',\n 'iagree', 'tcpaconsent', 'permissiontotext', 'subscribe'],\n serviceInterest: ['service', 'serviceinterest', 'interestedin', 'product', 'jobtype', 'servicetype'],\n budget: ['budget', 'pricerange', 'estimatedbudget', 'spend'],\n timeframe: ['timeframe', 'timeline', 'whenneeded', 'startdate', 'urgency'],\n submittedAt: ['submittedat', 'createdat', 'timestamp', 'datesubmitted', 'time', 'date', 'createdtime'],\n pageUrl: ['pageurl', 'url', 'landingpage', 'sourceurl', 'formurl', 'page'],\n ipAddress: ['ip', 'ipaddress', 'clientip', 'remoteip', 'userip'],\n userAgent: ['useragent', 'browser', 'ua'],\n};\n\n// ===========================================================================\n// Small utilities\n// ===========================================================================\n\n/** Canonicalise a field name so alias lookup is punctuation- and case-blind. */\nfunction canonicalKey(key) {\n return String(key).toLowerCase().replace(/[^a-z0-9]/g, '');\n}\n\n/** Same, but for a dotted path \u2014 segment boundaries are preserved so that\n * \"contact.lastname\" can be matched on its last segment. Without this, a naive\n * suffix match makes \"fullname\" look like a match for \"lname\". */\nfunction canonicalPath(path) {\n return String(path).split('.').map(canonicalKey).filter(Boolean).join('.');\n}\n\n/** Strip tags and collapse whitespace. Form fields are a common XSS vector into\n * whatever dashboard renders them later \u2014 we are not the last line of defence,\n * but we should not be the hole either. */\nfunction cleanText(value, maxLength) {\n if (value === null || value === undefined) return '';\n let text = typeof value === 'object' ? JSON.stringify(value) : String(value);\n text = text.replace(/<script[\\s\\S]*?<\\/script>/gi, ' ');\n text = text.replace(/<[^>]*>/g, ' ');\n text = text.replace(/ /gi, ' ').replace(/&/gi, '&');\n text = text.replace(/[\\u0000-\\u001F\\u007F]/g, ' '); // control chars break CRM imports\n text = text.replace(/\\s+/g, ' ').trim();\n const limit = maxLength || CONFIG.maxFieldLength;\n return text.length > limit ? text.slice(0, limit).trim() : text;\n}\n\n/** Title-case a name part, preserving particles, hyphens, apostrophes and\n * the O'/Mc/Mac patterns. \"MCDONALD\" -> \"McDonald\", \"o'brien\" -> \"O'Brien\". */\nconst MAC_SURNAMES = ['macdonald', 'macarthur', 'macgregor', 'mackenzie', 'macleod',\n 'macmillan', 'macpherson', 'macintyre', 'macneil', 'macfarlane', 'macintosh',\n 'maclean', 'macaulay', 'macallister', 'macgowan'];\n\nfunction titleCaseName(part) {\n if (!part) return '';\n const particles = ['van', 'von', 'der', 'den', 'de', 'del', 'della', 'di', 'da',\n 'du', 'la', 'le', 'bin', 'binti', 'ibn', 'al', 'el'];\n // \"van der Berg\" keeps its lowercase particles even when the surname is stored\n // in its own field and the particle is therefore at position 0. A particle is\n // only capitalised when it is the entire name (someone actually surnamed \"De\").\n const wordCount = part.trim().split(/[\\s\\-']+/).filter(Boolean).length;\n return part\n .toLowerCase()\n .split(/([\\s\\-'])/)\n .map((token) => {\n if (/^[\\s\\-']$/.test(token) || token === '') return token;\n if (wordCount > 1 && particles.includes(token)) return token;\n if (token.startsWith('mc') && token.length > 2) {\n return 'Mc' + token.charAt(2).toUpperCase() + token.slice(3);\n }\n // Mac- needs an allow-list, not a rule. \"Macdonald\" is MacDonald;\n // \"Machado\" and \"Macey\" are not MacHado and MacEy.\n if (MAC_SURNAMES.includes(token)) {\n return 'Mac' + token.charAt(3).toUpperCase() + token.slice(4);\n }\n return token.charAt(0).toUpperCase() + token.slice(1);\n })\n .join('');\n}\n\n/** Deterministic 32-bit hash (FNV-1a). Used for idempotency keys only \u2014 this is\n * not a security primitive and is not used for anything that needs one. */\nfunction fnv1a(input) {\n let hash = 0x811c9dc5;\n for (let i = 0; i < input.length; i++) {\n hash ^= input.charCodeAt(i);\n hash = Math.imul(hash, 0x01000193) >>> 0;\n }\n return hash.toString(16).padStart(8, '0');\n}\n\nfunction isTruthyFlag(value) {\n if (value === true) return true;\n if (value === false || value === null || value === undefined) return false;\n const text = String(value).trim().toLowerCase();\n return ['true', 'yes', 'y', '1', 'on', 'checked', 'agreed', 'accept', 'accepted'].includes(text);\n}\n\n// ===========================================================================\n// Envelope handling \u2014 get from \"whatever they posted\" to a flat key/value bag\n// ===========================================================================\n\n/**\n * Some objects are just a box around a single value:\n * {\"value\": \"Tomas\"}, {\"text\": \"...\"}, {\"label\": \"...\"}\n * Form builders emit these constantly. Unwrap them instead of stringifying the\n * whole object into a name field.\n */\nconst SCALAR_WRAPPER_KEYS = ['value', 'text', 'label', 'answer', 'email', 'phone_number', 'choice'];\n\nfunction unwrapScalar(input) {\n if (!input || typeof input !== 'object' || Array.isArray(input)) return input;\n const keys = Object.keys(input);\n if (keys.length > 2) return input;\n for (const candidate of SCALAR_WRAPPER_KEYS) {\n if (candidate in input) {\n const inner = input[candidate];\n if (inner === null || typeof inner !== 'object') return inner;\n }\n }\n return input;\n}\n\n/**\n * Source-specific adapters. A generic flattener can *almost* handle Facebook and\n * Typeform, and \"almost\" is how leads go missing. Each platform gets six lines of\n * explicit handling instead.\n *\n * Returns { payload, source } where payload is a plain flat-ish object.\n */\nfunction unwrapEnvelope(raw) {\n if (!raw || typeof raw !== 'object') return { payload: raw, source: null };\n\n // n8n's Webhook node nests the posted body under `body`.\n const body = (raw.body !== null && typeof raw.body === 'object') ? raw.body : raw;\n\n // --- Facebook Lead Ads -------------------------------------------------\n let leadgen = null;\n if (body.object === 'page' && Array.isArray(body.entry)) {\n const change = ((body.entry[0] || {}).changes || [])[0];\n if (change && change.value) leadgen = change.value;\n } else if (Array.isArray(body.field_data)) {\n leadgen = body;\n }\n if (leadgen && Array.isArray(leadgen.field_data)) {\n const payload = {};\n for (const field of leadgen.field_data) {\n let value = field.values !== undefined ? field.values : field.value;\n if (Array.isArray(value)) value = value[0];\n payload[field.name] = unwrapScalar(value);\n }\n payload.created_time = leadgen.created_time;\n payload.leadgen_id = leadgen.leadgen_id;\n payload.form_id = leadgen.form_id;\n return { payload, source: 'facebook_lead_ads' };\n }\n\n // --- Typeform ----------------------------------------------------------\n const formResponse = body.form_response;\n if (formResponse && Array.isArray(formResponse.answers)) {\n const payload = {};\n for (const answer of formResponse.answers) {\n const field = answer.field || {};\n const name = field.ref || field.id || answer.type || 'answer';\n // Typeform keys the value by its own `type`: {type:\"email\", email:\"...\"}.\n let value = answer.type !== undefined ? answer[answer.type] : undefined;\n if (value === undefined) {\n value = answer.text !== undefined ? answer.text\n : answer.email !== undefined ? answer.email\n : answer.phone_number !== undefined ? answer.phone_number\n : answer.value !== undefined ? answer.value\n : answer.label;\n }\n value = unwrapScalar(value);\n if (Array.isArray(value)) value = value.map((entry) => unwrapScalar(entry)).join(', ');\n payload[name] = value;\n }\n payload.submitted_at = formResponse.submitted_at;\n payload.form_id = formResponse.form_id;\n return { payload, source: 'typeform' };\n }\n\n return { payload: body, source: null };\n}\n\n/**\n * Recursively flatten an object into canonical dotted paths, and also register\n * each leaf under its bare name so that both `contact.email` and `email` resolve.\n */\nfunction flattenPayload(input, prefix, output) {\n const flat = output || {};\n const base = prefix || '';\n\n if (input === null || input === undefined) return flat;\n\n if (Array.isArray(input)) {\n // A [{name, value}] pair list \u2014 the shape most form builders use for\n // \"extra fields\". Expand it in place.\n const isPairList = input.length > 0 && input.every(\n (entry) => entry && typeof entry === 'object' && !Array.isArray(entry) &&\n ('name' in entry || 'key' in entry) &&\n ('value' in entry || 'values' in entry || 'answer' in entry)\n );\n if (isPairList) {\n for (const entry of input) {\n let value = entry.value !== undefined ? entry.value\n : entry.values !== undefined ? entry.values : entry.answer;\n if (Array.isArray(value)) value = value[0];\n value = unwrapScalar(value);\n if (value === null || typeof value !== 'object') {\n flat[canonicalKey(entry.name || entry.key)] = value;\n }\n }\n return flat;\n }\n input.forEach((entry, index) =>\n flattenPayload(entry, base ? base + '.' + index : String(index), flat));\n return flat;\n }\n\n if (typeof input === 'object') {\n for (const [key, rawValue] of Object.entries(input)) {\n const value = unwrapScalar(rawValue);\n const path = base ? base + '.' + key : key;\n if (value !== null && typeof value === 'object') {\n flattenPayload(value, path, flat);\n } else {\n flat[canonicalPath(path)] = value;\n // Leaf-name shortcut: only set if a shallower key has not claimed it.\n const leaf = canonicalKey(key);\n if (!(leaf in flat)) flat[leaf] = value;\n }\n }\n return flat;\n }\n\n flat[base ? canonicalPath(base) : 'value'] = input;\n return flat;\n}\n\n/** Best-effort identification of where this lead came from. Used for scoring and\n * for choosing which quirks to expect. */\nfunction detectSource(flat, adapterSource) {\n if (adapterSource) return adapterSource;\n if (flat.leadgenid || (flat.formid && flat.pageid)) return 'facebook_lead_ads';\n if (flat.source) return canonicalKey(flat.source);\n if (flat.leadsource) return canonicalKey(flat.leadsource);\n if (flat.utmsource) return 'paid_' + canonicalKey(flat.utmsource);\n if (flat.formid || flat.formname) return 'website_form';\n return 'unknown';\n}\n\n/** Pull the first non-empty value matching any alias for a canonical field. */\nfunction pickField(flat, field) {\n const aliases = ALIASES[field] || [];\n\n // Pass 1: exact key.\n for (const alias of aliases) {\n if (alias in flat && flat[alias] !== null && flat[alias] !== undefined &&\n String(flat[alias]).trim() !== '') {\n return flat[alias];\n }\n }\n\n // Pass 2: last path segment, so \"data.contact.email_address\" resolves but\n // \"fullname\" is not mistaken for \"lname\".\n for (const alias of aliases) {\n const hit = Object.keys(flat).find(\n (key) => key.endsWith('.' + alias) && String(flat[key] ?? '').trim() !== ''\n );\n if (hit) return flat[hit];\n }\n\n return '';\n}\n\n/** Keys we pulled a value from, so unmapped fields can be reported rather than\n * silently dropped. Agencies find real form-config bugs this way. */\nfunction unmappedKeys(flat) {\n const claimed = new Set();\n for (const aliases of Object.values(ALIASES)) {\n for (const alias of aliases) claimed.add(alias);\n }\n for (const key of ['utmsource', 'utmmedium', 'utmcampaign', 'utmterm', 'utmcontent',\n 'gclid', 'fbclid', 'formid', 'formname', 'leadgenid', 'pageid', 'createdtime']) {\n claimed.add(key);\n }\n return Object.keys(flat)\n .filter((key) => !key.includes('.') && !claimed.has(key) && !key.startsWith('_'))\n .filter((key) => String(flat[key] ?? '').trim() !== '')\n .slice(0, 20);\n}\n\n// ===========================================================================\n// Email\n// ===========================================================================\n\n/**\n * Returns { value, valid, corrected, issues[], flags{} }.\n * `value` is the address you should actually send to (plus-tags preserved \u2014\n * stripping them breaks people's inbox filters). The dedupe key strips them.\n */\nfunction normalizeEmail(input) {\n const issues = [];\n const flags = { role: false, disposable: false, freemail: false };\n\n let raw = String(input ?? '').trim();\n if (!raw) return { value: '', valid: false, corrected: false, issues: ['EMAIL_MISSING'], flags };\n\n // Unwrap \"Name <a@example.com>\", \"mailto:a@example.com\", stray quotes, internal spaces.\n const angle = raw.match(/<([^>]+)>/);\n if (angle) raw = angle[1];\n raw = raw.replace(/^mailto:/i, '').replace(/[\"']/g, '').replace(/\\s+/g, '');\n raw = raw.replace(/[.,;:]+$/, '');\n raw = raw.toLowerCase();\n\n // Some form builders submit \"a@example.com,c@example.net\" from a copy-paste. Take the first.\n if (raw.includes(',') || raw.includes(';')) {\n raw = raw.split(/[;,]/)[0];\n issues.push('EMAIL_MULTIPLE_SUPPLIED');\n }\n\n const parts = raw.split('@');\n if (parts.length !== 2 || !parts[0] || !parts[1]) {\n return { value: raw, valid: false, corrected: false, issues: ['EMAIL_UNPARSEABLE'], flags };\n }\n\n let [local, domain] = parts;\n let corrected = false;\n\n // Repair the handful of typo domains that cause most bounced confirmations.\n if (CONFIG.domainCorrections[domain]) {\n domain = CONFIG.domainCorrections[domain];\n corrected = true;\n issues.push('EMAIL_DOMAIN_CORRECTED');\n }\n // \".con\" / \".cmo\" are almost always \".com\".\n if (/\\.(con|cmo|ocm|xom)$/.test(domain)) {\n domain = domain.replace(/\\.(con|cmo|ocm|xom)$/, '.com');\n corrected = true;\n issues.push('EMAIL_DOMAIN_CORRECTED');\n }\n\n const value = local + '@' + domain;\n\n // Pragmatic validation: rejects what mail servers reject, accepts what they\n // accept. Full RFC 5322 compliance is not the goal \u2014 deliverability is.\n const valid = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/.test(value)\n && domain.includes('.')\n && !domain.endsWith('.')\n && !/\\.\\./.test(value);\n\n if (!valid) issues.push('EMAIL_INVALID_SYNTAX');\n\n const bareLocal = local.split('+')[0];\n flags.role = CONFIG.roleLocalParts.includes(bareLocal);\n flags.disposable = CONFIG.disposableDomains.includes(domain);\n flags.freemail = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com',\n 'aol.com', 'live.com', 'msn.com', 'protonmail.com', 'gmx.com'].includes(domain);\n\n if (flags.role) issues.push('EMAIL_ROLE_ADDRESS');\n if (flags.disposable) issues.push('EMAIL_DISPOSABLE_DOMAIN');\n\n // Gmail ignores dots and everything after '+'. Two \"different\" addresses can be\n // the same inbox \u2014 that matters for dedupe, not for sending.\n let dedupeLocal = bareLocal;\n if (domain === 'gmail.com' || domain === 'googlemail.com') {\n dedupeLocal = dedupeLocal.replace(/\\./g, '');\n }\n const dedupeValue = valid ? dedupeLocal + '@' + (domain === 'googlemail.com' ? 'gmail.com' : domain) : '';\n\n return { value, dedupeValue, valid, corrected, issues, flags };\n}\n\n// ===========================================================================\n// Phone\n// ===========================================================================\n\n/**\n * Returns { e164, national, country, valid, issues[] }.\n *\n * Order of operations matters and is where hand-rolled attempts usually break:\n * 1. Keep a leading '+' only if it is genuinely leading.\n * 2. '00' is the international prefix in most of the world -> treat as '+'.\n * 3. If it already carries a known country code, trust it.\n * 4. Otherwise assume defaultCountry, strip that country's trunk prefix, and\n * validate the remaining length against that country's rules.\n */\nfunction normalizePhone(input, defaultCountry) {\n const issues = [];\n const country = defaultCountry || CONFIG.defaultCountry;\n const rule = COUNTRY_RULES[country];\n\n let raw = String(input ?? '').trim();\n if (!raw) return { e164: '', national: '', country: '', valid: false, issues: ['PHONE_MISSING'] };\n\n // Drop extensions before we strip punctuation, otherwise \"x123\" becomes digits.\n const extMatch = raw.match(/(?:ext|x|extension)\\.?\\s*(\\d+)\\s*$/i);\n const extension = extMatch ? extMatch[1] : '';\n if (extMatch) raw = raw.slice(0, extMatch.index);\n\n const hadPlus = /^\\s*\\+/.test(raw);\n let digits = raw.replace(/\\D/g, '');\n\n if (!digits) return { e164: '', national: '', country: '', valid: false, issues: ['PHONE_NO_DIGITS'] };\n\n let international = hadPlus;\n if (!international && digits.startsWith('00')) {\n digits = digits.slice(2);\n international = true;\n }\n\n let cc = '';\n let nsn = '';\n\n if (international) {\n const match = CC_TO_COUNTRY.find((entry) => digits.startsWith(entry.cc));\n if (match) {\n cc = match.cc;\n nsn = digits.slice(cc.length);\n } else {\n // A country we have no rule for. Keep it, flag it, do not guess a length.\n const guessedCc = digits.slice(0, 2);\n return {\n e164: '+' + digits,\n national: digits,\n country: '',\n valid: digits.length >= 8 && digits.length <= 15,\n issues: digits.length >= 8 && digits.length <= 15\n ? ['PHONE_COUNTRY_UNKNOWN']\n : ['PHONE_COUNTRY_UNKNOWN', 'PHONE_LENGTH_IMPLAUSIBLE'],\n extension,\n ccGuess: guessedCc,\n };\n }\n } else if (rule) {\n nsn = digits;\n cc = rule.cc;\n // A US number typed as \"1 (415) 555-0128\" has the country code but no plus.\n if (rule.trunk && nsn.startsWith(rule.trunk) && !rule.nsnLengths.includes(nsn.length)) {\n const stripped = nsn.slice(rule.trunk.length);\n if (rule.nsnLengths.includes(stripped.length)) nsn = stripped;\n }\n } else {\n return { e164: '', national: digits, country: '', valid: false, issues: ['PHONE_NO_COUNTRY_RULE'] };\n }\n\n const iso = (CC_TO_COUNTRY.find((entry) => entry.cc === cc) || {}).iso || country;\n const isoRule = COUNTRY_RULES[iso] || rule;\n\n // Trunk prefix again, now that we know which country's rules apply.\n if (isoRule && isoRule.trunk && nsn.startsWith(isoRule.trunk) &&\n !isoRule.nsnLengths.includes(nsn.length) &&\n isoRule.nsnLengths.includes(nsn.length - isoRule.trunk.length)) {\n nsn = nsn.slice(isoRule.trunk.length);\n }\n\n const lengthOk = isoRule ? isoRule.nsnLengths.includes(nsn.length) : (nsn.length >= 7 && nsn.length <= 12);\n if (!lengthOk) issues.push('PHONE_LENGTH_INVALID');\n\n // Obvious placeholder junk. People type these to get past a required field.\n const junk = /^(\\d)\\1+$/.test(nsn)\n || ['1234567890', '0123456789', '9876543210', '5555555555', '1111111111'].includes(nsn)\n || /^0+$/.test(nsn);\n if (junk) issues.push('PHONE_PLACEHOLDER');\n\n // US/CA NANP structural rules \u2014 area code and exchange cannot start with 0 or 1.\n if (cc === '1' && nsn.length === 10) {\n if (/^[01]/.test(nsn) || /^[01]/.test(nsn.slice(3))) issues.push('PHONE_INVALID_NANP');\n if (nsn.slice(3, 6) === '555' && nsn.slice(6, 8) === '01') issues.push('PHONE_FICTIONAL_RANGE');\n }\n\n const valid = lengthOk && !junk && !issues.includes('PHONE_INVALID_NANP');\n\n return {\n e164: valid ? '+' + cc + nsn : '',\n national: nsn,\n country: iso,\n extension,\n valid,\n issues,\n };\n}\n\n// ===========================================================================\n// Name\n// ===========================================================================\n\nconst NAME_TITLES = ['mr', 'mrs', 'ms', 'miss', 'dr', 'prof', 'sir', 'madam', 'mx', 'rev'];\nconst NAME_SUFFIXES = ['jr', 'sr', 'ii', 'iii', 'iv', 'phd', 'md', 'esq'];\n\n/**\n * Split a name that may arrive as one field, two fields, or \"Last, First\".\n * Returns { firstName, lastName, fullName, issues[] }.\n */\nfunction splitName(firstRaw, lastRaw, fullRaw) {\n const issues = [];\n let first = cleanText(firstRaw, 80);\n let last = cleanText(lastRaw, 80);\n const full = cleanText(fullRaw, 160);\n\n // A very common failure: the whole name gets posted into the first-name field.\n const firstLooksLikeFullName = !last && first.includes(' ');\n\n if ((!first && !last && full) || firstLooksLikeFullName) {\n let source = firstLooksLikeFullName ? first : full;\n\n if (source.includes(',')) {\n // \"Doe, John\" \u2014 surname first.\n const [lastPart, firstPart] = source.split(',');\n last = cleanText(lastPart, 80);\n first = cleanText(firstPart, 80);\n } else {\n let tokens = source.split(/\\s+/).filter(Boolean);\n\n if (tokens.length && NAME_TITLES.includes(tokens[0].toLowerCase().replace(/\\./g, ''))) {\n tokens = tokens.slice(1);\n }\n let suffix = '';\n if (tokens.length > 1) {\n const tail = tokens[tokens.length - 1].toLowerCase().replace(/[.,]/g, '');\n if (NAME_SUFFIXES.includes(tail)) {\n suffix = tokens.pop();\n }\n }\n\n if (tokens.length === 0) {\n first = '';\n last = '';\n } else if (tokens.length === 1) {\n first = tokens[0];\n last = '';\n issues.push('NAME_SINGLE_TOKEN');\n } else {\n // Keep particles with the surname: \"john van der berg\" -> van der berg.\n const particles = ['van', 'von', 'der', 'den', 'de', 'del', 'della', 'di', 'da',\n 'du', 'la', 'le', 'bin', 'binti', 'ibn', 'al', 'el'];\n let splitIndex = tokens.length - 1;\n while (splitIndex > 1 && particles.includes(tokens[splitIndex - 1].toLowerCase())) {\n splitIndex -= 1;\n }\n first = tokens.slice(0, splitIndex).join(' ');\n last = tokens.slice(splitIndex).join(' ');\n }\n if (suffix) last = (last + ' ' + suffix).trim();\n }\n }\n\n if (!first && !last) issues.push('NAME_MISSING');\n\n // Reject the placeholders people type to get past a required field.\n const junkNames = ['test', 'asdf', 'aaa', 'na', 'n/a', 'none', 'xxx', 'abc', 'qwerty', '.'];\n if (junkNames.includes(first.toLowerCase()) && (!last || junkNames.includes(last.toLowerCase()))) {\n issues.push('NAME_PLACEHOLDER');\n }\n if (/^\\d+$/.test(first)) issues.push('NAME_NUMERIC');\n\n first = titleCaseName(first);\n last = titleCaseName(last);\n\n return {\n firstName: first,\n lastName: last,\n fullName: [first, last].filter(Boolean).join(' '),\n issues,\n };\n}\n\n// ===========================================================================\n// Attribution, consent, timestamps\n// ===========================================================================\n\nfunction parseUtm(flat) {\n const utm = {};\n for (const key of ['utmsource', 'utmmedium', 'utmcampaign', 'utmterm', 'utmcontent', 'gclid', 'fbclid']) {\n if (flat[key]) utm[key.replace('utm', 'utm_')] = cleanText(flat[key], 120);\n }\n\n // If nothing came through as its own field, mine the landing page URL.\n const url = pickField(flat, 'pageUrl');\n if (url && String(url).includes('?')) {\n const query = String(url).split('?')[1].split('#')[0];\n for (const pair of query.split('&')) {\n const [rawKey, rawValue] = pair.split('=');\n if (!rawKey) continue;\n const key = canonicalKey(rawKey);\n if (['utmsource', 'utmmedium', 'utmcampaign', 'utmterm', 'utmcontent', 'gclid', 'fbclid'].includes(key)) {\n const outKey = key.replace('utm', 'utm_');\n if (!utm[outKey]) {\n try {\n utm[outKey] = cleanText(decodeURIComponent(rawValue || ''), 120);\n } catch (error) {\n utm[outKey] = cleanText(rawValue || '', 120);\n }\n }\n }\n }\n }\n return utm;\n}\n\n/** Accepts ISO strings, epoch seconds, epoch milliseconds and \"DD/MM/YYYY HH:mm\".\n * Returns an ISO 8601 UTC string, or the workflow run time if nothing parses. */\nfunction parseTimestamp(input, fallbackIso) {\n const fallback = fallbackIso || new Date().toISOString();\n if (input === null || input === undefined || input === '') return fallback;\n\n if (typeof input === 'number' || /^\\d+$/.test(String(input))) {\n const numeric = Number(input);\n // 10 digits = seconds, 13 = milliseconds.\n const ms = String(Math.trunc(numeric)).length <= 10 ? numeric * 1000 : numeric;\n const date = new Date(ms);\n return isNaN(date.getTime()) ? fallback : date.toISOString();\n }\n\n const text = String(input).trim();\n const dmy = text.match(/^(\\d{1,2})[/\\-.](\\d{1,2})[/\\-.](\\d{4})(?:[ T](\\d{1,2}):(\\d{2})(?::(\\d{2}))?)?$/);\n if (dmy) {\n // Ambiguous by design: 03/04/2026 is March 4 in the US and 3 April elsewhere.\n // We follow the configured default country rather than silently picking one.\n const usStyle = ['US', 'CA'].includes(CONFIG.defaultCountry);\n const day = Number(usStyle ? dmy[2] : dmy[1]);\n const month = Number(usStyle ? dmy[1] : dmy[2]);\n const date = new Date(Date.UTC(Number(dmy[3]), month - 1, day,\n Number(dmy[4] || 0), Number(dmy[5] || 0), Number(dmy[6] || 0)));\n return isNaN(date.getTime()) ? fallback : date.toISOString();\n }\n\n // \"August 14, 2026 3:15 PM\" and \"2026-08-14T15:15:00\" carry no timezone, and\n // `new Date()` resolves those against whatever timezone the machine happens to\n // be in. That makes the same payload normalise differently on a laptop in\n // Mal\u00e9 and on a UTC droplet \u2014 different consent timestamps, different\n // idempotency keys, and a samples file that only reproduces on the machine\n // that generated it. A naive timestamp is therefore read as UTC unless the\n // string says otherwise. If the client's forms post local wall-clock time,\n // convert it before it reaches this node, or set naiveTimestampOffsetMinutes.\n const carriesZone = /(?:Z|[+-]\\d{2}:?\\d{2})$/i.test(text) || /\\bGMT\\b|\\bUTC\\b/i.test(text);\n const parsed = new Date(text);\n if (isNaN(parsed.getTime())) return fallback;\n if (carriesZone) return parsed.toISOString();\n\n // Re-read the same instant as UTC: strip the host offset the parser applied,\n // then apply the configured one (0 = UTC).\n const hostOffsetMs = parsed.getTimezoneOffset() * 60 * 1000;\n const configuredOffsetMs = Number(CONFIG.naiveTimestampOffsetMinutes || 0) * 60 * 1000;\n return new Date(parsed.getTime() - hostOffsetMs - configuredOffsetMs).toISOString();\n}\n\n// ===========================================================================\n// Scoring\n// ===========================================================================\n\n/** 0-100. Not magic \u2014 just a transparent, tunable weighting an agency can defend\n * to a client. Every contribution is returned so you can show your working. */\nfunction scoreLead(record) {\n const breakdown = {};\n\n breakdown.email = record.email.valid ? (record.email.flags.freemail ? 15 : 20) : 0;\n breakdown.phone = record.phone.valid ? 25 : 0;\n breakdown.name = record.name.firstName && record.name.lastName ? 10 : record.name.firstName ? 5 : 0;\n breakdown.company = record.company ? 5 : 0;\n breakdown.consent = record.consent.granted ? 10 : 0;\n\n const message = (record.message || '').toLowerCase();\n const intentHits = CONFIG.intentKeywords.filter((word) => message.includes(word));\n breakdown.intent = Math.min(15, intentHits.length * 5);\n\n const sourceWeights = {\n website_form: 10, typeform: 10, facebook_lead_ads: 5, paid_google: 8,\n referral: 15, unknown: 0,\n };\n breakdown.source = sourceWeights[record.source] !== undefined ? sourceWeights[record.source] : 3;\n\n breakdown.detail = record.message && record.message.length > 60 ? 5 : 0;\n\n let penalties = 0;\n if (record.email.flags.disposable) penalties -= 25;\n if (record.email.flags.role) penalties -= 10;\n if (record.issues.includes('NAME_PLACEHOLDER')) penalties -= 20;\n if (record.issues.includes('PHONE_PLACEHOLDER')) penalties -= 15;\n breakdown.penalties = penalties;\n\n const total = Object.values(breakdown).reduce((sum, value) => sum + value, 0);\n return { score: Math.max(0, Math.min(100, total)), breakdown, intentHits };\n}\n\n// ===========================================================================\n// Main per-item normalisation\n// ===========================================================================\n\nfunction normalizeOne(raw, meta) {\n const runIso = (meta && meta.runIso) || new Date().toISOString();\n const { payload, source: adapterSource } = unwrapEnvelope(raw);\n const flat = flattenPayload(payload);\n\n const source = detectSource(flat, adapterSource);\n const issues = [];\n\n const name = splitName(pickField(flat, 'firstName'), pickField(flat, 'lastName'), pickField(flat, 'fullName'));\n issues.push(...name.issues);\n\n const email = normalizeEmail(pickField(flat, 'email'));\n if (email.value) issues.push(...email.issues.filter((code) => code !== 'EMAIL_MISSING'));\n else issues.push('EMAIL_MISSING');\n\n // If the form collected a country, honour it over the global default.\n const declaredCountry = String(pickField(flat, 'country') || '').trim().toUpperCase();\n const countryHint = COUNTRY_RULES[declaredCountry] ? declaredCountry : CONFIG.defaultCountry;\n\n const phone = normalizePhone(pickField(flat, 'phone'), countryHint);\n issues.push(...phone.issues);\n\n const consentRaw = pickField(flat, 'consent');\n const consent = {\n granted: isTruthyFlag(consentRaw),\n raw: consentRaw === '' ? null : consentRaw,\n capturedAt: parseTimestamp(pickField(flat, 'submittedAt'), runIso),\n ipAddress: cleanText(pickField(flat, 'ipAddress'), 45),\n userAgent: cleanText(pickField(flat, 'userAgent'), 255),\n sourceUrl: cleanText(pickField(flat, 'pageUrl'), 500),\n };\n if (!consent.granted) issues.push('CONSENT_NOT_GRANTED');\n\n const record = {\n source,\n name,\n email,\n phone,\n consent,\n company: cleanText(pickField(flat, 'company')),\n message: cleanText(pickField(flat, 'message'), CONFIG.maxNoteLength),\n serviceInterest: cleanText(pickField(flat, 'serviceInterest')),\n budget: cleanText(pickField(flat, 'budget'), 60),\n timeframe: cleanText(pickField(flat, 'timeframe'), 60),\n address: {\n line1: cleanText(pickField(flat, 'address')),\n city: cleanText(pickField(flat, 'city'), 80),\n state: cleanText(pickField(flat, 'state'), 80),\n postalCode: cleanText(pickField(flat, 'postalCode'), 20).toUpperCase(),\n // If the form did not ask for a country, the phone number just told us one.\n // Writing the default country onto an obviously Maldivian number is how a\n // CRM ends up with 4,000 contacts in the wrong timezone.\n country: COUNTRY_RULES[declaredCountry] ? declaredCountry\n : (phone.country || CONFIG.defaultCountry),\n },\n utm: parseUtm(flat),\n submittedAt: parseTimestamp(pickField(flat, 'submittedAt'), runIso),\n issues,\n };\n\n const scored = scoreLead(record);\n\n // ---- Decide what happens to this lead -----------------------------------\n const hasUsableEmail = record.email.valid && !record.email.flags.disposable;\n const hasUsablePhone = record.phone.valid;\n\n let status;\n if (!hasUsableEmail && !hasUsablePhone) {\n status = 'rejected';\n issues.push('NO_REACHABLE_CHANNEL');\n } else if (!hasUsableEmail && !CONFIG.allowPhoneOnly) {\n status = 'rejected';\n issues.push('EMAIL_REQUIRED_BY_POLICY');\n } else if (!hasUsablePhone && !CONFIG.allowEmailOnly) {\n status = 'rejected';\n issues.push('PHONE_REQUIRED_BY_POLICY');\n } else if (issues.includes('NAME_PLACEHOLDER') || record.email.flags.disposable) {\n status = 'needs_review';\n } else if (scored.score < CONFIG.reviewScoreThreshold) {\n status = 'needs_review';\n } else {\n status = 'accepted';\n }\n\n // ---- Dedupe + idempotency keys ------------------------------------------\n\n // A fingerprint of what the submission *says*, taken after normalisation, so\n // the same enquiry typed as \"(415) 555-0143\" and as \"+1 415 555 0143\" hashes\n // identically. Nothing time-derived goes in here \u2014 not `receivedAt`, and not\n // `consent.capturedAt`, which falls back to the receipt time.\n const contentHash = fnv1a(JSON.stringify([\n record.name.firstName,\n record.name.lastName,\n record.email.dedupeValue || record.email.value,\n record.phone.e164 || record.phone.national,\n record.company,\n record.message,\n record.serviceInterest,\n record.budget,\n record.timeframe,\n record.address.line1,\n record.address.city,\n record.address.postalCode,\n record.consent.granted ? 'consent' : 'no-consent',\n record.utm.utm_source || '',\n record.utm.utm_campaign || '',\n ].map((part) => String(part).toLowerCase())));\n\n const dedupeKeys = {\n phone: record.phone.e164 || null,\n email: record.email.dedupeValue || null,\n // Last-resort fingerprint when neither channel is clean enough to key on.\n fingerprint: fnv1a([\n record.name.firstName.toLowerCase(),\n record.name.lastName.toLowerCase(),\n record.address.postalCode.toLowerCase(),\n ].join('|')),\n content: contentHash,\n };\n\n // Same payload delivered twice (sender retry, double-click) produces the same\n // key, whenever it is delivered, so a caller can short-circuit instead of\n // creating a twin.\n //\n // The key is derived from the payload's own content and from nothing else. An\n // earlier version mixed in the time of receipt whenever the sender posted no\n // timestamp of its own, and that broke the exact case the key exists for: a\n // Facebook Lead Ads field_data payload and a bare form POST both arrive\n // without a timestamp, so every redelivery got a fresh key and the\n // short-circuit never fired. Receipt time is a property of the delivery, not\n // of the submission, and it has no business in a key meant to survive one.\n //\n // A sender-supplied timestamp is still mixed in, because that one is stable\n // across retries, and it is what separates \"this submission\" from \"this\n // person\": the same enquiry sent again next month carries a new submitted_at\n // and correctly gets a new key. When the sender supplies no timestamp there is\n // nothing stable to separate the two by, so two byte-identical enquiries\n // months apart do share a key. That is the right trade \u2014 this key's job is\n // retry suppression, and \"same person, new enquiry, later\" is what the CRM\n // phone/email match downstream is for.\n const NO_TIMESTAMP = '\u0000none';\n const submittedAtWasProvided =\n parseTimestamp(pickField(flat, 'submittedAt'), NO_TIMESTAMP) !== NO_TIMESTAMP;\n\n const idempotencyKey = fnv1a(JSON.stringify({\n e: dedupeKeys.email,\n p: dedupeKeys.phone,\n // Only when there is no reachable channel to key on. Otherwise two people at\n // one postcode would share the identity half of the key.\n f: (!dedupeKeys.email && !dedupeKeys.phone) ? dedupeKeys.fingerprint : null,\n c: contentHash,\n t: submittedAtWasProvided ? record.submittedAt : null,\n }));\n\n const tags = ['source:' + source];\n if (status === 'needs_review') tags.push('needs-review');\n if (scored.score >= 70) tags.push('hot-lead');\n if (record.email.flags.role) tags.push('role-email');\n if (record.utm.utm_campaign) tags.push('campaign:' + record.utm.utm_campaign);\n\n return {\n // Flat, CRM-shaped block \u2014 this is what the HTTP nodes actually send.\n contact: {\n firstName: record.name.firstName,\n lastName: record.name.lastName,\n name: record.name.fullName,\n email: record.email.value,\n phone: record.phone.e164,\n companyName: record.company,\n address1: record.address.line1,\n city: record.address.city,\n state: record.address.state,\n postalCode: record.address.postalCode,\n country: record.address.country,\n source: source,\n tags,\n customFields: {\n lead_score: scored.score,\n service_interest: record.serviceInterest,\n budget: record.budget,\n timeframe: record.timeframe,\n message: record.message,\n utm_source: record.utm.utm_source || '',\n utm_medium: record.utm.utm_medium || '',\n utm_campaign: record.utm.utm_campaign || '',\n consent_granted: record.consent.granted,\n consent_captured_at: record.consent.capturedAt,\n consent_ip: record.consent.ipAddress,\n },\n },\n // Everything the workflow needs to make decisions, kept out of the CRM payload.\n validation: {\n status,\n issues: Array.from(new Set(issues)),\n emailValid: record.email.valid,\n phoneValid: record.phone.valid,\n emailCorrected: record.email.corrected,\n phoneCountry: record.phone.country,\n },\n scoring: scored,\n dedupeKeys,\n idempotencyKey,\n meta: {\n source,\n submittedAt: record.submittedAt,\n receivedAt: runIso,\n consent: record.consent,\n rawKeyCount: Object.keys(flat).length,\n // Fields the form sent that no alias claims. Not an error \u2014 but if a\n // client keeps asking \"where did the budget answer go\", this is the answer.\n unmappedFields: unmappedKeys(flat),\n },\n // Routing flags. The IF nodes read these booleans and nothing else \u2014 all the\n // real decision-making stays here in code where it can be tested.\n isUsable: status !== 'rejected',\n needsReview: status === 'needs_review',\n };\n}\n\n/** Entry point. Never throws: a normaliser that dies on one bad item takes the\n * whole batch down with it. */\nfunction processAll(items, options) {\n // The receive time is injectable so a test can deliver the same payload twice,\n // minutes apart, and assert the idempotency key did not move.\n const runIso = (options && options.runIso) || new Date().toISOString();\n const seenIdempotencyKeys = new Set();\n\n return items.map((item, index) => {\n try {\n const result = normalizeOne(item.json, { runIso });\n\n // Two identical payloads inside one batch (an ad platform replaying a page\n // of leads into a single call) must not both be written.\n //\n // Scope, stated plainly because it is easy to over-read: this Set lives for\n // one execution. On the webhook path one POST is one execution carrying one\n // lead, so it has nothing to compare against and never fires there \u2014 a\n // resubmission over the webhook is caught by the CRM search downstream,\n // which matches it to the contact created a minute ago and turns it into an\n // update. Cross-execution suppression would need durable storage (n8n\n // static data, a Data Table, the CRM itself); `idempotencyKey` is the value\n // to key such a ledger on, and it is stable enough to do it with.\n if (seenIdempotencyKeys.has(result.idempotencyKey)) {\n result.validation.status = 'duplicate_in_batch';\n result.validation.issues.push('DUPLICATE_IN_BATCH');\n result.isUsable = false;\n }\n seenIdempotencyKeys.add(result.idempotencyKey);\n\n return { json: result, pairedItem: index };\n } catch (error) {\n // A normaliser crash is a bug in this file, not a reason to lose the lead.\n return {\n json: {\n contact: null,\n validation: {\n status: 'rejected',\n issues: ['NORMALIZER_ERROR'],\n error: String(error && error.message ? error.message : error),\n },\n isUsable: false,\n needsReview: true,\n rawPayload: item.json,\n meta: { receivedAt: runIso },\n },\n pairedItem: index,\n };\n }\n });\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n module.exports = {\n CONFIG, COUNTRY_RULES, canonicalKey, canonicalPath, cleanText, titleCaseName,\n fnv1a, unwrapScalar, unwrapEnvelope, flattenPayload, detectSource, pickField,\n unmappedKeys, normalizeEmail, normalizePhone, splitName, parseUtm,\n parseTimestamp, scoreLead, normalizeOne, processAll,\n };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n return processAll($input.all());\n}"
},
"id": "610c1d6c-f1fd-4a32-8363-29b60642fc70"
},
{
"name": "Lead Usable?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
-680,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "lead-usable",
"leftValue": "={{ $json.isUsable }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"looseTypeValidation": true,
"options": {}
},
"id": "61122ab9-1299-4a80-82d5-1a8eb8b39552"
},
{
"name": "Prepare CRM Lookup",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-460,
180
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "/**\n * NODE: \"Prepare CRM Lookup\" (Code node, Run Once for All Items)\n * --------------------------------------------------------------\n * Sits between the normaliser and the CRM search call. Three jobs:\n *\n * 1. Build the search query. Phone first, because it is the higher-confidence\n * key; fall back to email; if neither is clean, do not search at all.\n * 2. Carry the whole normalised lead forward on the item, so downstream nodes\n * never have to reach back across the graph with $node[\"Some Name\"] \u2014 that\n * pattern breaks silently the first time anyone renames a node.\n * 3. Seed the retry state that \"Classify API Error\" and the backoff loop use.\n *\n * MOCK_MODE also lives here. With MOCK_MODE on, the workflow answers its own CRM\n * search from the fixtures below, so the whole thing runs end to end with no\n * credentials and no live account. That is how you should evaluate it first.\n */\n\n// Set to false and connect real credentials when you want to run this live.\nconst MOCK_MODE = true;\n\n// GoHighLevel sub-account (location) the contacts belong to. Every v2 API call\n// needs it. Left as a placeholder on purpose \u2014 nothing here requires a real key.\nconst LOCATION_ID = 'REPLACE_WITH_LOCATION_ID';\n\n/**\n * Fixture \"CRM\": a handful of contacts that already exist. Keyed by E.164 phone\n * and by normalised email so the mock behaves like a real lookup endpoint.\n * These deliberately include a record with a missing last name and a record\n * with a stale address, so the merge policy has something to do.\n */\nconst MOCK_CRM_CONTACTS = [\n {\n id: 'ct_8f21a0',\n firstName: 'Aminath',\n lastName: '',\n email: 'aminath.r@example.com',\n phone: '+9607712345',\n companyName: '',\n address1: '',\n city: '',\n state: '',\n postalCode: '',\n country: 'MV',\n source: 'referral',\n tags: ['existing-client'],\n customFields: {\n lead_score: 55,\n message: 'Called in about a quote in March.',\n utm_source: '',\n },\n },\n {\n id: 'ct_44b917',\n firstName: 'Robert',\n lastName: 'Chen',\n email: 'rob.chen@northsidehvac.example.com',\n phone: '+14155550143',\n companyName: 'Northside HVAC',\n address1: '18 Alder Street',\n city: 'San Francisco',\n state: 'CA',\n postalCode: '94110',\n country: 'US',\n source: 'website_form',\n tags: ['source:website_form'],\n customFields: {\n lead_score: 70,\n service_interest: 'Ducted install',\n message: 'Wants a quote for a 3-bed retrofit.',\n utm_source: 'google',\n },\n },\n {\n id: 'ct_0d5c3e',\n firstName: 'Sarah',\n lastName: 'Okonkwo',\n email: 'sarah.okonkwo@gmail.com',\n phone: '',\n companyName: '',\n address1: '',\n city: 'Manchester',\n state: '',\n postalCode: 'M1 4BT',\n country: 'GB',\n source: 'facebook_lead_ads',\n tags: ['source:facebook_lead_ads'],\n customFields: { lead_score: 40, message: '' },\n },\n];\n\nfunction digitsOnly(value) {\n return String(value ?? '').replace(/\\D/g, '');\n}\n\nfunction normalizeEmailForCompare(value) {\n const text = String(value ?? '').trim().toLowerCase();\n if (!text.includes('@')) return '';\n let [local, domain] = text.split('@');\n local = local.split('+')[0];\n if (domain === 'googlemail.com') domain = 'gmail.com';\n if (domain === 'gmail.com') local = local.replace(/\\./g, '');\n return local + '@' + domain;\n}\n\n/** Stands in for GET /contacts/search \u2014 same response shape, no network. */\nfunction mockSearch(query) {\n const byPhone = query.phone ? digitsOnly(query.phone) : '';\n const byEmail = query.email ? normalizeEmailForCompare(query.email) : '';\n\n const contacts = MOCK_CRM_CONTACTS.filter((contact) => {\n if (byPhone && digitsOnly(contact.phone) === byPhone) return true;\n if (byEmail && normalizeEmailForCompare(contact.email) === byEmail) return true;\n return false;\n });\n\n return { contacts, meta: { total: contacts.length, source: 'MOCK_MODE' } };\n}\n\nfunction processAll(items) {\n return items.map((item, index) => {\n const lead = item.json;\n\n // Rejected leads never reach here (the IF node routes them away), but be\n // defensive: an item without dedupe keys must not become a blank search.\n const phone = lead && lead.dedupeKeys ? lead.dedupeKeys.phone : null;\n const email = lead && lead.dedupeKeys ? lead.dedupeKeys.email : null;\n\n const query = {};\n if (phone) query.phone = phone;\n else if (email) query.email = email;\n\n const canSearch = Object.keys(query).length > 0;\n\n return {\n json: {\n lead,\n query,\n canSearch,\n mockMode: MOCK_MODE,\n locationId: LOCATION_ID,\n // Pre-computed so the mock and live paths hand the next node an\n // identical shape. Downstream code cannot tell which one ran.\n searchResponse: MOCK_MODE && canSearch ? mockSearch(query) : null,\n retry: {\n attempt: 0,\n maxAttempts: 5,\n waitSeconds: 0,\n history: [],\n },\n },\n pairedItem: index,\n };\n });\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n module.exports = { MOCK_MODE, LOCATION_ID, MOCK_CRM_CONTACTS, mockSearch, processAll };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n return processAll($input.all());\n}"
},
"id": "5aa91389-a031-4997-8e8c-92cd1ff312ac"
},
{
"name": "Use Live CRM?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
-240,
180
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "live-crm",
"leftValue": "={{ $json.mockMode !== true && $json.canSearch === true }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"looseTypeValidation": true,
"options": {}
},
"id": "e67893be-06fb-45a0-9335-fe656ef518c3"
},
{
"name": "CRM: Find Contact",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-20,
60
],
"alwaysOutputData": false,
"parameters": {
"method": "GET",
"url": "https://services.leadconnectorhq.com/contacts/",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendQuery": true,
"specifyQuery": "keypair",
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "={{ $('Prepare CRM Lookup').item.json.locationId }}"
},
{
"name": "query",
"value": "={{ $('Prepare CRM Lookup').item.json.query.phone || $('Prepare CRM Lookup').item.json.query.email }}"
},
{
"name": "limit",
"value": "20"
}
]
},
"sendHeaders": true,
"specifyHeaders": "keypair",
"headerParameters": {
"parameters": [
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"options": {
"timeout": 15000,
"response": {
"response": {
"fullResponse": true,
"neverError": true
}
}
}
},
"id": "d3070c1a-aeda-4eea-b659-8a202bb51d79"
},
{
"name": "Classify Search Error",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
200,
-120
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "/**\n * NODE: \"Classify API Error\" (Code node, Run Once for All Items)\n * --------------------------------------------------------------\n * Wired to the non-2xx branch of every HTTP Request node in this workflow.\n * Turns a raw failure into a decision: retry after N seconds, alert a human, or\n * give up and queue the lead for manual handling \u2014 never \"fail silently\" and\n * never \"retry forever\".\n *\n * Why this is a Code node and not the HTTP node's built-in retry:\n *\n * n8n's node-level \"Retry On Fail\" is ignored when \"On Error\" is set to either\n * Continue option \u2014 the node continues down the error branch on the first\n * failure instead of retrying (n8n issue #10763). So if you want retries *and*\n * a graceful error path, you have to own the retry
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Lead Intake Normalizer & Deduplicator. Uses httpRequest. Webhook trigger; 22 nodes.
Source: https://github.com/ihthicodes/n8n-agency-demos/blob/main/demos/01-lead-intake-normalizer/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.
This workflow automates bulk email campaigns with built-in validation, deliverability protection, and smart send-time optimization.
Universal Lead Processing Workflow. Uses googleSheets, gmail, httpRequest, gmailTrigger. Webhook trigger; 34 nodes.
This workflow turns Feishu bot messages into a human-in-the-loop short-video production pipeline, using Tavily for web search, DeepSeek for topic and script generation, Jimeng AI for text-to-video, an
This workflow is designed to manage the assignment and validation of unique QR code coupons within a lead generation system with SuiteCRM.
This workflow acts as an instant SDR that replies to new inbound leads across multiple channels in real time. It first captures and normalizes all incoming lead data into a unified structure. The work