Track lead-to-MQL conversion rate by source and report to Slack using n8n

medium complexityCost: $0-24/mo

Prerequisites

Prerequisites
  • n8n instance — either n8n cloud or self-hosted
  • HubSpot private app token with crm.objects.contacts.read scope
  • Slack workspace with a Slack app configured (Bot Token Scopes: chat:write, chat:write.public)
  • n8n credentials set up for both HubSpot and Slack
  • Lifecycle stages configured in HubSpot (at minimum: Lead, Marketing Qualified Lead)

Step 1: Create the workflow and schedule trigger

Open n8n and create a new workflow. Add a Schedule Trigger node:

  • Trigger interval: Weeks
  • Day of week: Monday
  • Hour: 9
  • Minute: 0
  • Timezone: Set to your team's timezone

Step 2: Set date range variables

Add a Set node to calculate the date range for the last 7 days:

// Expression fields in the Set node
// seven_days_ago (ISO string)
{{ new Date(Date.now() - 7 * 86400000).toISOString().split('T')[0] + 'T00:00:00.000Z' }}
 
// seven_days_ago_ms (Unix milliseconds for HubSpot filter)
{{ new Date(Date.now() - 7 * 86400000).setHours(0,0,0,0).toString() }}
 
// today_ms
{{ new Date().setHours(0,0,0,0).toString() }}

Step 3: Search for leads created in the last 7 days

Add an HTTP Request node to find all contacts created in the period:

  • Method: POST
  • URL: https://api.hubapi.com/crm/v3/objects/contacts/search
  • Authentication: Predefined Credential Type -> HubSpot API
  • Body content type: JSON
  • Body:
{
  "filterGroups": [
    {
      "filters": [
        {
          "propertyName": "createdate",
          "operator": "GTE",
          "value": "{{$json.seven_days_ago_ms}}"
        },
        {
          "propertyName": "createdate",
          "operator": "LT",
          "value": "{{$json.today_ms}}"
        }
      ]
    }
  ],
  "properties": [
    "hs_analytics_source", "lifecyclestage", "createdate"
  ],
  "limit": 100
}
Pagination

The HubSpot Search API returns a max of 100 results per request. If you generate more than 100 leads per week, add a loop: use an IF node to check for $json.paging.next.after, then loop back to the HTTP Request with the after parameter.

Step 4: Search for MQLs from the same period

Add a second HTTP Request node for contacts that became MQLs. Use a filter on lifecyclestage:

{
  "filterGroups": [
    {
      "filters": [
        {
          "propertyName": "createdate",
          "operator": "GTE",
          "value": "{{$json.seven_days_ago_ms}}"
        },
        {
          "propertyName": "createdate",
          "operator": "LT",
          "value": "{{$json.today_ms}}"
        },
        {
          "propertyName": "lifecyclestage",
          "operator": "EQ",
          "value": "marketingqualifiedlead"
        }
      ]
    }
  ],
  "properties": [
    "hs_analytics_source", "lifecyclestage", "createdate"
  ],
  "limit": 100
}
Why two searches?

HubSpot's Search API doesn't support OR filters across filter groups for aggregation. It's simplest to run two queries — one for all leads, one filtered to MQLs — and compare counts per source in a Code node.

Step 5: Calculate conversion rates with a Code node

Add a Code node (Run Once for All Items):

const allLeads = $('Fetch Leads').first().json.results || [];
const mqls = $('Fetch MQLs').first().json.results || [];
 
// Group leads by source
const leadsBySource = {};
for (const lead of allLeads) {
  const source = lead.properties.hs_analytics_source || 'UNKNOWN';
  leadsBySource[source] = (leadsBySource[source] || 0) + 1;
}
 
// Group MQLs by source
const mqlsBySource = {};
for (const mql of mqls) {
  const source = mql.properties.hs_analytics_source || 'UNKNOWN';
  mqlsBySource[source] = (mqlsBySource[source] || 0) + 1;
}
 
// Calculate conversion rate per source
const sources = [...new Set([...Object.keys(leadsBySource), ...Object.keys(mqlsBySource)])];
const report = sources
  .map(source => ({
    source,
    leads: leadsBySource[source] || 0,
    mqls: mqlsBySource[source] || 0,
    rate: leadsBySource[source]
      ? ((mqlsBySource[source] || 0) / leadsBySource[source] * 100).toFixed(1)
      : '0.0',
  }))
  .sort((a, b) => b.leads - a.leads);
 
const totalLeads = allLeads.length;
const totalMQLs = mqls.length;
const overallRate = totalLeads > 0 ? (totalMQLs / totalLeads * 100).toFixed(1) : '0.0';
 
return [{
  json: {
    report,
    totalLeads,
    totalMQLs,
    overallRate,
  }
}];

Step 6: Format and send to Slack

Add a Slack node:

  • Resource: Message
  • Operation: Send a Message
  • Channel: #marketing-reports
  • Message Type: Block Kit
{
  "blocks": [
    {
      "type": "header",
      "text": {
        "type": "plain_text",
        "text": "📈 Weekly Lead-to-MQL Conversion Report"
      }
    },
    {
      "type": "section",
      "fields": [
        {
          "type": "mrkdwn",
          "text": "*Total Leads*\n{{ $json.totalLeads }}"
        },
        {
          "type": "mrkdwn",
          "text": "*Total MQLs*\n{{ $json.totalMQLs }}"
        },
        {
          "type": "mrkdwn",
          "text": "*Overall Conversion*\n{{ $json.overallRate }}%"
        }
      ]
    },
    {
      "type": "divider"
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*Conversion by Source*\n{{ $json.report.map(r => `• *${r.source}*: ${r.leads} leads → ${r.mqls} MQLs (${r.rate}%)`).join('\\n') }}"
      }
    },
    {
      "type": "context",
      "elements": [
        {
          "type": "mrkdwn",
          "text": "Last 7 days | Generated {{ new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) }}"
        }
      ]
    }
  ]
}
Block Kit JSON format

n8n's Slack node expects the full {"blocks": [...]} wrapper. If you pass just the array, n8n silently falls back to the notification text field.

Step 7: Test and activate

  1. Click Execute Workflow to test with your current data
  2. Check that sources resolve correctly — common values are ORGANIC_SEARCH, PAID_SEARCH, DIRECT_TRAFFIC, REFERRALS, SOCIAL_MEDIA
  3. Toggle the workflow to Active

Cost

  • n8n Cloud Starter: $24/mo for 2,500 executions. A weekly report uses ~4 executions/month. Self-hosted n8n is free.
  • Maintenance: update the lifecycle stage value if you rename your MQL stage. Monitor for HubSpot API changes.

Next steps

  • Add month-to-date totals — duplicate the search with a wider date range and add a second section to the Slack message
  • Week-over-week trend — store last week's rates in n8n's static data ($getWorkflowStaticData('global')) and show deltas with arrows
  • Source drill-down — for high-volume sources like PAID_SEARCH, add UTM campaign-level breakdown using hs_analytics_source_data_1

Need help implementing this?

We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.