AutomationFlowsMarketing & Ads › Track Lead-source Roi with Fluent Forms, Callrail, Salesmate, and Google Sheets

Track Lead-source Roi with Fluent Forms, Callrail, Salesmate, and Google Sheets

ByDele Tosh @ Protomated @deletosh on n8n.io

This workflow receives inbound leads via a webhook, normalizes and validates the payload, tags the lead’s acquisition channel using UTM/referrer/gclid signals, creates a contact in Salesmate, logs the lead to a Google Sheets ROI dashboard, optionally sends a compliant…

Webhook trigger★★★★☆ complexity20 nodesHTTP RequestGoogle SheetsEmail Send
Marketing & Ads Trigger: Webhook Nodes: 20 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #17337 — we link there as the canonical source.

This workflow follows the Emailsend → Google Sheets recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "Lead-Source ROI Tracker",
  "tags": [],
  "nodes": [
    {
      "id": "3200ee00-0005-4000-e000-000000000001",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -740,
        -560
      ],
      "parameters": {
        "width": 480,
        "height": 640,
        "content": "## Lead-Source ROI Tracker\n\n### How it works\n\nThis workflow receives inbound lead events from Fluent Forms, CallRail, or any webhook-enabled form, normalizes the payload, and identifies the source channel using UTM parameters, gclid (Google Ads auto-tag), referrer, and keyword signals. Valid leads are tagged and created as contacts in Salesmate, logged to a Google Sheets Lead Log dashboard, optionally sent an acknowledgment email through the Bar-Compliance Guardrail, and always trigger a firm alert email with the full lead summary.\n\n### Setup steps\n\n- Create a Google Sheet named 'Lead Source ROI Dashboard' with a 'Lead Log' tab and the required column headers (see deploy guide).\n- Set FIRM_NAME, FIRM_EMAIL, SALESMATE_BASE_URL, SALESMATE_LINK_NAME, ROI_SHEET_ID, FIRM_BOOKING_URL, GUARDRAIL_WORKFLOW_ID, and OPS1_SHEET_ID in n8n project Variables.\n- Wire Salesmate API (Header Auth), Google Sheets OAuth2, and SMTP credentials in project \u2192 Credentials tab.\n- Paste the Production URL into Fluent Forms and/or CallRail webhook settings.\n- Deploy the Bar-Compliance Guardrail workflow first and set its ID in GUARDRAIL_WORKFLOW_ID.\n\n### Customization\n\nExtend the channel mapping in Tag Lead Source to recognize additional UTM sources specific to your ad campaigns. Add hidden UTM fields to every Fluent Form to improve attribution accuracy. Enable Google Ads auto-tagging to fill gclid automatically on every ad click."
      },
      "typeVersion": 1
    },
    {
      "id": "3200ee00-0005-4000-e000-000000000002",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -60,
        -200
      ],
      "parameters": {
        "color": 7,
        "width": 744,
        "height": 350,
        "content": "## Receive and validate lead\n\nWebhook intake and normalization. Accepts submissions from Fluent Forms, CallRail, or any JSON/form-encoded POST. Rejects developer test submissions and payloads with no identifiable contact."
      },
      "typeVersion": 1
    },
    {
      "id": "3200ee00-0005-4000-e000-000000000003",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        696,
        -220
      ],
      "parameters": {
        "color": 7,
        "width": 938,
        "height": 530,
        "content": "## Tag and record lead\n\nMaps source signals to a canonical channel name, creates the contact in Salesmate tagged with the channel, and logs the lead to your Google Sheets ROI dashboard."
      },
      "typeVersion": 1
    },
    {
      "id": "3200ee00-0005-4000-e000-000000000004",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1646,
        -460
      ],
      "parameters": {
        "color": 7,
        "width": 948,
        "height": 590,
        "content": "## Acknowledge prospect\n\nSends the prospect a brief acknowledgment email (opt-out compliant) when an email address is available. Skips gracefully for phone-only leads like CallRail calls."
      },
      "typeVersion": 1
    },
    {
      "id": "3200ee00-0005-4000-e000-000000000005",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2606,
        -340
      ],
      "parameters": {
        "color": 7,
        "width": 514,
        "height": 350,
        "content": "## Notify firm\n\nAlways emails your intake team the full lead summary \u2014 name, contact details, source channel, Salesmate link, and acknowledgment status \u2014 regardless of which path above was taken."
      },
      "typeVersion": 1
    },
    {
      "id": "3200ee00-0005-4000-e000-000000000006",
      "name": "When Lead Submitted",
      "type": "n8n-nodes-base.webhook",
      "notes": "Receives inbound lead events from any connected source \u2014 one URL handles all of them.\n\nPaste the Production URL (visible after activating this workflow) into:\n  Fluent Forms: Form Settings \u2192 Notifications \u2192 Webhooks \u2192 add this URL (format: JSON)\n  CallRail: Settings \u2192 Integrations \u2192 Webhooks \u2192 All Call Events \u2192 add this URL\n  Any web form: standard HTTP POST, JSON or form-encoded\n\nThe Normalize and Validate Lead step detects the source automatically and normalises everything into a consistent shape.",
      "position": [
        0,
        0
      ],
      "parameters": {
        "path": "lead-source",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 2.1
    },
    {
      "id": "3200ee00-0005-4000-e000-000000000007",
      "name": "Normalize and Validate Lead",
      "type": "n8n-nodes-base.code",
      "notes": "Reads and normalises the inbound lead from whichever source sent it.\n\nWorks with:\n  Fluent Forms \u2014 flat JSON or nested 'names' object\n  CallRail \u2014 detected by presence of caller_number or tracking_number\n  Generic form POST \u2014 any JSON or form-encoded payload with standard field names\n\nis_valid_lead is false when: (1) the payload has no name, email, or phone, or (2) both the name AND email contain 'test' (developer test submission).\n\nTo capture UTM parameters and gclid from ad clicks: add hidden fields named utm_source, utm_medium, utm_campaign, and gclid to every Fluent Form placed on a page with UTM parameters in the URL. Google Ads fills gclid automatically when auto-tagging is enabled in your Google Ads account.\n\nOutput: lead_name, lead_email, lead_phone, matter_type, message, raw_source, source_detail, form_source, referrer, utm_source, utm_medium, utm_campaign, gclid, received_at, is_valid_lead.",
      "position": [
        240,
        0
      ],
      "parameters": {
        "jsCode": "// Normalises lead data from Fluent Forms, CallRail, or any generic form POST.\n// Sets is_valid_lead = true when the submission has recognisable contact information\n// and is not a developer test submission (both name AND email contain 'test').\n\nconst item = $input.first().json;\nconst raw  = item.body || item;\n\nconst CALLRAIL_SIGNALS = ['caller_number', 'caller_name', 'tracking_number', 'business_phone_number'];\nconst isCallRail       = CALLRAIL_SIGNALS.some(f => raw[f] !== undefined);\n\nlet lead;\n\nif (isCallRail) {\n  const phone = (raw.caller_number || raw.caller_phone_number || '').trim();\n  lead = {\n    lead_name:    (raw.caller_name || raw.customer_name || 'Unknown Caller').trim(),\n    lead_email:   (raw.email || '').trim(),\n    lead_phone:   phone,\n    matter_type:  (raw.campaign_name || '').trim(),\n    message:      '',\n    raw_source:   (raw.source || raw.utm_source || 'call').trim(),\n    source_detail:(raw.keyword || raw.landing_page_url || 'CallRail').trim(),\n    form_source:  'callrail',\n    referrer:     (raw.landing_page_url || '').trim(),\n    utm_source:   (raw.utm_source || '').trim(),\n    utm_medium:   (raw.utm_medium || '').trim(),\n    utm_campaign: (raw.utm_campaign || '').trim(),\n    gclid:        (raw.gclid || '').trim(),\n  };\n} else {\n  // Fluent Forms or generic JSON form POST.\n  // Fluent Forms sometimes nests first/last name under a 'names' object.\n  let firstName = '';\n  let lastName  = '';\n  if (raw.names && typeof raw.names === 'object') {\n    firstName = (raw.names.first_name || raw.names.firstName || '').trim();\n    lastName  = (raw.names.last_name  || raw.names.lastName  || '').trim();\n  } else {\n    firstName = (raw.first_name || raw.names_first_name || raw['first-name'] || '').trim();\n    lastName  = (raw.last_name  || raw.names_last_name  || raw['last-name']  || '').trim();\n  }\n  const fullName = (raw.full_name || raw.name || `${firstName} ${lastName}`).trim();\n  lead = {\n    lead_name:    fullName || 'Unknown',\n    lead_email:   (raw.email || raw.email_address || '').trim(),\n    lead_phone:   (raw.phone || raw.phone_number  || raw.mobile || '').trim(),\n    matter_type:  (raw.matter_type || raw.legal_issue || raw.practice_area || raw.case_type || '').trim(),\n    message:      (raw.message || raw.comments || raw.how_can_we_help || '').trim(),\n    raw_source:   (raw.utm_source || raw.source || raw.how_did_you_hear || raw.referral_source || '').trim(),\n    source_detail:(raw.utm_campaign || raw.utm_content || raw.referrer || '').trim(),\n    form_source:  'form',\n    referrer:     (raw.referrer || raw._wp_http_referer || '').trim(),\n    utm_source:   (raw.utm_source || '').trim(),\n    utm_medium:   (raw.utm_medium || '').trim(),\n    utm_campaign: (raw.utm_campaign || '').trim(),\n    gclid:        (raw.gclid || '').trim(),\n  };\n}\n\n// Require BOTH name and email to contain 'test' to suppress \u2014 avoids dropping\n// real leads with names like 'Testani' when using a real email.\nconst isTest     = /test/i.test(lead.lead_name) && /test/i.test(lead.lead_email);\nconst hasContact = lead.lead_phone !== '' || lead.lead_email !== '' || (lead.lead_name !== '' && lead.lead_name !== 'Unknown');\n\nreturn [{ json: {\n  ...lead,\n  received_at:   new Date().toISOString(),\n  is_valid_lead: hasContact && !isTest,\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "3200ee00-0005-4000-e000-000000000008",
      "name": "If Valid Lead",
      "type": "n8n-nodes-base.if",
      "notes": "Filters out test submissions and payloads with no contact information before any CRM or sheet writes.\n\nYes \u2192 proceed to source tagging and CRM creation.\nNo \u2192 stop here. Nothing is written to Salesmate or Google Sheets.",
      "onError": "continueErrorOutput",
      "position": [
        480,
        0
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c_valid",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.is_valid_lead }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "3200ee00-0005-4000-e000-000000000009",
      "name": "Ignore Invalid Lead",
      "type": "n8n-nodes-base.noOp",
      "notes": "Stops the workflow for developer test submissions and payloads with no identifiable contact. Visible in execution history so you can confirm the filter is working.",
      "position": [
        700,
        160
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "3200ee00-0005-4000-e000-00000000000a",
      "name": "Tag Lead Source",
      "type": "n8n-nodes-base.code",
      "notes": "Determines which channel brought this lead and assigns a canonical name for the dashboard.\n\nChannel mapping (first match wins, applied to combined utm_source + utm_medium + referrer + raw_source):\n  google-ads \u2014 gclid present (Google Ads auto-tag) or signals match 'cpc', 'ppc', 'google ads'\n  social-media \u2014 facebook, instagram, meta\n  referral \u2014 'referral', 'word of mouth', 'attorney', 'colleague', 'friend', 'family'\n  legal-directory \u2014 Avvo, Justia, FindLaw, Martindale, Nolo, 'directory'\n  yelp \u2014 yelp\n  email-campaign \u2014 email, newsletter, campaign, drip\n  inbound-call \u2014 payload came from a CallRail webhook\n  website-organic \u2014 organic, seo, blog, or any referrer URL present\n  unknown \u2014 no recognisable signal\n\nTo improve accuracy: add hidden fields utm_source, utm_medium, utm_campaign, gclid to every Fluent Form on a page where UTM parameters may be present. Enable auto-tagging in Google Ads to fill gclid on every ad click.\n\nAlso reads firm config from n8n Variables and builds the acknowledgment message text.",
      "position": [
        700,
        -20
      ],
      "parameters": {
        "jsCode": "// Maps the raw source signals to a canonical channel name for the dashboard.\n// Channel is the grouping label (e.g. 'google-ads'); source_detail is the granular label.\n\nconst FIRM_NAME   = $vars.FIRM_NAME   || 'Your Firm';\nconst FIRM_EMAIL  = $vars.FIRM_EMAIL;\nconst BOOKING_URL = $vars.FIRM_BOOKING_URL || '';\nconst SHEET_ID    = $vars.ROI_SHEET_ID;\n\nif (!SHEET_ID) {\n  throw new Error('ROI_SHEET_ID n8n Variable is not set. Go to project \u2192 Variables tab and add it.');\n}\nif (!FIRM_EMAIL) {\n  throw new Error('FIRM_EMAIL n8n Variable is not set. Go to project \u2192 Variables tab and add it.');\n}\n\nconst item = $input.first().json;\n\n// Combine all available source signals into one searchable string\nconst signals = [\n  item.raw_source,\n  item.utm_source,\n  item.utm_medium,\n  item.referrer,\n].map(s => (s || '').toLowerCase()).join(' ');\n\nconst hasGclid = Boolean(item.gclid);\n\nlet channel;\nif (hasGclid || /google.?ads?|google paid|cpc|ppc/i.test(signals)) {\n  channel = 'google-ads';\n} else if (/facebook|instagram|fb|meta|social/i.test(signals)) {\n  channel = 'social-media';\n} else if (/referral|referred|attorney|colleague|word.?of.?mouth|friend|family/i.test(signals)) {\n  channel = 'referral';\n} else if (/avvo|justia|findlaw|martindale|lawyers\\.com|nolo|directory/i.test(signals)) {\n  channel = 'legal-directory';\n} else if (/yelp/i.test(signals)) {\n  channel = 'yelp';\n} else if (/email|newsletter|campaign|drip/i.test(signals)) {\n  channel = 'email-campaign';\n} else if (/callrail|call/i.test(item.form_source || '')) {\n  channel = 'inbound-call';\n} else if (/organic|seo|blog|search/i.test(signals) || item.referrer !== '') {\n  channel = 'website-organic';\n} else {\n  channel = 'unknown';\n}\n\nconst source_detail = [\n  item.utm_campaign,\n  item.utm_content,\n  item.source_detail,\n  item.raw_source,\n].filter(Boolean).join(' / ') || channel;\n\nconst firstName = (item.lead_name || '').split(' ')[0] || 'there';\nconst ack_message = `Hi ${firstName}, thank you for reaching out to ${FIRM_NAME}. We received your inquiry and will be in touch within one business day. To schedule a consultation sooner: ${BOOKING_URL}`;\n\nreturn [{ json: {\n  ...item,\n  channel,\n  source_detail,\n  firm_name:   FIRM_NAME,\n  firm_email:  FIRM_EMAIL,\n  booking_url: BOOKING_URL,\n  sheet_id:    SHEET_ID,\n  ack_message,\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "3200ee00-0005-4000-e000-00000000000b",
      "name": "Add Lead to Salesmate",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Creates the contact in Salesmate and tags them with their source channel \u2014 so every lead in your CRM shows exactly where they came from.\n\nTags written (comma-separated CSV): 'lead-source-google-ads', 'matter-family-law', etc. Filter contacts by these tags in Salesmate to see all leads from a specific channel.\nDescription field records source channel, source detail, received timestamp, and first message.\n\nNote: this step does not deduplicate. If the same person submits a form twice, two Salesmate contacts are created. The done-for-you upgrade includes deduplication by email.\n\nIf this step fails (e.g., wrong API token), the workflow continues \u2014 the lead is still logged to Google Sheets and the firm notification fires.\n\nCredential to wire: project \u2192 Credentials tab \u2192 New \u2192 Header Auth\n  Header Name: accessToken\n  Header Value: your Salesmate Personal API Token\n  Find it: Salesmate \u2192 Profile icon (top right) \u2192 My Account \u2192 Developer \u2192 Personal API Token",
      "onError": "continueRegularOutput",
      "position": [
        940,
        -20
      ],
      "parameters": {
        "url": "={{ $vars.SALESMATE_BASE_URL }}/apis/contact/v4",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "jsonBody": "={\n  \"firstName\": {{ JSON.stringify($json.lead_name.split(' ')[0] || '') }},\n  \"lastName\":  {{ JSON.stringify($json.lead_name.split(' ').slice(1).join(' ') || '') }},\n  \"email\":     {{ JSON.stringify($json.lead_email) }},\n  \"phone\":     {{ JSON.stringify($json.lead_phone) }},\n  \"tags\":      {{ JSON.stringify('lead-source-' + $json.channel + (($json.matter_type) ? ',matter-' + $json.matter_type.toLowerCase().replace(/[^a-z0-9]+/g, '-') : '')) }},\n  \"description\": {{ JSON.stringify('Source: ' + $json.channel + ' | ' + $json.source_detail + ' | Received: ' + $json.received_at + ($json.message ? ' | Message: ' + $json.message.substring(0, 300) : '')) }}\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "x-linkname",
              "value": "={{ $vars.SALESMATE_LINK_NAME }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "3200ee00-0005-4000-e000-00000000000c",
      "name": "Read Salesmate Response",
      "type": "n8n-nodes-base.code",
      "notes": "Extracts the new Salesmate contact ID and carries all lead data forward to the dashboard step.\n\nThe contact ID is logged in your Google Sheet so you can jump from any dashboard row directly to the contact in Salesmate: https://yourfirm.salesmate.io/contacts/{id}\n\nIf the Salesmate step failed, salesmate_contact_id will be empty. The lead is still logged in the dashboard and you can add the ID manually later.",
      "position": [
        1180,
        -20
      ],
      "parameters": {
        "jsCode": "// Extracts the Salesmate contact ID from the API response and merges it with the lead data.\n// If the Salesmate call failed (neverError: true), the response won't have an ID \u2014 the dashboard log still proceeds.\nconst sm   = $input.first().json;\nconst lead = $('Tag Lead Source').first().json;\n\nconst salesmate_contact_id = (\n  sm?.Data?.id ||\n  sm?.data?.id ||\n  sm?.id ||\n  ''\n).toString();\n\nreturn [{ json: {\n  ...lead,\n  salesmate_contact_id,\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "3200ee00-0005-4000-e000-00000000000d",
      "name": "Log Lead to Dashboard",
      "type": "n8n-nodes-base.googleSheets",
      "notes": "Appends a row to your Lead Log \u2014 the foundation of the ROI dashboard.\n\nColumns written automatically by this workflow:\n  date, channel, source_detail, lead_name, lead_email, lead_phone, matter_type, message preview, Salesmate contact ID\n\nColumns you fill manually in the sheet to complete the ROI calculation:\n  ad_spend \u2014 what you spent on this channel this period (enter once per channel, not per row)\n  signed_case \u2014 yes/no when the lead becomes a client\n  case_value \u2014 the retainer or total fee when a case signs\n\nOnce the manual columns have data, use Insert \u2192 Pivot Table to see cost-per-lead and cost-per-signed-case by channel. This is the free-template version of the ROI dashboard.\n\nCredential: project \u2192 Credentials tab \u2192 New \u2192 Google Sheets OAuth2 \u2192 sign in with the account that owns this sheet.",
      "onError": "continueRegularOutput",
      "position": [
        1420,
        -20
      ],
      "parameters": {
        "columns": {
          "value": {
            "date": "={{ $json.received_at }}",
            "channel": "={{ $json.channel }}",
            "lead_name": "={{ $json.lead_name }}",
            "lead_email": "={{ $json.lead_email }}",
            "lead_phone": "={{ $json.lead_phone }}",
            "matter_type": "={{ $json.matter_type }}",
            "source_detail": "={{ $json.source_detail }}",
            "message_preview": "={{ $json.message.substring(0, 200) }}",
            "salesmate_contact_id": "={{ $json.salesmate_contact_id }}"
          },
          "schema": [],
          "mappingMode": "defineBelow",
          "matchingColumns": []
        },
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Lead Log"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.sheet_id }}"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4
    },
    {
      "id": "3200ee00-0005-4000-e000-00000000000e",
      "name": "If Has Email Address",
      "type": "n8n-nodes-base.if",
      "notes": "Decides whether to send an acknowledgment email to the prospect.\n\nYes (email present) \u2192 compliance check, then acknowledgment.\nNo (no email \u2014 e.g., a CallRail call where no email was captured) \u2192 skip acknowledgment and go straight to the firm alert.\n\nThis gate also prevents the Bar-Compliance Guardrail from throwing a 'missing recipient' error on a blank email address.",
      "onError": "continueErrorOutput",
      "position": [
        1660,
        -20
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c_has_email",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $('Tag Lead Source').first().json.lead_email }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "3200ee00-0005-4000-e000-00000000000f",
      "name": "Compliance Check \u2014 Acknowledgment",
      "type": "n8n-nodes-base.executeWorkflow",
      "notes": "Calls the Bar-Compliance Guardrail before sending the acknowledgment email to the prospect.\n\nChecks whether this contact has opted out, appends the required unsubscribe footer to the message, and logs the attempt to your compliance audit sheet.\n\nReturns { approved, message_out, recipient, suppression_reason } for the next step to act on.\n\nSet GUARDRAIL_WORKFLOW_ID in n8n Variables to the numeric workflow ID from the Bar-Compliance Guardrail URL: .../workflow/WORKFLOW_ID.\n\nPrerequisite: the Bar-Compliance Guardrail must be deployed, the OPS1_SHEET_ID Variable must be set, and the guardrail must be active.",
      "position": [
        1900,
        -140
      ],
      "parameters": {
        "options": {},
        "workflowId": {
          "__rl": true,
          "mode": "expression",
          "value": "={{ $vars.GUARDRAIL_WORKFLOW_ID }}"
        },
        "workflowInputs": {
          "value": {
            "channel": "email",
            "message": "={{ $('Tag Lead Source').first().json.ack_message }}",
            "matter_id": "",
            "recipient": "={{ $('Tag Lead Source').first().json.lead_email }}",
            "template_id": "PAC-32"
          },
          "schema": [],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "3200ee00-0005-4000-e000-000000000010",
      "name": "If Approved to Acknowledge",
      "type": "n8n-nodes-base.if",
      "notes": "Routes based on the compliance guardrail's decision.\n\nYes (approved: true) \u2192 sends the acknowledgment email to the prospect.\nNo (approved: false) \u2192 contact has opted out; message suppressed. The guardrail has already logged this suppression to the compliance audit sheet.",
      "onError": "continueErrorOutput",
      "position": [
        2140,
        -140
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c_approved",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.approved }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "3200ee00-0005-4000-e000-000000000011",
      "name": "Send Acknowledgment to Lead",
      "type": "n8n-nodes-base.emailSend",
      "notes": "Sends the prospect a brief acknowledgment confirming their inquiry was received.\n\nThe message body comes from the compliance guardrail's message_out \u2014 it has already had the required unsubscribe footer appended.\n\nCredential: project \u2192 Credentials tab \u2192 New \u2192 SMTP \u2192 enter your firm email settings.\n  Gmail: Settings \u2192 Security \u2192 App Passwords (not your regular Gmail password)\n  Outlook 365: email + password; enable SMTP AUTH in Microsoft 365 admin centre\n  Other providers: check your provider's SMTP settings page for host, port, and credentials",
      "onError": "continueRegularOutput",
      "position": [
        2380,
        -260
      ],
      "parameters": {
        "text": "={{ $json.message_out }}",
        "subject": "=We received your inquiry \u2014 {{ $('Tag Lead Source').first().json.firm_name }}",
        "toEmail": "={{ $json.recipient }}",
        "emailType": "text",
        "fromEmail": "={{ $('Tag Lead Source').first().json.firm_email }}"
      },
      "credentials": {
        "smtp": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2
    },
    {
      "id": "3200ee00-0005-4000-e000-000000000012",
      "name": "Skip \u2014 contact opted out",
      "type": "n8n-nodes-base.noOp",
      "notes": "No acknowledgment sent \u2014 contact is on the opt-out list. The compliance guardrail has already logged this suppression to the audit sheet. The lead is still in your Google Sheets dashboard and Salesmate. The firm notification fires next.",
      "position": [
        2380,
        -60
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "3200ee00-0005-4000-e000-000000000013",
      "name": "Build Firm Alert",
      "type": "n8n-nodes-base.code",
      "notes": "Assembles the firm alert email content from earlier nodes \u2014 runs regardless of which path the workflow took.\n\nReceives from three possible paths:\n  Acknowledgment sent \u2014 $json.approved = true\n  Acknowledgment suppressed \u2014 $json.approved = false (opted out)\n  No email address \u2014 $json.approved is undefined (skipped the compliance check)\n\nAll three paths produce the same firm notification. The ack_status field reflects which path was taken.",
      "position": [
        2620,
        -140
      ],
      "parameters": {
        "jsCode": "// Builds the firm alert email content using data from earlier stable nodes.\n// Runs from three paths: ack sent, ack suppressed (opted out), or no email provided.\n// $json.approved is only present when the compliance check ran.\nconst lead    = $('Tag Lead Source').first().json;\nconst sm      = $('Read Salesmate Response').first().json;\nconst current = $input.first().json;\n\nlet ack_status;\nif (current.approved === true) {\n  ack_status = 'Sent \u2713';\n} else if (current.approved === false) {\n  ack_status = 'Not sent (contact opted out)';\n} else {\n  // No email path: approved is undefined\n  ack_status = 'Not sent (no email address provided)';\n}\n\nreturn [{ json: {\n  lead_name:            lead.lead_name,\n  lead_email:           lead.lead_email,\n  lead_phone:           lead.lead_phone,\n  matter_type:          lead.matter_type,\n  message:              lead.message,\n  channel:              lead.channel,\n  source_detail:        lead.source_detail,\n  received_at:          lead.received_at,\n  salesmate_contact_id: sm.salesmate_contact_id || '(not created \u2014 check Salesmate credentials)',\n  firm_name:            lead.firm_name,\n  firm_email:           lead.firm_email,\n  booking_url:          lead.booking_url,\n  salesmate_base_url:   ($vars.SALESMATE_BASE_URL || '').replace(/\\/$/, ''),\n  ack_status,\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "3200ee00-0005-4000-e000-000000000014",
      "name": "Notify Firm of New Lead",
      "type": "n8n-nodes-base.emailSend",
      "notes": "Emails your firm a summary of every new lead so the intake team can follow up immediately.\n\nIncludes: name, email, phone, matter type, message, source channel, source detail, a direct link to the Salesmate contact, and whether an acknowledgment was sent.\n\nThe subject line includes the channel ('New google-ads lead: Jane Smith') so you can filter by channel in your inbox.\n\nCredential: same SMTP account used for the acknowledgment email.",
      "position": [
        2860,
        -140
      ],
      "parameters": {
        "text": "=New lead \u2014 {{ $json.channel }}\n\nName:         {{ $json.lead_name }}\nEmail:        {{ $json.lead_email || '(not provided)' }}\nPhone:        {{ $json.lead_phone || '(not provided)' }}\nMatter type:  {{ $json.matter_type || '(not specified)' }}\nMessage:      {{ $json.message || '(none)' }}\n\nSource channel: {{ $json.channel }}\nSource detail:  {{ $json.source_detail }}\nReceived:       {{ $json.received_at }}\n\nSalesmate contact: {{ $json.salesmate_contact_id ? ($json.salesmate_base_url + '/contacts/' + $json.salesmate_contact_id) : $json.salesmate_contact_id }}\nAcknowledgment:    {{ $json.ack_status }}\n\nSchedule a call: {{ $json.booking_url }}",
        "subject": "=New {{ $json.channel }} lead: {{ $json.lead_name }}",
        "toEmail": "={{ $json.firm_email }}",
        "emailType": "text",
        "fromEmail": "={{ $json.firm_email }}"
      },
      "credentials": {
        "smtp": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2
    }
  ],
  "active": false,
  "settings": {
    "availableInMCP": false,
    "executionOrder": "v1",
    "executionTimeout": 60,
    "saveManualExecutions": true,
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all"
  },
  "nodeGroups": [],
  "staticData": null,
  "connections": {
    "If Valid Lead": {
      "main": [
        [
          {
            "node": "Tag Lead Source",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Ignore Invalid Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tag Lead Source": {
      "main": [
        [
          {
            "node": "Add Lead to Salesmate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Firm Alert": {
      "main": [
        [
          {
            "node": "Notify Firm of New Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Lead Submitted": {
      "main": [
        [
          {
            "node": "Normalize and Validate Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Has Email Address": {
      "main": [
        [
          {
            "node": "Compliance Check \u2014 Acknowledgment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Firm Alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add Lead to Salesmate": {
      "main": [
        [
          {
            "node": "Read Salesmate Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Lead to Dashboard": {
      "main": [
        [
          {
            "node": "If Has Email Address",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Salesmate Response": {
      "main": [
        [
          {
            "node": "Log Lead to Dashboard",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Approved to Acknowledge": {
      "main": [
        [
          {
            "node": "Send Acknowledgment to Lead",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip \u2014 contact opted out",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip \u2014 contact opted out": {
      "main": [
        [
          {
            "node": "Build Firm Alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize and Validate Lead": {
      "main": [
        [
          {
            "node": "If Valid Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Acknowledgment to Lead": {
      "main": [
        [
          {
            "node": "Build Firm Alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compliance Check \u2014 Acknowledgment": {
      "main": [
        [
          {
            "node": "If Approved to Acknowledge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "description": "Tags every inbound lead with its source channel (web form, call, referral, ad, etc.), creates the contact in Salesmate CRM, and appends a row to your Google Sheets ROI dashboard \u2014 giving you a live view of cost-per-signed-case by channel. Replaces CallRail Analytics ($45\u2013145/mo) + manual spreadsheets (~3 hrs/week)."
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow receives inbound leads via a webhook, normalizes and validates the payload, tags the lead’s acquisition channel using UTM/referrer/gclid signals, creates a contact in Salesmate, logs the lead to a Google Sheets ROI dashboard, optionally sends a compliant…

Source: https://n8n.io/workflows/17337/ — original creator credit. Request a take-down →

More Marketing & Ads workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Marketing & Ads

This workflow is designed to manage the assignment and validation of unique QR code coupons within a lead generation system with SuiteCRM.

HTTP Request, Form Trigger, Google Sheets +1
Marketing & Ads

This n8n workflow automates the generation of personalized marketing content for events, including emails, social media posts, and advertisements. Leveraging AI, it tailors content based on event deta

HTTP Request, Google Sheets, Email Send +1
Marketing & Ads

VMP | Lead CRM + Alerta Ventas. Uses googleSheets, httpRequest, emailSend. Webhook trigger; 7 nodes.

Google Sheets, HTTP Request, Email Send
Marketing & Ads

Lead Gen -> Outreach -> Reply Digest. Uses googleSheets, httpRequest, emailSend, emailReadImap. Event-driven trigger; 36 nodes.

Google Sheets, HTTP Request, Email Send +1
Marketing & Ads

This workflow acts as an instant SDR that replies to new inbound leads across multiple channels in real time. It first captures and normalizes all incoming lead data into a unified structure. The work

Google Sheets, HTTP Request, Gmail +1