{
  "name": "Capture website form leads into Google Sheets with duplicate detection for HVAC, plumbing and home service businesses",
  "tags": [],
  "nodes": [
    {
      "id": "sticky-0",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -800,
        -256
      ],
      "parameters": {
        "width": 480,
        "height": 896,
        "content": "## Capture website form leads into Google Sheets with duplicate detection for HVAC, plumbing and home service businesses\n\n### How it works\n\nThis workflow captures website form submissions through a webhook, immediately confirms receipt, and normalizes incoming lead fields from JSON or form-encoded posts. It checks Google Sheets for an existing lead, then either updates the existing row for repeat enquiries or appends a new row for first-time leads. The workflow finishes by sending a Telegram alert tailored to whether the enquiry was new or a repeat.\n\n### Setup steps\n\n- Configure the webhook URL in the website form so submissions are sent to the n8n webhook endpoint.\n- Create a sheet named Leads with these headers in row 1: received_at, last_enquiry_at, full_name, phone, dedupe_key, email, message, source, enquiry_count.\n- Connect Google Sheets credentials and select the same spreadsheet and sheet in all three Google Sheets nodes.\n- In Update Lead in Sheets, select dedupe_key under Column to Match On. This field resets whenever the document is re-selected, and the node fails without it.\n- Connect Telegram bot credentials and set the chat ID in both alert nodes.\n- Review the ALIASES block in Map Lead Fields and add any field names your own form uses.\n\n### Customization\n\nChange the duplicate detection key in the Map Lead Fields code node to match on email instead of phone, or on both. Customize the Telegram message templates, and add extra columns to the sheet and to the output of Map Lead Fields to track things such as job type or service area."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-1",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -240,
        -80
      ],
      "parameters": {
        "color": 7,
        "width": 672,
        "height": 304,
        "content": "## Receive and normalize lead\n\nAccepts the website form submission, returns an immediate webhook response, and standardizes the incoming lead fields for downstream processing."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-2",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        688,
        -96
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 320,
        "content": "## Check existing records\n\nLooks up the normalized lead in Google Sheets and branches the workflow depending on whether a matching lead already exists."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-3",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1168,
        -224
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 320,
        "content": "## Update repeat enquiry\n\nBuilds the changed fields for a repeat enquiry and updates the matching Google Sheets row without overwriting unchanged lead details."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-4",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1168,
        128
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 304,
        "content": "## Add first-time lead\n\nPrepares a complete row for a new prospect and appends it to the Google Sheets lead tracker."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-5",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1792,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 608,
        "content": "## Send lead alerts\n\nSends Telegram notifications for both workflow outcomes, with separate alerts for repeat enquiries and newly added leads."
      },
      "typeVersion": 1
    },
    {
      "id": "webhook-new-lead",
      "name": "When Lead Form Submitted",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -200,
        60
      ],
      "parameters": {
        "path": "new-lead",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "respond-received",
      "name": "Confirm Form Submission",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        20,
        60
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={\"status\":\"received\"}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "code-normalise",
      "name": "Map Lead Fields",
      "type": "n8n-nodes-base.code",
      "position": [
        280,
        60
      ],
      "parameters": {
        "jsCode": "// Accepts JSON or form-encoded posts from any web form and maps the usual\n// field-name variants onto the exact column names used in the sheet.\n\n// ---------- ADD YOUR OWN FIELD NAMES HERE ----------\nconst ALIASES = {\n  full_name: ['name', 'full_name', 'fullname', 'your-name', 'contact_name', 'first_name'],\n  phone: ['phone', 'telephone', 'tel', 'phone_number', 'phonenumber', 'mobile', 'your-phone'],\n  email: ['email', 'e-mail', 'email_address', 'your-email'],\n  message: ['message', 'comments', 'details', 'description', 'job_description', 'your-message'],\n  source: ['source', 'utm_source', 'form_name', 'page'],\n};\n// ---------------------------------------------------\n\nfunction pick(body, aliases) {\n  const keys = Object.keys(body);\n  for (const alias of aliases) {\n    const match = keys.find((k) => k.toLowerCase() === alias);\n    if (match && String(body[match]).trim() !== '') return String(body[match]).trim();\n  }\n  return '';\n}\n\nreturn $input.all().map((item) => {\n  const body = item.json.body || item.json || {};\n\n  const phone = pick(body, ALIASES.phone);\n  const email = pick(body, ALIASES.email);\n\n  // Digits only, with a leading US country code dropped, so the same person\n  // typing their number two different ways still counts as one lead.\n  const digits = phone.replace(/\\D/g, '').replace(/^1(?=\\d{10}$)/, '');\n\n  // Prefixed so the key is never a bare number: a digits-only value gets stored\n  // as a number by the spreadsheet and the lookup then stops matching.\n  const dedupe_key = digits ? `p:${digits}` : (email ? `e:${email.toLowerCase()}` : '');\n\n  const now = new Date().toISOString();\n\n  // The keys below match the sheet headers exactly, which lets the Google Sheets\n  // nodes map them automatically with no manual column mapping.\n  return {\n    json: {\n      received_at: now,\n      last_enquiry_at: now,\n      full_name: pick(body, ALIASES.full_name),\n      phone,\n      dedupe_key,\n      email,\n      message: pick(body, ALIASES.message),\n      source: pick(body, ALIASES.source) || 'website',\n      enquiry_count: 1,\n    },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "sheets-lookup",
      "name": "Check Existing Lead in Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        740,
        60
      ],
      "parameters": {
        "options": {},
        "filtersUI": {
          "values": [
            {
              "lookupValue": "={{ $json.dedupe_key }}",
              "lookupColumn": "dedupe_key"
            }
          ]
        },
        "operation": "read",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Leads"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_YOUR_SPREADSHEET_ID"
        }
      },
      "typeVersion": 4.5,
      "alwaysOutputData": true
    },
    {
      "id": "if-already-known",
      "name": "If Lead Exists in Sheets",
      "type": "n8n-nodes-base.if",
      "position": [
        980,
        60
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "row-exists",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json.dedupe_key }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "code-build-update",
      "name": "Build Updated Lead Row",
      "type": "n8n-nodes-base.code",
      "position": [
        1220,
        -60
      ],
      "parameters": {
        "jsCode": "// Builds only the columns that change on a repeat enquiry. Anything not listed\n// here keeps its original value in the sheet.\nconst lead = $('Map Lead Fields').first().json;\nconst existing = $input.first().json;\n\nreturn [\n  {\n    json: {\n      dedupe_key: lead.dedupe_key,\n      last_enquiry_at: lead.last_enquiry_at,\n      message: lead.message,\n      source: lead.source,\n      enquiry_count: Number(existing.enquiry_count || 1) + 1,\n    },\n  },\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "sheets-update",
      "name": "Update Lead in Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1460,
        -60
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [
            "dedupe_key"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "update",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Leads"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_YOUR_SPREADSHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "code-build-new",
      "name": "Create New Lead Row",
      "type": "n8n-nodes-base.code",
      "position": [
        1216,
        256
      ],
      "parameters": {
        "jsCode": "// The lookup node replaces the item with an empty object when no match is found,\n// so pull the normalised lead back in before the row is written.\nreturn [{ json: $('Map Lead Fields').first().json }];"
      },
      "typeVersion": 2
    },
    {
      "id": "sheets-append",
      "name": "Append Lead to Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1456,
        256
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Leads"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_YOUR_SPREADSHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "telegram-repeat",
      "name": "Notify Repeat Inquiry via Telegram",
      "type": "n8n-nodes-base.telegram",
      "position": [
        1840,
        -60
      ],
      "parameters": {
        "text": "=Repeat enquiry (already in the sheet)\n\nName: {{ $('Map Lead Fields').first().json.full_name }}\nPhone: {{ $('Map Lead Fields').first().json.phone }}\nEmail: {{ $('Map Lead Fields').first().json.email }}\nSource: {{ $('Map Lead Fields').first().json.source }}\n\n{{ $('Map Lead Fields').first().json.message }}",
        "chatId": "REPLACE_WITH_YOUR_CHAT_ID",
        "resource": "message",
        "operation": "sendMessage",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "telegram-new",
      "name": "Notify New Lead via Telegram",
      "type": "n8n-nodes-base.telegram",
      "position": [
        1840,
        180
      ],
      "parameters": {
        "text": "=New lead\n\nName: {{ $('Map Lead Fields').first().json.full_name }}\nPhone: {{ $('Map Lead Fields').first().json.phone }}\nEmail: {{ $('Map Lead Fields').first().json.email }}\nSource: {{ $('Map Lead Fields').first().json.source }}\n\n{{ $('Map Lead Fields').first().json.message }}",
        "chatId": "REPLACE_WITH_YOUR_CHAT_ID",
        "resource": "message",
        "operation": "sendMessage",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "typeVersion": 1.2
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Map Lead Fields": {
      "main": [
        [
          {
            "node": "Check Existing Lead in Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create New Lead Row": {
      "main": [
        [
          {
            "node": "Append Lead to Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append Lead to Sheets": {
      "main": [
        [
          {
            "node": "Notify New Lead via Telegram",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Lead in Sheets": {
      "main": [
        [
          {
            "node": "Notify Repeat Inquiry via Telegram",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Updated Lead Row": {
      "main": [
        [
          {
            "node": "Update Lead in Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Confirm Form Submission": {
      "main": [
        [
          {
            "node": "Map Lead Fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Lead Exists in Sheets": {
      "main": [
        [
          {
            "node": "Build Updated Lead Row",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create New Lead Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Lead Form Submitted": {
      "main": [
        [
          {
            "node": "Confirm Form Submission",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Existing Lead in Sheets": {
      "main": [
        [
          {
            "node": "If Lead Exists in Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}