{
  "name": "Audit Zoho CRM Lead Data Quality with n8n (free workflow JSON)",
  "nodes": [
    {
      "id": "08710672-102e-4641-88d6-ba503a40b7c9",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3824,
        704
      ],
      "parameters": {
        "color": 7,
        "width": 512,
        "height": 512,
        "content": "## Contact Data Validation\n\nThis section validates and standardizes key customer contact fields. Email addresses are cleaned and checked for format accuracy, while phone numbers are normalized into international structure for consistent storage, reporting and future communication workflows."
      },
      "typeVersion": 1
    },
    {
      "id": "34120122-8478-4a88-a905-11883a008f9a",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4368,
        704
      ],
      "parameters": {
        "color": 7,
        "width": 448,
        "height": 512,
        "content": "## Duplicate Detection & Enrichment\n\nThis section improves CRM accuracy by detecting possible duplicate records and enriching incomplete customer data. Duplicates are exact matches on email plus phone. The company guess is stored on each item, but the flags and the report do not use it, and nothing is written back to Zoho."
      },
      "typeVersion": 1
    },
    {
      "id": "e21f01f0-81c1-4cc8-9a63-e011d149bf69",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4848,
        704
      ],
      "parameters": {
        "color": 7,
        "width": 304,
        "height": 512,
        "content": "## Quality Rules & Flagging\n\nThis section evaluates each CRM record against business quality rules and generates warning flags such as invalid email, invalid phone, duplicate entry or missing company information. It enables proactive data governance and cleaner CRM operations."
      },
      "typeVersion": 1
    },
    {
      "id": "d366f741-23f7-47c3-ab0a-bd1430d2811b",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2976,
        304
      ],
      "parameters": {
        "width": 752,
        "height": 368,
        "content": "## Workflow Overview & Setup Guide\n\nThis workflow automatically audits CRM records from Zoho CRM using a scheduled trigger. At the selected interval, it fetches all leads and processes each record individually. Email addresses are validated and standardized, phone numbers are normalized, duplicate records are identified and missing company names are enriched.\n\nAfter processing, business rules assign quality flags such as invalid email, duplicate record, missing company or invalid phone. A reporting node generates a structured summary, which is then converted into a clean HTML report and automatically sent via email for easy review.\n\n**Recommended Setup Steps:**\n**1.** Connect your Zoho CRM credentials in n8n.\n**2.** The module is set to Leads. Contacts use different field names, so the Code nodes would need changes.\n**3.** Configure the Schedule Trigger (hourly, every 4 hours or daily).\n**4.** Select your Gmail credential and type the recipient address in the last node.\n**5.** Test with a small batch first: in the Zoho node, turn off Return All and set a limit.\n**6.** Review summary reports.\n**7.** Adjust validation rules as business needs evolve.\n\nSource: based on a template from the n8n Library at n8n.io, documented by LeenLabs.\nNot tested on live APIs."
      },
      "typeVersion": 1
    },
    {
      "id": "b253810c-8bf4-489d-aad3-b7d3dc7da95e",
      "name": "Sticky Note - Ingestion",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2976,
        704
      ],
      "parameters": {
        "color": 7,
        "width": 468,
        "height": 508,
        "content": "## CRM Data Intake & Trigger\n\nThis section automatically starts the workflow on a defined schedule and retrieves lead records from Zoho CRM. It ensures CRM data is reviewed regularly for validation, duplicate detection, enrichment and quality monitoring without requiring manual execution."
      },
      "typeVersion": 1
    },
    {
      "id": "d47e0e28-098b-444b-a497-7f2f0faf49d4",
      "name": "Sticky Note - Normalization",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3472,
        704
      ],
      "parameters": {
        "color": 7,
        "width": 324,
        "height": 508,
        "content": "## Record Parsing & Preparation\n\nThis section converts CRM records into individual workflow items so each record can be processed independently. It also copies Zoho's field names (Email, Phone, Company) into the lowercase names the next steps read."
      },
      "typeVersion": 1
    },
    {
      "id": "98c25fd5-ca8b-4396-bd51-f1b97c03c1cc",
      "name": "Fetch Zoho CRM Records",
      "type": "n8n-nodes-base.zohoCrm",
      "position": [
        3264,
        976
      ],
      "parameters": {
        "options": {},
        "resource": "lead",
        "operation": "getAll",
        "returnAll": true
      },
      "typeVersion": 1
    },
    {
      "id": "fd0b451b-f5ef-48d7-9292-033f4ae5359c",
      "name": "Split Records for Processing",
      "type": "n8n-nodes-base.code",
      "position": [
        3584,
        976
      ],
      "parameters": {
        "jsCode": "// The Zoho CRM node returns one item per lead. This step keeps them as they are\n// and copies Zoho's field names (Email, Phone, Company, ...) into the lowercase names used by the next steps.\n// If a field called \"records\" holds a list (or JSON text of a list), that list is split into items instead.\nconst out = [];\nfor (const item of items) {\n  const j = item.json;\n  let list = [j];\n  if (typeof j.records === 'string') list = JSON.parse(j.records || '[]');\n  else if (Array.isArray(j.records)) list = j.records;\n  for (const r of list) {\n    const name = r.Full_Name || [r.First_Name, r.Last_Name].filter(Boolean).join(' ') || r.name || '';\n    out.push({ json: { ...r, id: r.id, name, email: r.Email ?? r.email ?? '', phone: r.Phone ?? r.phone ?? '', company: r.Company ?? r.company ?? '' } });\n  }\n}\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "2c0a7dad-a579-40f4-b94d-d6d3b348dc32",
      "name": "Validate Email Addresses",
      "type": "n8n-nodes-base.code",
      "position": [
        3920,
        976
      ],
      "parameters": {
        "jsCode": "return items.map(item => {\nconst email = (item.json.email || '').trim().toLowerCase();\n\nconst email_valid =\n/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email) &&\n!email.includes('..');\n\nreturn {\njson:{\n...item.json,\nemail,\nemail_valid\n}\n};\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "970049ae-8c6a-4b21-b96d-b50941f17055",
      "name": "Normalize Phone Numbers",
      "type": "n8n-nodes-base.code",
      "position": [
        4128,
        976
      ],
      "parameters": {
        "jsCode": "// Country code added to numbers that have exactly 10 digits. Change these two lines for your market.\nconst DEFAULT_CODE = '91';\nconst DEFAULT_COUNTRY = 'IN';\n\nreturn items.map(item => {\n\nlet phone = (item.json.phone || '')\n.toString()\n.replace(/\\D/g,'');\n\nlet country='unknown';\n\nif(phone.length===10){\nphone=DEFAULT_CODE+phone;\ncountry=DEFAULT_COUNTRY;\n}\nelse if(phone.startsWith('1')) country='US';\nelse if(phone.startsWith('44')) country='UK';\nelse if(phone.startsWith('971')) country='UAE';\nelse if(phone.startsWith('49')) country='DE';\nelse if(phone.startsWith('61')) country='AU';\n\nif(phone.length < 10 || phone.length > 15){\nphone='';\n}\n\nreturn {\njson:{\n...item.json,\nphone_standardized: phone,\ncountry\n}\n};\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "6fec4e7b-ff3e-42eb-a3e6-d78e9d503795",
      "name": "Detect Duplicate Records",
      "type": "n8n-nodes-base.code",
      "position": [
        4448,
        976
      ],
      "parameters": {
        "jsCode": "const seen = {};\n\nreturn items.map(item => {\n\nconst email = item.json.email;\nconst phone = item.json.phone_standardized;\n\nconst key = email + \"|\" + phone;\n\nlet is_duplicate = false;\n\n// A lead with no email and no phone has nothing to compare, so it is not called a duplicate.\nif(email || phone){\nif(seen[key]){\nis_duplicate = true;\n}else{\nseen[key] = true;\n}\n}\n\nreturn {\njson:{\n...item.json,\nis_duplicate\n}\n};\n\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "33ccb61b-820f-400b-b155-43ce073b9748",
      "name": "Enrich Missing Company Data",
      "type": "n8n-nodes-base.code",
      "position": [
        4624,
        976
      ],
      "parameters": {
        "jsCode": "return items.map(item => {\n\nlet company = item.json.company;\n\nif(!company || company.trim()===''){\n\nconst email = item.json.email || '';\n\nif(email.includes('@')){\ncompany = email.split('@')[1].split('.')[0];\ncompany =\ncompany.charAt(0).toUpperCase() +\ncompany.slice(1);\n}else{\ncompany='Unknown Company';\n}\n\n}\n\nreturn {\njson:{\n...item.json,\ncompany_enriched: company\n}\n};\n\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "f9113ab3-eaba-4490-92cf-b7a0d493a372",
      "name": "Generate Quality Flags",
      "type": "n8n-nodes-base.code",
      "position": [
        4944,
        976
      ],
      "parameters": {
        "jsCode": "return items.map(item => {\n\nconst flags=[];\n\nif(!item.json.email_valid)\nflags.push('Invalid Email');\n\nif(!item.json.phone_standardized)\nflags.push('Invalid Phone');\n\nif(item.json.is_duplicate)\nflags.push('Duplicate');\n\nif(!item.json.company || item.json.company==='')\nflags.push('Missing Company');\n\nreturn {\njson:{\n...item.json,\ndata_quality_flags: flags,\nquality_score: 100 - (flags.length * 25)\n}\n};\n\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "6a235f60-3a14-465b-a2db-5b886fad0b2f",
      "name": "Generate Quality Summary Report",
      "type": "n8n-nodes-base.code",
      "position": [
        5296,
        976
      ],
      "parameters": {
        "jsCode": "let total = items.length;\n\nlet summary = {\n  total_records: total,\n  valid_records: 0,\n  invalid_email: 0,\n  invalid_phone: 0,\n  duplicates: 0,\n  missing_company: 0\n};\n\nlet report = [];\n\nfor (const item of items) {\n  const d = item.json;\n\n  let isValid = true;\n\n  // Email validation\n  if (!d.email_valid) {\n    summary.invalid_email++;\n    isValid = false;\n  }\n\n  // Phone validation\n  if (!d.phone_standardized) {\n    summary.invalid_phone++;\n    isValid = false;\n  }\n\n  // Duplicate check\n  if (d.is_duplicate) {\n    summary.duplicates++;\n    isValid = false;\n  }\n\n  // Company check\n  if (!d.company || d.company === '' || d.company === null) {\n    summary.missing_company++;\n    isValid = false;\n  }\n\n  if (isValid) {\n    summary.valid_records++;\n  }\n\n  report.push({\n    id: d.id,\n    name: d.name,\n    email: d.email,\n    country: d.country || 'unknown',\n    status: isValid ? 'VALID' : 'INVALID',\n    flags: d.data_quality_flags || []\n  });\n}\n\nreturn [\n  {\n    json: {\n      summary,\n      report\n    }\n  }\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "70f1c232-5eb9-4a89-97a3-073c884041b4",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5184,
        704
      ],
      "parameters": {
        "color": 7,
        "width": 368,
        "height": 512,
        "content": "## Reporting & Analytics\n\nThis section aggregates processed records into a summary report showing valid entries, duplicates, incomplete records and validation issues. It provides a quick operational view of CRM data health and helps teams track improvement over time."
      },
      "typeVersion": 1
    },
    {
      "id": "140c559c-0d24-4085-8360-212b307ad6d5",
      "name": "Automated Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        3040,
        976
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "triggerAtMinute": 5
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "9c47b7ef-b298-4344-852c-dd55845c3d51",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5584,
        704
      ],
      "parameters": {
        "color": 7,
        "width": 368,
        "height": 512,
        "content": "## HTML Report Formatting\n\nThis section converts the generated JSON summary and record data into a structured and visually readable HTML report. It formats key metrics and detailed records into tables and sections, making the output suitable for email delivery and quick business review."
      },
      "typeVersion": 1
    },
    {
      "id": "d55a7169-bd77-468e-be3b-ab6ebc021f2f",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5984,
        704
      ],
      "parameters": {
        "color": 7,
        "width": 384,
        "height": 512,
        "content": "## Email Notification & Delivery\nThis section sends the formatted HTML report via email to designated recipients. It ensures stakeholders receive automated CRM data quality insights directly in their inbox on a scheduled basis, enabling timely monitoring and action."
      },
      "typeVersion": 1
    },
    {
      "id": "6be3a895-046d-439c-b173-a4a640b727b9",
      "name": "Format CRM Report to HTML",
      "type": "n8n-nodes-base.code",
      "position": [
        5712,
        976
      ],
      "parameters": {
        "jsCode": "const { summary, report } = items[0].json;\n\n// Lead values come from your CRM, so special characters are escaped before they go into the HTML.\nconst esc = v => String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\n\nlet rows = report.map(r => {\n  const statusColor = r.status === 'VALID' ? '#16a34a' : '#dc2626';\n\n  return `\n  <tr>\n    <td style=\"padding:8px;border:1px solid #ddd;\">${esc(r.id)}</td>\n    <td style=\"padding:8px;border:1px solid #ddd;\">${esc(r.name)}</td>\n    <td style=\"padding:8px;border:1px solid #ddd;\">${esc(r.email)}</td>\n    <td style=\"padding:8px;border:1px solid #ddd;\">${esc(r.country)}</td>\n    <td style=\"padding:8px;border:1px solid #ddd;font-weight:bold;color:${statusColor};\">\n      ${r.status}\n    </td>\n    <td style=\"padding:8px;border:1px solid #ddd;\">\n      ${esc((r.flags || []).join(', ') || '-')}\n    </td>\n  </tr>\n  `;\n}).join('');\n\nconst html = `\n<div style=\"font-family: Arial, sans-serif; background:#f9fafb; padding:20px;\">\n  \n  <h2 style=\"color:#111827;\">📊 CRM Data Quality Report</h2>\n\n  <div style=\"background:#ffffff;padding:15px;border-radius:8px;margin-bottom:20px;\">\n    <h3 style=\"margin-top:0;\">Summary</h3>\n    <p><strong>Total Records:</strong> ${summary.total_records}</p>\n    <p><strong style=\"color:#16a34a;\">Valid Records:</strong> ${summary.valid_records}</p>\n    <p><strong style=\"color:#dc2626;\">Invalid Emails:</strong> ${summary.invalid_email}</p>\n    <p><strong style=\"color:#dc2626;\">Invalid Phones:</strong> ${summary.invalid_phone}</p>\n    <p><strong style=\"color:#dc2626;\">Duplicates:</strong> ${summary.duplicates}</p>\n    <p><strong style=\"color:#dc2626;\">Missing Company:</strong> ${summary.missing_company}</p>\n  </div>\n\n  <div style=\"background:#ffffff;padding:15px;border-radius:8px;\">\n    <h3 style=\"margin-top:0;\">Details</h3>\n\n    <table style=\"width:100%;border-collapse:collapse;font-size:14px;\">\n      <thead>\n        <tr style=\"background:#111827;color:#ffffff;\">\n          <th style=\"padding:10px;border:1px solid #ddd;\">ID</th>\n          <th style=\"padding:10px;border:1px solid #ddd;\">Name</th>\n          <th style=\"padding:10px;border:1px solid #ddd;\">Email</th>\n          <th style=\"padding:10px;border:1px solid #ddd;\">Country</th>\n          <th style=\"padding:10px;border:1px solid #ddd;\">Status</th>\n          <th style=\"padding:10px;border:1px solid #ddd;\">Flags</th>\n        </tr>\n      </thead>\n      <tbody>\n        ${rows}\n      </tbody>\n    </table>\n  </div>\n\n  <p style=\"margin-top:20px;font-size:12px;color:#6b7280;\">\n    Generated automatically by n8n workflow\n  </p>\n\n</div>\n`;\n\nreturn [{ json: { html } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "b17d66ab-47eb-4d2b-a8a9-81c3608ea176",
      "name": "Send Data Quality Report Email",
      "type": "n8n-nodes-base.gmail",
      "position": [
        6128,
        976
      ],
      "parameters": {
        "sendTo": "",
        "message": "={{$json.html}}",
        "options": {
          "appendAttribution": false
        },
        "subject": "Zoho CRM Data Quality Report"
      },
      "typeVersion": 2.2
    },
    {
      "id": "5b1f0c3e-6a52-4c1e-9d0e-3c7a1e6f2a10",
      "name": "Sticky Note - Before you turn it on",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3760,
        304
      ],
      "parameters": {
        "width": 752,
        "height": 368,
        "content": "## Before you turn it on\n\n1. The schedule runs every hour and each run reads ALL your leads and emails one report listing every lead. Change it to daily or weekly first.\n2. Type your own address in the last node, and select your own Zoho and Gmail credentials.\n3. Run it by hand once and check the report against a few leads in Zoho.\n4. The country code added to 10-digit phone numbers is 91 (India). Change it in Normalize Phone Numbers if that is wrong for you.\n5. Duplicates are exact matches on email plus phone only.\n6. The quality score and the company guess are calculated but never appear in the report.\n7. The report contains personal data (names, emails). Send it only to people allowed to see your leads."
      },
      "typeVersion": 1
    }
  ],
  "connections": {
    "Fetch Zoho CRM Records": {
      "main": [
        [
          {
            "node": "Split Records for Processing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Quality Flags": {
      "main": [
        [
          {
            "node": "Generate Quality Summary Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Phone Numbers": {
      "main": [
        [
          {
            "node": "Detect Duplicate Records",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detect Duplicate Records": {
      "main": [
        [
          {
            "node": "Enrich Missing Company Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Email Addresses": {
      "main": [
        [
          {
            "node": "Normalize Phone Numbers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format CRM Report to HTML": {
      "main": [
        [
          {
            "node": "Send Data Quality Report Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Automated Schedule Trigger": {
      "main": [
        [
          {
            "node": "Fetch Zoho CRM Records",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Enrich Missing Company Data": {
      "main": [
        [
          {
            "node": "Generate Quality Flags",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Records for Processing": {
      "main": [
        [
          {
            "node": "Validate Email Addresses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Quality Summary Report": {
      "main": [
        [
          {
            "node": "Format CRM Report to HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  }
}