{
  "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(/&nbsp;/gi, ' ').replace(/&amp;/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 loop yourself. This node\n *   plus a Wait node is that loop.\n *\n * It also does the thing most retry code skips: it reads Retry-After. A 429 that\n * says \"wait 30 seconds\" and gets retried in 2 seconds is not a retry, it is a\n * second offence, and providers extend the block for it.\n */\n\nconst RETRY = {\n  maxAttempts: 5,\n  baseDelaySeconds: 2,     // 2, 4, 8, 16, 32 before jitter\n  maxDelaySeconds: 120,\n  jitterRatio: 0.3,        // \u00b130%, so parallel executions do not retry in lockstep\n};\n\n// Transport-level failures worth retrying \u2014 the request never reached the app.\nconst RETRYABLE_NETWORK_CODES = [\n  'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN',\n  'EPIPE', 'ESOCKETTIMEDOUT', 'ERR_SOCKET_CONNECTION_TIMEOUT',\n];\n\nconst RETRYABLE_STATUSES = [408, 425, 429, 500, 502, 503, 504, 522, 524];\n\n/**\n * n8n wraps HTTP failures differently depending on node version, whether the\n * response was JSON, and whether the failure was transport or application level.\n * Dig the useful bits out of all of them rather than assuming one shape.\n */\nfunction extractError(json) {\n  // Preferred shape: the HTTP node is configured Full Response + Never Error, so\n  // a 429 arrives as an ordinary item with statusCode, headers and body intact.\n  // That is the only configuration in which Retry-After is readable at all \u2014 on\n  // the error output, n8n hands you the error, not the response envelope.\n  const fullResponse = typeof json.statusCode === 'number' && json.headers !== undefined;\n  const container = fullResponse ? json : (json.error || json);\n\n  const status = Number(\n    container.statusCode ?? container.status ?? container.httpCode ??\n    (container.response && (container.response.status ?? container.response.statusCode)) ??\n    (container.context && container.context.statusCode) ??\n    (typeof container.code === 'number' ? container.code : undefined) ?? 0\n  ) || 0;\n\n  const headers =\n    container.headers ||\n    (container.response && container.response.headers) ||\n    (container.context && container.context.headers) || {};\n\n  const body =\n    (fullResponse ? container.body : undefined) ??\n    (container.response && (container.response.body ?? container.response.data)) ??\n    container.body ?? container.data ?? null;\n\n  const networkCode = typeof container.code === 'string' ? container.code\n    : typeof container.errno === 'string' ? container.errno : '';\n\n  const message = String(\n    container.message || container.description ||\n    (body && (body.message || body.error || body.msg)) ||\n    'Unknown error'\n  ).slice(0, 500);\n\n  return { status, headers, body, networkCode, message };\n}\n\n/** Header lookup that does not care about casing, because proxies do not either. */\nfunction header(headers, name) {\n  if (!headers) return undefined;\n  const target = name.toLowerCase();\n  for (const [key, value] of Object.entries(headers)) {\n    if (String(key).toLowerCase() === target) return value;\n  }\n  return undefined;\n}\n\n/**\n * Retry-After is legal in two forms: delay-seconds (\"120\") and an HTTP-date\n * (\"Wed, 21 Oct 2026 07:28:00 GMT\"). Both appear in the wild. Also handles the\n * x-ratelimit-reset variants, which some APIs give as a unix timestamp.\n */\nfunction retryAfterSeconds(headers, nowMs) {\n  const raw = header(headers, 'retry-after');\n  if (raw !== undefined && raw !== null && String(raw).trim() !== '') {\n    const text = String(raw).trim();\n    if (/^\\d+$/.test(text)) return Number(text);\n    const date = new Date(text);\n    if (!isNaN(date.getTime())) {\n      return Math.max(0, Math.ceil((date.getTime() - nowMs) / 1000));\n    }\n  }\n\n  const reset = header(headers, 'x-ratelimit-reset') ?? header(headers, 'ratelimit-reset');\n  if (reset !== undefined && /^\\d+$/.test(String(reset))) {\n    const value = Number(reset);\n    // Ten digits or more is an absolute unix timestamp; anything less is a delta.\n    const seconds = String(value).length >= 10\n      ? Math.ceil((value * 1000 - nowMs) / 1000)\n      : value;\n    if (seconds > 0) return seconds;\n  }\n\n  return null;\n}\n\nfunction backoffSeconds(attempt) {\n  const exponential = RETRY.baseDelaySeconds * Math.pow(2, Math.max(0, attempt - 1));\n  const capped = Math.min(exponential, RETRY.maxDelaySeconds);\n  const jitter = capped * RETRY.jitterRatio * (Math.random() * 2 - 1);\n  return Math.max(1, Math.round(capped + jitter));\n}\n\n/**\n * Returns a decision object. `disposition` is the only field the IF nodes read:\n *   'retry'      -> Wait node, then back to the HTTP node\n *   'alert'      -> notify a human immediately; the whole integration is down\n *   'quarantine' -> this one lead is bad; park it, keep processing the rest\n */\nfunction classify(json, nowMs, runIndex) {\n  const state = json.retry || { attempt: 0, maxAttempts: RETRY.maxAttempts, history: [] };\n\n  // Attempt counting has to survive the item losing its state: when an HTTP node\n  // fails, what arrives on the error output is the error, not necessarily the\n  // item that went in. n8n's $runIndex counts how many times this node has run\n  // inside the current loop, so it is the reliable floor for \"which attempt is\n  // this\". Without it, a retry loop that loses state retries forever.\n  const floor = Number.isFinite(Number(runIndex)) ? Number(runIndex) : 0;\n  const attempt = Math.max(Number(state.attempt || 0), floor) + 1;\n  const maxAttempts = Number(state.maxAttempts || RETRY.maxAttempts);\n\n  const error = extractError(json);\n  const { status, networkCode } = error;\n\n  let category;\n  let disposition;\n  let operatorMessage;\n  let retryable = false;\n\n  if (status === 401 || status === 403) {\n    category = 'auth';\n    disposition = 'alert';\n    operatorMessage = status === 401\n      ? 'CRM rejected the credentials (401). The token has expired or been revoked \u2014 reconnect the credential in n8n. Nothing will sync until this is fixed.'\n      : 'CRM accepted the credentials but refused the action (403). The connected user is missing a scope or the location ID is wrong.';\n  } else if (status === 429) {\n    category = 'rate_limit';\n    retryable = true;\n    operatorMessage = 'Rate limited by the CRM. Backing off and retrying.';\n  } else if (status === 422 || status === 400) {\n    category = 'validation';\n    disposition = 'quarantine';\n    operatorMessage = 'The CRM rejected this payload (' + status + '). This is a data problem with one lead, not an outage \u2014 parked for review. Detail: ' + error.message;\n  } else if (status === 404) {\n    category = 'not_found';\n    disposition = 'quarantine';\n    operatorMessage = 'Endpoint or record not found (404). Check the URL and the location/subaccount ID.';\n  } else if (status === 409) {\n    category = 'conflict';\n    disposition = 'quarantine';\n    operatorMessage = 'Conflict (409) \u2014 the CRM believes this contact already exists. Re-run the search rather than retrying the write.';\n  } else if (RETRYABLE_STATUSES.includes(status)) {\n    category = 'server';\n    retryable = true;\n    operatorMessage = 'CRM returned ' + status + '. Transient server-side failure, retrying.';\n  } else if (RETRYABLE_NETWORK_CODES.includes(networkCode)) {\n    category = 'network';\n    retryable = true;\n    operatorMessage = 'Network failure (' + networkCode + ') before the CRM answered. Retrying.';\n  } else if (status >= 400 && status < 500) {\n    category = 'client';\n    disposition = 'quarantine';\n    operatorMessage = 'CRM returned ' + status + '. Not retryable \u2014 the request itself is wrong.';\n  } else {\n    category = 'unknown';\n    retryable = true;\n    operatorMessage = 'Unclassified failure: ' + error.message + '. Treating as transient.';\n  }\n\n  let waitSeconds = 0;\n  if (retryable) {\n    if (attempt >= maxAttempts) {\n      disposition = 'quarantine';\n      operatorMessage = 'Gave up after ' + maxAttempts + ' attempts. Last failure: ' + operatorMessage;\n    } else {\n      disposition = 'retry';\n      // Honour the server's own instruction when it gives one; it knows better\n      // than our exponential curve does.\n      const serverHint = retryAfterSeconds(error.headers, nowMs);\n      waitSeconds = serverHint !== null\n        ? Math.min(serverHint, RETRY.maxDelaySeconds)\n        : backoffSeconds(attempt);\n      if (serverHint !== null) {\n        operatorMessage += ' Server asked for ' + serverHint + 's, honouring it.';\n      }\n    }\n  }\n\n  const history = Array.isArray(state.history) ? state.history.slice(-9) : [];\n  history.push({\n    attempt,\n    at: new Date(nowMs).toISOString(),\n    status,\n    category,\n    waitSeconds,\n  });\n\n  return {\n    ...json,\n    retry: { attempt, maxAttempts, waitSeconds, history },\n    failure: {\n      category,\n      disposition,\n      status,\n      networkCode,\n      message: error.message,\n      operatorMessage,\n      // Body is capped: a 500 from a CRM sometimes returns an entire HTML error\n      // page, and stuffing that into every execution log is how you fill a disk.\n      responseSnippet: error.body\n        ? String(typeof error.body === 'string' ? error.body : JSON.stringify(error.body)).slice(0, 600)\n        : null,\n    },\n    shouldRetry: disposition === 'retry',\n    shouldAlert: disposition === 'alert',\n    isQuarantined: disposition === 'quarantine',\n  };\n}\n\nfunction processAll(items, runIndex) {\n  const nowMs = Date.now();\n  return items.map((item, index) => ({\n    json: classify(item.json || {}, nowMs, runIndex || 0),\n    pairedItem: index,\n  }));\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = {\n    RETRY, extractError, header, retryAfterSeconds, backoffSeconds, classify, processAll,\n  };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return processAll($input.all(), typeof $runIndex !== 'undefined' ? $runIndex : 0);\n}"
      },
      "id": "009949e1-df16-451d-bed5-4248c24341bf"
    },
    {
      "name": "Retry Search?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        420,
        -120
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "should-retry",
              "leftValue": "={{ $json.shouldRetry }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "76c42e8f-5aaf-4c86-8324-bb46f8ab39de"
    },
    {
      "name": "Backoff Wait",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        420,
        -320
      ],
      "parameters": {
        "amount": "={{ $json.retry.waitSeconds }}",
        "unit": "seconds"
      },
      "id": "2e875c7c-5cb0-4643-8a95-b60a3fb83bf0"
    },
    {
      "name": "Decide Create or Update",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        180
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Decide Create or Update\"  (Code node, Run Once for All Items)\n * -------------------------------------------------------------------\n * Input : the CRM contact-search response, plus the normalised lead carried\n *         forward from the \"Normalize & Validate Lead\" node.\n * Output: one item carrying `action` (\"create\" | \"update\" | \"skip\"), the exact\n *         payload to send, and a human-readable explanation of why.\n *\n * The part that actually matters here is the merge policy. The default behaviour\n * of most \"upsert\" integrations is to PUT the new payload over the old record,\n * which quietly deletes data: a lead who fills in a short form on Tuesday wipes\n * the address, company and notes captured on Monday. This node never lets a blank\n * beat a value, and it skips the API call entirely when nothing would change \u2014\n * which on a 40k-contact account is the difference between staying inside the\n * rate limit and not.\n */\n\n// ---------------------------------------------------------------------------\n// Merge policy per field.\n//   'fill'      write only if the CRM value is empty\n//   'prefer_new' incoming value wins when it is non-empty\n//   'union'     combine both (arrays / tag lists)\n//   'append'    append to the existing text with a timestamped separator\n//   'never'     never touched by an inbound lead\n// ---------------------------------------------------------------------------\nconst MERGE_POLICY = {\n  firstName: 'fill',\n  lastName: 'fill',\n  email: 'fill',           // never overwrite a known-good email with a new one\n  phone: 'fill',\n  companyName: 'fill',\n  address1: 'prefer_new',\n  city: 'prefer_new',\n  state: 'prefer_new',\n  postalCode: 'prefer_new',\n  country: 'fill',\n  source: 'never',         // first-touch attribution is the whole point of source\n  tags: 'union',\n  dnd: 'never',            // an inbound form must never un-suppress someone\n  customFields: {\n    lead_score: 'prefer_new',\n    service_interest: 'prefer_new',\n    budget: 'prefer_new',\n    timeframe: 'prefer_new',\n    message: 'append',\n    utm_source: 'fill',\n    utm_medium: 'fill',\n    utm_campaign: 'fill',\n    consent_granted: 'prefer_new',\n    consent_captured_at: 'prefer_new',\n    consent_ip: 'prefer_new',\n  },\n};\n\n/** Empty means empty: null, undefined, '', '   ', [] and {} all count. */\nfunction isEmpty(value) {\n  if (value === null || value === undefined) return true;\n  if (typeof value === 'string') return value.trim() === '';\n  if (Array.isArray(value)) return value.length === 0;\n  if (typeof value === 'object') return Object.keys(value).length === 0;\n  return false;\n}\n\n/**\n * The search endpoint can legitimately answer in five different shapes depending\n * on CRM, version and whether the caller used lookup vs search. Normalise them\n * all to an array before doing anything else.\n */\nfunction extractContacts(response) {\n  if (!response) return [];\n  if (Array.isArray(response)) return response;\n  if (Array.isArray(response.contacts)) return response.contacts;\n  if (Array.isArray(response.contact)) return response.contact;\n  if (response.contact && typeof response.contact === 'object') return [response.contact];\n  if (Array.isArray(response.data)) return response.data;\n  if (response.data && Array.isArray(response.data.contacts)) return response.data.contacts;\n  if (response.id) return [response];              // single record returned bare\n  return [];\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/**\n * Pick the right existing contact when the search returns several.\n * Phone is the stronger signal (people share family email addresses far more\n * often than they share a mobile), so an exact phone match outranks an email\n * match, and both outrank a name-only match, which we refuse outright.\n */\nfunction chooseMatch(candidates, lead) {\n  const leadPhone = digitsOnly(lead.dedupeKeys.phone);\n  const leadEmail = lead.dedupeKeys.email;\n\n  const scored = candidates.map((candidate) => {\n    const candidatePhone = digitsOnly(candidate.phone);\n    const candidateEmail = normalizeEmailForCompare(candidate.email);\n\n    let confidence = 0;\n    const reasons = [];\n\n    if (leadPhone && candidatePhone && leadPhone === candidatePhone) {\n      confidence += 60;\n      reasons.push('exact_phone');\n    } else if (leadPhone && candidatePhone && leadPhone.slice(-9) === candidatePhone.slice(-9)\n               && leadPhone.length >= 9) {\n      // Same subscriber number, different country-code handling in the old record.\n      confidence += 40;\n      reasons.push('phone_national_match');\n    }\n\n    if (leadEmail && candidateEmail && leadEmail === candidateEmail) {\n      confidence += 45;\n      reasons.push('exact_email');\n    }\n\n    const sameLast = lead.contact.lastName && candidate.lastName &&\n      lead.contact.lastName.toLowerCase() === String(candidate.lastName).toLowerCase();\n    if (sameLast) {\n      confidence += 10;\n      reasons.push('same_last_name');\n    }\n\n    return { candidate, confidence, reasons };\n  });\n\n  scored.sort((a, b) => b.confidence - a.confidence ||\n    String(a.candidate.id).localeCompare(String(b.candidate.id)));\n\n  const best = scored[0];\n  if (!best || best.confidence < 40) return { match: null, confidence: 0, reasons: [], alternatives: scored.length };\n\n  return {\n    match: best.candidate,\n    confidence: best.confidence,\n    reasons: best.reasons,\n    alternatives: scored.filter((entry) => entry.confidence >= 40).length,\n  };\n}\n\n/** Apply MERGE_POLICY to one level of fields. Returns { patch, changed[] }. */\nfunction mergeFields(existing, incoming, policy, nowIso) {\n  const patch = {};\n  const changed = [];\n\n  for (const [field, incomingValue] of Object.entries(incoming)) {\n    const rule = policy[field];\n    if (!rule || rule === 'never') continue;\n    if (typeof rule === 'object') continue; // nested block, handled by caller\n\n    const existingValue = existing ? existing[field] : undefined;\n\n    if (rule === 'union') {\n      const existingList = Array.isArray(existingValue) ? existingValue\n        : isEmpty(existingValue) ? [] : [existingValue];\n      const incomingList = Array.isArray(incomingValue) ? incomingValue\n        : isEmpty(incomingValue) ? [] : [incomingValue];\n      const merged = Array.from(new Set([...existingList, ...incomingList].map(String)));\n      if (merged.length !== existingList.length) {\n        patch[field] = merged;\n        changed.push(field);\n      }\n      continue;\n    }\n\n    if (isEmpty(incomingValue)) continue; // a blank never wins, under any rule\n\n    if (rule === 'fill') {\n      if (isEmpty(existingValue)) {\n        patch[field] = incomingValue;\n        changed.push(field);\n      }\n      continue;\n    }\n\n    if (rule === 'prefer_new') {\n      if (String(existingValue ?? '') !== String(incomingValue)) {\n        patch[field] = incomingValue;\n        changed.push(field);\n      }\n      continue;\n    }\n\n    if (rule === 'append') {\n      const existingText = isEmpty(existingValue) ? '' : String(existingValue);\n      if (existingText.includes(String(incomingValue))) continue; // already recorded\n      const stamp = nowIso.slice(0, 10);\n      patch[field] = existingText\n        ? existingText + '\\n\\n--- ' + stamp + ' ---\\n' + incomingValue\n        : String(incomingValue);\n      changed.push(field);\n    }\n  }\n\n  return { patch, changed };\n}\n\nfunction decide(lead, searchResponse, nowIso) {\n  const candidates = extractContacts(searchResponse);\n  const { match, confidence, reasons, alternatives } = chooseMatch(candidates, lead);\n\n  if (!match) {\n    return {\n      action: 'create',\n      reason: candidates.length\n        ? 'Search returned ' + candidates.length + ' contact(s) but none matched strongly enough to merge.'\n        : 'No existing contact matched this phone or email.',\n      contactId: null,\n      payload: lead.contact,\n      matchConfidence: 0,\n      matchReasons: [],\n      changedFields: Object.keys(lead.contact),\n      lead,\n    };\n  }\n\n  const top = mergeFields(match, lead.contact, MERGE_POLICY, nowIso);\n\n  const existingCustom = match.customFields || match.custom_fields || {};\n  const custom = mergeFields(existingCustom, lead.contact.customFields || {},\n    MERGE_POLICY.customFields, nowIso);\n\n  const changedFields = [...top.changed, ...custom.changed.map((field) => 'customFields.' + field)];\n\n  // Nothing to write. Do not spend an API call, and do not stamp an \"updated\"\n  // timestamp that makes the record look freshly touched in the client's reports.\n  if (changedFields.length === 0) {\n    return {\n      action: 'skip',\n      reason: 'Matched existing contact ' + match.id + ' and every field already holds an equal or better value.',\n      contactId: match.id,\n      payload: null,\n      matchConfidence: confidence,\n      matchReasons: reasons,\n      changedFields: [],\n      alternatives,\n      lead,\n    };\n  }\n\n  const payload = { ...top.patch };\n  if (custom.changed.length) payload.customFields = { ...existingCustom, ...custom.patch };\n\n  return {\n    action: 'update',\n    reason: 'Matched existing contact ' + match.id + ' (' + reasons.join(', ') + '). Updating '\n      + changedFields.length + ' field(s) without overwriting existing data.',\n    contactId: match.id,\n    payload,\n    matchConfidence: confidence,\n    matchReasons: reasons,\n    changedFields,\n    alternatives,\n    lead,\n  };\n}\n\nfunction processAll(items) {\n  const nowIso = new Date().toISOString();\n\n  return items.map((item, index) => {\n    const json = item.json || {};\n\n    // The HTTP node passes the API response through; the lead is carried on the\n    // item by the \"Prepare CRM Lookup\" node so we never have to reach across the\n    // graph with $node[...] (which breaks the moment somebody renames a node).\n    const lead = json.lead || json;\n    const searchResponse = json.searchResponse !== undefined ? json.searchResponse : json;\n\n    if (!lead || !lead.contact) {\n      return {\n        json: {\n          action: 'skip',\n          reason: 'No normalised lead attached to this item \u2014 check the \"Prepare CRM Lookup\" node.',\n          error: true,\n        },\n        pairedItem: index,\n      };\n    }\n\n    const decision = decide(lead, searchResponse, nowIso);\n    decision.mockMode = json.mockMode === true;\n    decision.locationId = json.locationId || null;\n    decision.retry = json.retry || { attempt: 0, maxAttempts: 5, waitSeconds: 0, history: [] };\n    return { json: decision, pairedItem: index };\n  });\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = {\n    MERGE_POLICY, isEmpty, extractContacts, normalizeEmailForCompare,\n    chooseMatch, mergeFields, decide, processAll,\n  };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  // Two ways in:\n  //   MOCK path  \u2014 the item still carries { lead, searchResponse, mockMode }.\n  //   LIVE path  \u2014 an HTTP Request node ran in between and replaced the item's\n  //                json with the API response, so the lead has to be read back\n  //                from the node that built it.\n  //\n  // itemMatching(), not positional indexing. Positional indexing looks fine\n  // until one item takes a different branch \u2014 then output item 1 is input item\n  // 2, and you write one lead's data onto another lead's contact. n8n's\n  // paired-item tracking exists precisely to prevent that.\n  const incoming = $input.all().map((item, index) => {\n    if (item.json && item.json.lead) return item;\n\n    let upstream;\n    try {\n      upstream = $('Prepare CRM Lookup').itemMatching(index).json;\n    } catch (error) {\n      throw new Error('Could not pair this CRM response back to the lead that caused it. '\n        + 'Paired-item tracking is broken somewhere upstream \u2014 do not let this write. '\n        + 'Original error: ' + error.message);\n    }\n\n    // Full Response is on, so the parsed API body is under `body`.\n    const response = item.json && item.json.body !== undefined ? item.json.body : item.json;\n\n    return {\n      json: {\n        lead: upstream.lead,\n        mockMode: upstream.mockMode,\n        locationId: upstream.locationId,\n        retry: upstream.retry,\n        searchResponse: response,\n      },\n    };\n  });\n  return processAll(incoming);\n}"
      },
      "id": "28a66e47-0ce5-4e91-ada9-e266437f6351"
    },
    {
      "name": "Needs a CRM Write?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        660,
        180
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "needs-write",
              "leftValue": "={{ $json.action !== 'skip' && $json.mockMode !== true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "c7c7d166-7c46-4656-a591-21de2348c0a8"
    },
    {
      "name": "Create or Update?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        880,
        80
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "is-create",
              "leftValue": "={{ $json.action === 'create' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "324690cc-c7bf-410a-b668-b7ab15b39645"
    },
    {
      "name": "CRM: Create Contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1120,
        -60
      ],
      "parameters": {
        "method": "POST",
        "url": "https://services.leadconnectorhq.com/contacts/",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "Version",
              "value": "2021-07-28"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify(Object.assign({}, $json.payload, { locationId: $json.locationId })) }}",
        "options": {
          "timeout": 15000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "id": "0b6b18ae-aaa2-48ce-9343-053daa84558e"
    },
    {
      "name": "CRM: Update Contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1120,
        240
      ],
      "parameters": {
        "method": "PUT",
        "url": "=https://services.leadconnectorhq.com/contacts/{{ $json.contactId }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "Version",
              "value": "2021-07-28"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.payload) }}",
        "options": {
          "timeout": 15000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "id": "8d35882e-0c36-46a5-bddd-b1834925d5d7"
    },
    {
      "name": "Classify Write Error",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1360,
        -80
      ],
      "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 loop yourself. This node\n *   plus a Wait node is that loop.\n *\n * It also does the thing most retry code skips: it reads Retry-After. A 429 that\n * says \"wait 30 seconds\" and gets retried in 2 seconds is not a retry, it is a\n * second offence, and providers extend the block for it.\n */\n\nconst RETRY = {\n  maxAttempts: 5,\n  baseDelaySeconds: 2,     // 2, 4, 8, 16, 32 before jitter\n  maxDelaySeconds: 120,\n  jitterRatio: 0.3,        // \u00b130%, so parallel executions do not retry in lockstep\n};\n\n// Transport-level failures worth retrying \u2014 the request never reached the app.\nconst RETRYABLE_NETWORK_CODES = [\n  'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN',\n  'EPIPE', 'ESOCKETTIMEDOUT', 'ERR_SOCKET_CONNECTION_TIMEOUT',\n];\n\nconst RETRYABLE_STATUSES = [408, 425, 429, 500, 502, 503, 504, 522, 524];\n\n/**\n * n8n wraps HTTP failures differently depending on node version, whether the\n * response was JSON, and whether the failure was transport or application level.\n * Dig the useful bits out of all of them rather than assuming one shape.\n */\nfunction extractError(json) {\n  // Preferred shape: the HTTP node is configured Full Response + Never Error, so\n  // a 429 arrives as an ordinary item with statusCode, headers and body intact.\n  // That is the only configuration in which Retry-After is readable at all \u2014 on\n  // the error output, n8n hands you the error, not the response envelope.\n  const fullResponse = typeof json.statusCode === 'number' && json.headers !== undefined;\n  const container = fullResponse ? json : (json.error || json);\n\n  const status = Number(\n    container.statusCode ?? container.status ?? container.httpCode ??\n    (container.response && (container.response.status ?? container.response.statusCode)) ??\n    (container.context && container.context.statusCode) ??\n    (typeof container.code === 'number' ? container.code : undefined) ?? 0\n  ) || 0;\n\n  const headers =\n    container.headers ||\n    (container.response && container.response.headers) ||\n    (container.context && container.context.headers) || {};\n\n  const body =\n    (fullResponse ? container.body : undefined) ??\n    (container.response && (container.response.body ?? container.response.data)) ??\n    container.body ?? container.data ?? null;\n\n  const networkCode = typeof container.code === 'string' ? container.code\n    : typeof container.errno === 'string' ? container.errno : '';\n\n  const message = String(\n    container.message || container.description ||\n    (body && (body.message || body.error || body.msg)) ||\n    'Unknown error'\n  ).slice(0, 500);\n\n  return { status, headers, body, networkCode, message };\n}\n\n/** Header lookup that does not care about casing, because proxies do not either. */\nfunction header(headers, name) {\n  if (!headers) return undefined;\n  const target = name.toLowerCase();\n  for (const [key, value] of Object.entries(headers)) {\n    if (String(key).toLowerCase() === target) return value;\n  }\n  return undefined;\n}\n\n/**\n * Retry-After is legal in two forms: delay-seconds (\"120\") and an HTTP-date\n * (\"Wed, 21 Oct 2026 07:28:00 GMT\"). Both appear in the wild. Also handles the\n * x-ratelimit-reset variants, which some APIs give as a unix timestamp.\n */\nfunction retryAfterSeconds(headers, nowMs) {\n  const raw = header(headers, 'retry-after');\n  if (raw !== undefined && raw !== null && String(raw).trim() !== '') {\n    const text = String(raw).trim();\n    if (/^\\d+$/.test(text)) return Number(text);\n    const date = new Date(text);\n    if (!isNaN(date.getTime())) {\n      return Math.max(0, Math.ceil((date.getTime() - nowMs) / 1000));\n    }\n  }\n\n  const reset = header(headers, 'x-ratelimit-reset') ?? header(headers, 'ratelimit-reset');\n  if (reset !== undefined && /^\\d+$/.test(String(reset))) {\n    const value = Number(reset);\n    // Ten digits or more is an absolute unix timestamp; anything less is a delta.\n    const seconds = String(value).length >= 10\n      ? Math.ceil((value * 1000 - nowMs) / 1000)\n      : value;\n    if (seconds > 0) return seconds;\n  }\n\n  return null;\n}\n\nfunction backoffSeconds(attempt) {\n  const exponential = RETRY.baseDelaySeconds * Math.pow(2, Math.max(0, attempt - 1));\n  const capped = Math.min(exponential, RETRY.maxDelaySeconds);\n  const jitter = capped * RETRY.jitterRatio * (Math.random() * 2 - 1);\n  return Math.max(1, Math.round(capped + jitter));\n}\n\n/**\n * Returns a decision object. `disposition` is the only field the IF nodes read:\n *   'retry'      -> Wait node, then back to the HTTP node\n *   'alert'      -> notify a human immediately; the whole integration is down\n *   'quarantine' -> this one lead is bad; park it, keep processing the rest\n */\nfunction classify(json, nowMs, runIndex) {\n  const state = json.retry || { attempt: 0, maxAttempts: RETRY.maxAttempts, history: [] };\n\n  // Attempt counting has to survive the item losing its state: when an HTTP node\n  // fails, what arrives on the error output is the error, not necessarily the\n  // item that went in. n8n's $runIndex counts how many times this node has run\n  // inside the current loop, so it is the reliable floor for \"which attempt is\n  // this\". Without it, a retry loop that loses state retries forever.\n  const floor = Number.isFinite(Number(runIndex)) ? Number(runIndex) : 0;\n  const attempt = Math.max(Number(state.attempt || 0), floor) + 1;\n  const maxAttempts = Number(state.maxAttempts || RETRY.maxAttempts);\n\n  const error = extractError(json);\n  const { status, networkCode } = error;\n\n  let category;\n  let disposition;\n  let operatorMessage;\n  let retryable = false;\n\n  if (status === 401 || status === 403) {\n    category = 'auth';\n    disposition = 'alert';\n    operatorMessage = status === 401\n      ? 'CRM rejected the credentials (401). The token has expired or been revoked \u2014 reconnect the credential in n8n. Nothing will sync until this is fixed.'\n      : 'CRM accepted the credentials but refused the action (403). The connected user is missing a scope or the location ID is wrong.';\n  } else if (status === 429) {\n    category = 'rate_limit';\n    retryable = true;\n    operatorMessage = 'Rate limited by the CRM. Backing off and retrying.';\n  } else if (status === 422 || status === 400) {\n    category = 'validation';\n    disposition = 'quarantine';\n    operatorMessage = 'The CRM rejected this payload (' + status + '). This is a data problem with one lead, not an outage \u2014 parked for review. Detail: ' + error.message;\n  } else if (status === 404) {\n    category = 'not_found';\n    disposition = 'quarantine';\n    operatorMessage = 'Endpoint or record not found (404). Check the URL and the location/subaccount ID.';\n  } else if (status === 409) {\n    category = 'conflict';\n    disposition = 'quarantine';\n    operatorMessage = 'Conflict (409) \u2014 the CRM believes this contact already exists. Re-run the search rather than retrying the write.';\n  } else if (RETRYABLE_STATUSES.includes(status)) {\n    category = 'server';\n    retryable = true;\n    operatorMessage = 'CRM returned ' + status + '. Transient server-side failure, retrying.';\n  } else if (RETRYABLE_NETWORK_CODES.includes(networkCode)) {\n    category = 'network';\n    retryable = true;\n    operatorMessage = 'Network failure (' + networkCode + ') before the CRM answered. Retrying.';\n  } else if (status >= 400 && status < 500) {\n    category = 'client';\n    disposition = 'quarantine';\n    operatorMessage = 'CRM returned ' + status + '. Not retryable \u2014 the request itself is wrong.';\n  } else {\n    category = 'unknown';\n    retryable = true;\n    operatorMessage = 'Unclassified failure: ' + error.message + '. Treating as transient.';\n  }\n\n  let waitSeconds = 0;\n  if (retryable) {\n    if (attempt >= maxAttempts) {\n      disposition = 'quarantine';\n      operatorMessage = 'Gave up after ' + maxAttempts + ' attempts. Last failure: ' + operatorMessage;\n    } else {\n      disposition = 'retry';\n      // Honour the server's own instruction when it gives one; it knows better\n      // than our exponential curve does.\n      const serverHint = retryAfterSeconds(error.headers, nowMs);\n      waitSeconds = serverHint !== null\n        ? Math.min(serverHint, RETRY.maxDelaySeconds)\n        : backoffSeconds(attempt);\n      if (serverHint !== null) {\n        operatorMessage += ' Server asked for ' + serverHint + 's, honouring it.';\n      }\n    }\n  }\n\n  const history = Array.isArray(state.history) ? state.history.slice(-9) : [];\n  history.push({\n    attempt,\n    at: new Date(nowMs).toISOString(),\n    status,\n    category,\n    waitSeconds,\n  });\n\n  return {\n    ...json,\n    retry: { attempt, maxAttempts, waitSeconds, history },\n    failure: {\n      category,\n      disposition,\n      status,\n      networkCode,\n      message: error.message,\n      operatorMessage,\n      // Body is capped: a 500 from a CRM sometimes returns an entire HTML error\n      // page, and stuffing that into every execution log is how you fill a disk.\n      responseSnippet: error.body\n        ? String(typeof error.body === 'string' ? error.body : JSON.stringify(error.body)).slice(0, 600)\n        : null,\n    },\n    shouldRetry: disposition === 'retry',\n    shouldAlert: disposition === 'alert',\n    isQuarantined: disposition === 'quarantine',\n  };\n}\n\nfunction processAll(items, runIndex) {\n  const nowMs = Date.now();\n  return items.map((item, index) => ({\n    json: classify(item.json || {}, nowMs, runIndex || 0),\n    pairedItem: index,\n  }));\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = {\n    RETRY, extractError, header, retryAfterSeconds, backoffSeconds, classify, processAll,\n  };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return processAll($input.all(), typeof $runIndex !== 'undefined' ? $runIndex : 0);\n}"
      },
      "id": "6f597fba-0e6c-490f-b25a-a44b32fc50f8"
    },
    {
      "name": "Resolve Without Writing",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        380
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Resolve Without Writing\"  (Code node, Run Once for All Items)\n * -------------------------------------------------------------------\n * Handles the two cases where the correct thing to do is *not* call the CRM:\n *\n *   1. MOCK_MODE is on \u2014 we simulate the write so the workflow still produces a\n *      complete, realistic result with no credentials attached.\n *   2. The decision node returned action = \"skip\" \u2014 the contact already exists\n *      and nothing would change. Skipping is a feature: on a busy account this\n *      is most of the traffic, and every skipped call is quota you keep.\n */\n\nfunction processAll(items) {\n  const nowIso = new Date().toISOString();\n\n  return items.map((item, index) => {\n    const json = item.json || {};\n    const simulated = json.mockMode === true && json.action !== 'skip';\n\n    return {\n      json: {\n        ...json,\n        crmResult: simulated\n          ? {\n              simulated: true,\n              contactId: json.contactId || 'mock_' + Math.random().toString(16).slice(2, 8),\n              wroteFields: json.changedFields || [],\n              note: 'MOCK_MODE is on in \"Prepare CRM Lookup\". No request was sent.',\n            }\n          : {\n              simulated: false,\n              contactId: json.contactId || null,\n              wroteFields: [],\n              note: json.action === 'skip'\n                ? 'No write needed \u2014 existing record already holds equal or better values.'\n                : 'No write performed.',\n            },\n        resolvedAt: nowIso,\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 = { processAll };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return processAll($input.all());\n}"
      },
      "id": "0e2fc9b6-5e47-428d-9a61-586e725cc16b"
    },
    {
      "name": "Build Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1600,
        260
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Build Response\"  (Code node, Run Once for All Items)\n * ----------------------------------------------------------\n * Everything converges here before the webhook answers.\n *\n * The response contract matters more than it looks. Form builders and ad\n * platforms retry on any non-2xx, so:\n *\n *   - A lead we rejected still gets 200. It is not the sender's fault that the\n *     visitor typed \"asdf\" in the phone box, and a 4xx just makes them resend it.\n *   - A lead we could not process because *our* dependency was down gets 503,\n *     because in that case a retry is genuinely useful.\n *   - Every response carries a reference id so a support conversation can start\n *     with \"what happened to lead abc123\" instead of \"it didn't work\".\n */\n\nfunction statusCodeFor(outcome) {\n  if (outcome === 'accepted' || outcome === 'skipped' || outcome === 'quarantined') return 200;\n  if (outcome === 'upstream_unavailable') return 503;\n  return 200;\n}\n\nfunction summarise(json) {\n  // Path 1: the lead never made it past validation.\n  if (json.validation && json.isUsable === false) {\n    return {\n      outcome: 'quarantined',\n      reference: json.idempotencyKey || null,\n      message: 'Lead received but not written to the CRM.',\n      issues: json.validation.issues,\n      detail: json.validation.status,\n      contactId: null,\n    };\n  }\n\n  // Path 2: an API failure we decided not to retry.\n  if (json.failure) {\n    return {\n      outcome: json.failure.disposition === 'alert' ? 'upstream_unavailable' : 'quarantined',\n      reference: (json.lead && json.lead.idempotencyKey) || null,\n      message: json.failure.operatorMessage,\n      issues: [json.failure.category.toUpperCase()],\n      detail: json.failure.status ? 'HTTP ' + json.failure.status : json.failure.networkCode,\n      contactId: null,\n    };\n  }\n\n  // Path 3: the normal one.\n  const lead = json.lead || {};\n  const crmResult = json.crmResult || {};\n  const contactId = crmResult.contactId || json.contactId ||\n    (json.id ? String(json.id) : null) ||\n    (json.contact && json.contact.id) || null;\n\n  return {\n    outcome: json.action === 'skip' ? 'skipped' : 'accepted',\n    reference: lead.idempotencyKey || null,\n    message: json.reason || 'Lead processed.',\n    issues: (lead.validation && lead.validation.issues) || [],\n    detail: json.action || 'processed',\n    contactId,\n    changedFields: json.changedFields || [],\n    leadScore: lead.scoring ? lead.scoring.score : null,\n    simulated: crmResult.simulated === true,\n  };\n}\n\nfunction processAll(items) {\n  const nowIso = new Date().toISOString();\n\n  return items.map((item, index) => {\n    const summary = summarise(item.json || {});\n    return {\n      json: {\n        ...summary,\n        statusCode: statusCodeFor(summary.outcome),\n        processedAt: nowIso,\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 = { statusCodeFor, summarise, processAll };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  // Items reach this node from five places. Three of them (quarantine, the mock\n  // path, the error paths) still carry the context. The two live write paths do\n  // not: an HTTP node replaced the item with the API response, so the decision \u2014\n  // and with it the reference id and the lead score \u2014 has to be paired back.\n  const incoming = $input.all().map((item, index) => {\n    const json = item.json || {};\n    if (json.lead || json.validation || json.failure) return item;\n\n    try {\n      const decision = $('Decide Create or Update').itemMatching(index).json;\n      const body = json.body !== undefined ? json.body : json;\n      return { json: { ...decision, crmResult: {\n        simulated: false,\n        contactId: (body && (body.id || (body.contact && body.contact.id))) || decision.contactId || null,\n        wroteFields: decision.changedFields || [],\n        note: 'Written to the CRM (HTTP ' + (json.statusCode || 200) + ').',\n      } } };\n    } catch (error) {\n      // Better a response with no reference id than a thrown workflow.\n      return item;\n    }\n  });\n  return processAll(incoming);\n}"
      },
      "id": "2dfc5145-b60e-4926-9df7-c76d2ca30eb9"
    },
    {
      "name": "Respond to Source",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1820,
        260
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify($json) }}",
        "options": {
          "responseCode": "={{ $json.statusCode }}"
        }
      },
      "id": "f39e07e8-925c-4746-bdaf-b676f99f6289"
    },
    {
      "name": "README",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1140,
        -80
      ],
      "parameters": {
        "width": 460,
        "height": 300,
        "color": 4,
        "content": "## Lead Intake Normalizer\n\nPOST any lead payload to this webhook \u2014 website form, Facebook Lead Ads, Typeform, or a partner's own envelope \u2014 and it comes out as one clean contact or gets quarantined with a reason code.\n\n**Runs with no credentials.** `MOCK_MODE = true` in **Prepare CRM Lookup** makes the workflow answer its own CRM search from fixtures. Execute the workflow, POST the sample payload, and watch it work.\n\nTo go live: set `MOCK_MODE = false`, set `LOCATION_ID`, and attach a Header Auth credential to the three CRM nodes."
      },
      "id": "37362a9a-f43d-40bb-9ec4-ffe0771b8007"
    },
    {
      "name": "Error handling note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        180,
        -420
      ],
      "parameters": {
        "width": 460,
        "height": 240,
        "color": 3,
        "content": "## Why the retry loop is hand-built\n\nn8n's node-level **Retry On Fail** is ignored when **On Error** is set to a Continue option, so you cannot have both built-in retries and a graceful error branch.\n\nThese HTTP nodes use **Full Response + Never Error** instead of the error output, so a 429 arrives as a normal item with its `statusCode` and `headers` intact \u2014 which is the only way to read `Retry-After` at all. An IF routes non-2xx to the classifier.\n\nOnly the **search** retries. A failed create/update is never blindly repeated \u2014 a retried POST is how you get duplicate contacts."
      },
      "id": "5b9ad863-f956-48c0-92d3-3d968514f8ee"
    },
    {
      "name": "Search Succeeded?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        200,
        60
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "search-succeeded",
              "leftValue": "={{ $json.statusCode >= 200 && $json.statusCode < 300 }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "fa2365f7-d7b3-41d8-86a3-a14ecfbfaa4d"
    },
    {
      "name": "Write Succeeded?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1120,
        80
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "write-succeeded",
              "leftValue": "={{ $json.statusCode >= 200 && $json.statusCode < 300 }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "e07f6268-06e4-4c47-8aa0-e709c0cd49cd"
    }
  ],
  "connections": {
    "Lead Webhook": {
      "main": [
        [
          {
            "node": "Normalize & Validate Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize & Validate Lead": {
      "main": [
        [
          {
            "node": "Lead Usable?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lead Usable?": {
      "main": [
        [
          {
            "node": "Prepare CRM Lookup",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare CRM Lookup": {
      "main": [
        [
          {
            "node": "Use Live CRM?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Use Live CRM?": {
      "main": [
        [
          {
            "node": "CRM: Find Contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Decide Create or Update",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CRM: Find Contact": {
      "main": [
        [
          {
            "node": "Search Succeeded?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify Search Error": {
      "main": [
        [
          {
            "node": "Retry Search?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Retry Search?": {
      "main": [
        [
          {
            "node": "Backoff Wait",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Backoff Wait": {
      "main": [
        [
          {
            "node": "CRM: Find Contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Decide Create or Update": {
      "main": [
        [
          {
            "node": "Needs a CRM Write?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Needs a CRM Write?": {
      "main": [
        [
          {
            "node": "Create or Update?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Resolve Without Writing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create or Update?": {
      "main": [
        [
          {
            "node": "CRM: Create Contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "CRM: Update Contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CRM: Create Contact": {
      "main": [
        [
          {
            "node": "Write Succeeded?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CRM: Update Contact": {
      "main": [
        [
          {
            "node": "Write Succeeded?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify Write Error": {
      "main": [
        [
          {
            "node": "Build Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resolve Without Writing": {
      "main": [
        [
          {
            "node": "Build Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Response": {
      "main": [
        [
          {
            "node": "Respond to Source",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search Succeeded?": {
      "main": [
        [
          {
            "node": "Decide Create or Update",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Classify Search Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write Succeeded?": {
      "main": [
        [
          {
            "node": "Build Response",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Classify Write Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}